AI extended workkit

This commit is contained in:
Fabian Wagner 2025-11-20 15:53:34 +01:00
parent 4b61653d2d
commit 84cc1d15d4
8 changed files with 770 additions and 3 deletions

View File

@ -16,21 +16,27 @@
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Blax\\Workkit\\": "src" "Blax\\Workkit\\": "src"
} },
"files": [
"src/helpers.php"
]
}, },
"config": { "config": {
"sort-packages": true "sort-packages": true
}, },
"require": { "require": {
"php": ">=8.0", "php": ">=8.0",
"laravel/framework": "*" "laravel/framework": "*",
"spatie/once": "*"
}, },
"require-dev": { "require-dev": {
"laravel/pint": "^1.22" "laravel/pint": "^1.22"
}, },
"extra": { "extra": {
"laravel": { "laravel": {
"providers": [] "providers": [
"Blax\\Workkit\\WorkkitServiceProvider"
]
} }
}, },
"minimum-stability": "dev", "minimum-stability": "dev",

View File

@ -0,0 +1,26 @@
<?php
namespace Blax\Workkit;
class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap the application events.
*
* @return void
*/
public function boot()
{
//
}
}

View File

@ -0,0 +1,198 @@
<?php
namespace App\Services;
use JsonException;
class IncompleteJsonService
{
private $parsers = [];
private $lastParseReminding = null;
private $onExtraToken;
public function __construct()
{
$this->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;
}
}

View File

@ -0,0 +1,171 @@
<?php
namespace Blax\Workkit\Services;
use App\Services\IncompleteJsonService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MiscService
{
public static function asPaginated(
$paginated,
$resource_class,
array $meta = []
) {
$data = $paginated->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);
}
}

24
src/Traits/HasMeta.php Normal file
View File

@ -0,0 +1,24 @@
<?php
namespace App\Traits;
trait HasMeta
{
public function getMeta(): object
{
return (object) $this->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;
}
}

View File

@ -0,0 +1,326 @@
<?php
namespace App\Traits;
use App\Services\IncompleteJsonService;
trait HasMetaTranslation
{
use HasMeta;
protected array $__localizedFallbackGuard = [];
public function getMeta(): object
{
$r = (object) $this->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);
}
});
}
}
}

16
src/helpers.php Normal file
View File

@ -0,0 +1,16 @@
<?php
use Blax\Workkit\Services\MiscService;
if (!function_exists('misc')) {
function misc(): MiscService
{
static $instance;
if (!$instance) {
$instance = new MiscService();
}
return $instance;
}
}