diff --git a/README.md b/README.md index b8ad80e..795835e 100644 --- a/README.md +++ b/README.md @@ -941,6 +941,22 @@ en tres idiomas: si llegaron juntos, ninguno va por detrás de otro. El cálculo se cachea contra el `updated_at` del registro, así que se rehace en cuanto algo cambia y ni un minuto antes. +### El asistente de traducción + +Contenido → Traducciones lista lo que falta escribir en un idioma (azul) y lo +que se quedó atrás (ámbar, con desde cuándo), con un botón por idioma. Desde la +edición de una página, proyecto, servicio o convocatoria, «Traducir» lleva al +mismo sitio. + +Al abrir uno, cada campo traducible sale **con el original al lado** y el campo +a escribir enfrente (texto enriquecido donde lo es). Y si la traducción se quedó +atrás, ese campo dice desde cuándo y enseña **qué cambió en el original desde +que se tradujo**, palabra a palabra: lo que se quitó tachado y lo que entró +marcado. El texto «de entonces» sale del historial, como la fecha. Así quien +traduce rehace la frase que cambió en vez de releerlo todo. Solo se guarda el +idioma que se está traduciendo, y el slug se deja al formulario de la ficha: +una dirección no es una traducción. + ## Revisión `pages.publish` lleva separado desde el principio: **poner algo en la web es un diff --git a/app/Filament/Pages/TranslationAssistant.php b/app/Filament/Pages/TranslationAssistant.php new file mode 100644 index 0000000..5e4edc2 --- /dev/null +++ b/app/Filament/Pages/TranslationAssistant.php @@ -0,0 +1,244 @@ + + */ + public const RICH = ['body', 'description', 'decisions', 'postmortem']; + + /** + * Never translated here: an address is not a translation, and changing it + * has its own consequences on the record's own form. + * + * @var array + */ + protected const SKIPPED = ['slug', 'featured_image_alt', 'cover_image_alt']; + + protected string $view = 'filament.pages.translation-assistant'; + + protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedLanguage; + + protected static ?int $navigationSort = 7; + + #[Url] + public ?string $type = null; + + #[Url] + public ?string $record = null; + + #[Url] + public ?string $locale = null; + + public ?array $data = []; + + public static function canAccess(): bool + { + return Auth::user()?->can('panel.access') ?? false; + } + + public static function getNavigationGroup(): string|UnitEnum|null + { + return __('panel.groups.content'); + } + + public static function getNavigationLabel(): string + { + return __('panel.translate.title'); + } + + public static function urlFor(Model $record, ?string $locale = null): string + { + $type = array_search($record::class, PendingTranslations::TYPES, true); + + return self::getUrl(array_filter(['type' => $type, 'record' => $record->getKey(), 'locale' => $locale])); + } + + public function getTitle(): string|Htmlable + { + $record = $this->target(); + + return $record + ? __('panel.translate.title_for', ['title' => ContentTypes::title($record), 'language' => $this->languageName($this->locale)]) + : __('panel.translate.title'); + } + + public function mount(): void + { + $record = $this->target(); + + if ($record === null) { + return; + } + + abort_unless(Auth::user()->can('update', $record), 403); + + $this->form->fill(collect($this->fields($record)) + ->mapWithKeys(fn (string $field) => [$field => $record->getTranslation($field, $this->locale, false)]) + ->all()); + } + + /** + * The record being translated, when there is one and the language is one + * to translate into. + */ + public function target(): ?Model + { + $model = PendingTranslations::TYPES[$this->type] ?? null; + $locales = array_keys((array) config('locales.supported')); + + if ($model === null || $this->record === null || ! in_array($this->locale, $locales, true) || $this->locale === config('locales.default')) { + return null; + } + + return $model::query()->find($this->record); + } + + /** + * @return Collection> + */ + public function pending(): Collection + { + return PendingTranslations::find(Auth::user()); + } + + /** + * @return array + */ + public function targetLocales(): array + { + return array_values(array_diff(array_keys((array) config('locales.supported')), [config('locales.default')])); + } + + public function languageName(?string $locale): string + { + return (string) config("locales.supported.{$locale}.native", $locale); + } + + public function form(Schema $schema): Schema + { + $record = $this->target(); + + if ($record === null) { + return $schema->statePath('data')->components([]); + } + + $source = (string) config('locales.default'); + $stale = TranslationFreshness::staleFields($record, $this->locale); + + return $schema + ->statePath('data') + ->components(collect($this->fields($record))->map(function (string $field) use ($record, $source, $stale) { + $original = (string) $record->getTranslation($field, $source, false); + $rich = in_array($field, self::RICH, true); + $label = __("panel.translate.fields.{$field}"); + + $input = match (true) { + $rich => RichEditor::make($field)->hiddenLabel(), + mb_strlen($original) > 120 => Textarea::make($field)->hiddenLabel()->rows(4), + default => TextInput::make($field)->hiddenLabel(), + }; + + $diff = isset($stale[$field]) ? TextDiff::html($stale[$field]['was'], $original) : null; + + return Section::make($label) + ->description(isset($stale[$field]) + ? __('panel.translate.stale_since', ['date' => $stale[$field]['since']->isoFormat('LL')]) + : null) + ->icon(isset($stale[$field]) ? 'heroicon-o-exclamation-triangle' : null) + ->iconColor('warning') + ->compact() + ->schema(array_filter([ + $diff ? Text::make(new HtmlString('
'.e(__('panel.translate.changed')).' '.$diff.'
')) : null, + Grid::make(['default' => 1, 'lg' => 2])->schema([ + Text::make(new HtmlString( + '
'.e($this->languageName($source)).'' + .($rich ? $original : nl2br(e($original))).'
' + )), + $input, + ]), + ])); + })->values()->all()); + } + + protected function getHeaderActions(): array + { + return $this->target() === null ? [] : [ + Action::make('back') + ->label(__('panel.translate.back')) + ->color('gray') + ->url(self::getUrl()), + ]; + } + + public function save(): void + { + $record = $this->target(); + + abort_unless($record !== null && Auth::user()->can('update', $record), 403); + + foreach ($this->form->getState() as $field => $value) { + if (in_array($field, $this->fields($record), true)) { + $record->setTranslation($field, $this->locale, (string) ($value ?? '')); + } + } + + $record->save(); + + Notification::make()->title(__('panel.translate.saved'))->success()->send(); + } + + /** + * The fields worth translating on this record: translatable, not an + * address, and with something in the original to translate. + * + * @return array + */ + protected function fields(Model $record): array + { + $source = (string) config('locales.default'); + + return array_values(array_filter( + array_diff($record->getTranslatableAttributes(), self::SKIPPED), + fn (string $field) => filled($record->getTranslation($field, $source, false)), + )); + } +} diff --git a/app/Filament/Resources/Calls/Pages/EditCall.php b/app/Filament/Resources/Calls/Pages/EditCall.php index 4fc22e0..003921b 100644 --- a/app/Filament/Resources/Calls/Pages/EditCall.php +++ b/app/Filament/Resources/Calls/Pages/EditCall.php @@ -8,6 +8,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing; use App\Filament\Resources\Calls\CallResource; use App\Filament\Support\DuplicateAction; use App\Filament\Support\PreviewAction; +use App\Filament\Support\TranslateAction; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; @@ -23,6 +24,7 @@ class EditCall extends EditRecord // Seeing it on the real site without leaving the form. PreviewAction::for('call'), DuplicateAction::make(), + TranslateAction::make(), DeleteAction::make(), ]; } diff --git a/app/Filament/Resources/Pages/Pages/EditPage.php b/app/Filament/Resources/Pages/Pages/EditPage.php index 7c98bb4..ee413d3 100644 --- a/app/Filament/Resources/Pages/Pages/EditPage.php +++ b/app/Filament/Resources/Pages/Pages/EditPage.php @@ -9,6 +9,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing; use App\Filament\Resources\Pages\PageResource; use App\Filament\Support\DuplicateAction; use App\Filament\Support\PreviewAction; +use App\Filament\Support\TranslateAction; use Filament\Actions\DeleteAction; use Filament\Actions\ForceDeleteAction; use Filament\Actions\RestoreAction; @@ -26,6 +27,7 @@ class EditPage extends EditRecord // Seeing it on the real site without leaving the form. PreviewAction::for('page'), DuplicateAction::make(), + TranslateAction::make(), DeleteAction::make(), ForceDeleteAction::make(), RestoreAction::make(), diff --git a/app/Filament/Resources/Projects/Pages/EditProject.php b/app/Filament/Resources/Projects/Pages/EditProject.php index 0f4a3b5..ecfd0c1 100644 --- a/app/Filament/Resources/Projects/Pages/EditProject.php +++ b/app/Filament/Resources/Projects/Pages/EditProject.php @@ -8,6 +8,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing; use App\Filament\Resources\Projects\ProjectResource; use App\Filament\Support\DuplicateAction; use App\Filament\Support\PreviewAction; +use App\Filament\Support\TranslateAction; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; @@ -23,6 +24,7 @@ class EditProject extends EditRecord // Seeing it on the real site without leaving the form. PreviewAction::for('project'), DuplicateAction::make(), + TranslateAction::make(), DeleteAction::make(), ]; } diff --git a/app/Filament/Resources/Services/Pages/EditService.php b/app/Filament/Resources/Services/Pages/EditService.php index b70f505..9bead30 100644 --- a/app/Filament/Resources/Services/Pages/EditService.php +++ b/app/Filament/Resources/Services/Pages/EditService.php @@ -8,6 +8,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing; use App\Filament\Resources\Services\ServiceResource; use App\Filament\Support\DuplicateAction; use App\Filament\Support\PreviewAction; +use App\Filament\Support\TranslateAction; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; @@ -23,6 +24,7 @@ class EditService extends EditRecord // Seeing it on the real site without leaving the form. PreviewAction::for('service'), DuplicateAction::make(), + TranslateAction::make(), DeleteAction::make(), ]; } diff --git a/app/Filament/Support/TranslateAction.php b/app/Filament/Support/TranslateAction.php new file mode 100644 index 0000000..7cb58e2 --- /dev/null +++ b/app/Filament/Support/TranslateAction.php @@ -0,0 +1,34 @@ +except($default) + ->map(fn (array $meta, string $locale) => Action::make("translate_{$locale}") + ->label($meta['native'] ?? $locale) + ->url(fn (Model $record) => TranslationAssistant::urlFor($record, $locale))) + ->values() + ->all()) + ->label(__('panel.translate.action')) + ->icon('heroicon-o-language') + ->color('gray') + ->button(); + } +} diff --git a/app/Http/Controllers/Api/V1/HealthController.php b/app/Http/Controllers/Api/V1/HealthController.php index 8f831c8..2d2420b 100644 --- a/app/Http/Controllers/Api/V1/HealthController.php +++ b/app/Http/Controllers/Api/V1/HealthController.php @@ -5,16 +5,9 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; -use App\Models\Call; -use App\Models\Page; -use App\Models\Project; -use App\Models\Service; -use App\Support\AdminContent; -use App\Support\ContentTypes; use App\Support\Health\Finding; use App\Support\Health\HealthReport; -use App\Support\TranslationFreshness; -use Illuminate\Database\Eloquent\Model; +use App\Support\PendingTranslations; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -25,13 +18,6 @@ use Illuminate\Validation\Rule; */ class HealthController extends Controller { - /** - * The kinds of content written in several languages and checked for it. - * - * @var array> - */ - protected const TRANSLATED = [Page::class, Project::class, Service::class, Call::class]; - /** * Every check the caller may see, problems first. A check behind a * permission they lack is left out, as on the panel. @@ -77,52 +63,18 @@ class HealthController extends Controller 'published' => ['sometimes', 'boolean'], ]); - $wanted = $request->input('locale'); - $items = collect(); - - foreach (self::TRANSLATED as $model) { - $type = AdminContent::nameOf(new $model); - - if ($request->filled('type') && $request->input('type') !== $type) { - continue; - } - - if ($request->user()->cannot('viewAny', $model)) { - continue; - } - - $model::query() - ->when($request->boolean('published'), fn ($q) => $model === Call::class ? $q->open() : $q->published()) - ->with('revisions') - ->get() - ->each(function (Model $record) use ($locales, $wanted, $type, $items): void { - $missing = array_values(array_filter( - $locales, - fn (string $locale) => blank($record->getTranslation('title', $locale, false)), - )); - - $stale = collect(TranslationFreshness::for($record)) - ->map(fn ($since) => $since->toIso8601String()) - ->all(); - - if ($wanted !== null) { - $missing = array_values(array_intersect($missing, [$wanted])); - $stale = array_intersect_key($stale, [$wanted => true]); - } - - if ($missing === [] && $stale === []) { - return; - } - - $items->push([ - 'type' => $type, - 'id' => $record->getKey(), - 'title' => ContentTypes::title($record), - 'missing' => $missing, - 'stale' => (object) $stale, - ]); - }); - } + $items = PendingTranslations::find( + $request->user(), + $request->input('locale'), + $request->input('type'), + $request->boolean('published'), + )->map(fn (array $item) => [ + 'type' => $item['type'], + 'id' => $item['id'], + 'title' => $item['title'], + 'missing' => $item['missing'], + 'stale' => (object) collect($item['stale'])->map(fn ($since) => $since->toIso8601String())->all(), + ]); return response()->json(['data' => $items->values()]); } diff --git a/app/Support/PendingTranslations.php b/app/Support/PendingTranslations.php new file mode 100644 index 0000000..14525c7 --- /dev/null +++ b/app/Support/PendingTranslations.php @@ -0,0 +1,81 @@ +> + */ + public const TYPES = [ + 'pages' => Page::class, + 'projects' => Project::class, + 'services' => Service::class, + 'calls' => Call::class, + ]; + + /** + * @return Collection, stale: array}> + */ + public static function find(User $user, ?string $locale = null, ?string $type = null, bool $publishedOnly = false): Collection + { + $locales = array_keys((array) config('locales.supported')); + $source = (string) config('locales.default'); + $items = collect(); + + foreach (self::TYPES as $name => $model) { + if (($type !== null && $type !== $name) || $user->cannot('viewAny', $model)) { + continue; + } + + $model::query() + ->when($publishedOnly, fn ($q) => $model === Call::class ? $q->open() : $q->published()) + ->with('revisions') + ->get() + ->each(function (Model $record) use ($locales, $source, $locale, $name, $items): void { + $missing = array_values(array_filter( + $locales, + fn (string $l) => $l !== $source && blank($record->getTranslation('title', $l, false)), + )); + + $stale = TranslationFreshness::for($record); + + if ($locale !== null) { + $missing = array_values(array_intersect($missing, [$locale])); + $stale = array_intersect_key($stale, [$locale => true]); + } + + if ($missing === [] && $stale === []) { + return; + } + + $items->push([ + 'type' => $name, + 'id' => $record->getKey(), + 'record' => $record, + 'title' => ContentTypes::title($record), + 'missing' => $missing, + 'stale' => $stale, + ]); + }); + } + + return $items->values(); + } +} diff --git a/app/Support/TextDiff.php b/app/Support/TextDiff.php new file mode 100644 index 0000000..1df3a93 --- /dev/null +++ b/app/Support/TextDiff.php @@ -0,0 +1,99 @@ +` and `` — enough to show a translator which sentence of the + * original to look at again. + */ +final class TextDiff +{ + /** + * Above this many words on either side the comparison is skipped: it is + * quadratic, and a text that long changed is better reread whole. + */ + protected const MAX_WORDS = 1500; + + public static function html(?string $old, ?string $new): ?string + { + $a = self::words($old); + $b = self::words($new); + + if (count($a) > self::MAX_WORDS || count($b) > self::MAX_WORDS) { + return null; + } + + // Longest common subsequence, then walked back into runs. + $m = count($a); + $n = count($b); + $lcs = array_fill(0, $m + 1, array_fill(0, $n + 1, 0)); + + for ($i = $m - 1; $i >= 0; $i--) { + for ($j = $n - 1; $j >= 0; $j--) { + $lcs[$i][$j] = $a[$i] === $b[$j] ? $lcs[$i + 1][$j + 1] + 1 : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]); + } + } + + $out = []; + $i = $j = 0; + + while ($i < $m || $j < $n) { + if ($i < $m && $j < $n && $a[$i] === $b[$j]) { + $out[] = ['=', $a[$i]]; + $i++; + $j++; + } elseif ($i < $m && ($j === $n || $lcs[$i + 1][$j] >= $lcs[$i][$j + 1])) { + // What went comes before what replaced it. + $out[] = ['-', $a[$i]]; + $i++; + } else { + $out[] = ['+', $b[$j]]; + $j++; + } + } + + $html = ''; + $run = null; + $buffer = []; + + $flush = function () use (&$html, &$run, &$buffer): void { + if ($buffer === []) { + return; + } + + $text = e(implode(' ', $buffer)); + $html .= match ($run) { + '+' => "{$text} ", + '-' => "{$text} ", + default => "{$text} ", + }; + $buffer = []; + }; + + foreach ($out as [$op, $word]) { + if ($op !== $run) { + $flush(); + $run = $op; + } + + $buffer[] = $word; + } + + $flush(); + + return trim($html); + } + + /** + * @return array + */ + protected static function words(?string $text): array + { + $plain = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags(str_replace(['

