Files
kotonoha/server/db.js
T
2026-07-06 20:46:31 +08:00

347 lines
10 KiB
JavaScript

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;