更新内容
This commit is contained in:
+346
@@ -0,0 +1,346 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user