Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
180
app/Support/TranslationFreshness.php
Normal file
180
app/Support/TranslationFreshness.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?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>
|
||||
*/
|
||||
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'],
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user