*/ 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 $query * @return array */ public static function staleIds(Builder $query): array { return $query ->with('revisions') ->get() ->filter(fn (Model $record) => self::for($record) !== []) ->modelKeys(); } /** * @return array */ 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 $fields * @return array> */ 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 */ protected static function fields(Model $record): array { return array_values(array_diff( method_exists($record, 'getTranslatableAttributes') ? $record->getTranslatableAttributes() : [], ['slug'], )); } }