feat: persist image captions and managed tags with fuzzy search
This commit is contained in:
4 files changed
+348
-125
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import { HttpError } from './errors.js';
|
||||
import { normalizeTag } from './metadata.js';
|
||||
|
||||
export function searchOptions(params) {
|
||||
const q = (params.get('q') || '').trim();
|
||||
if (q.length > 200) throw new HttpError(400, 'Search query must be at most 200 characters.');
|
||||
const tags = params.getAll('tag');
|
||||
if (tags.length > 20) throw new HttpError(400, 'Use at most 20 tag filters.');
|
||||
return { q, tags: [...new Set(tags.map(normalizeTag))] };
|
||||
}
|
||||
|
||||
export function rankMemes(entries, { q = '', tags = [], missingCaption = false } = {}) {
|
||||
const query = fold(q);
|
||||
const terms = [...new Set(words(query))];
|
||||
return entries.filter((entry) => tags.every((tag) => entry.tags.includes(tag)) && (!missingCaption || !entry.caption))
|
||||
.map((entry) => ({ entry, score: relevance(entry, query, terms) }))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score || b.entry.createdAt.localeCompare(a.entry.createdAt) || a.entry.id.localeCompare(b.entry.id))
|
||||
.map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
function relevance(entry, query, terms) {
|
||||
if (!query) return 1;
|
||||
if (!terms.length) return 0;
|
||||
const text = fold([entry.caption, ...entry.tags].join(' '));
|
||||
const candidates = [...new Set(words(text))];
|
||||
let score = text.includes(query) ? 20 : 0;
|
||||
for (const term of terms) {
|
||||
let best = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === term) best = Math.max(best, 10);
|
||||
else if (candidate.startsWith(term)) best = Math.max(best, 6);
|
||||
else if (term.length >= 4 && near(term, candidate, term.length >= 7 ? 2 : 1)) best = Math.max(best, 3);
|
||||
}
|
||||
if (!best) return 0; // Every query term must match; results remain useful for multiword searches.
|
||||
score += best;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function fold(value) {
|
||||
return value.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
|
||||
}
|
||||
|
||||
function words(value) {
|
||||
return value.match(/[\p{L}\p{N}]+/gu) || [];
|
||||
}
|
||||
|
||||
function near(a, b, limit) {
|
||||
if (Math.abs(a.length - b.length) > limit) return false;
|
||||
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= a.length; i += 1) {
|
||||
const row = [i];
|
||||
for (let j = 1; j <= b.length; j += 1) {
|
||||
row[j] = Math.min(row[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
}
|
||||
if (Math.min(...row) > limit) return false;
|
||||
previous = row;
|
||||
}
|
||||
return previous[b.length] <= limit;
|
||||
}
|
||||
Reference in new issue
Block a user