347 lines
10 KiB
JavaScript
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;
|