Files
landing-api/app/Support/TranslationFreshness.php
Josep Piguillem 6fc600e6a1 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>
2026-10-01 10:53:31 +02:00

244 lines
8.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Revision;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
/**
* Which translations have fallen behind the language they were made from.
*
* The existing check finds the hole — published in Spanish, absent in Catalan.
* This finds the subtler one, which is also the one that actually happens: the
* Catalan text is *there*, so nothing complains, and it has been saying
* something the Spanish stopped saying two months ago. A translation nobody
* knows is out of date is worse than one that is missing, because the missing
* one is at least honest about it.
*
* Worked out from the change history rather than from a column, because the
* history already knows: every revision records the whole locale map of the
* field it touched, before and after, so "when did the Spanish body last
* change, and when did the Catalan" is a question it can already answer. A
* column would be a second copy of that, kept in step by hand.
*/
final class TranslationFreshness
{
/**
* Locales whose translation is older than the source text, mapped to when
* the source moved ahead of them.
*
* Cached against the record's `updated_at`, so the answer is recomputed
* the moment anything about the record changes and never before. The
* panel reads this once per row of a listing.
*
* @return array<string, CarbonInterface>
*/
public static function for(Model $record): array
{
return Cache::remember(
'translation-freshness:'.$record->getMorphClass().':'.$record->getKey()
.':'.($record->updated_at?->getTimestamp() ?? 0),
now()->addHour(),
fn () => self::compute($record),
);
}
/**
* The ids of the records in a query whose translations have fallen behind.
*
* For the panel's filter. This cannot be expressed as a condition — the
* answer lives in the change history, one record at a time — so it is
* worked out here and handed to the query as a list, the same way the
* media screen filters on files that are no longer on disk.
*
* @param Builder<covariant Model> $query
* @return array<int, int|string>
*/
public static function staleIds(Builder $query): array
{
return $query
->with('revisions')
->get()
->filter(fn (Model $record) => self::for($record) !== [])
->modelKeys();
}
/**
* @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');
$fields = self::fields($record);
if ($fields === []) {
return [];
}
$touched = self::lastTouched($record, $fields);
$stale = [];
foreach (array_keys((array) config('locales.supported')) as $locale) {
if ($locale === $source) {
continue;
}
foreach ($fields as $field) {
// A language with nothing in this field is not out of date, it
// is untranslated — which the other check already reports, and
// saying both about the same record helps nobody.
if (blank($record->getTranslation($field, $locale, false))) {
continue;
}
$sourceAt = $touched[$field][$source] ?? null;
if ($sourceAt === null) {
continue;
}
// Never edited on its own since the record was created, so the
// day it was written is the day it was last true.
$translatedAt = $touched[$field][$locale] ?? $record->created_at;
if ($translatedAt !== null && $sourceAt->gt($translatedAt)) {
// The oldest complaint wins: it says how long this has
// been drifting, which is the part worth acting on.
$stale[$locale] = isset($stale[$locale]) && $stale[$locale]->lt($sourceAt)
? $stale[$locale]
: $sourceAt;
}
}
}
return $stale;
}
/**
* When each field last changed, in each language.
*
* @param array<int, string> $fields
* @return array<string, array<string, CarbonInterface>>
*/
protected static function lastTouched(Model $record, array $fields): array
{
$touched = [];
// Oldest first, so the last write wins without comparing timestamps.
$revisions = $record->relationLoaded('revisions')
? $record->getRelation('revisions')->sortBy('id')
: $record->revisions()->reorder()->orderBy('id')->get();
foreach ($revisions as $revision) {
/** @var Revision $revision */
foreach ($fields as $field) {
$change = $revision->changeset[$field] ?? null;
if (! is_array($change)) {
continue;
}
$before = (array) ($change['old'] ?? []);
$after = (array) ($change['new'] ?? []);
foreach (array_keys((array) config('locales.supported')) as $locale) {
if (($before[$locale] ?? null) !== ($after[$locale] ?? null)) {
$touched[$field][$locale] = $revision->created_at;
}
}
}
}
return $touched;
}
/**
* The translatable fields worth comparing.
*
* `slug` is left out deliberately: renaming the Spanish address does not
* make the Catalan text any less true, and it is the one translatable
* field that routinely changes on its own — counting it would mark half
* the site out of date the first time somebody tidied a URL.
*
* @return array<int, string>
*/
protected static function fields(Model $record): array
{
return array_values(array_diff(
method_exists($record, 'getTranslatableAttributes')
? $record->getTranslatableAttributes()
: [],
['slug'],
));
}
}