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:
121
app/Support/DuplicateTitles.php
Normal file
121
app/Support/DuplicateTitles.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user