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:
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>
|
||||
*/
|
||||
/**
|
||||
* 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');
|
||||
|
||||
Reference in New Issue
Block a user