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,39 @@
|
|||||||
|
import { HttpError } from './errors.js';
|
||||||
|
|
||||||
|
export const CAPTION_MAX_LENGTH = 2000;
|
||||||
|
export const TAG_MAX_LENGTH = 60;
|
||||||
|
export const TAG_LIMIT = 200;
|
||||||
|
|
||||||
|
export function normalizeTag(value) {
|
||||||
|
if (typeof value !== 'string') throw new HttpError(400, 'Tag must be a string.');
|
||||||
|
const name = value.normalize('NFC').trim().replace(/\s+/gu, ' ').toLowerCase();
|
||||||
|
if (!name || name.length > TAG_MAX_LENGTH || !/^[\p{L}\p{N}][\p{L}\p{N} _-]*$/u.test(name)) {
|
||||||
|
throw new HttpError(400, 'Tags must be 1–60 characters: letters, numbers, spaces, underscores, or hyphens.');
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMetadata(input, availableTags) {
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new HttpError(400, 'Expected a metadata object.');
|
||||||
|
const result = {};
|
||||||
|
if ('caption' in input) {
|
||||||
|
if (typeof input.caption !== 'string' || input.caption.length > CAPTION_MAX_LENGTH) {
|
||||||
|
throw new HttpError(400, `Caption must be a string of at most ${CAPTION_MAX_LENGTH} characters.`);
|
||||||
|
}
|
||||||
|
result.caption = input.caption.trim();
|
||||||
|
}
|
||||||
|
if ('tags' in input) {
|
||||||
|
if (!Array.isArray(input.tags) || input.tags.length > TAG_LIMIT) throw new HttpError(400, 'Tags must be an array of at most 200 managed tag names.');
|
||||||
|
result.tags = [...new Set(input.tags.map(normalizeTag))];
|
||||||
|
if (result.tags.some((tag) => !availableTags.includes(tag))) throw new HttpError(400, 'Choose tags from the managed tag catalog.');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAiMetadata(raw, availableTags) {
|
||||||
|
return {
|
||||||
|
caption: typeof raw?.caption === 'string' ? raw.caption.trim().slice(0, CAPTION_MAX_LENGTH) : '',
|
||||||
|
tags: Array.isArray(raw?.tags) ? [...new Set(raw.tags.filter((tag) => typeof tag === 'string')
|
||||||
|
.map((tag) => tag.trim().toLowerCase()).filter((tag) => availableTags.includes(tag)))] : []
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+154
-125
@@ -1,15 +1,32 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { HttpError } from './errors.js';
|
||||||
|
import { normalizeTag, validateMetadata, TAG_LIMIT } from './metadata.js';
|
||||||
|
import { rankMemes } from './search.js';
|
||||||
|
|
||||||
export async function createStore({ dataDir }) {
|
export async function createStore({ dataDir }) {
|
||||||
const root = path.resolve(dataDir);
|
const root = path.resolve(dataDir);
|
||||||
const indexDir = path.join(root, 'index');
|
const indexDir = path.join(root, 'index');
|
||||||
const indexFile = path.join(indexDir, 'memes.jsonl');
|
const indexFile = path.join(indexDir, 'memes.jsonl');
|
||||||
|
const tagsFile = path.join(indexDir, 'tags.json');
|
||||||
await fs.mkdir(indexDir, { recursive: true });
|
await fs.mkdir(indexDir, { recursive: true });
|
||||||
const entries = await loadIndex(indexFile, root);
|
const entries = await loadIndex(indexFile, root);
|
||||||
|
let tags = [];
|
||||||
|
try {
|
||||||
|
tags = [...new Set(JSON.parse(await fs.readFile(tagsFile, 'utf8')).map(normalizeTag))].sort();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
for (const entry of entries) entry.tags = entry.tags.filter((tag) => tags.includes(tag));
|
||||||
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||||
let writeChain = Promise.resolve();
|
let writeChain = Promise.resolve();
|
||||||
|
function mutate(operation) {
|
||||||
|
const result = writeChain.then(operation);
|
||||||
|
writeChain = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const persist = () => persistAll(root, indexFile, entries);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
count(status) {
|
count(status) {
|
||||||
@@ -21,123 +38,142 @@ export async function createStore({ dataDir }) {
|
|||||||
absolutePath(storageKey) {
|
absolutePath(storageKey) {
|
||||||
return path.join(root, storageKey);
|
return path.join(root, storageKey);
|
||||||
},
|
},
|
||||||
list({ page, pageSize }) {
|
listTags() {
|
||||||
const visible = entries.filter((entry) => entry.status === 'approved');
|
return [...tags];
|
||||||
const total = visible.length;
|
},
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
addTag(name) {
|
||||||
const safePage = Math.min(page, totalPages);
|
return mutate(async () => {
|
||||||
const start = (safePage - 1) * pageSize;
|
const tag = normalizeTag(name);
|
||||||
return {
|
if (tags.includes(tag)) throw new HttpError(409, 'Tag already exists.');
|
||||||
page: safePage,
|
if (tags.length >= TAG_LIMIT) throw new HttpError(400, 'The tag catalog is limited to 200 tags.');
|
||||||
pageSize,
|
tags = [...tags, tag].sort();
|
||||||
total,
|
await atomicWrite(tagsFile, `${JSON.stringify(tags, null, 2)}\n`);
|
||||||
totalPages,
|
return tag;
|
||||||
memes: visible.slice(start, start + pageSize).map(toPublicMeme)
|
});
|
||||||
};
|
},
|
||||||
|
deleteTag(name) {
|
||||||
|
return mutate(async () => {
|
||||||
|
const tag = normalizeTag(name);
|
||||||
|
if (!tags.includes(tag)) throw new HttpError(404, 'Tag not found.');
|
||||||
|
tags = tags.filter((value) => value !== tag);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.tags.includes(tag)) {
|
||||||
|
entry.tags = entry.tags.filter((value) => value !== tag);
|
||||||
|
entry.metadataVersion += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await persist();
|
||||||
|
await atomicWrite(tagsFile, `${JSON.stringify(tags, null, 2)}\n`);
|
||||||
|
return tag;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
list({ page, pageSize, q = '', tags: filters = [] }) {
|
||||||
|
return paginate(rankMemes(entries.filter((entry) => entry.status === 'approved'), { q, tags: filters }), page, pageSize);
|
||||||
|
},
|
||||||
|
listForAdmin({ page = 1, pageSize = 12, status = 'all', ...search } = {}) {
|
||||||
|
if (!['all', 'approved', 'pending'].includes(status)) throw new HttpError(400, 'Status must be all, approved, or pending.');
|
||||||
|
return paginate(rankMemes(entries.filter((entry) => status === 'all' || entry.status === status), search), page, pageSize);
|
||||||
},
|
},
|
||||||
listForReview({ status = 'pending' } = {}) {
|
listForReview({ status = 'pending' } = {}) {
|
||||||
return {
|
const memes = entries.filter((entry) => entry.status === status);
|
||||||
total: entries.filter((entry) => entry.status === status).length,
|
return { total: memes.length, memes: memes.map(toPublicMeme) };
|
||||||
memes: entries.filter((entry) => entry.status === status).map(toPublicMeme)
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
async approve(ids) {
|
updateMetadata(id, input, { expectedVersion } = {}) {
|
||||||
const approved = [];
|
return mutate(async () => {
|
||||||
for (const id of ids) {
|
|
||||||
const record = byId.get(id);
|
const record = byId.get(id);
|
||||||
if (!record || record.status !== 'pending') continue;
|
if (!record) throw new HttpError(404, 'Meme not found.');
|
||||||
record.status = 'approved';
|
if (expectedVersion !== undefined && record.metadataVersion !== expectedVersion) {
|
||||||
record.reviewedAt = new Date().toISOString();
|
throw new HttpError(409, 'Metadata changed during AI generation. Refresh and try again.');
|
||||||
record.moderationReason = `${record.moderationReason || 'Queued for review.'} Admin approved.`;
|
}
|
||||||
approved.push(toPublicMeme(record));
|
const metadata = validateMetadata(input, tags);
|
||||||
}
|
if (!Object.keys(metadata).length) throw new HttpError(400, 'Supply caption and/or tags.');
|
||||||
if (approved.length > 0) {
|
Object.assign(record, metadata, { metadataVersion: record.metadataVersion + 1 });
|
||||||
writeChain = writeChain.then(() => persistAll(root, indexFile, entries));
|
await persist();
|
||||||
await writeChain;
|
return toPublicMeme(record);
|
||||||
}
|
});
|
||||||
return approved;
|
|
||||||
},
|
},
|
||||||
async delete(ids) {
|
approve(ids) {
|
||||||
const deleted = [];
|
return mutate(async () => {
|
||||||
for (const id of ids) {
|
const approved = [];
|
||||||
const index = entries.findIndex((entry) => entry.id === id);
|
for (const id of ids) {
|
||||||
if (index === -1) continue;
|
const record = byId.get(id);
|
||||||
const [record] = entries.splice(index, 1);
|
if (!record || record.status !== 'pending') continue;
|
||||||
byId.delete(id);
|
record.status = 'approved';
|
||||||
deleted.push(id);
|
record.reviewedAt = new Date().toISOString();
|
||||||
await fs.rm(path.join(root, record.storageKey), { force: true });
|
record.moderationReason = `${record.moderationReason || 'Queued for review.'} Admin approved.`;
|
||||||
await fs.rm(path.join(root, record.metaKey), { force: true });
|
approved.push(toPublicMeme(record));
|
||||||
}
|
}
|
||||||
if (deleted.length > 0) {
|
if (approved.length) await persist();
|
||||||
writeChain = writeChain.then(() => persistAll(root, indexFile, entries));
|
return approved;
|
||||||
await writeChain;
|
});
|
||||||
}
|
|
||||||
return deleted;
|
|
||||||
},
|
},
|
||||||
async incrementMetric(id, metric) {
|
delete(ids) {
|
||||||
const record = byId.get(id);
|
return mutate(async () => {
|
||||||
if (!record) return null;
|
const deleted = [];
|
||||||
if (metric !== 'viewCount' && metric !== 'downloadCount') return toPublicMeme(record);
|
for (const id of ids) {
|
||||||
|
const index = entries.findIndex((entry) => entry.id === id);
|
||||||
record[metric] = Number.isFinite(record[metric]) ? record[metric] + 1 : 1;
|
if (index === -1) continue;
|
||||||
writeChain = writeChain.then(() => persistRecord(root, indexFile, entries, record));
|
const [record] = entries.splice(index, 1);
|
||||||
await writeChain;
|
byId.delete(id);
|
||||||
return toPublicMeme(record);
|
deleted.push(id);
|
||||||
|
await fs.rm(path.join(root, record.storageKey), { force: true });
|
||||||
|
await fs.rm(path.join(root, record.metaKey), { force: true });
|
||||||
|
}
|
||||||
|
if (deleted.length) await persist();
|
||||||
|
return deleted;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async save({
|
incrementMetric(id, metric) {
|
||||||
buffer,
|
return mutate(async () => {
|
||||||
image,
|
const record = byId.get(id);
|
||||||
originalName,
|
if (!record) return null;
|
||||||
createdAt = new Date().toISOString(),
|
if (metric !== 'viewCount' && metric !== 'downloadCount') return toPublicMeme(record);
|
||||||
demo = false,
|
record[metric] = Number.isFinite(record[metric]) ? record[metric] + 1 : 1;
|
||||||
status = 'approved',
|
await persist();
|
||||||
moderationScore = demo ? 90 : 0,
|
return toPublicMeme(record);
|
||||||
moderationReason = '',
|
});
|
||||||
originalMime = image.mime
|
},
|
||||||
}) {
|
save({ buffer, image, originalName, createdAt = new Date().toISOString(), demo = false,
|
||||||
const id = crypto.createHash('sha256').update(buffer).digest('hex');
|
status = 'approved', moderationScore = demo ? 90 : 0, moderationReason = '', originalMime = image.mime,
|
||||||
const existing = byId.get(id);
|
caption = '', tags: assignedTags = [] }) {
|
||||||
if (existing) return toPublicMeme(existing);
|
return mutate(async () => {
|
||||||
|
const id = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
const date = createdAt.slice(0, 10).replaceAll('-', '/');
|
const existing = byId.get(id);
|
||||||
const shard = `${id.slice(0, 2)}/${id.slice(2, 4)}`;
|
if (existing) return toPublicMeme(existing);
|
||||||
const storageKey = `memes/${date}/${shard}/${id}.${image.ext}`;
|
const date = createdAt.slice(0, 10).replaceAll('-', '/');
|
||||||
const metaKey = `meta/${date}/${shard}/${id}.json`;
|
const shard = `${id.slice(0, 2)}/${id.slice(2, 4)}`;
|
||||||
const record = {
|
const storageKey = `memes/${date}/${shard}/${id}.${image.ext}`;
|
||||||
id,
|
const metaKey = `meta/${date}/${shard}/${id}.json`;
|
||||||
createdAt,
|
// Tags can be removed while an upload's AI request is running.
|
||||||
originalName,
|
const metadata = validateMetadata({ caption, tags: assignedTags.filter((tag) => tags.includes(tag)) }, tags);
|
||||||
byteSize: image.byteSize,
|
const record = {
|
||||||
width: image.width,
|
id, createdAt, originalName, byteSize: image.byteSize, width: image.width, height: image.height,
|
||||||
height: image.height,
|
mime: image.mime, originalMime, ext: image.ext, status, moderationScore, moderationReason,
|
||||||
mime: image.mime,
|
...metadata, metadataVersion: 0, viewCount: 0, downloadCount: 0, storageKey, metaKey, demo
|
||||||
originalMime,
|
};
|
||||||
ext: image.ext,
|
await fs.mkdir(path.dirname(path.join(root, storageKey)), { recursive: true });
|
||||||
status,
|
await fs.mkdir(path.dirname(path.join(root, metaKey)), { recursive: true });
|
||||||
moderationScore,
|
await fs.writeFile(path.join(root, storageKey), buffer, { flag: 'wx' }).catch((error) => {
|
||||||
moderationReason,
|
if (error.code !== 'EEXIST') throw error;
|
||||||
viewCount: 0,
|
});
|
||||||
downloadCount: 0,
|
entries.unshift(record);
|
||||||
storageKey,
|
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
metaKey,
|
byId.set(id, record);
|
||||||
demo
|
await persist();
|
||||||
};
|
return toPublicMeme(record);
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(path.join(root, storageKey)), { recursive: true });
|
|
||||||
await fs.mkdir(path.dirname(path.join(root, metaKey)), { recursive: true });
|
|
||||||
await fs.writeFile(path.join(root, storageKey), buffer, { flag: 'wx' }).catch((error) => {
|
|
||||||
if (error.code !== 'EEXIST') throw error;
|
|
||||||
});
|
});
|
||||||
await persistRecord(root, indexFile, [...entries, record], record, { writeFileFlag: 'wx' });
|
|
||||||
|
|
||||||
entries.unshift(record);
|
|
||||||
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
||||||
byId.set(id, record);
|
|
||||||
return toPublicMeme(record);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paginate(entries, page, pageSize) {
|
||||||
|
const total = entries.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
return { page: safePage, pageSize, total, totalPages, memes: entries.slice(start, start + pageSize).map(toPublicMeme) };
|
||||||
|
}
|
||||||
|
|
||||||
async function loadIndex(indexFile, root) {
|
async function loadIndex(indexFile, root) {
|
||||||
let content = '';
|
let content = '';
|
||||||
try {
|
try {
|
||||||
@@ -145,7 +181,6 @@ async function loadIndex(indexFile, root) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code !== 'ENOENT') throw error;
|
if (error.code !== 'ENOENT') throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordsById = new Map();
|
const recordsById = new Map();
|
||||||
for (const line of content.split('\n')) {
|
for (const line of content.split('\n')) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
@@ -157,6 +192,9 @@ async function loadIndex(indexFile, root) {
|
|||||||
record.originalMime = record.originalMime || record.mime;
|
record.originalMime = record.originalMime || record.mime;
|
||||||
record.viewCount = Number.isFinite(record.viewCount) ? record.viewCount : 0;
|
record.viewCount = Number.isFinite(record.viewCount) ? record.viewCount : 0;
|
||||||
record.downloadCount = Number.isFinite(record.downloadCount) ? record.downloadCount : 0;
|
record.downloadCount = Number.isFinite(record.downloadCount) ? record.downloadCount : 0;
|
||||||
|
record.caption = typeof record.caption === 'string' ? record.caption : '';
|
||||||
|
record.tags = Array.isArray(record.tags) ? record.tags : [];
|
||||||
|
record.metadataVersion = Number.isInteger(record.metadataVersion) ? record.metadataVersion : 0;
|
||||||
const mediaPath = path.join(root, record.storageKey);
|
const mediaPath = path.join(root, record.storageKey);
|
||||||
const relative = path.relative(root, mediaPath);
|
const relative = path.relative(root, mediaPath);
|
||||||
if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
|
if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
|
||||||
@@ -166,32 +204,21 @@ async function loadIndex(indexFile, root) {
|
|||||||
// Ignore corrupt index lines; individual metadata files remain on disk for recovery.
|
// Ignore corrupt index lines; individual metadata files remain on disk for recovery.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const records = [...recordsById.values()];
|
return [...recordsById.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
records.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistRecord(root, indexFile, entries, record, options = {}) {
|
async function atomicWrite(file, content) {
|
||||||
await fs.writeFile(path.join(root, record.metaKey), `${JSON.stringify(record, null, 2)}\n`, {
|
const temporary = `${file}.tmp`;
|
||||||
flag: options.writeFileFlag || 'w'
|
await fs.writeFile(temporary, content);
|
||||||
}).catch((error) => {
|
await fs.rename(temporary, file);
|
||||||
if (options.writeFileFlag === 'wx' && error.code === 'EEXIST') return;
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
await persistAll(root, indexFile, entries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistAll(root, indexFile, entries) {
|
async function persistAll(root, indexFile, entries) {
|
||||||
await Promise.all(entries.map((entry) => fs.writeFile(
|
await Promise.all(entries.map((entry) => atomicWrite(path.join(root, entry.metaKey), `${JSON.stringify(entry, null, 2)}\n`)));
|
||||||
path.join(root, entry.metaKey),
|
await atomicWrite(indexFile, `${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`);
|
||||||
`${JSON.stringify(entry, null, 2)}\n`
|
|
||||||
).catch((error) => {
|
|
||||||
if (error.code !== 'ENOENT') throw error;
|
|
||||||
})));
|
|
||||||
await fs.writeFile(indexFile, `${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPublicMeme(record) {
|
export function toPublicMeme(record) {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
createdAt: record.createdAt,
|
createdAt: record.createdAt,
|
||||||
@@ -203,6 +230,8 @@ function toPublicMeme(record) {
|
|||||||
status: record.status,
|
status: record.status,
|
||||||
moderationScore: record.moderationScore,
|
moderationScore: record.moderationScore,
|
||||||
moderationReason: record.moderationReason,
|
moderationReason: record.moderationReason,
|
||||||
|
caption: record.caption || '',
|
||||||
|
tags: [...(record.tags || [])],
|
||||||
viewCount: record.viewCount,
|
viewCount: record.viewCount,
|
||||||
downloadCount: record.downloadCount,
|
downloadCount: record.downloadCount,
|
||||||
originalName: record.originalName,
|
originalName: record.originalName,
|
||||||
|
|||||||
@@ -55,3 +55,97 @@ test('increments view and download counters', async () => {
|
|||||||
assert.equal(listed.viewCount, 1);
|
assert.equal(listed.viewCount, 1);
|
||||||
assert.equal(listed.downloadCount, 1);
|
assert.equal(listed.downloadCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function metadataStore(t) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'meme-metadata-'));
|
||||||
|
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||||
|
const store = await createStore({ dataDir: dir });
|
||||||
|
let seed = 0;
|
||||||
|
async function save(options = {}) {
|
||||||
|
const color = ++seed;
|
||||||
|
const buffer = encodePng(8, 8, () => [color, 200, 50, 255]);
|
||||||
|
const image = validateImage(buffer, { maxBytes: 100_000, maxWidth: 6000, maxHeight: 6000, maxPixels: 20_000_000 });
|
||||||
|
return store.save({ buffer, image, originalName: 'test.png', ...options });
|
||||||
|
}
|
||||||
|
return { dir, store, save };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('persists editable metadata and managed tags, removing assignments on tag deletion', async (t) => {
|
||||||
|
const { dir, store, save } = await metadataStore(t);
|
||||||
|
await store.addTag(' Cats ');
|
||||||
|
await store.addTag('work');
|
||||||
|
await assert.rejects(store.addTag('CATS'), { statusCode: 409 });
|
||||||
|
await assert.rejects(store.addTag('<script>'), { statusCode: 400 });
|
||||||
|
const meme = await save({ caption: 'An angry cat', tags: ['cats'], status: 'pending' });
|
||||||
|
await assert.rejects(store.updateMetadata(meme.id, { tags: ['invented'] }), { statusCode: 400 });
|
||||||
|
await assert.rejects(store.updateMetadata(meme.id, { caption: 'x'.repeat(2001) }), { statusCode: 400 });
|
||||||
|
await assert.rejects(store.updateMetadata(meme.id, { tags: null }), { statusCode: 400 });
|
||||||
|
await store.updateMetadata(meme.id, { caption: 'A cat sleeping at work', tags: ['WORK', 'cats', 'cats'] });
|
||||||
|
const reloaded = await createStore({ dataDir: dir });
|
||||||
|
assert.deepEqual(reloaded.listTags(), ['cats', 'work']);
|
||||||
|
assert.equal(reloaded.get(meme.id).caption, 'A cat sleeping at work');
|
||||||
|
assert.deepEqual(reloaded.get(meme.id).tags, ['work', 'cats']);
|
||||||
|
assert.equal(reloaded.get(meme.id).status, 'pending');
|
||||||
|
await reloaded.deleteTag('cats');
|
||||||
|
const finalStore = await createStore({ dataDir: dir });
|
||||||
|
assert.deepEqual(finalStore.get(meme.id).tags, ['work']);
|
||||||
|
assert.deepEqual(finalStore.listTags(), ['work']);
|
||||||
|
const diskRecord = JSON.parse(await fs.readFile(path.join(dir, finalStore.get(meme.id).metaKey), 'utf8'));
|
||||||
|
assert.deepEqual(diskRecord.tags, ['work']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searches captions with relevance, typo tolerance, accents, topic intersections, and approved-only visibility', async (t) => {
|
||||||
|
const { store, save } = await metadataStore(t);
|
||||||
|
await store.addTag('cats');
|
||||||
|
await store.addTag('work');
|
||||||
|
const older = await save({ caption: 'An angry cat at the office', tags: ['cats', 'work'], createdAt: '2026-01-01T00:00:00Z' });
|
||||||
|
const newer = await save({ caption: 'An angry catfish sleeping', tags: [], createdAt: '2026-02-01T00:00:00Z' });
|
||||||
|
const accent = await save({ caption: 'A café worker', tags: ['work'] });
|
||||||
|
const pending = await save({ caption: 'An angry cat at the office', status: 'pending', tags: ['cats', 'work'] });
|
||||||
|
const list = (options = {}) => store.list({ page: 1, pageSize: 12, ...options });
|
||||||
|
assert.deepEqual(list({ q: 'ANGRY cat' }).memes.map((meme) => meme.id), [older.id, newer.id]);
|
||||||
|
assert.deepEqual(list({ q: 'angry catt' }).memes.map((meme) => meme.id), [older.id]);
|
||||||
|
assert.equal(list({ q: 'cafe' }).memes[0].id, accent.id);
|
||||||
|
assert.equal(list({ q: 'ang' }).memes[0].id, newer.id); // Equal relevance uses newest-first order.
|
||||||
|
assert.deepEqual(list({ tags: ['cats', 'work'] }).memes.map((meme) => meme.id), [older.id]);
|
||||||
|
assert.equal(list({ q: 'cat banana' }).total, 0);
|
||||||
|
assert.equal(list({ q: '!!!' }).total, 0);
|
||||||
|
assert.equal(list({ tags: ['unknown'] }).total, 0);
|
||||||
|
assert.equal(list({ q: older.id }).total, 0);
|
||||||
|
assert.ok(!list().memes.some((meme) => meme.id === pending.id));
|
||||||
|
assert.equal(list({ page: 99, pageSize: 1 }).page, 3);
|
||||||
|
const missing = await save();
|
||||||
|
assert.deepEqual(store.listForAdmin({ missingCaption: true }).memes.map((meme) => meme.id), [missing.id]);
|
||||||
|
assert.equal(store.listForAdmin({ status: 'all' }).total, 5);
|
||||||
|
assert.equal(list().memes.some((meme) => meme.id === newer.id), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prevents AI generation from overwriting concurrent edits and serializes writes', async (t) => {
|
||||||
|
const { dir, store, save } = await metadataStore(t);
|
||||||
|
const meme = await save({ caption: 'Original' });
|
||||||
|
const version = store.get(meme.id).metadataVersion;
|
||||||
|
await Promise.all([
|
||||||
|
store.updateMetadata(meme.id, { caption: 'Admin correction' }),
|
||||||
|
store.incrementMetric(meme.id, 'viewCount')
|
||||||
|
]);
|
||||||
|
await assert.rejects(store.updateMetadata(meme.id, { caption: 'AI result' }, { expectedVersion: version }), { statusCode: 409 });
|
||||||
|
const reloaded = await createStore({ dataDir: dir });
|
||||||
|
assert.equal(reloaded.get(meme.id).caption, 'Admin correction');
|
||||||
|
assert.equal(reloaded.get(meme.id).viewCount, 1);
|
||||||
|
assert.equal('metadataVersion' in reloaded.list({ page: 1, pageSize: 12 }).memes[0], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads legacy images with empty metadata without changing their status', async (t) => {
|
||||||
|
const { dir, store, save } = await metadataStore(t);
|
||||||
|
const meme = await save();
|
||||||
|
const record = { ...store.get(meme.id) };
|
||||||
|
delete record.caption;
|
||||||
|
delete record.tags;
|
||||||
|
delete record.metadataVersion;
|
||||||
|
await fs.writeFile(path.join(dir, 'index', 'memes.jsonl'), `${JSON.stringify(record)}\n`);
|
||||||
|
const reloaded = await createStore({ dataDir: dir });
|
||||||
|
const result = reloaded.list({ page: 1, pageSize: 12 }).memes[0];
|
||||||
|
assert.equal(result.caption, '');
|
||||||
|
assert.deepEqual(result.tags, []);
|
||||||
|
assert.equal(result.status, 'approved');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user