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>
244 lines
8.5 KiB
PHP
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'],
|
|
));
|
|
}
|
|
}
|