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:
16
README.md
16
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
|
El cálculo se cachea contra el `updated_at` del registro, así que se rehace en
|
||||||
cuanto algo cambia y ni un minuto antes.
|
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
|
## Revisión
|
||||||
|
|
||||||
`pages.publish` lleva separado desde el principio: **poner algo en la web es un
|
`pages.publish` lleva separado desde el principio: **poner algo en la web es un
|
||||||
|
|||||||
244
app/Filament/Pages/TranslationAssistant.php
Normal file
244
app/Filament/Pages/TranslationAssistant.php
Normal 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)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
|||||||
use App\Filament\Resources\Calls\CallResource;
|
use App\Filament\Resources\Calls\CallResource;
|
||||||
use App\Filament\Support\DuplicateAction;
|
use App\Filament\Support\DuplicateAction;
|
||||||
use App\Filament\Support\PreviewAction;
|
use App\Filament\Support\PreviewAction;
|
||||||
|
use App\Filament\Support\TranslateAction;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class EditCall extends EditRecord
|
|||||||
// Seeing it on the real site without leaving the form.
|
// Seeing it on the real site without leaving the form.
|
||||||
PreviewAction::for('call'),
|
PreviewAction::for('call'),
|
||||||
DuplicateAction::make(),
|
DuplicateAction::make(),
|
||||||
|
TranslateAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
|||||||
use App\Filament\Resources\Pages\PageResource;
|
use App\Filament\Resources\Pages\PageResource;
|
||||||
use App\Filament\Support\DuplicateAction;
|
use App\Filament\Support\DuplicateAction;
|
||||||
use App\Filament\Support\PreviewAction;
|
use App\Filament\Support\PreviewAction;
|
||||||
|
use App\Filament\Support\TranslateAction;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\ForceDeleteAction;
|
use Filament\Actions\ForceDeleteAction;
|
||||||
use Filament\Actions\RestoreAction;
|
use Filament\Actions\RestoreAction;
|
||||||
@@ -26,6 +27,7 @@ class EditPage extends EditRecord
|
|||||||
// Seeing it on the real site without leaving the form.
|
// Seeing it on the real site without leaving the form.
|
||||||
PreviewAction::for('page'),
|
PreviewAction::for('page'),
|
||||||
DuplicateAction::make(),
|
DuplicateAction::make(),
|
||||||
|
TranslateAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
ForceDeleteAction::make(),
|
ForceDeleteAction::make(),
|
||||||
RestoreAction::make(),
|
RestoreAction::make(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
|||||||
use App\Filament\Resources\Projects\ProjectResource;
|
use App\Filament\Resources\Projects\ProjectResource;
|
||||||
use App\Filament\Support\DuplicateAction;
|
use App\Filament\Support\DuplicateAction;
|
||||||
use App\Filament\Support\PreviewAction;
|
use App\Filament\Support\PreviewAction;
|
||||||
|
use App\Filament\Support\TranslateAction;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class EditProject extends EditRecord
|
|||||||
// Seeing it on the real site without leaving the form.
|
// Seeing it on the real site without leaving the form.
|
||||||
PreviewAction::for('project'),
|
PreviewAction::for('project'),
|
||||||
DuplicateAction::make(),
|
DuplicateAction::make(),
|
||||||
|
TranslateAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
|||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
use App\Filament\Support\DuplicateAction;
|
use App\Filament\Support\DuplicateAction;
|
||||||
use App\Filament\Support\PreviewAction;
|
use App\Filament\Support\PreviewAction;
|
||||||
|
use App\Filament\Support\TranslateAction;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class EditService extends EditRecord
|
|||||||
// Seeing it on the real site without leaving the form.
|
// Seeing it on the real site without leaving the form.
|
||||||
PreviewAction::for('service'),
|
PreviewAction::for('service'),
|
||||||
DuplicateAction::make(),
|
DuplicateAction::make(),
|
||||||
|
TranslateAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
34
app/Filament/Support/TranslateAction.php
Normal file
34
app/Filament/Support/TranslateAction.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,16 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
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\Finding;
|
||||||
use App\Support\Health\HealthReport;
|
use App\Support\Health\HealthReport;
|
||||||
use App\Support\TranslationFreshness;
|
use App\Support\PendingTranslations;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -25,13 +18,6 @@ use Illuminate\Validation\Rule;
|
|||||||
*/
|
*/
|
||||||
class HealthController extends Controller
|
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
|
* Every check the caller may see, problems first. A check behind a
|
||||||
* permission they lack is left out, as on the panel.
|
* permission they lack is left out, as on the panel.
|
||||||
@@ -77,52 +63,18 @@ class HealthController extends Controller
|
|||||||
'published' => ['sometimes', 'boolean'],
|
'published' => ['sometimes', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$wanted = $request->input('locale');
|
$items = PendingTranslations::find(
|
||||||
$items = collect();
|
$request->user(),
|
||||||
|
$request->input('locale'),
|
||||||
foreach (self::TRANSLATED as $model) {
|
$request->input('type'),
|
||||||
$type = AdminContent::nameOf(new $model);
|
$request->boolean('published'),
|
||||||
|
)->map(fn (array $item) => [
|
||||||
if ($request->filled('type') && $request->input('type') !== $type) {
|
'type' => $item['type'],
|
||||||
continue;
|
'id' => $item['id'],
|
||||||
}
|
'title' => $item['title'],
|
||||||
|
'missing' => $item['missing'],
|
||||||
if ($request->user()->cannot('viewAny', $model)) {
|
'stale' => (object) collect($item['stale'])->map(fn ($since) => $since->toIso8601String())->all(),
|
||||||
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,
|
|
||||||
]);
|
]);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['data' => $items->values()]);
|
return response()->json(['data' => $items->values()]);
|
||||||
}
|
}
|
||||||
|
|||||||
81
app/Support/PendingTranslations.php
Normal file
81
app/Support/PendingTranslations.php
Normal 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
99
app/Support/TextDiff.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,69 @@ final class TranslationFreshness
|
|||||||
/**
|
/**
|
||||||
* @return array<string, CarbonInterface>
|
* @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
|
protected static function compute(Model $record): array
|
||||||
{
|
{
|
||||||
$source = (string) config('locales.default');
|
$source = (string) config('locales.default');
|
||||||
|
|||||||
@@ -144,6 +144,38 @@ return [
|
|||||||
'select_all' => 'Selecciona-ho tot',
|
'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' => [
|
'replies' => [
|
||||||
'empty_template' => 'La plantilla no té text en l\'idioma d\'aquesta sol·licitud.',
|
'empty_template' => 'La plantilla no té text en l\'idioma d\'aquesta sol·licitud.',
|
||||||
'heading' => 'Respondre a :name',
|
'heading' => 'Respondre a :name',
|
||||||
|
|||||||
@@ -144,6 +144,38 @@ return [
|
|||||||
'select_all' => 'Select all',
|
'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' => [
|
'replies' => [
|
||||||
'empty_template' => 'The template has no text in the language of this submission.',
|
'empty_template' => 'The template has no text in the language of this submission.',
|
||||||
'heading' => 'Reply to :name',
|
'heading' => 'Reply to :name',
|
||||||
|
|||||||
@@ -144,6 +144,38 @@ return [
|
|||||||
'select_all' => 'Seleccionar todos',
|
'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' => [
|
'replies' => [
|
||||||
'empty_template' => 'La plantilla no tiene texto en el idioma de esta solicitud.',
|
'empty_template' => 'La plantilla no tiene texto en el idioma de esta solicitud.',
|
||||||
'heading' => 'Responder a :name',
|
'heading' => 'Responder a :name',
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<style>
|
||||||
|
.ta-source { padding: .5rem .75rem; border-radius: .5rem; background: color-mix(in oklab, currentColor 5%, transparent); font-size: .875rem; line-height: 1.5; }
|
||||||
|
.ta-source p { margin: 0 0 .5rem; }
|
||||||
|
.ta-lang { display: block; font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; opacity: .6; margin-bottom: .25rem; }
|
||||||
|
.ta-diff { font-size: .875rem; line-height: 1.6; padding: .5rem .75rem; border-left: 3px solid #f59e0b; background: color-mix(in oklab, #f59e0b 8%, transparent); border-radius: .25rem; }
|
||||||
|
.ta-diff del { background: color-mix(in oklab, #dc2626 18%, transparent); text-decoration: line-through; }
|
||||||
|
.ta-diff ins { background: color-mix(in oklab, #16a34a 20%, transparent); text-decoration: none; }
|
||||||
|
.ta-table { width: 100%; border-collapse: collapse; font-size: .875rem; }
|
||||||
|
.ta-table th { text-align: left; font-size: .75rem; text-transform: uppercase; opacity: .7; padding: .5rem; }
|
||||||
|
.ta-table td { padding: .5rem; border-top: 1px solid color-mix(in oklab, currentColor 10%, transparent); vertical-align: top; }
|
||||||
|
.ta-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@if ($this->target())
|
||||||
|
<form wire:submit="save" style="display: grid; gap: 1.5rem;">
|
||||||
|
{{ $this->form }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-filament::button type="submit">
|
||||||
|
{{ __('panel.translate.save', ['language' => $this->languageName($this->locale)]) }}
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
@php($pending = $this->pending())
|
||||||
|
|
||||||
|
<x-filament::section :heading="__('panel.translate.pending')" :description="__('panel.translate.pending_hint')">
|
||||||
|
@if ($pending->isEmpty())
|
||||||
|
<p>{{ __('panel.translate.nothing') }}</p>
|
||||||
|
@else
|
||||||
|
<table class="ta-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{ __('panel.translate.columns.record') }}</th>
|
||||||
|
<th scope="col">{{ __('panel.translate.columns.languages') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($pending as $item)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __('panel.statistics.types.'.$item['type']) }}</x-filament::badge>
|
||||||
|
{{ $item['title'] }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="ta-actions">
|
||||||
|
@foreach ($this->targetLocales() as $locale)
|
||||||
|
@if (in_array($locale, $item['missing'], true) || array_key_exists($locale, $item['stale']))
|
||||||
|
<x-filament::button
|
||||||
|
size="xs"
|
||||||
|
:color="in_array($locale, $item['missing'], true) ? 'primary' : 'warning'"
|
||||||
|
tag="a"
|
||||||
|
:href="\App\Filament\Pages\TranslationAssistant::urlFor($item['record'], $locale)"
|
||||||
|
>
|
||||||
|
{{ 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()]) }}
|
||||||
|
</x-filament::button>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@endif
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
</x-filament-panels::page>
|
||||||
112
tests/Feature/Panel/TranslationAssistantTest.php
Normal file
112
tests/Feature/Panel/TranslationAssistantTest.php
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Panel;
|
||||||
|
|
||||||
|
use App\Filament\Pages\TranslationAssistant;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Support\TextDiff;
|
||||||
|
use App\Support\TranslationFreshness;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TranslationAssistantTest extends TestCase
|
||||||
|
{
|
||||||
|
use FakesPireon, RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->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' => '<p>Cuerpo.</p>'],
|
||||||
|
'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('<del>pequeñas.</del> <ins>pequeñas y rápidas.</ins>');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_diff_marks_words_in_and_out(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('Hacemos webs <del>pequeñas.</del> <ins>rápidas.</ins>', 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user