', '
', '
', '
'], ' ', (string) $text))))); + + return $plain === '' ? [] : explode(' ', $plain); + } +} diff --git a/app/Support/TranslationFreshness.php b/app/Support/TranslationFreshness.php index 69c9830..a0f3c40 100644 --- a/app/Support/TranslationFreshness.php +++ b/app/Support/TranslationFreshness.php @@ -71,6 +71,69 @@ final class TranslationFreshness /** * @return array */ + /** + * Field by field, for one language: which fields the original changed + * after this language was last written, since when, and what the + * original said back then — so a translator can see what to redo rather + * than rereading the whole text. + * + * @return array + */ + public static function staleFields(Model $record, string $locale): array + { + $source = (string) config('locales.default'); + $fields = self::fields($record); + $touched = self::lastTouched($record, $fields); + $stale = []; + + foreach ($fields as $field) { + if ($locale === $source || blank($record->getTranslation($field, $locale, false))) { + continue; + } + + $sourceAt = $touched[$field][$source] ?? null; + $translatedAt = $touched[$field][$locale] ?? $record->created_at; + + if ($sourceAt !== null && $translatedAt !== null && $sourceAt->gt($translatedAt)) { + $stale[$field] = [ + 'since' => $sourceAt, + 'was' => self::sourceAsOf($record, $field, $translatedAt), + ]; + } + } + + return $stale; + } + + /** + * The original's text in a field as it stood at a moment, from the + * history: the last value written at or before it, or the value the first + * later change replaced. + */ + protected static function sourceAsOf(Model $record, string $field, CarbonInterface $at): ?string + { + $source = (string) config('locales.default'); + $value = null; + $found = false; + + foreach ($record->revisions()->reorder()->orderBy('id')->get() as $revision) { + $change = $revision->changeset[$field] ?? null; + + if (! is_array($change)) { + continue; + } + + if ($revision->created_at->lte($at)) { + $value = ((array) ($change['new'] ?? []))[$source] ?? null; + $found = true; + } elseif (! $found) { + return ((array) ($change['old'] ?? []))[$source] ?? null; + } + } + + return $found ? $value : $record->getTranslation($field, $source, false); + } + protected static function compute(Model $record): array { $source = (string) config('locales.default'); diff --git a/lang/ca/panel.php b/lang/ca/panel.php index e18c6af..a0a476f 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -144,6 +144,38 @@ return [ 'select_all' => 'Selecciona-ho tot', ], + 'translate' => [ + 'title' => 'Traduccions', + 'title_for' => ':title en :language', + 'action' => 'Traduir', + 'pending' => 'Per traduir', + 'pending_hint' => 'El que falta escriure en un idioma (blau) o s\'ha quedat enrere perquè l\'original va canviar després (ambre).', + 'nothing' => 'Tot està traduït i al dia.', + 'translate_into' => 'Traduir al :language', + 'update_in' => 'Actualitzar :language (des de :since)', + 'changed' => 'Ha canviat a l\'original:', + 'stale_since' => 'L\'original va canviar el :date, després de traduir-se.', + 'save' => 'Desar en :language', + 'saved' => 'Traducció desada', + 'back' => 'Tornar a la llista', + 'columns' => [ + 'record' => 'Contingut', + 'languages' => 'Idiomes', + ], + 'fields' => [ + 'title' => 'Títol', + 'excerpt' => 'Extracte', + 'body' => 'Contingut', + 'meta_title' => 'Títol SEO', + 'meta_description' => 'Descripció SEO', + 'tagline' => 'Lema', + 'description' => 'Descripció', + 'decisions' => 'Decisions', + 'postmortem' => 'Postmortem', + 'what_you_get' => 'Què t\'emportes', + ], + ], + 'replies' => [ 'empty_template' => 'La plantilla no té text en l\'idioma d\'aquesta sol·licitud.', 'heading' => 'Respondre a :name', diff --git a/lang/en/panel.php b/lang/en/panel.php index c1a390f..3e052f0 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -144,6 +144,38 @@ return [ 'select_all' => 'Select all', ], + 'translate' => [ + 'title' => 'Translations', + 'title_for' => ':title in :language', + 'action' => 'Translate', + 'pending' => 'To translate', + 'pending_hint' => 'What is still to be written in a language (blue) or fell behind because the original changed afterwards (amber).', + 'nothing' => 'Everything is translated and up to date.', + 'translate_into' => 'Translate into :language', + 'update_in' => 'Update :language (since :since)', + 'changed' => 'Changed in the original:', + 'stale_since' => 'The original changed on :date, after it was translated.', + 'save' => 'Save in :language', + 'saved' => 'Translation saved', + 'back' => 'Back to the list', + 'columns' => [ + 'record' => 'Content', + 'languages' => 'Languages', + ], + 'fields' => [ + 'title' => 'Title', + 'excerpt' => 'Excerpt', + 'body' => 'Content', + 'meta_title' => 'SEO title', + 'meta_description' => 'SEO description', + 'tagline' => 'Tagline', + 'description' => 'Description', + 'decisions' => 'Decisions', + 'postmortem' => 'Postmortem', + 'what_you_get' => 'What you get', + ], + ], + 'replies' => [ 'empty_template' => 'The template has no text in the language of this submission.', 'heading' => 'Reply to :name', diff --git a/lang/es/panel.php b/lang/es/panel.php index ad6c25b..05b8202 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -144,6 +144,38 @@ return [ 'select_all' => 'Seleccionar todos', ], + 'translate' => [ + 'title' => 'Traducciones', + 'title_for' => ':title en :language', + 'action' => 'Traducir', + 'pending' => 'Por traducir', + 'pending_hint' => 'Lo que falta escribir en un idioma (azul) o se quedó atrás porque el original cambió después (ámbar).', + 'nothing' => 'Todo está traducido y al día.', + 'translate_into' => 'Traducir al :language', + 'update_in' => 'Actualizar :language (desde :since)', + 'changed' => 'Cambió en el original:', + 'stale_since' => 'El original cambió el :date, después de traducirse.', + 'save' => 'Guardar en :language', + 'saved' => 'Traducción guardada', + 'back' => 'Volver a la lista', + 'columns' => [ + 'record' => 'Contenido', + 'languages' => 'Idiomas', + ], + 'fields' => [ + 'title' => 'Título', + 'excerpt' => 'Extracto', + 'body' => 'Contenido', + 'meta_title' => 'Título SEO', + 'meta_description' => 'Descripción SEO', + 'tagline' => 'Lema', + 'description' => 'Descripción', + 'decisions' => 'Decisiones', + 'postmortem' => 'Postmortem', + 'what_you_get' => 'Qué te llevas', + ], + ], + 'replies' => [ 'empty_template' => 'La plantilla no tiene texto en el idioma de esta solicitud.', 'heading' => 'Responder a :name', diff --git a/resources/views/filament/pages/translation-assistant.blade.php b/resources/views/filament/pages/translation-assistant.blade.php new file mode 100644 index 0000000..1d87d39 --- /dev/null +++ b/resources/views/filament/pages/translation-assistant.blade.php @@ -0,0 +1,71 @@ + + + + @if ($this->target()) +
+ {{ $this->form }} + +
+ + {{ __('panel.translate.save', ['language' => $this->languageName($this->locale)]) }} + +
+
+ @else + @php($pending = $this->pending()) + + + @if ($pending->isEmpty()) +

{{ __('panel.translate.nothing') }}

+ @else + + + + + + + + + @foreach ($pending as $item) + + + + + @endforeach + +
{{ __('panel.translate.columns.record') }}{{ __('panel.translate.columns.languages') }}
+ {{ __('panel.statistics.types.'.$item['type']) }} + {{ $item['title'] }} + +
+ @foreach ($this->targetLocales() as $locale) + @if (in_array($locale, $item['missing'], true) || array_key_exists($locale, $item['stale'])) + + {{ in_array($locale, $item['missing'], true) + ? __('panel.translate.translate_into', ['language' => $this->languageName($locale)]) + : __('panel.translate.update_in', ['language' => $this->languageName($locale), 'since' => $item['stale'][$locale]->diffForHumans()]) }} + + @endif + @endforeach +
+
+ @endif +
+ @endif +
diff --git a/tests/Feature/Panel/TranslationAssistantTest.php b/tests/Feature/Panel/TranslationAssistantTest.php new file mode 100644 index 0000000..7200bb4 --- /dev/null +++ b/tests/Feature/Panel/TranslationAssistantTest.php @@ -0,0 +1,112 @@ +seed(RolePermissionSeeder::class); + } + + #[Test] + public function the_list_offers_each_missing_language(): void + { + $this->actingAs($this->userWithRoles('editor')); + + Page::factory()->draft()->create(['title' => ['es' => 'Solo en castellano'], 'slug' => ['es' => 'solo']]); + + Livewire::test(TranslationAssistant::class) + ->assertSee('Solo en castellano') + ->assertSee('Traducir al English'); + } + + #[Test] + public function a_translation_is_written_beside_the_original(): void + { + $this->actingAs($this->userWithRoles('editor')); + + $page = Page::factory()->draft()->create([ + 'title' => ['es' => 'Hola'], + 'slug' => ['es' => 'hola'], + 'excerpt' => ['es' => 'Un extracto.'], + 'body' => ['es' => '

Cuerpo.

'], + 'meta_title' => null, + 'meta_description' => null, + ]); + + Livewire::withQueryParams(['type' => 'pages', 'record' => $page->id, 'locale' => 'en']) + ->test(TranslationAssistant::class) + ->assertSee('Un extracto.') + ->fillForm(['title' => 'Hello', 'excerpt' => 'An excerpt.']) + ->call('save') + ->assertHasNoFormErrors(); + + $page->refresh(); + + $this->assertSame('Hello', $page->getTranslation('title', 'en')); + $this->assertSame('An excerpt.', $page->getTranslation('excerpt', 'en')); + // The original is untouched. + $this->assertSame('Hola', $page->getTranslation('title', 'es')); + } + + #[Test] + public function a_translation_left_behind_shows_what_changed_in_the_original(): void + { + $this->actingAs($this->userWithRoles('editor')); + + $this->travelTo('2026-09-01 10:00'); + $page = Page::factory()->draft()->create([ + 'title' => ['es' => 'Hola', 'en' => 'Hello'], + 'slug' => ['es' => 'hola', 'en' => 'hello'], + 'excerpt' => ['es' => 'Hacemos webs pequeñas.', 'en' => 'We make small websites.'], + ]); + + $this->travelTo('2026-09-10 10:00'); + $page->update(['excerpt' => ['es' => 'Hacemos webs pequeñas y rápidas.', 'en' => 'We make small websites.']]); + + $stale = TranslationFreshness::staleFields($page->fresh(), 'en'); + + $this->assertSame(['excerpt'], array_keys($stale)); + $this->assertSame('Hacemos webs pequeñas.', $stale['excerpt']['was']); + + Livewire::withQueryParams(['type' => 'pages', 'record' => $page->id, 'locale' => 'en']) + ->test(TranslationAssistant::class) + ->assertSeeHtml('pequeñas. pequeñas y rápidas.'); + } + + #[Test] + public function the_diff_marks_words_in_and_out(): void + { + $this->assertSame('Hacemos webs pequeñas. rápidas.', TextDiff::html('Hacemos webs pequeñas.', 'Hacemos webs rápidas.')); + } + + #[Test] + public function only_who_may_edit_it_may_translate_it(): void + { + $this->actingAs($this->userWithRoles('viewer')); + + $page = Page::factory()->create(); + + Livewire::withQueryParams(['type' => 'pages', 'record' => $page->id, 'locale' => 'en']) + ->test(TranslationAssistant::class) + ->assertForbidden(); + } +}