$model * @param class-string $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 * @return Collection */ 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; } }