feat: sistema de series/autores/franquicias + sidebar navegacion
This commit is contained in:
+203
@@ -1,6 +1,7 @@
|
||||
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")
|
||||
|
||||
@@ -52,6 +53,27 @@ function migrate(db: Database.Database) {
|
||||
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).
|
||||
@@ -84,6 +106,12 @@ function migrate(db: Database.Database) {
|
||||
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 {
|
||||
@@ -159,6 +187,18 @@ 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})
|
||||
@@ -295,3 +335,166 @@ export function getPostsPage(page: number, perPage: number, publishedOnly = true
|
||||
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
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user