build collections

This commit is contained in:
2026-03-28 01:34:27 -05:00
parent 2c128a404e
commit 8b502119f1
12 changed files with 704 additions and 114 deletions

View File

@@ -10,23 +10,31 @@ fs.mkdirSync(DB_DIR, { recursive: true });
const db = new Database(DB_PATH);
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Core tables
db.exec(`
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS memes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
parent_id TEXT REFERENCES memes(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
parent_id TEXT REFERENCES memes(id) ON DELETE CASCADE,
collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tags (
@@ -42,8 +50,34 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_memes_parent_id ON memes(parent_id);
CREATE INDEX IF NOT EXISTS idx_memes_created_at ON memes(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_memes_collection_id ON memes(collection_id);
CREATE INDEX IF NOT EXISTS idx_meme_tags_meme_id ON meme_tags(meme_id);
CREATE INDEX IF NOT EXISTS idx_meme_tags_tag_id ON meme_tags(tag_id);
`);
// Migration: add collection_id column if upgrading from earlier schema
const memesCols = db.prepare('PRAGMA table_info(memes)').all() as { name: string }[];
if (!memesCols.find((c) => c.name === 'collection_id')) {
db.exec('ALTER TABLE memes ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL');
db.exec('CREATE INDEX IF NOT EXISTS idx_memes_collection_id ON memes(collection_id)');
}
// Seed the default UNSORTED collection
const defaultCollection = db
.prepare('SELECT id FROM collections WHERE is_default = 1')
.get() as { id: number } | undefined;
if (!defaultCollection) {
db.prepare("INSERT INTO collections (name, is_default) VALUES ('Unsorted', 1)").run();
}
const unsorted = db
.prepare('SELECT id FROM collections WHERE is_default = 1')
.get() as { id: number };
// Assign any existing memes with no collection to UNSORTED
db.prepare('UPDATE memes SET collection_id = ? WHERE collection_id IS NULL').run(unsorted.id);
export const UNSORTED_ID = unsorted.id;
export default db;

View File

