import Database from 'better-sqlite3'; import fs from 'fs/promises'; import { existsSync, statSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PROJECT_ROOT = path.resolve(__dirname, '..'); const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(PROJECT_ROOT, 'data')); const DB_PATH = path.resolve(process.env.SQLITE_DB || path.join(DATA_DIR, 'kotonoha.sqlite')); let database; export const sanitizeSaveName = (name) => { const cleaned = String(name || '') .replace(/\.json$/i, '') .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_') .trim(); return `${cleaned || `save-${Date.now()}`}.json`; }; const normalizeTerm = (text) => String(text || '').trim(); const safeParseJson = (value, fallback) => { try { return value ? JSON.parse(value) : fallback; } catch { return fallback; } }; const ensureDatabase = () => { if (database) return database; if (!existsSync(DATA_DIR)) { // better-sqlite3 is synchronous, so create the folder synchronously via mkdir from fs/promises is not suitable here. throw new Error(`Database directory does not exist: ${DATA_DIR}`); } database = new Database(DB_PATH); database.pragma('journal_mode = WAL'); database.pragma('foreign_keys = ON'); database.exec(` CREATE TABLE IF NOT EXISTS documents ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT NOT NULL UNIQUE, title TEXT NOT NULL, document_json TEXT NOT NULL, saved_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS lines ( id INTEGER PRIMARY KEY AUTOINCREMENT, document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE, line_id TEXT NOT NULL, line_index INTEGER NOT NULL, raw_text TEXT NOT NULL DEFAULT '', furigana_items_json TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, note_id TEXT NOT NULL, document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE, line_id TEXT NOT NULL, line_index INTEGER NOT NULL, text TEXT NOT NULL, normalized_text TEXT NOT NULL, comment TEXT NOT NULL, color TEXT NOT NULL DEFAULT 'yellow', created_at INTEGER NOT NULL, line_text TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS vocab_terms ( normalized_text TEXT PRIMARY KEY, text TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_notes_normalized_text ON notes(normalized_text); CREATE INDEX IF NOT EXISTS idx_notes_document_id ON notes(document_id); CREATE INDEX IF NOT EXISTS idx_lines_document_id ON lines(document_id); `); return database; }; export const initDatabase = async () => { await fs.mkdir(DATA_DIR, { recursive: true }); return ensureDatabase(); }; const upsertVocabTerms = (db, notes) => { const stmt = db.prepare(` INSERT INTO vocab_terms (normalized_text, text, updated_at) VALUES (@normalizedText, @text, @updatedAt) ON CONFLICT(normalized_text) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at `); const updatedAt = new Date().toISOString(); for (const note of notes) { if (!note.normalizedText) continue; stmt.run({ normalizedText: note.normalizedText, text: note.text, updatedAt, }); } }; const insertDocumentChildren = (db, documentId, document) => { const lines = Array.isArray(document?.lines) ? document.lines : []; const insertLine = db.prepare(` INSERT INTO lines (document_id, line_id, line_index, raw_text, furigana_items_json) VALUES (@documentId, @lineId, @lineIndex, @rawText, @furiganaItemsJson) `); const insertNote = db.prepare(` INSERT INTO notes ( note_id, document_id, line_id, line_index, text, normalized_text, comment, color, created_at, line_text ) VALUES ( @noteId, @documentId, @lineId, @lineIndex, @text, @normalizedText, @comment, @color, @createdAt, @lineText ) `); const allNotes = []; lines.forEach((line, lineIndex) => { const lineId = String(line?.id || `line-${lineIndex}`); const rawText = typeof line?.rawText === 'string' ? line.rawText : ''; const furiganaItems = Array.isArray(line?.furiganaItems) ? line.furiganaItems : []; insertLine.run({ documentId, lineId, lineIndex, rawText, furiganaItemsJson: JSON.stringify(furiganaItems), }); const notes = Array.isArray(line?.notes) ? line.notes : []; notes.forEach((note, noteIndex) => { const text = String(note?.text || '').trim(); const normalizedText = normalizeTerm(text); if (!normalizedText) return; const noteRow = { noteId: String(note?.id || `note-${lineIndex}-${noteIndex}`), documentId, lineId, lineIndex, text, normalizedText, comment: String(note?.comment || ''), color: String(note?.color || 'yellow'), createdAt: Number(note?.createdAt || Date.now()), lineText: rawText, }; insertNote.run(noteRow); allNotes.push(noteRow); }); }); upsertVocabTerms(db, allNotes); }; export const saveDocument = async ({ name, document }) => { await initDatabase(); const db = ensureDatabase(); const fileName = sanitizeSaveName(name || document?.title || 'current'); const title = typeof document?.title === 'string' && document.title.trim() ? document.title.trim() : fileName.replace(/\.json$/i, ''); const now = new Date().toISOString(); const documentJson = JSON.stringify(document || {}); const transaction = db.transaction(() => { const existing = db.prepare('SELECT id FROM documents WHERE file_name = ?').get(fileName); let documentId = existing?.id; if (documentId) { db.prepare(` UPDATE documents SET title = ?, document_json = ?, saved_at = ?, updated_at = ? WHERE id = ? `).run(title, documentJson, now, now, documentId); db.prepare('DELETE FROM lines WHERE document_id = ?').run(documentId); db.prepare('DELETE FROM notes WHERE document_id = ?').run(documentId); } else { const result = db.prepare(` INSERT INTO documents (file_name, title, document_json, saved_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) `).run(fileName, title, documentJson, now, now, now); documentId = Number(result.lastInsertRowid); } insertDocumentChildren(db, documentId, document); return documentId; }); const id = transaction(); return { id, name: fileName, title, savedAt: now, size: Buffer.byteLength(documentJson, 'utf-8'), lineCount: Array.isArray(document?.lines) ? document.lines.length : null, }; }; export const deleteDocumentByFileName = async (fileName) => { await initDatabase(); const db = ensureDatabase(); db.prepare('DELETE FROM documents WHERE file_name = ?').run(sanitizeSaveName(fileName)); }; export const listDocuments = async () => { await initDatabase(); const db = ensureDatabase(); const rows = db.prepare(` SELECT d.id, d.file_name AS name, d.title, d.saved_at AS savedAt, LENGTH(d.document_json) AS size, COUNT(l.id) AS lineCount FROM documents d LEFT JOIN lines l ON l.document_id = d.id GROUP BY d.id ORDER BY datetime(d.saved_at) DESC `).all(); return rows.map((row) => ({ ...row, lineCount: Number(row.lineCount || 0), })); }; export const getDocumentByFileName = async (fileName) => { await initDatabase(); const db = ensureDatabase(); const row = db.prepare('SELECT document_json FROM documents WHERE file_name = ?').get(sanitizeSaveName(fileName)); return row ? safeParseJson(row.document_json, null) : null; }; export const getDocumentById = async (id) => { await initDatabase(); const db = ensureDatabase(); const row = db.prepare('SELECT document_json FROM documents WHERE id = ?').get(Number(id)); return row ? safeParseJson(row.document_json, null) : null; }; export const getVocabCards = async () => { await initDatabase(); const db = ensureDatabase(); const rows = db.prepare(` SELECT n.normalized_text AS normalizedText, n.text, n.comment, n.color, n.line_text AS lineText, n.line_index AS lineIndex, n.created_at AS createdAt, d.title AS documentTitle, d.file_name AS fileName, d.id AS documentId FROM notes n JOIN documents d ON d.id = n.document_id ORDER BY n.normalized_text ASC, n.created_at DESC `).all(); const grouped = new Map(); for (const row of rows) { const key = row.normalizedText; if (!grouped.has(key)) { grouped.set(key, { text: row.text, normalizedText: key, noteCount: 0, documentTitles: [], latestComment: row.comment, latestColor: row.color, entries: [], }); } const card = grouped.get(key); card.noteCount += 1; if (!card.documentTitles.includes(row.documentTitle)) { card.documentTitles.push(row.documentTitle); } card.entries.push({ comment: row.comment, color: row.color, lineText: row.lineText, lineNumber: Number(row.lineIndex) + 1, documentTitle: row.documentTitle, fileName: row.fileName, documentId: row.documentId, createdAt: row.createdAt, }); } return [...grouped.values()]; }; export const migrateSavesToDatabase = async (savesDir) => { await initDatabase(); await fs.mkdir(savesDir, { recursive: true }); const entries = await fs.readdir(savesDir); const migrated = []; for (const entry of entries.filter((item) => item.toLowerCase().endsWith('.json'))) { const filePath = path.join(savesDir, entry); try { const document = safeParseJson(await fs.readFile(filePath, 'utf-8'), null); if (!document || typeof document !== 'object') continue; const stat = statSync(filePath); const file = await saveDocument({ name: entry, document: { ...document, exportedAt: document.exportedAt || stat.mtime.toISOString(), }, }); migrated.push(file); } catch (error) { console.error(`Failed to migrate save file: ${entry}`, error); } } return migrated; }; export const getDatabasePath = () => DB_PATH;