import sqlite3 from 'better-sqlite3' import * as z from 'zod' import { builtPath } from './dir' const zMatter = z.object({ date: z.date().optional(), createdAt: z.date().optional(), updatedAt: z.date().optional(), priority: z.number().optional(), title: z.string(), image: z.string().optional(), tag: z.array(z.string()).optional(), category: z.string().optional(), }) export type IMatter = z.infer export const zPost = z .object({ path: z.string(), /** * Cleaned and jieba-separated markdown */ content: z.string(), excerptHtml: z.string(), contentHtml: z.string(), }) .merge(zMatter.nonstrict()) export type IPost = z.infer export class Database { db: sqlite3.Database constructor(opts?: sqlite3.Options) { this.db = sqlite3(builtPath('data.db'), opts) } public init(): void { this.db.exec(/* sql */ ` CREATE TABLE IF NOT EXISTS files ( [path] TEXT PRIMARY KEY, [date] INT, -- epoch milli createdAt INT, -- epoch milli updatedAt INT, -- epoch milli priority INT, -- q.title [image] TEXT, -- q.tag.split(/ /g) -- q.category -- q.content excerptHtml TEXT NOT NULL, contentHtml TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS files_date ON files([date]); CREATE INDEX IF NOT EXISTS files_createdAt ON files(createdAt); CREATE INDEX IF NOT EXISTS files_updatedAt ON files(updatedAt); CREATE INDEX IF NOT EXISTS files_priority ON files(priority); `) this.db.exec(/* sql */ ` CREATE VIRTUAL TABLE IF NOT EXISTS q USING fts5 ( [path], -- REFERENCES files([path]) title, tag, -- space separated category, -- can compare via equal content -- cleaned markdown ); `) } public insert(...ps: IPost[]): void { const fStmt = this.db.prepare(/* sql */ ` INSERT INTO files ([path], [date], [createdAt], [updatedAt], priority, [image], excerptHtml, contentHtml) VALUES (@path, @date, @createdAt, @updatedAt, @priority, @image, @excerptHtml, @contentHtml) `) const qStmt = this.db.prepare(/* sql */ ` INSERT INTO q ([path], title, tag, category, content) VALUES (@path, @title, @tag, @category, @content) `) this.db.transaction(() => { // eslint-disable-next-line array-callback-return ps.map((p) => { const { path, date, createdAt, updatedAt, priority, image, excerptHtml, contentHtml, title, tag, category, content, } = zPost.parse(p) fStmt.run({ path, priority, image, excerptHtml, contentHtml, date: date ? date.getTime() : null, createdAt: createdAt ? createdAt.getTime() : null, updatedAt: updatedAt ? updatedAt.getTime() : null, }) qStmt.run({ path, title, category, content, tag: tag ? tag.join(' ') : undefined, }) }) })() } public get(id: string) { const r = this.db .prepare( /* sql */ ` SELECT [date], title, [image], tag, category, contentHtml content FROM files f JOIN q ON q.path = f.path WHERE f.path = @id ` ) .get({ id }) if (!r) { return null } return { ...r, date: r.date ? new Date(r.date) : undefined, tag: r.tag ? r.tag.split(/ /g) : undefined, } as { date?: Date title: string image?: string tag?: string[] category?: string content: string } } public q( q: string, opts: { offset: number limit: number } ) { return this._search(q, { ...opts, orderedBy: 'RANK', }) } public by( q: string, opts: { offset: number limit: number } ) { return this._search(q, { ...opts, orderedBy: 'priority DESC, [date] DESC, createdAt DESC, updatedAt DESC', where: '[date] IS NOT NULL', }) } private _search( q: string, opts: { offset: number limit: number orderedBy: string where?: string } ) { const fromSegment = /* sql */ ` FROM files f JOIN q ON q.path = f.path WHERE ${q ? `q = @q` : 'TRUE'} ${opts.where ? `AND ${opts.where}` : ''} ` const count = this.db .prepare( /* sql */ ` SELECT COUNT(f.path) [count] ${fromSegment} ` ) .get({ q })?.count || 0 const result = this.db .prepare( /* sql */ ` SELECT f.path [path], [date], title, [image], tag, category, excerptHtml excerpt ${fromSegment} ORDER BY ${opts.orderedBy} LIMIT ${opts.limit} OFFSET ${opts.offset} ` ) .all({ q }) as { path: string date?: Date title: string image?: string tag?: string[] category?: string excerpt: string }[] return { result, count, } } }