@@ -7,6 +7,7 @@ import { ensureImagesDir, IMAGES_DIR } from './services/storage.js';
import { memesRoutes } from './routes/memes.js';
import { tagsRoutes } from './routes/tags.js';
import { authRoutes } from './routes/auth.js';
import { collectionsRoutes } from './routes/collections.js';
// Ensure data dirs exist
ensureImagesDir();
@@ -37,6 +38,7 @@ await app.register(fastifyStatic, {
// API routes
await app.register(authRoutes);
await app.register(collectionsRoutes);
await app.register(memesRoutes);
await app.register(tagsRoutes);

View File

@@ -0,0 +1,97 @@
import type { FastifyInstance } from 'fastify';
import db from '../db.js';
import { requireAuth } from '../auth.js';
import type { Collection } from '../types.js';
export async function collectionsRoutes(app: FastifyInstance) {
// List all collections with meme counts
app.get('/api/collections', async () => {
return db
.prepare(
`SELECT c.id, c.name, c.is_default, c.created_at,
COUNT(m.id) as meme_count
FROM collections c
LEFT JOIN memes m ON m.collection_id = c.id AND m.parent_id IS NULL
GROUP BY c.id
ORDER BY c.is_default DESC, c.name ASC`
)
.all() as Collection[];
});
// Create collection
app.post<{ Body: { name: string } }>(
'/api/collections',
{ preHandler: requireAuth },
async (req, reply) => {
const name = req.body?.name?.trim();
if (!name) return reply.status(400).send({ error: 'Name is required' });
try {
const result = db
.prepare('INSERT INTO collections (name) VALUES (?)')
.run(name);
return reply.status(201).send(
db
.prepare(
`SELECT c.id, c.name, c.is_default, c.created_at, 0 as meme_count
FROM collections c WHERE c.id = ?`
)
.get(result.lastInsertRowid)
);
} catch {
return reply.status(409).send({ error: 'A folder with that name already exists' });
}
}
);
// Rename collection
app.put<{ Params: { id: string }; Body: { name: string } }>(
'/api/collections/:id',
{ preHandler: requireAuth },
async (req, reply) => {
const id = Number(req.params.id);
const name = req.body?.name?.trim();
if (!name) return reply.status(400).send({ error: 'Name is required' });
const col = db.prepare('SELECT * FROM collections WHERE id = ?').get(id) as Collection | undefined;
if (!col) return reply.status(404).send({ error: 'Folder not found' });
if (col.is_default) return reply.status(400).send({ error: 'Cannot rename the Unsorted folder' });
try {
db.prepare('UPDATE collections SET name = ? WHERE id = ?').run(name, id);
return db
.prepare(
`SELECT c.id, c.name, c.is_default, c.created_at,
COUNT(m.id) as meme_count
FROM collections c
LEFT JOIN memes m ON m.collection_id = c.id AND m.parent_id IS NULL
WHERE c.id = ?
GROUP BY c.id`
)
.get(id);
} catch {
return reply.status(409).send({ error: 'A folder with that name already exists' });
}
}
);
// Delete collection — moves its memes to Unsorted first
app.delete<{ Params: { id: string } }>(
'/api/collections/:id',
{ preHandler: requireAuth },
async (req, reply) => {
const id = Number(req.params.id);
const col = db.prepare('SELECT * FROM collections WHERE id = ?').get(id) as Collection | undefined;
if (!col) return reply.status(404).send({ error: 'Folder not found' });
if (col.is_default) return reply.status(400).send({ error: 'Cannot delete the Unsorted folder' });
const unsorted = db
.prepare('SELECT id FROM collections WHERE is_default = 1')
.get() as { id: number };
db.prepare('UPDATE memes SET collection_id = ? WHERE collection_id = ?').run(unsorted.id, id);
db.prepare('DELETE FROM collections WHERE id = ?').run(id);
return { ok: true };
}
);
}

View File

@@ -1,11 +1,11 @@
import type { FastifyInstance } from 'fastify';
import type { MultipartFile } from '@fastify/multipart';
import { v4 as uuidv4 } from 'uuid';
import db from '../db.js';
import db, { UNSORTED_ID } from '../db.js';
import { buildFilePath, deleteFile, getExtension } from '../services/storage.js';
import { extractMeta, resizeImage, saveBuffer } from '../services/image.js';
import { requireAuth } from '../auth.js';
import type { ListQuery, UpdateBody, RescaleBody, Meme } from '../types.js';
import type { ListQuery, UpdateBody, RescaleBody, MoveBody, Meme } from '../types.js';
const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
@@ -40,23 +40,17 @@ function setMemeTags(memeId: string, tagNames: string[]): void {
const res = db.prepare('INSERT INTO tags (name) VALUES (?)').run(trimmed);
tag = { id: Number(res.lastInsertRowid) };
}
db.prepare('INSERT OR IGNORE INTO meme_tags (meme_id, tag_id) VALUES (?, ?)').run(
memeId,
tag.id
);
db.prepare('INSERT OR IGNORE INTO meme_tags (meme_id, tag_id) VALUES (?, ?)').run(memeId, tag.id);
}
}
export async function memesRoutes(app: FastifyInstance) {
// List memes
app.get<{ Querystring: ListQuery }>('/api/memes', async (req) => {
const { tag, q, page = 1, limit = 50, parent_only = 'true' } = req.query;
const { tag, q, page = 1, limit = 50, parent_only = 'true', collection_id } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let sql = `
SELECT DISTINCT m.*
FROM memes m
`;
let sql = `SELECT DISTINCT m.* FROM memes m`;
const params: (string | number)[] = [];
const conditions: string[] = [];
@@ -64,16 +58,21 @@ export async function memesRoutes(app: FastifyInstance) {
conditions.push('m.parent_id IS NULL');
}
if (collection_id !== undefined) {
conditions.push('m.collection_id = ?');
params.push(Number(collection_id));
}
if (tag) {
sql += `
JOIN meme_tags mt_filter ON mt_filter.meme_id = m.id
JOIN tags t_filter ON t_filter.id = mt_filter.tag_id AND t_filter.name = ?
`;
params.push(tag.toLowerCase());
params.unshift(tag.toLowerCase());
}
if (q) {
conditions.push(`(m.title LIKE ? OR m.description LIKE ?)`);
conditions.push('(m.title LIKE ? OR m.description LIKE ?)');
params.push(`%${q}%`, `%${q}%`);
}
@@ -85,17 +84,25 @@ export async function memesRoutes(app: FastifyInstance) {
params.push(Number(limit), offset);
const memes = db.prepare(sql).all(...params) as Meme[];
const total = (
db
.prepare(
`SELECT COUNT(DISTINCT m.id) as count FROM memes m
${tag ? `JOIN meme_tags mt_filter ON mt_filter.meme_id = m.id JOIN tags t_filter ON t_filter.id = mt_filter.tag_id AND t_filter.name = ?` : ''}
${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''}`
)
.get(...(tag ? [tag.toLowerCase(), ...params.slice(1, -2)] : params.slice(0, -2))) as {
count: number;
}
).count;
// Count query — rebuild without ORDER/LIMIT
let countSql = `SELECT COUNT(DISTINCT m.id) as count FROM memes m`;
const countParams: (string | number)[] = [];
const countConditions = [...conditions];
if (tag) {
countSql += `
JOIN meme_tags mt_filter ON mt_filter.meme_id = m.id
JOIN tags t_filter ON t_filter.id = mt_filter.tag_id AND t_filter.name = ?
`;
countParams.push(tag.toLowerCase());
}
if (collection_id !== undefined) countParams.push(Number(collection_id));
if (q) countParams.push(`%${q}%`, `%${q}%`);
if (countConditions.length) countSql += ' WHERE ' + countConditions.join(' AND ');
const { count: total } = db.prepare(countSql).get(...countParams) as { count: number };
return {
memes: memes.map((m) => ({ ...m, tags: getMemeTags(m.id) })),
@@ -135,62 +142,81 @@ export async function memesRoutes(app: FastifyInstance) {
const filePath = buildFilePath(id, ext);
await saveBuffer(buffer, filePath);
const meta = await extractMeta(filePath);
const fields = file.fields as Record<string, { value: string }>;
const title = fields.title?.value ?? file.filename ?? 'Untitled';
const description = fields.description?.value ?? null;
const tagsRaw = fields.tags?.value ?? '';
const collectionId = fields.collection_id?.value
? Number(fields.collection_id.value)
: UNSORTED_ID;
db.prepare(
`INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(id, title, description, filePath, file.filename, meta.size, meta.mimeType, meta.width, meta.height);
`INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height, collection_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(id, title, description, filePath, file.filename, meta.size, meta.mimeType, meta.width, meta.height, collectionId);
if (tagsRaw) {
setMemeTags(id, tagsRaw.split(','));
}
if (tagsRaw) setMemeTags(id, tagsRaw.split(','));
return reply.status(201).send(getMemeById(id));
});
// Update meme metadata
app.put<{ Params: { id: string }; Body: UpdateBody }>('/api/memes/:id', { preHandler: requireAuth }, async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
app.put<{ Params: { id: string }; Body: UpdateBody }>(
'/api/memes/:id',
{ preHandler: requireAuth },
async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
const { title, description, tags } = req.body;
const { title, description, tags } = req.body;
db.prepare('UPDATE memes SET title = ?, description = ? WHERE id = ?').run(
title ?? meme.title,
description ?? meme.description,
meme.id
);
db.prepare(
`UPDATE memes SET title = ?, description = ? WHERE id = ?`
).run(title ?? meme.title, description ?? meme.description, meme.id);
if (tags !== undefined) {
setMemeTags(meme.id, tags);
if (tags !== undefined) setMemeTags(meme.id, tags);
return getMemeById(meme.id);
}
);
return getMemeById(meme.id);
});
// Move meme to a different collection
app.put<{ Params: { id: string }; Body: MoveBody }>(
'/api/memes/:id/collection',
{ preHandler: requireAuth },
async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
const { collection_id } = req.body;
const col = db.prepare('SELECT id FROM collections WHERE id = ?').get(collection_id);
if (!col) return reply.status(404).send({ error: 'Folder not found' });
db.prepare('UPDATE memes SET collection_id = ? WHERE id = ?').run(collection_id, meme.id);
return getMemeById(meme.id);
}
);
// Delete meme (children cascade)
app.delete<{ Params: { id: string } }>('/api/memes/:id', { preHandler: requireAuth }, async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
app.delete<{ Params: { id: string } }>(
'/api/memes/:id',
{ preHandler: requireAuth },
async (req, reply) => {
const meme = getMemeById(req.params.id);
if (!meme) return reply.status(404).send({ error: 'Not found' });
// Delete child files first
const children = db
.prepare('SELECT file_path FROM memes WHERE parent_id = ?')
.all(meme.id) as { file_path: string }[];
for (const child of children) {
deleteFile(child.file_path);
const children = db
.prepare('SELECT file_path FROM memes WHERE parent_id = ?')
.all(meme.id) as { file_path: string }[];
for (const child of children) deleteFile(child.file_path);
deleteFile(meme.file_path);
db.prepare('DELETE FROM memes WHERE id = ?').run(meme.id);
return { ok: true };
}
deleteFile(meme.file_path);
db.prepare('DELETE FROM memes WHERE id = ?').run(meme.id);
return { ok: true };
});
);
// Non-destructive rescale
app.post<{ Params: { id: string }; Body: RescaleBody }>(
@@ -204,9 +230,7 @@ export async function memesRoutes(app: FastifyInstance) {
}
const { width, height, quality = 85, label } = req.body;
if (!width && !height) {
return reply.status(400).send({ error: 'width or height is required' });
}
if (!width && !height) return reply.status(400).send({ error: 'width or height is required' });
const childId = uuidv4();
const ext = getExtension(parent.mime_type);
@@ -216,8 +240,8 @@ export async function memesRoutes(app: FastifyInstance) {
const meta = await resizeImage(parent.file_path, childPath, { width, height, quality });
db.prepare(
`INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO memes (id, title, description, file_path, file_name, file_size, mime_type, width, height, parent_id, collection_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
childId,
`${parent.title} (${autoLabel})`,
@@ -228,7 +252,8 @@ export async function memesRoutes(app: FastifyInstance) {
meta.mimeType,
meta.width,
meta.height,
parent.id
parent.id,
parent.collection_id
);
return reply.status(201).send(getMemeById(childId));

View File

@@ -9,10 +9,19 @@ export interface Meme {
width: number;
height: number;
parent_id: string | null;
collection_id: number | null;
created_at: string;
tags: string[];
}
export interface Collection {
id: number;
name: string;
is_default: number;
created_at: string;
meme_count: number;
}
export interface Tag {
id: number;
name: string;
@@ -23,6 +32,7 @@ export interface UploadBody {
title?: string;
description?: string;
tags?: string;
collection_id?: string;
}
export interface UpdateBody {
@@ -31,6 +41,10 @@ export interface UpdateBody {
tags?: string[];
}
export interface MoveBody {
collection_id: number;
}
export interface RescaleBody {
width?: number;
height?: number;
@@ -44,4 +58,5 @@ export interface ListQuery {
page?: number;
limit?: number;
parent_only?: string;
collection_id?: string;
}