Files
worst-scan-web/src/lib/db.ts
T

528 lines
17 KiB
TypeScript

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<string, unknown>): 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<string, unknown>[]).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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<PostInput>): Post | null {
const d = getDb()
const fields: string[] = []
const values: Record<string, unknown> = { 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<string, unknown>[]).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<string, unknown>[]
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<string, unknown>[]
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<string, unknown>[]
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<string, unknown>): 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<string, unknown>): 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<string, unknown>[]
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<string, unknown>[]
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<string, unknown> | 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<string, unknown> | 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<string, unknown>[]
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<string, unknown>[]
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<string, unknown>[]
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 []
}
}