diff --git a/composer.json b/composer.json index 09a1774..0a36ed3 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ "providers": [ "Modules\\Core\\Providers\\CoreServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider", - "Modules\\Core\\Providers\\CustomerServiceProvider" + "Modules\\Core\\Providers\\CustomerServiceProvider", + "Modules\\Core\\Providers\\LocalizationServiceProvider" ] } }, diff --git a/docs/localization.md b/docs/localization.md index bf1a7b5..18fe74d 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -63,10 +63,35 @@ fine — just keep admin/Livewire/webhook routes registered outside of it (as th - the first language row, as a last resort. The language list is cached with `Cache::rememberForever()` under `core.localization.languages` -and invalidated automatically by `Modules\Core\Localization\LanguageCacheObserver`, which -observes `Lunar\Models\Language` `saved`/`deleted` events. Adding, editing, or removing a -language via the Filament **Languages** resource clears the cache immediately — no TTL, no -stale reads. +and invalidated automatically. Adding, editing, or removing a language via the Filament +**Languages** resource clears the cache immediately — no TTL, no stale reads. + +### How invalidation is wired (event-driven, not the observer itself) + +`Modules\Core\Localization\LanguageCacheObserver` observes `Lunar\Models\Language`'s +`created`/`updated`/`deleted` Eloquent events, but it's a thin trigger only — it doesn't do any +invalidation work itself. It dispatches one of three events from +`Modules\Core\Localization\Events` (`LanguageCreated`, `LanguageUpdated` — carrying the old +`code` — or `LanguageDeleted`), and two listeners, wired in +`Modules\Core\Providers\LocalizationServiceProvider`, react: + +- **`FlushLanguageCache`** — flushes `core.localization.languages` on all three events. +- **`MigrateTranslationsForRenamedLanguage`** — `LanguageUpdated` only, and only when `code` + actually changed. A renamed `Language::code` (e.g. `el` → `gr`) would otherwise strand every + `LanguageLine`'s translated text under the old, now-unroutable key — + `getTranslationsForGroup('gr', ...)` would silently return nothing for that locale even though + the translated content still exists. This listener moves the `text.{oldCode}` key to + `text.{newCode}` on every affected `LanguageLine` row and flushes both the old and new code's + translation cache for every group touched. + +Deleting a `Language` only flushes the language-list cache — `LanguageLine.text` keys for the +deleted code are left in place rather than destructively erased, in case the language is ever +re-added under the same code. + +Splitting cache-flush and text-migration into separate listeners (rather than one +`LanguageCacheObserver` method doing both) mirrors the same event → listener pattern used for +`TranslationService`'s writes below — the observer only detects *what happened*, listeners own +*what to do about it*. --- @@ -133,20 +158,58 @@ English + Greek) is seeded by `Modules\Core\Command\InstallLunarCommand` (overri `lunar:install`), guarded by `LanguageLine::where('group', 'storefront')->exists()` — same idempotent pattern as the rest of that command, safe to run unattended on every boot. -**Editing existing labels or adding new ones is a normal Eloquent operation**, not a -re-seed: +### Admin UI + +`Modules\Core\Localization\Filament\Resources\LanguageLineResource` (registered in +`CorePlugin`, under the panel's Settings group) lists/searches/filters `language_lines` and +edits each row's `group`, `key`, and one text input per row currently in `lunar_languages` — +the locale columns are generated dynamically from `Language::query()->pluck('code')`, so adding +a third language automatically adds a third input, no resource changes needed. + +### `TranslationService` — writes go through here, not the model directly + +`Modules\Core\Localization\TranslationService` wraps create/update/delete on `LanguageLine` and +dispatches a domain event after each write, following this project's standard event-driven +pattern (see `modules.md`'s "Splitting Service Providers" / event-listener convention — +the same shape as `Modules\Core\Auth\Events\UserCreated`): ```php -use Spatie\TranslationLoader\LanguageLine; +use Modules\Core\Localization\TranslationService; -LanguageLine::create([ - 'group' => 'storefront', - 'key' => 'nav.wishlist', - 'text' => ['en' => 'Wishlist', 'el' => 'Λίστα Επιθυμιών'], +app(TranslationService::class)->create('storefront', 'nav.wishlist', [ + 'en' => 'Wishlist', + 'el' => 'Λίστα Επιθυμιών', ]); + +app(TranslationService::class)->update( + $languageLine, + 'storefront', + 'nav.wishlist', + ['en' => 'Wishlist ♥', 'el' => 'Λίστα Επιθυμιών ♥'], +); + +app(TranslationService::class)->delete($languageLine); ``` -There is currently no Filament resource for editing `language_lines` — labels are edited via -tinker/seeder until one is built. `LanguageLine` caches per group+locale -(`Cache::rememberForever`) and flushes itself automatically on `saved`/`deleted`, so edits take -effect immediately with no manual cache-clear step. +`update()` takes the full `group`/`key`/`text` state, not just `text` — a rename is a normal +update, not a special case. `TranslationCreated`, `TranslationUpdated` (carries the full +`{group, key, text}` snapshot from *before* the update, so a listener can tell a rename from a +text edit), and `TranslationDeleted` are dispatched from `Modules\Core\Localization\Events`. Two +listeners are wired in `Modules\Core\Providers\LocalizationServiceProvider` for all three events: + +- **`FlushTranslationCache`** — `LanguageLine::boot()` already flushes the cache for the + *current* group's locales present after a save, but misses two cases on update: locales a save + *removed* from `text` (e.g. dropping the `el` key leaves `storefront.el` stale), and a changed + `group`/`key` (the *old* group's cached array is never told a row left it). This listener + flushes every group+locale combination touched by either the old or new state, so nothing — + including the group a row was renamed away from — can remain stale. +- **`LogTranslationActivity`** — records the change via `Modules\Core\Logging\ActivityLogService` + on the `lunar` activity log channel, same `created`/`updated`/`deleted` shape as every other + domain write in this project. A rename shows up in the log as an `old`/`attributes` diff across + `group`, `key`, and `text` together, not just a text diff. + +The Filament resource's Create/Edit/Delete pages route through `TranslationService` (via +`handleRecordCreation`/`handleRecordUpdate`/the delete action's `->action()` override) rather +than Filament's default direct-model calls, so **every** edit made in the admin UI — including a +bare `group`/`key` rename with no `text` change — dispatches `TranslationUpdated` and is both +cache-invalidated and audit-logged. diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 38a356f..ef0bd4a 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -15,6 +15,7 @@ use Lunar\Shipping\ShippingPlugin; use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Mail\InviteMail; +use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Review\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; @@ -32,6 +33,9 @@ class CorePlugin implements Plugin ->brandLogo(asset('static/logos/core/boboko-logo.svg')) ->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg')) ->login(Login::class) + ->resources([ + LanguageLineResource::class, + ]) ->plugin(ShippingPlugin::make()); LunarPanel::extensions([ diff --git a/src/Localization/Events/LanguageCreated.php b/src/Localization/Events/LanguageCreated.php new file mode 100644 index 0000000..d9aabf0 --- /dev/null +++ b/src/Localization/Events/LanguageCreated.php @@ -0,0 +1,12 @@ +schema([ + Forms\Components\TextInput::make('group') + ->required() + ->maxLength(255) + ->default('storefront') + ->helperText('Namespace for this label, e.g. "storefront" for e-shop UI text.'), + + Forms\Components\TextInput::make('key') + ->required() + ->maxLength(255) + ->helperText('Dot-notation key, e.g. "nav.cart".'), + + Forms\Components\Fieldset::make('Translations') + ->schema(static::localeInputs()), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('group') + ->badge() + ->sortable(), + Tables\Columns\TextColumn::make('key') + ->searchable() + ->sortable(), + ...static::localeColumns(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('group') + ->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')), + ]) + ->defaultSort('key'); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListLanguageLines::route('/'), + 'create' => Pages\CreateLanguageLine::route('/create'), + 'edit' => Pages\EditLanguageLine::route('/{record}/edit'), + ]; + } + + /** + * @return array + */ + private static function localeInputs(): array + { + return static::localeCodes() + ->map(fn (string $code) => Forms\Components\Textarea::make("text.{$code}") + ->label(strtoupper($code)) + ->rows(2)) + ->all(); + } + + /** + * @return array + */ + private static function localeColumns(): array + { + return static::localeCodes() + ->map(fn (string $code) => Tables\Columns\TextColumn::make("text.{$code}") + ->label(strtoupper($code)) + ->limit(40) + ->toggleable()) + ->all(); + } + + private static function localeCodes(): \Illuminate\Support\Collection + { + return Language::query()->pluck('code'); + } +} diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php new file mode 100644 index 0000000..7870fe2 --- /dev/null +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php @@ -0,0 +1,22 @@ +create( + $data['group'], + $data['key'], + $data['text'] ?? [], + ); + } +} diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php new file mode 100644 index 0000000..80c864a --- /dev/null +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php @@ -0,0 +1,53 @@ +action(function (LanguageLine $record) { + app(TranslationService::class)->delete($record); + + $this->redirect($this->getResource()::getUrl('index')); + }), + ]; + } + + /** + * Filament's default Cancel button uses window.history.back(), which + * restores the browser's cached previous page instead of re-fetching — + * so an edit made just before clicking Cancel doesn't show up in the + * list until a manual refresh. Redirect through Livewire instead, which + * always re-queries. + */ + protected function getCancelFormAction(): Action + { + return Action::make('cancel') + ->label(__('filament-panels::resources/pages/edit-record.form.actions.cancel.label')) + ->url(static::getResource()::getUrl('index')) + ->color('gray'); + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + return app(TranslationService::class)->update( + $record, + $data['group'], + $data['key'], + $data['text'] ?? [], + ); + } +} diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/ListLanguageLines.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/ListLanguageLines.php new file mode 100644 index 0000000..12c7b37 --- /dev/null +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/ListLanguageLines.php @@ -0,0 +1,19 @@ + $language->getOriginal('code'), + ])); + } + + public function deleted(Language $language): void + { + Event::dispatch(new LanguageDeleted($language)); } } diff --git a/src/Localization/Listeners/FlushLanguageCache.php b/src/Localization/Listeners/FlushLanguageCache.php new file mode 100644 index 0000000..575811c --- /dev/null +++ b/src/Localization/Listeners/FlushLanguageCache.php @@ -0,0 +1,16 @@ +flush($event->languageLine->group, array_keys($event->languageLine->text ?? [])); + + if ($event instanceof TranslationUpdated) { + $this->flush($event->old['group'], array_keys($event->old['text'] ?? [])); + } + } + + private function flush(string $group, array $locales): void + { + foreach ($locales as $locale) { + Cache::forget(LanguageLine::getCacheKey($group, $locale)); + } + } +} diff --git a/src/Localization/Listeners/LogTranslationActivity.php b/src/Localization/Listeners/LogTranslationActivity.php new file mode 100644 index 0000000..3a83b7b --- /dev/null +++ b/src/Localization/Listeners/LogTranslationActivity.php @@ -0,0 +1,53 @@ +languageLine; + + match (true) { + $event instanceof TranslationCreated => $this->activityLog->created( + $languageLine, + $this->flatten($languageLine), + ), + $event instanceof TranslationUpdated => $this->activityLog->updated( + $languageLine, + Arr::dot($event->old), + $this->flatten($languageLine), + ), + $event instanceof TranslationDeleted => $this->activityLog->deleted( + $languageLine, + $this->flatten($languageLine), + ), + }; + } + + /** + * Filament's Activity resource renders `properties` with a flat KeyValue + * field, which can't display a nested value like `text: {en, el}` — it + * shows as "[object Object]". Flatten to dot-notation ("text.en", + * "text.el") so every property is a plain string, viewable as-is. + */ + private function flatten(LanguageLine $languageLine): array + { + return Arr::dot([ + 'group' => $languageLine->group, + 'key' => $languageLine->key, + 'text' => $languageLine->text, + ]); + } +} diff --git a/src/Localization/Listeners/MigrateTranslationsForRenamedLanguage.php b/src/Localization/Listeners/MigrateTranslationsForRenamedLanguage.php new file mode 100644 index 0000000..046fd23 --- /dev/null +++ b/src/Localization/Listeners/MigrateTranslationsForRenamedLanguage.php @@ -0,0 +1,46 @@ + "gr") would otherwise strand every + * LanguageLine's translated text under the old, now-unroutable key — + * getTranslationsForGroup($newCode, ...) would silently return nothing for + * that locale even though the translated content still exists. Move the + * text.{oldCode} key to text.{newCode} on every affected row instead. + */ +class MigrateTranslationsForRenamedLanguage +{ + public function handle(LanguageUpdated $event): void + { + $oldCode = $event->old['code']; + $newCode = $event->language->code; + + if ($oldCode === $newCode) { + return; + } + + $affectedGroups = []; + + LanguageLine::query() + ->whereJsonContainsKey('text->'.$oldCode) + ->each(function (LanguageLine $languageLine) use ($oldCode, $newCode, &$affectedGroups) { + $text = $languageLine->text; + $text[$newCode] = $text[$oldCode]; + unset($text[$oldCode]); + + $languageLine->update(['text' => $text]); + + $affectedGroups[$languageLine->group] = true; + }); + + foreach (array_keys($affectedGroups) as $group) { + Cache::forget(LanguageLine::getCacheKey($group, $oldCode)); + Cache::forget(LanguageLine::getCacheKey($group, $newCode)); + } + } +} diff --git a/src/Localization/TranslationReader.php b/src/Localization/TranslationReader.php new file mode 100644 index 0000000..4f57c70 --- /dev/null +++ b/src/Localization/TranslationReader.php @@ -0,0 +1,22 @@ + 'Cart', 'nav.home' => 'Home']. + * Backed by LanguageLine's own forever-cache, so this is a cache hit after + * the first call for a given group+locale. + */ + public function group(string $group = self::DEFAULT_GROUP, ?string $locale = null): array + { + return LanguageLine::getTranslationsForGroup($locale ?? App::getLocale(), $group); + } +} diff --git a/src/Localization/TranslationService.php b/src/Localization/TranslationService.php new file mode 100644 index 0000000..b6bf271 --- /dev/null +++ b/src/Localization/TranslationService.php @@ -0,0 +1,57 @@ + $group, + 'key' => $key, + 'text' => $text, + ]); + + Event::dispatch(new TranslationCreated($languageLine)); + + return $languageLine; + } + + public function update(LanguageLine $languageLine, string $group, string $key, array $text): LanguageLine + { + // Callers (e.g. Filament's EditRecord) may hand us a model instance + // already filled with the new form values in memory — refresh from the + // database first so $old reflects what's actually persisted, not what's + // about to be written. + $persisted = $languageLine->fresh(); + + $old = [ + 'group' => $persisted->group, + 'key' => $persisted->key, + 'text' => $persisted->text, + ]; + + $languageLine->update([ + 'group' => $group, + 'key' => $key, + 'text' => $text, + ]); + + Event::dispatch(new TranslationUpdated($languageLine, $old)); + + return $languageLine; + } + + public function delete(LanguageLine $languageLine): void + { + $languageLine->delete(); + + Event::dispatch(new TranslationDeleted($languageLine)); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index a1d3303..0574c40 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -4,15 +4,12 @@ namespace Modules\Core\Providers; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; -use Lunar\Models\Language; use Modules\Core\Command\AnonymizeCommand; use Modules\Core\Command\ExportCleanupCommand; use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ImportCommand; use Modules\Core\Command\InstallLunarCommand; use Modules\Core\Command\MigrateImportCommand; -use Modules\Core\Localization\LanguageCacheObserver; -use Modules\Core\Localization\LocaleMiddleware; class CoreServiceProvider extends ServiceProvider { @@ -27,9 +24,6 @@ class CoreServiceProvider extends ServiceProvider Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - $this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class); - Language::observe(LanguageCacheObserver::class); - $this->publishes([ __DIR__ . '/../../config/core.php' => config_path('core.php'), __DIR__ . '/../../config/scout.php' => config_path('scout.php'), diff --git a/src/Providers/LocalizationServiceProvider.php b/src/Providers/LocalizationServiceProvider.php new file mode 100644 index 0000000..d2392eb --- /dev/null +++ b/src/Providers/LocalizationServiceProvider.php @@ -0,0 +1,39 @@ +app['router']->aliasMiddleware('locale', LocaleMiddleware::class); + Language::observe(LanguageCacheObserver::class); + + foreach ([TranslationCreated::class, TranslationUpdated::class, TranslationDeleted::class] as $event) { + Event::listen($event, FlushTranslationCache::class); + Event::listen($event, LogTranslationActivity::class); + } + + foreach ([LanguageCreated::class, LanguageUpdated::class, LanguageDeleted::class] as $event) { + Event::listen($event, FlushLanguageCache::class); + } + + Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class); + } +}