From 84cc1d15d45bfef510a575fb895b2acbff6a0555 Mon Sep 17 00:00:00 2001 From: Fabian Wagner Date: Thu, 20 Nov 2025 15:53:34 +0100 Subject: [PATCH] AI extended workkit --- composer.json | 12 +- src/RolesServiceProvider.php | 26 ++ src/Services/IncompleteJsonService.php | 198 +++++++++++ src/Services/MiscService.php | 171 +++++++++ .../{WillExpire.php => HasExpiration.php} | 0 src/Traits/HasMeta.php | 24 ++ src/Traits/HasMetaTranslation.php | 326 ++++++++++++++++++ src/helpers.php | 16 + 8 files changed, 770 insertions(+), 3 deletions(-) create mode 100644 src/RolesServiceProvider.php create mode 100644 src/Services/IncompleteJsonService.php create mode 100644 src/Services/MiscService.php rename src/Traits/{WillExpire.php => HasExpiration.php} (100%) create mode 100644 src/Traits/HasMeta.php create mode 100644 src/Traits/HasMetaTranslation.php create mode 100644 src/helpers.php diff --git a/composer.json b/composer.json index a57f52d..cc15389 100644 --- a/composer.json +++ b/composer.json @@ -16,21 +16,27 @@ "autoload": { "psr-4": { "Blax\\Workkit\\": "src" - } + }, + "files": [ + "src/helpers.php" + ] }, "config": { "sort-packages": true }, "require": { "php": ">=8.0", - "laravel/framework": "*" + "laravel/framework": "*", + "spatie/once": "*" }, "require-dev": { "laravel/pint": "^1.22" }, "extra": { "laravel": { - "providers": [] + "providers": [ + "Blax\\Workkit\\WorkkitServiceProvider" + ] } }, "minimum-stability": "dev", diff --git a/src/RolesServiceProvider.php b/src/RolesServiceProvider.php new file mode 100644 index 0000000..c4ca89f --- /dev/null +++ b/src/RolesServiceProvider.php @@ -0,0 +1,26 @@ +parsers = array_fill_keys([' ', "\r", "\n", "\t"], 'parseSpace'); + $this->parsers['['] = 'parseArray'; + $this->parsers['{'] = 'parseObject'; + $this->parsers['"'] = 'parseString'; + $this->parsers['t'] = 'parseTrue'; + $this->parsers['f'] = 'parseFalse'; + $this->parsers['n'] = 'parseNull'; + + foreach (str_split('0123456789.-') as $char) { + $this->parsers[$char] = 'parseNumber'; + } + + $this->onExtraToken = function ($text, $data, $reminding) { + echo 'Parsed JSON with extra tokens: '.json_encode(['text' => $text, 'data' => $data, 'reminding' => $reminding]); + }; + } + + public function parse($s, bool $associative = true) + { + if (strlen($s) >= 1) { + try { + return json_decode($s, $associative, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + [$data, $reminding] = $this->parseAny($s, $e); + $this->lastParseReminding = $reminding; + if ($this->onExtraToken && $reminding) { + call_user_func($this->onExtraToken, $s, $data, $reminding); + } + + return $data; + } + } else { + return json_decode('{}', $associative); + } + } + + private function parseAny($s, $e) + { + if (! $s) { + throw $e; + } + $parser = $this->parsers[$s[0]] ?? null; + if (! $parser) { + throw $e; + } + + return $this->{$parser}($s, $e); + } + + private function parseSpace($s, $e) + { + return $this->parseAny(trim($s), $e); + } + + private function parseArray($s, $e) + { + $s = substr($s, 1); // skip starting '[' + $acc = []; + $s = trim($s); + while ($s) { + if ($s[0] == ']') { + $s = substr($s, 1); // skip ending ']' + break; + } + [$res, $s] = $this->parseAny($s, $e); + $acc[] = $res; + $s = trim($s); + if (strpos($s, ',') === 0) { + $s = substr($s, 1); + $s = trim($s); + } + } + + return [$acc, $s]; + } + + private function parseObject($s, $e) + { + $s = substr($s, 1); // skip starting '{' + $acc = []; + $s = trim($s); + while ($s) { + if ($s[0] == '}') { + $s = substr($s, 1); // skip ending '}' + break; + } + [$key, $s] = $this->parseAny($s, $e); + $s = trim($s); + + if (! $s || $s[0] == '}') { + $acc[$key] = null; + break; + } + + if ($s[0] != ':') { + throw $e; + } + + $s = substr($s, 1); // skip ':' + $s = trim($s); + + if (! $s || in_array($s[0], [',', '}'])) { + $acc[$key] = null; + if (strpos($s, ',') === 0) { + $s = substr($s, 1); + } + break; + } + + [$value, $s] = $this->parseAny($s, $e); + $acc[$key] = $value; + $s = trim($s); + if (strpos($s, ',') === 0) { + $s = substr($s, 1); + $s = trim($s); + } + } + + return [$acc, $s]; + } + + private function parseString($s, $e) + { + $end = strpos($s, '"', 1); + while ($end !== false && $s[$end - 1] == '\\') { // Handle escaped quotes + $end = strpos($s, '"', $end + 1); + } + if ($end === false) { + // Return the incomplete string without the opening quote + return [substr($s, 1), '']; + } + $strVal = substr($s, 0, $end + 1); + $s = substr($s, $end + 1); + + return [json_decode($strVal), $s]; + } + + private function parseNumber($s, $e) + { + $i = 0; + while ($i < strlen($s) && strpos('0123456789.-', $s[$i]) !== false) { + $i++; + } + $numStr = substr($s, 0, $i); + $s = substr($s, $i); + if ($numStr == '' || substr($numStr, -1) == '.' || substr($numStr, -1) == '-') { + // Return the incomplete number as is + return [$numStr, '']; + } + if (strpos($numStr, '.') !== false || strpos($numStr, 'e') !== false || strpos($numStr, 'E') !== false) { + $num = floatval($numStr); + } else { + $num = intval($numStr); + } + + return [$num, $s]; + } + + private function parseTrue($s, $e) + { + if (substr($s, 0, 4) == 'true') { + return [true, substr($s, 4)]; + } + throw $e; + } + + private function parseFalse($s, $e) + { + if (substr($s, 0, 5) == 'false') { + return [false, substr($s, 5)]; + } + throw $e; + } + + private function parseNull($s, $e) + { + if (substr($s, 0, 4) == 'null') { + return [null, substr($s, 4)]; + } + throw $e; + } +} diff --git a/src/Services/MiscService.php b/src/Services/MiscService.php new file mode 100644 index 0000000..480675f --- /dev/null +++ b/src/Services/MiscService.php @@ -0,0 +1,171 @@ +toArray(); + + $payload = [ + 'data' => $resource_class::collection($paginated), + 'meta' => [ + 'from' => @$data['from'], + 'to' => @$data['to'], + 'total' => @$data['total'], + 'last_page' => @$data['last_page'], + 'current_page' => @$data['current_page'], + 'options' => (object) request('options'), + ], + ]; + + if ($meta) { + $payload['meta'] = array_merge($payload['meta'], $meta); + } + + return $payload; + } + + public static function bytesToHuman($bytes) + { + $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + + for ($i = 0; $bytes > 1024; $i++) { + $bytes /= 1024; + } + + return round($bytes, 2) . ' ' . $units[$i]; + } + + public static function deterministicEncrypt($data) + { + return base64_encode(openssl_encrypt($data, 'AES-128-ECB', config('app.key'), OPENSSL_RAW_DATA)); + } + + public static function deterministicDecrypt($encrypted) + { + return openssl_decrypt(base64_decode($encrypted), 'AES-128-ECB', config('app.key'), OPENSSL_RAW_DATA); + } + + public static function logExecutionTime( + string $logtext, + $callable = null + ) { + $start = microtime(true); + + if (!$callable) { + return; + } + + $result = $callable(); + $end = microtime(true); + + $executionTime = $end - $start; + + Log::debug($logtext, [ + 'execution_time' => $executionTime + ]); + + return $result; + } + + public static function getIpInformation($ip) + { + return once(function () use ($ip) { + return cache()->flexible('ipapi-' . $ip, [60 * 60 * 24 * 2, 60 * 60 * 24 * 7], function () use ($ip) { + $response = Http::get("https://ipapi.co/{$ip}/json/"); + + if ($response->failed()) { + return null; + } + + return $response->json(); + }); + }); + } + + public static function countryToCode($country_long): ?string + { + return match (str()->lower($country_long)) { + 'deutschland' => 'de', + 'österreich' => 'at', + 'schweiz' => 'ch', + 'spanien' => 'es', + 'luxemburg' => 'lu', + 'estland' => 'ee', + 'belgien' => 'be', + default => null, + }; + } + + public static function codeToCountry($country_code, string|null $locale = null) + { + $country_code = str()->lower($country_code); + $locale ??= app()->getLocale(); + + if ($locale === 'de') { + return match ($country_code) { + 'de' => 'Deutschland', + 'at' => 'Österreich', + 'ch' => 'Schweiz', + 'es' => 'Spanien', + 'lu' => 'Luxemburg', + 'ee' => 'Estland', + 'be' => 'Belgien', + default => null, + }; + } + + if ($locale === 'es') { + return match ($country_code) { + 'de' => 'Alemania', + 'at' => 'Austria', + 'ch' => 'Suiza', + 'es' => 'España', + 'lu' => 'Luxemburgo', + 'ee' => 'Estonia', + 'be' => 'Bélgica', + default => null, + }; + } + + if ($locale === 'uk') { + return match ($country_code) { + 'de' => 'Німеччина', + 'at' => 'Австрія', + 'ch' => 'Швейцарія', + 'es' => 'Іспанія', + 'lu' => 'Люксембург', + 'ee' => 'Естонія', + 'be' => 'Бельгія', + default => null, + }; + } + + return match ($country_code) { + 'de' => 'Germany', + 'at' => 'Austria', + 'ch' => 'Switzerland', + 'es' => 'Spain', + 'lu' => 'Luxembourg', + 'ee' => 'Estonia', + 'be' => 'Belgium', + default => null, + }; + } + + public static function parseIncompleteJson( + string $json, + bool $associative = true + ): array|object|null { + return (new IncompleteJsonService())->parse($json, $associative); + } +} diff --git a/src/Traits/WillExpire.php b/src/Traits/HasExpiration.php similarity index 100% rename from src/Traits/WillExpire.php rename to src/Traits/HasExpiration.php diff --git a/src/Traits/HasMeta.php b/src/Traits/HasMeta.php new file mode 100644 index 0000000..f0a1147 --- /dev/null +++ b/src/Traits/HasMeta.php @@ -0,0 +1,24 @@ +meta; + } + + public final function updateMetaKey($key, $value, bool $update = true): self + { + $meta = $this->getMeta(); + $meta->{$key} = $value; + $this->meta = (object) $meta; + + if ($update) { + $this->update(['meta' => $this->meta]); + } + + return $this; + } +} diff --git a/src/Traits/HasMetaTranslation.php b/src/Traits/HasMetaTranslation.php new file mode 100644 index 0000000..f6b5f58 --- /dev/null +++ b/src/Traits/HasMetaTranslation.php @@ -0,0 +1,326 @@ +meta; + $r->i18n ??= (object) []; + + if (is_array($r->i18n)) { + $r->i18n = (object) $r->i18n; + } + + foreach (config('app.i18n.supporting') as $lang => $longlang) { + if ($lang == 'en') { + continue; + } + + if (!isset($r->i18n->{$lang})) { + $r->i18n->{$lang} = (object) []; + } elseif (is_array($r->i18n->{$lang})) { + // Normalize any existing array locale bucket to object + $r->i18n->{$lang} = (object) $r->i18n->{$lang}; + } + } + + return $r; + } + + public function getLocalized($key, string|null $locale = null, bool $allowAttrFallback = true) + { + $locale = $locale ?: request()->get('locale') ?: app()->getLocale() ?: 'en'; + + $meta = $this->getMeta(); + $i18n = $meta->i18n ?? null; + + $get = function ($container, $prop) { + if ($container === null) { + return null; + } + if (is_array($container)) { + return $container[$prop] ?? null; + } + if (is_object($container)) { + return $container->{$prop} ?? null; + } + return null; + }; + + // Build recursive fallback chain from config + $fallbackMap = (array) config('app.i18n.fallback', []); + $chain = []; + $visited = []; + + $expand = function ($loc) use (&$expand, &$chain, &$visited, $fallbackMap) { + if (!$loc || isset($visited[$loc])) { + return; + } + $visited[$loc] = true; + $chain[] = $loc; + if (!isset($fallbackMap[$loc])) { + return; + } + $targets = (array) $fallbackMap[$loc]; + foreach ($targets as $t) { + $expand($t); + } + }; + + $expand($locale); + + // Always ensure 'en' ends up as last resort if not already included + if (!in_array('en', $chain, true)) { + $expand('en'); + } + + $value = null; + foreach ($chain as $loc) { + // Prefer normalized meta object path + $candidate = $get($get($i18n, $loc), $key); + if ($candidate === null || $candidate === '') { + // Try raw meta structure if different + $candidate = $get($get($get($this->meta ?? null, 'i18n'), $loc), $key); + } + if ($candidate !== null && $candidate !== '') { + $value = $candidate; + break; + } + } + + // Optional model attribute fallback (raw attribute array only to avoid recursion) + if (($value === null || $value === '') && $allowAttrFallback) { + $attr = $this->attributes[$key] ?? null; + if ($attr !== null && $attr !== '') { + $value = $attr; + } + } + + // Apply model casts (e.g., json) when present so localized values respect attribute casting + if ($value !== null && $value !== '') { + try { + $casts = method_exists($this, 'getCasts') ? $this->getCasts() : (property_exists($this, 'casts') ? (array) $this->casts : []); + $cast = $casts[$key] ?? null; + $castStr = is_string($cast) ? strtolower($cast) : ''; + + $isJsonLike = (function ($t) { + if (!$t) return false; + $t = strtolower($t); + return $t === 'array' || $t === 'object' || $t === 'collection' || str_contains($t, 'json'); + })($castStr); + + if ($isJsonLike) { + if (method_exists($this, 'hasCast') && method_exists($this, 'castAttribute') && $this->hasCast($key)) { + // Let Eloquent do the casting for consistency + $value = $this->castAttribute($key, $value); + } else { + // Minimal manual handling for json/array/object casts + if (is_string($value)) { + $assoc = ($castStr === 'array' || str_contains($castStr, 'json')); + $decoded = json_decode($value, $assoc); + if (json_last_error() === JSON_ERROR_NONE) { + $value = $decoded; + } + } elseif (is_object($value) && ($castStr === 'array' || str_contains($castStr, 'json'))) { + $value = json_decode(json_encode($value), true); + } + } + } + } catch (\Throwable $e) { + // Swallow casting errors to avoid breaking localization + } + } + + return $value; + } + + public function setLocalized($key, $value, string|null $locale = null, bool $save = false) + { + $locale = $locale ?: request()->get('locale') ?: app()->getLocale() ?: 'en'; + + $meta = $this->getMeta(); + $i18n = (object) ($meta->i18n ?? []); + + if ( + $locale == 'en' + && array_key_exists($key, $this->attributes) + ) { + $this->attributes[$key] = $value; + } + + if (!isset($i18n->{$locale})) { + $i18n->{$locale} = (object) []; + } elseif (is_array($i18n->{$locale})) { + // Normalize existing array to object before property assignment + $i18n->{$locale} = (object) $i18n->{$locale}; + } + + $i18n->{$locale}->{$key} = $value; + + $meta->i18n = $i18n; + $this->meta = (object) $meta; + + if ($save) { + $this->update(['meta' => $this->meta]); + } + + return $this; + } + + public function wipeTranslation($locale = 'all') + { + $meta = $this->getMeta(); + $i18n = (object) ($meta->i18n ?? []); + + if ($locale === 'all') { + // Wipe all translations + $i18n = (object) []; + } elseif (isset($i18n->{$locale})) { + // Wipe specific locale + $i18n->{$locale} = (object) []; + } + + $meta->i18n = $i18n; + $this->meta = (object) $meta; + + $this->update(['meta' => $this->meta]); + + return $this; + } + + /** + * Returns all supported languages that are still an empty object + * */ + public function getMissingTranslationLanguagesAttribute() + { + $meta = $this->getMeta(); + $i18n = (object) ($meta->i18n ?? []); + + $missing = []; + + foreach (config('app.i18n.supporting') as $lang => $longlang) { + if ($lang == 'en') { + continue; + } + + $container = $i18n->{$lang} ?? null; + + if ($container === null) { + $missing[] = $lang; + } elseif (is_array($container) && count($container) === 0) { + $missing[] = $lang; + } elseif (is_object($container) && count(get_object_vars($container)) === 0) { + $missing[] = $lang; + } + } + + return $missing; + } + + public final function getMissingTranslationKeysAttribute() + { + // assume static variable $translatables, where keys of translatable fillables are listed + $missing = []; + $translatables = property_exists($this, 'translatables') ? (array) $this->translatables : []; + $supported_langs = array_keys(config('app.i18n.supporting', [])); + + // get translatables from meta i18n as well + $meta = $this->getMeta(); + $i18n_keys = array_keys((array) (((object) ($meta->i18n ?? []))?->en ?? [])); + $translatables = array_unique(array_merge($translatables, $i18n_keys)); + + + foreach ($translatables as $key) { + foreach ($supported_langs as $lang) { + if ($lang == 'en') { + continue; + } + + $value = $this->getLocalized($key, $lang, false); + + if ( + $value == null + || $value == '' + || $value == $this->getLocalized($key, 'en', false) + ) { + $missing[$key][] = $lang; + } + } + } + + return $missing; + } + + public final function getMissingKeyLanguagesAttribute() + { + $missing = $this->missing_translation_keys; + $result = []; + + foreach ($missing as $key => $langs) { + foreach ($langs as $lang) { + $result[$lang][] = $key; + } + } + + return $result; + } + + // Generic dynamic attribute fallback to localization (replaces specific title accessor) + public function __get($key) + { + $value = parent::__get($key); + + if (($value === null || $value === '') + && !in_array($key, ['meta', 'i18n']) + && empty($this->__localizedFallbackGuard[$key]) + ) { + $this->__localizedFallbackGuard[$key] = true; + try { + $localized = $this->getLocalized($key, null, false); + } finally { + unset($this->__localizedFallbackGuard[$key]); + } + if ($localized !== null && $localized !== '') { + return $localized; + } + } + + return $value; + } + + public function scopeWhereI18n($query, $key, $value, $locale = null) + { + if ($locale) { + $locale = $locale ?: app()->getLocale() ?: 'en'; + return $query->where("meta->i18n->{$locale}->{$key}", $value); + } else { + return $query->where(function ($q) use ($key, $value) { + foreach (array_keys(config('app.i18n.supporting', [])) as $locale) { + $q->orWhere("meta->i18n->{$locale}->{$key}", $value); + } + }); + } + } + + public function scopeOrWhereI18n($query, $key, $value, $locale = null) + { + if ($locale) { + $locale = $locale ?: app()->getLocale() ?: 'en'; + return $query->orWhere("meta->i18n->{$locale}->{$key}", $value); + } else { + return $query->orWhere(function ($q) use ($key, $value) { + foreach (array_keys(config('app.i18n.supporting', [])) as $locale) { + $q->orWhere("meta->i18n->{$locale}->{$key}", $value); + } + }); + } + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..7a13df6 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,16 @@ +