import Database from "better-sqlite3" import path from "path" import fs from "fs" import { slugify } from "./slug" const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "worst-scan.db") let db: Database.Database | null = null function getDb(): Database.Database { if (!db) { fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }) db = new Database(DB_PATH) db.pragma("journal_mode = WAL") db.pragma("foreign_keys = ON") migrate(db) } return db } function migrate(db: Database.Database) { db.exec(` CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, gid TEXT NOT NULL UNIQUE, title TEXT NOT NULL, title_jpn TEXT, artist TEXT, parody TEXT, tags TEXT, num_pages INTEGER DEFAULT 0, source TEXT, cover_url TEXT, url TEXT, summary TEXT, slug TEXT UNIQUE NOT NULL, published INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), published_at TEXT ); CREATE INDEX IF NOT EXISTS idx_posts_gid ON posts(gid); CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug); CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published); CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC); -- FTS5: búsqueda full-text sobre título/título_jpn/artista/parodia/tags. -- Tabla externa (content=posts) para que se sincronice con los triggers. CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( title, title_jpn, artist, parody, tags, content='posts', content_rowid='id', tokenize='unicode61' ); -- ===== Entidades de fansub: series y autores ===== CREATE TABLE IF NOT EXISTS authors ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, slug TEXT UNIQUE NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS series ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, slug TEXT UNIQUE NOT NULL, description TEXT, cover_url TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_authors_slug ON authors(slug); CREATE INDEX IF NOT EXISTS idx_series_slug ON series(slug); `) // Triggers de sincronización FTS (solo si no existen). db.exec(` CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN INSERT INTO posts_fts(rowid, title, title_jpn, artist, parody, tags) VALUES (new.id, new.title, new.title_jpn, new.artist, new.parody, new.tags); END; CREATE TRIGGER IF NOT EXISTS posts_ad AFTER DELETE ON posts BEGIN INSERT INTO posts_fts(posts_fts, rowid, title, title_jpn, artist, parody, tags) VALUES ('delete', old.id, old.title, old.title_jpn, old.artist, old.parody, old.tags); END; CREATE TRIGGER IF NOT EXISTS posts_au AFTER UPDATE ON posts BEGIN INSERT INTO posts_fts(posts_fts, rowid, title, title_jpn, artist, parody, tags) VALUES ('delete', old.id, old.title, old.title_jpn, old.artist, old.parody, old.tags); INSERT INTO posts_fts(rowid, title, title_jpn, artist, parody, tags) VALUES (new.id, new.title, new.title_jpn, new.artist, new.parody, new.tags); END; `) // Reindexar FTS si la tabla externa quedó desincronizada (posts sin indexar). try { const ftsCount = db.prepare("SELECT COUNT(*) as c FROM posts_fts").get() as { c: number } const postCount = db.prepare("SELECT COUNT(*) as c FROM posts").get() as { c: number } if (ftsCount.c < postCount.c) { db.exec("INSERT INTO posts_fts(posts_fts) VALUES('rebuild')") } } catch {} try { db.exec("ALTER TABLE posts ADD COLUMN url TEXT") } catch {} // series_id (FK) para agrupar capítulos/obras de una serie. try { db.exec("ALTER TABLE posts ADD COLUMN series_id INTEGER REFERENCES series(id)") db.exec("CREATE INDEX IF NOT EXISTS idx_posts_series ON posts(series_id)") } catch {} } export interface Post { id: number gid: string title: string title_jpn: string | null artist: string | null parody: string | null tags: string[] num_pages: number source: string | null cover_url: string | null url: string | null summary: string | null slug: string published: number created_at: string updated_at: string published_at: string | null } export interface PostInput { gid: string title: string title_jpn?: string artist?: string parody?: string tags?: string[] num_pages?: number source?: string cover_url?: string url?: string summary?: string slug: string published?: number } function rowToPost(row: Record): Post { return { ...row, tags: typeof row.tags === "string" ? JSON.parse(row.tags as string) : [], } as unknown as Post } export function getAllPosts(publishedOnly = false): Post[] { const d = getDb() const q = publishedOnly ? "SELECT * FROM posts WHERE published = 1 ORDER BY published_at DESC" : "SELECT * FROM posts ORDER BY created_at DESC" return (d.prepare(q).all() as Record[]).map(rowToPost) } export function getPostById(id: number): Post | null { const d = getDb() const row = d.prepare("SELECT * FROM posts WHERE id = ?").get(id) as Record | undefined return row ? rowToPost(row) : null } export function getPostByGid(gid: string): Post | null { const d = getDb() const row = d.prepare("SELECT * FROM posts WHERE gid = ?").get(gid) as Record | undefined return row ? rowToPost(row) : null } export function getPostBySlug(slug: string): Post | null { const d = getDb() const row = d.prepare("SELECT * FROM posts WHERE slug = ?").get(slug) as Record | undefined return row ? rowToPost(row) : null } export function createPost(input: PostInput): Post { const d = getDb() const published = input.published ?? 1 const publishedAt = published ? "datetime('now')" : null // Auto-registrar el autor en la tabla authors (para páginas de autor). if (input.artist) { const clean = input.artist.trim() if (clean && clean !== "sin artista" && clean !== "dummy") { d.prepare("INSERT OR IGNORE INTO authors(name, slug) VALUES (?, ?)").run( clean, slugify(clean), ) } } const stmt = d.prepare(` INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, published_at) VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt}) `) const result = stmt.run({ gid: input.gid, title: input.title, title_jpn: input.title_jpn || null, artist: input.artist || null, parody: input.parody || null, tags: JSON.stringify(input.tags || []), num_pages: input.num_pages || 0, source: input.source || null, cover_url: input.cover_url || null, url: input.url || null, summary: input.summary || null, slug: input.slug, published, }) return getPostById(result.lastInsertRowid as number)! } // Columnas permitidas en PATCH — whitelist estricta para impedir SQLi // por nombres de columna y el bypass de `published`/columnas internas. const UPDATE_FIELDS = new Set([ "title", "title_jpn", "artist", "parody", "tags", "num_pages", "source", "cover_url", "url", "summary", "slug", ]) export function updatePost(id: number, updates: Partial): Post | null { const d = getDb() const fields: string[] = [] const values: Record = { id } for (const [k, v] of Object.entries(updates)) { if (v === undefined || !UPDATE_FIELDS.has(k)) continue if (k === "tags") { fields.push("tags = @tags") values.tags = JSON.stringify(v) } else { fields.push(`${k} = @${k}`) values[k] = v } } if (fields.length === 0) return getPostById(id) fields.push("updated_at = datetime('now')") d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = @id`).run(values) return getPostById(id) } export function publishPost(id: number, publish: boolean): Post | null { const d = getDb() if (publish) { d.prepare("UPDATE posts SET published = 1, published_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(id) } else { d.prepare("UPDATE posts SET published = 0, published_at = NULL, updated_at = datetime('now') WHERE id = ?").run(id) } return getPostById(id) } export function deletePost(id: number): boolean { const d = getDb() const result = d.prepare("DELETE FROM posts WHERE id = ?").run(id) return result.changes > 0 } export function getPostsByTag(tag: string, publishedOnly = true): Post[] { const d = getDb() const q = publishedOnly ? "SELECT * FROM posts WHERE published = 1 AND EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE json_each.value = ?) ORDER BY published_at DESC" : "SELECT * FROM posts WHERE EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE json_each.value = ?) ORDER BY created_at DESC" return (d.prepare(q).all(tag) as Record[]).map(rowToPost) } export function getPostCount(): number { const d = getDb() const row = d.prepare("SELECT COUNT(*) as count FROM posts").get() as { count: number } return row.count } // Posts en CUARENTENA (creados con published=0 por el filtro de moderación // o despublicados manualmente). Para la página de revisión del admin. export function getQuarantinedPosts(): Post[] { const d = getDb() const rows = d .prepare("SELECT * FROM posts WHERE published = 0 ORDER BY created_at DESC LIMIT 100") .all() as Record[] return rows.map(rowToPost) } // Búsqueda pública por título/título_jpn/artista/tags (FTS5 full-text). // Devuelve los posts MATCH (por relevancia), filtrando publicados. export function searchPosts(query: string, publishedOnly = true): Post[] { const d = getDb() const q = query.trim() if (!q) return [] // Escapar comillas para FTS5 y construir una búsqueda por prefijo en cada token. const escaped = q.replace(/"/g, "").trim() if (!escaped) return [] const ftsQuery = escaped .split(/\s+/) .filter(Boolean) .map((tok) => `"${tok}"*`) .join(" AND ") const where = publishedOnly ? "AND p.published = 1" : "" const rows = d .prepare( `SELECT p.* FROM posts p JOIN posts_fts f ON f.rowid = p.id WHERE posts_fts MATCH @q ${where} ORDER BY bm25(posts_fts) LIMIT 100`, ) .all({ q: ftsQuery }) as Record[] return rows.map(rowToPost) } // Paginación de la home pública. export function getPostsPage(page: number, perPage: number, publishedOnly = true): { items: Post[]; total: number } { const d = getDb() const offset = (page - 1) * perPage const where = publishedOnly ? "WHERE published = 1" : "" const order = publishedOnly ? "published_at DESC" : "created_at DESC" const rows = d .prepare(`SELECT * FROM posts ${where} ORDER BY ${order} LIMIT @perPage OFFSET @offset`) .all({ perPage, offset }) as Record[] const total = (d.prepare(`SELECT COUNT(*) as count FROM posts ${where}`).get() as { count: number }).count return { items: rows.map(rowToPost), total } } export function getGidsNotInPosts(): string[] { return [] } // ===== Entidades de fansub: autores y series ===== export interface Author { id: number name: string slug: string created_at: string works: number // cantidad de obras (calculada) } export interface Series { id: number name: string slug: string description: string | null cover_url: string | null created_at: string updated_at: string works: number // cantidad de capítulos/obras (calculada) } function rowToAuthor(row: Record): Author { return { id: row.id as number, name: row.name as string, slug: row.slug as string, created_at: row.created_at as string, works: (row.works as number) || 0, } } function rowToSeries(row: Record): Series { return { id: row.id as number, name: row.name as string, slug: row.slug as string, description: row.description as string | null, cover_url: row.cover_url as string | null, created_at: row.created_at as string, updated_at: row.updated_at as string, works: (row.works as number) || 0, } } // Autores con contador de obras publicadas (para el panel lateral). export function getAuthorsWithWorks(limit = 20): Author[] { const d = getDb() const rows = d .prepare( `SELECT a.*, (SELECT COUNT(*) FROM posts p WHERE p.published = 1 AND p.artist = a.name) as works FROM authors a WHERE a.name IS NOT NULL AND a.name != '' AND a.name != 'sin artista' AND a.name != 'dummy' ORDER BY works DESC, a.name ASC LIMIT @limit`, ) .all({ limit }) as Record[] return rows.map(rowToAuthor) } // Series con contador de obras publicadas. export function getSeriesWithWorks(limit = 30): Series[] { const d = getDb() const rows = d .prepare( `SELECT s.*, (SELECT COUNT(*) FROM posts p WHERE p.published = 1 AND p.series_id = s.id) as works FROM series s ORDER BY works DESC, s.name ASC LIMIT @limit`, ) .all({ limit }) as Record[] return rows.map(rowToSeries) } export function getAuthorBySlug(slug: string): Author | null { const d = getDb() const row = d .prepare( `SELECT a.*, (SELECT COUNT(*) FROM posts p WHERE p.published = 1 AND p.artist = a.name) as works FROM authors a WHERE a.slug = ?`, ) .get(slug) as Record | undefined return row ? rowToAuthor(row) : null } // Devuelve el slug de un autor por su nombre (para enlazar desde los posts). // Si el autor no está registrado, lo registra sobre la marcha. export function getAuthorSlugByName(name: string): string | null { const d = getDb() const clean = (name || "").trim() if (!clean || clean === "sin artista" || clean === "dummy") return null d.prepare("INSERT OR IGNORE INTO authors(name, slug) VALUES (?, ?)").run( clean, slugify(clean), ) const row = d.prepare("SELECT slug FROM authors WHERE name = ?").get(clean) as | { slug: string } | undefined return row ? row.slug : null } export function getSeriesBySlug(slug: string): Series | null { const d = getDb() const row = d .prepare( `SELECT s.*, (SELECT COUNT(*) FROM posts p WHERE p.published = 1 AND p.series_id = s.id) as works FROM series s WHERE s.slug = ?`, ) .get(slug) as Record | undefined return row ? rowToSeries(row) : null } // Posts de un autor (todas sus obras publicadas). export function getPostsByAuthor(authorName: string): Post[] { const d = getDb() const rows = d .prepare( `SELECT * FROM posts WHERE published = 1 AND artist = ? ORDER BY published_at DESC`, ) .all(authorName) as Record[] return rows.map(rowToPost) } // Posts de una serie (capítulos/obras agrupados por serie_id). export function getPostsBySeries(seriesId: number): Post[] { const d = getDb() const rows = d .prepare( `SELECT * FROM posts WHERE published = 1 AND series_id = ? ORDER BY published_at ASC`, ) .all(seriesId) as Record[] return rows.map(rowToPost) } // Franquicias/parodias con contador (para el panel lateral). export function getParodies(limit = 20): { name: string; works: number }[] { const d = getDb() const rows = d .prepare( `SELECT COALESCE(parody, 'sin parodia') as name, COUNT(*) as works FROM posts WHERE published = 1 GROUP BY parody HAVING parody IS NOT NULL AND parody != '' AND parody != 'sin parodia' AND parody != 'original' ORDER BY works DESC, name ASC LIMIT @limit`, ) .all({ limit }) as { name: string; works: number }[] return rows } export function getPostsByParody(parody: string): Post[] { const d = getDb() const rows = d .prepare( `SELECT * FROM posts WHERE published = 1 AND parody = ? ORDER BY published_at DESC`, ) .all(parody) as Record[] return rows.map(rowToPost) } // Tags con contador (panel lateral). Usa json_each para aplanar el JSON de tags. export function getTagsWithCounts(limit = 25): { tag: string; count: number }[] { const d = getDb() try { const rows = d .prepare( `SELECT value as tag, COUNT(*) as count FROM posts, json_each(posts.tags) WHERE posts.published = 1 GROUP BY value ORDER BY count DESC, value ASC LIMIT @limit`, ) .all({ limit }) as { tag: string; count: number }[] return rows } catch { return [] } }