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>
122 lines
4.1 KiB
PHP
122 lines
4.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support;
|
|
|
|
use Filament\Actions\Action;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* A warning, not a rule: something that already exists under almost the same
|
|
* name.
|
|
*
|
|
* The slug already has a hard uniqueness rule — two records cannot answer at
|
|
* the same address. This catches what that rule cannot: two records with
|
|
* different addresses and nearly the same title, which is what actually
|
|
* happens when nobody remembers a project already exists and starts a second
|
|
* one. A soft nudge on the way past is worth more here than a validation rule
|
|
* would be — sometimes the second one really is a different thing that
|
|
* happens to be called almost the same.
|
|
*/
|
|
final class DuplicateTitles
|
|
{
|
|
/**
|
|
* Below this, two titles are merely both about similar things — not the
|
|
* near-duplicate anybody would benefit from being told about.
|
|
*/
|
|
protected const THRESHOLD = 80.0;
|
|
|
|
/**
|
|
* Compute the match and warn about it in one call — the only way this is
|
|
* ever used, from the title field's `afterStateUpdated` on three forms.
|
|
*
|
|
* @param class-string<Model> $model
|
|
* @param class-string<resource> $resource
|
|
*/
|
|
public static function warnIfSimilar(
|
|
string $model,
|
|
string $resource,
|
|
?string $title,
|
|
string $locale,
|
|
int|string|null $excludeId,
|
|
): void {
|
|
$matches = self::similarTo($model, (string) $title, $locale, $excludeId);
|
|
|
|
if ($matches->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->warning()
|
|
->title(__('panel.duplicates.title'))
|
|
->body(__('panel.duplicates.body', [
|
|
'titles' => $matches
|
|
->map(fn (Model $match) => (string) $match->getTranslation('title', $locale, false))
|
|
->implode(' · '),
|
|
]))
|
|
->actions([
|
|
Action::make('open')
|
|
->label(__('panel.duplicates.open'))
|
|
->url($resource::getUrl('edit', ['record' => $matches->first()]))
|
|
->openUrlInNewTab(),
|
|
])
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* The closest few existing records whose title, in this language, reads
|
|
* almost the same as this one.
|
|
*
|
|
* Compared in PHP over every record of the type rather than in SQL:
|
|
* there is no "how alike are these two strings" operator that both
|
|
* SQLite and Postgres agree on, and at the scale a collective's content
|
|
* actually reaches — dozens to a few hundred records of one type — the
|
|
* whole table fits in memory without anyone noticing the cost. This runs
|
|
* once per blur of a title field, not in a loop or a background job.
|
|
*
|
|
* @param class-string<Model> $model
|
|
* @return Collection<int, Model>
|
|
*/
|
|
public static function similarTo(string $model, string $title, string $locale, int|string|null $excludeId): Collection
|
|
{
|
|
$title = trim($title);
|
|
|
|
if ($title === '') {
|
|
return collect();
|
|
}
|
|
|
|
return $model::query()
|
|
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
|
|
->get(['id', 'title'])
|
|
->map(fn (Model $record) => [
|
|
$record,
|
|
self::similarity($title, (string) $record->getTranslation('title', $locale, false)),
|
|
])
|
|
->filter(fn (array $pair) => $pair[1] >= self::THRESHOLD)
|
|
->sortByDesc(fn (array $pair) => $pair[1])
|
|
->take(3)
|
|
->map(fn (array $pair) => $pair[0])
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* Percentage of characters in common, case-insensitively. Blunt on
|
|
* purpose — this only ever has to catch "basically the same words",
|
|
* not near-misses a real spell-checker would.
|
|
*/
|
|
protected static function similarity(string $a, string $b): float
|
|
{
|
|
if ($b === '') {
|
|
return 0.0;
|
|
}
|
|
|
|
similar_text(mb_strtolower($a), mb_strtolower($b), $percent);
|
|
|
|
return $percent;
|
|
}
|
|
}
|