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())
+
+ @else
+ @php($pending = $this->pending())
+
+
+ @if ($pending->isEmpty())
+ {{ __('panel.translate.nothing') }}
+ @else
+
+
+
+ | {{ __('panel.translate.columns.record') }} |
+ {{ __('panel.translate.columns.languages') }} |
+
+
+
+ @foreach ($pending as $item)
+
+ |
+ {{ __('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
+
+ |
+
+ @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();
+ }
+}