Feature: Adding Translation Service and Translation Seeder
This commit is contained in:
+2
-1
@@ -34,7 +34,8 @@
|
|||||||
"providers": [
|
"providers": [
|
||||||
"Modules\\Core\\Providers\\CoreServiceProvider",
|
"Modules\\Core\\Providers\\CoreServiceProvider",
|
||||||
"Modules\\Core\\Providers\\AuthServiceProvider",
|
"Modules\\Core\\Providers\\AuthServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CustomerServiceProvider"
|
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
||||||
|
"Modules\\Core\\Providers\\LocalizationServiceProvider"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+78
-15
@@ -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 first language row, as a last resort.
|
||||||
|
|
||||||
The language list is cached with `Cache::rememberForever()` under `core.localization.languages`
|
The language list is cached with `Cache::rememberForever()` under `core.localization.languages`
|
||||||
and invalidated automatically by `Modules\Core\Localization\LanguageCacheObserver`, which
|
and invalidated automatically. Adding, editing, or removing a language via the Filament
|
||||||
observes `Lunar\Models\Language` `saved`/`deleted` events. Adding, editing, or removing a
|
**Languages** resource clears the cache immediately — no TTL, no stale reads.
|
||||||
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
|
`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.
|
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
|
### Admin UI
|
||||||
re-seed:
|
|
||||||
|
`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
|
```php
|
||||||
use Spatie\TranslationLoader\LanguageLine;
|
use Modules\Core\Localization\TranslationService;
|
||||||
|
|
||||||
LanguageLine::create([
|
app(TranslationService::class)->create('storefront', 'nav.wishlist', [
|
||||||
'group' => 'storefront',
|
'en' => 'Wishlist',
|
||||||
'key' => 'nav.wishlist',
|
'el' => 'Λίστα Επιθυμιών',
|
||||||
'text' => ['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
|
`update()` takes the full `group`/`key`/`text` state, not just `text` — a rename is a normal
|
||||||
tinker/seeder until one is built. `LanguageLine` caches per group+locale
|
update, not a special case. `TranslationCreated`, `TranslationUpdated` (carries the full
|
||||||
(`Cache::rememberForever`) and flushes itself automatically on `saved`/`deleted`, so edits take
|
`{group, key, text}` snapshot from *before* the update, so a listener can tell a rename from a
|
||||||
effect immediately with no manual cache-clear step.
|
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.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use Lunar\Shipping\ShippingPlugin;
|
|||||||
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
||||||
use Modules\Core\Auth\Filament\Pages\Login;
|
use Modules\Core\Auth\Filament\Pages\Login;
|
||||||
use Modules\Core\Auth\Mail\InviteMail;
|
use Modules\Core\Auth\Mail\InviteMail;
|
||||||
|
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||||
use Modules\Core\Review\Extensions\ProductResourceExtension;
|
use Modules\Core\Review\Extensions\ProductResourceExtension;
|
||||||
use Modules\Core\Review\Models\ProductReview;
|
use Modules\Core\Review\Models\ProductReview;
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ class CorePlugin implements Plugin
|
|||||||
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
|
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
|
||||||
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
|
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
|
->resources([
|
||||||
|
LanguageLineResource::class,
|
||||||
|
])
|
||||||
->plugin(ShippingPlugin::make());
|
->plugin(ShippingPlugin::make());
|
||||||
|
|
||||||
LunarPanel::extensions([
|
LunarPanel::extensions([
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Events;
|
||||||
|
|
||||||
|
use Lunar\Models\Language;
|
||||||
|
|
||||||
|
class LanguageCreated
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly Language $language,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Events;
|
||||||
|
|
||||||
|
use Lunar\Models\Language;
|
||||||
|
|
||||||
|
class LanguageDeleted
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly Language $language,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Events;
|
||||||
|
|
||||||
|
use Lunar\Models\Language;
|
||||||
|
|
||||||
|
class LanguageUpdated
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{code: string} $old Snapshot of watched attributes before the update.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly Language $language,
|
||||||
|
public readonly array $old,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Events;
|
||||||
|
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
class TranslationCreated
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly LanguageLine $languageLine,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Events;
|
||||||
|
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
class TranslationDeleted
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly LanguageLine $languageLine,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Events;
|
||||||
|
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
class TranslationUpdated
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{group: string, key: string, text: array} $old Snapshot before the update.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly LanguageLine $languageLine,
|
||||||
|
public readonly array $old,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Filament\Resources;
|
||||||
|
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Lunar\Models\Language;
|
||||||
|
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
class LanguageLineResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = LanguageLine::class;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-language';
|
||||||
|
|
||||||
|
protected static ?string $navigationGroup = 'Settings';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'Translation';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'Translations';
|
||||||
|
|
||||||
|
public static function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form->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<Forms\Components\Textarea>
|
||||||
|
*/
|
||||||
|
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<Tables\Columns\TextColumn>
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
|
||||||
|
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||||
|
use Modules\Core\Localization\TranslationService;
|
||||||
|
|
||||||
|
class CreateLanguageLine extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = LanguageLineResource::class;
|
||||||
|
|
||||||
|
protected function handleRecordCreation(array $data): Model
|
||||||
|
{
|
||||||
|
return app(TranslationService::class)->create(
|
||||||
|
$data['group'],
|
||||||
|
$data['key'],
|
||||||
|
$data['text'] ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
|
||||||
|
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||||
|
use Modules\Core\Localization\TranslationService;
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
class EditLanguageLine extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = LanguageLineResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->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'] ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
|
||||||
|
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||||
|
|
||||||
|
class ListLanguageLines extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = LanguageLineResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,28 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Localization;
|
namespace Modules\Core\Localization;
|
||||||
|
|
||||||
use Lunar\Models\Contracts\Language as LanguageContract;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Lunar\Models\Language;
|
||||||
|
use Modules\Core\Localization\Events\LanguageCreated;
|
||||||
|
use Modules\Core\Localization\Events\LanguageDeleted;
|
||||||
|
use Modules\Core\Localization\Events\LanguageUpdated;
|
||||||
|
|
||||||
class LanguageCacheObserver
|
class LanguageCacheObserver
|
||||||
{
|
{
|
||||||
public function saved(LanguageContract $language): void
|
public function created(Language $language): void
|
||||||
{
|
{
|
||||||
LocaleMiddleware::forgetLanguagesCache();
|
Event::dispatch(new LanguageCreated($language));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleted(LanguageContract $language): void
|
public function updated(Language $language): void
|
||||||
{
|
{
|
||||||
LocaleMiddleware::forgetLanguagesCache();
|
Event::dispatch(new LanguageUpdated($language, [
|
||||||
|
'code' => $language->getOriginal('code'),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleted(Language $language): void
|
||||||
|
{
|
||||||
|
Event::dispatch(new LanguageDeleted($language));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Listeners;
|
||||||
|
|
||||||
|
use Modules\Core\Localization\Events\LanguageCreated;
|
||||||
|
use Modules\Core\Localization\Events\LanguageDeleted;
|
||||||
|
use Modules\Core\Localization\Events\LanguageUpdated;
|
||||||
|
use Modules\Core\Localization\LocaleMiddleware;
|
||||||
|
|
||||||
|
class FlushLanguageCache
|
||||||
|
{
|
||||||
|
public function handle(LanguageCreated|LanguageUpdated|LanguageDeleted $event): void
|
||||||
|
{
|
||||||
|
LocaleMiddleware::forgetLanguagesCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Listeners;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Modules\Core\Localization\Events\TranslationCreated;
|
||||||
|
use Modules\Core\Localization\Events\TranslationDeleted;
|
||||||
|
use Modules\Core\Localization\Events\TranslationUpdated;
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LanguageLine::boot() already flushes the cache for the current group's locales
|
||||||
|
* present after a save, but misses two cases on update: locales removed from
|
||||||
|
* `text` (e.g. dropping the "el" key leaves `{group}.el` stale), and a changed
|
||||||
|
* `group`/`key` (the old group's cached array never gets told a row left it).
|
||||||
|
* This listener flushes every group+locale combination touched by either the
|
||||||
|
* old or new state so nothing can remain stale.
|
||||||
|
*/
|
||||||
|
class FlushTranslationCache
|
||||||
|
{
|
||||||
|
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
|
||||||
|
{
|
||||||
|
$this->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Listeners;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Modules\Core\Localization\Events\TranslationCreated;
|
||||||
|
use Modules\Core\Localization\Events\TranslationDeleted;
|
||||||
|
use Modules\Core\Localization\Events\TranslationUpdated;
|
||||||
|
use Modules\Core\Logging\ActivityLogService;
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
class LogTranslationActivity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ActivityLogService $activityLog,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
|
||||||
|
{
|
||||||
|
$languageLine = $event->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Listeners;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Modules\Core\Localization\Events\LanguageUpdated;
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A renamed Language::code (e.g. "el" -> "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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
class TranslationReader
|
||||||
|
{
|
||||||
|
private const DEFAULT_GROUP = 'storefront';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All labels in a group for the given (or current) locale, keyed by their
|
||||||
|
* dot-notation key — e.g. ['nav.cart' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Modules\Core\Localization\Events\TranslationCreated;
|
||||||
|
use Modules\Core\Localization\Events\TranslationDeleted;
|
||||||
|
use Modules\Core\Localization\Events\TranslationUpdated;
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
class TranslationService
|
||||||
|
{
|
||||||
|
public function create(string $group, string $key, array $text): LanguageLine
|
||||||
|
{
|
||||||
|
$languageLine = LanguageLine::create([
|
||||||
|
'group' => $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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,12 @@ namespace Modules\Core\Providers;
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Lunar\Models\Language;
|
|
||||||
use Modules\Core\Command\AnonymizeCommand;
|
use Modules\Core\Command\AnonymizeCommand;
|
||||||
use Modules\Core\Command\ExportCleanupCommand;
|
use Modules\Core\Command\ExportCleanupCommand;
|
||||||
use Modules\Core\Command\ExportCommand;
|
use Modules\Core\Command\ExportCommand;
|
||||||
use Modules\Core\Command\ImportCommand;
|
use Modules\Core\Command\ImportCommand;
|
||||||
use Modules\Core\Command\InstallLunarCommand;
|
use Modules\Core\Command\InstallLunarCommand;
|
||||||
use Modules\Core\Command\MigrateImportCommand;
|
use Modules\Core\Command\MigrateImportCommand;
|
||||||
use Modules\Core\Localization\LanguageCacheObserver;
|
|
||||||
use Modules\Core\Localization\LocaleMiddleware;
|
|
||||||
|
|
||||||
class CoreServiceProvider extends ServiceProvider
|
class CoreServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -27,9 +24,6 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
|
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
||||||
|
|
||||||
$this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class);
|
|
||||||
Language::observe(LanguageCacheObserver::class);
|
|
||||||
|
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../../config/core.php' => config_path('core.php'),
|
__DIR__ . '/../../config/core.php' => config_path('core.php'),
|
||||||
__DIR__ . '/../../config/scout.php' => config_path('scout.php'),
|
__DIR__ . '/../../config/scout.php' => config_path('scout.php'),
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Lunar\Models\Language;
|
||||||
|
use Modules\Core\Localization\Events\LanguageCreated;
|
||||||
|
use Modules\Core\Localization\Events\LanguageDeleted;
|
||||||
|
use Modules\Core\Localization\Events\LanguageUpdated;
|
||||||
|
use Modules\Core\Localization\Events\TranslationCreated;
|
||||||
|
use Modules\Core\Localization\Events\TranslationDeleted;
|
||||||
|
use Modules\Core\Localization\Events\TranslationUpdated;
|
||||||
|
use Modules\Core\Localization\LanguageCacheObserver;
|
||||||
|
use Modules\Core\Localization\Listeners\FlushLanguageCache;
|
||||||
|
use Modules\Core\Localization\Listeners\FlushTranslationCache;
|
||||||
|
use Modules\Core\Localization\Listeners\LogTranslationActivity;
|
||||||
|
use Modules\Core\Localization\Listeners\MigrateTranslationsForRenamedLanguage;
|
||||||
|
use Modules\Core\Localization\LocaleMiddleware;
|
||||||
|
|
||||||
|
class LocalizationServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user