Add a translation assistant to the panel

Each field's original sits beside the field to write, and a translation that
fell behind shows what the original said when it was translated and what it
says now, word by word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 10:53:31 +02:00
parent 76d022053b
commit 6fc600e6a1
16 changed files with 837 additions and 61 deletions

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Support\ContentTypes;
use App\Support\PendingTranslations;
use App\Support\TextDiff;
use App\Support\TranslationFreshness;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page as FilamentPage;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\HtmlString;
use Livewire\Attributes\Url;
use UnitEnum;
/**
* Translating with the original beside you.
*
* Without a record: what is waiting to be translated, into which language.
* With one: each field's original next to the field to write, and for a
* translation that fell behind, what the original said when it was translated
* and what it says now — the sentence to redo rather than the whole text.
*/
class TranslationAssistant extends FilamentPage
{
/**
* Rich text, edited as such; everything else is plain.
*
* @var array<int, string>
*/
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<int, string>
*/
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<int, array<string, mixed>>
*/
public function pending(): Collection
{
return PendingTranslations::find(Auth::user());
}
/**
* @return array<int, string>
*/
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('<div class="ta-diff"><strong>'.e(__('panel.translate.changed')).'</strong> '.$diff.'</div>')) : null,
Grid::make(['default' => 1, 'lg' => 2])->schema([
Text::make(new HtmlString(
'<div class="ta-source"><span class="ta-lang">'.e($this->languageName($source)).'</span>'
.($rich ? $original : nl2br(e($original))).'</div>'
)),
$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<int, string>
*/
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)),
));
}
}

View File

@@ -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(),
];
}

View File

@@ -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(),

View File

@@ -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(),
];
}

View File

@@ -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(),
];
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
use App\Filament\Pages\TranslationAssistant;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Illuminate\Database\Eloquent\Model;
/**
* "Translate into…": this record in the translation assistant, one entry per
* language there is to translate into.
*/
final class TranslateAction
{
public static function make(): ActionGroup
{
$default = (string) config('locales.default');
return ActionGroup::make(collect(config('locales.supported'))
->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();
}
}

View File

@@ -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<int, class-string<Model>>
*/
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()]);
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Records with a language still to write (`missing`) or left behind by the
* one it was translated from (`stale`, with since when) — for the panel's
* translation assistant and the API alike.
*/
final class PendingTranslations
{
/**
* @var array<string, class-string<Model>>
*/
public const TYPES = [
'pages' => Page::class,
'projects' => Project::class,
'services' => Service::class,
'calls' => Call::class,
];
/**
* @return Collection<int, array{type: string, id: int|string, record: Model, title: string, missing: array<int, string>, stale: array<string, CarbonInterface>}>
*/
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();
}
}

99
app/Support/TextDiff.php Normal file
View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* What changed between two versions of a text, word by word, as HTML with
* `<del>` and `<ins>` — 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) {
'+' => "<ins>{$text}</ins> ",
'-' => "<del>{$text}</del> ",
default => "{$text} ",
};
$buffer = [];
};
foreach ($out as [$op, $word]) {
if ($op !== $run) {
$flush();
$run = $op;
}
$buffer[] = $word;
}
$flush();
return trim($html);
}
/**
* @return array<int, string>
*/
protected static function words(?string $text): array
{
$plain = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags(str_replace(['</p>', '<br>', '<br/>', '<br />'], ' ', (string) $text)))));
return $plain === '' ? [] : explode(' ', $plain);
}
}

View File

@@ -71,6 +71,69 @@ final class TranslationFreshness
/**
* @return array<string, CarbonInterface>
*/
/**
* 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<string, array{since: CarbonInterface, was: ?string}>
*/
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');