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>
245 lines
8.0 KiB
PHP
245 lines
8.0 KiB
PHP
<?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)),
|
|
));
|
|
}
|
|
}
|