更新内容

This commit is contained in:
TU
2026-07-06 20:46:31 +08:00
parent 1d1f7591db
commit 71bb89342d
83 changed files with 5961 additions and 427 deletions
+346
View File
@@ -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;
+223
View File
@@ -0,0 +1,223 @@
import express from 'express';
import fs from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import {
deleteDocumentByFileName,
getDatabasePath,
getDocumentByFileName,
getDocumentById,
getVocabCards,
initDatabase,
listDocuments,
migrateSavesToDatabase,
sanitizeSaveName,
saveDocument,
} from './db.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, '..');
const PORT = Number(process.env.PORT || 3002);
const HOST = process.env.HOST || '0.0.0.0';
const DIST_DIR = path.resolve(PROJECT_ROOT, 'dist');
const SAVES_DIR = path.resolve(process.env.SAVES_DIR || path.join(PROJECT_ROOT, 'saves'));
const app = express();
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', process.env.CORS_ORIGIN || '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.sendStatus(204);
return;
}
next();
});
app.use(express.json({ limit: '10mb' }));
const ensureSavesDir = async () => {
await fs.mkdir(SAVES_DIR, { recursive: true });
};
const getSaveFilePath = (name) => path.join(SAVES_DIR, sanitizeSaveName(name));
app.get('/api/health', (_req, res) => {
res.json({
ok: true,
savesDir: SAVES_DIR,
database: getDatabasePath(),
});
});
app.get('/api/saves', async (_req, res) => {
try {
await ensureSavesDir();
const files = await listDocuments();
res.json({ files });
} catch (error) {
console.error('Failed to list save files', error);
res.status(500).json({ error: 'Failed to list save files' });
}
});
app.get('/api/saves/:fileName', async (req, res) => {
try {
await ensureSavesDir();
const document = await getDocumentByFileName(req.params.fileName);
if (document) {
res.json(document);
return;
}
const filePath = getSaveFilePath(req.params.fileName);
if (!existsSync(filePath)) {
res.status(404).json({ error: 'Save file not found' });
return;
}
const content = await fs.readFile(filePath, 'utf-8');
res.type('application/json').send(content);
} catch (error) {
console.error('Failed to read save file', error);
res.status(500).json({ error: 'Failed to read save file' });
}
});
app.post('/api/saves', async (req, res) => {
try {
await ensureSavesDir();
const fileName = sanitizeSaveName(req.body?.name || 'current');
const document = req.body?.document;
if (!document || typeof document !== 'object') {
res.status(400).json({ error: 'Missing document payload' });
return;
}
const filePath = path.join(SAVES_DIR, fileName);
await fs.writeFile(filePath, JSON.stringify(document, null, 2), 'utf-8');
const file = await saveDocument({ name: fileName, document });
res.json({ file });
} catch (error) {
console.error('Failed to save document', error);
res.status(500).json({ error: 'Failed to save document' });
}
});
app.delete('/api/saves/:fileName', async (req, res) => {
try {
await ensureSavesDir();
const filePath = getSaveFilePath(req.params.fileName);
if (!existsSync(filePath)) {
res.status(404).json({ error: 'Save file not found' });
return;
}
await fs.unlink(filePath);
await deleteDocumentByFileName(req.params.fileName);
res.json({ ok: true });
} catch (error) {
console.error('Failed to delete save file', error);
res.status(500).json({ error: 'Failed to delete save file' });
}
});
app.get('/api/documents', async (_req, res) => {
try {
const files = await listDocuments();
res.json({ files });
} catch (error) {
console.error('Failed to list documents', error);
res.status(500).json({ error: 'Failed to list documents' });
}
});
app.get('/api/documents/:id', async (req, res) => {
try {
const document = await getDocumentById(req.params.id);
if (!document) {
res.status(404).json({ error: 'Document not found' });
return;
}
res.json(document);
} catch (error) {
console.error('Failed to read document', error);
res.status(500).json({ error: 'Failed to read document' });
}
});
app.post('/api/documents', async (req, res) => {
try {
const document = req.body?.document;
if (!document || typeof document !== 'object') {
res.status(400).json({ error: 'Missing document payload' });
return;
}
const file = await saveDocument({ name: req.body?.name || document.title || 'current', document });
await ensureSavesDir();
await fs.writeFile(path.join(SAVES_DIR, file.name), JSON.stringify(document, null, 2), 'utf-8');
res.json({ file });
} catch (error) {
console.error('Failed to save document', error);
res.status(500).json({ error: 'Failed to save document' });
}
});
app.get('/api/vocab', async (_req, res) => {
try {
const cards = await getVocabCards();
res.json({ cards });
} catch (error) {
console.error('Failed to list vocab cards', error);
res.status(500).json({ error: 'Failed to list vocab cards' });
}
});
app.get('/api/vocab/:term', async (req, res) => {
try {
const cards = await getVocabCards();
const card = cards.find((item) => item.normalizedText === req.params.term || item.text === req.params.term);
if (!card) {
res.status(404).json({ error: 'Vocab card not found' });
return;
}
res.json({ card });
} catch (error) {
console.error('Failed to read vocab card', error);
res.status(500).json({ error: 'Failed to read vocab card' });
}
});
if (existsSync(DIST_DIR)) {
app.use(express.static(DIST_DIR));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/')) {
next();
return;
}
res.sendFile(path.join(DIST_DIR, 'index.html'));
});
} else {
app.get('*', (_req, res) => {
res.status(503).send('Frontend dist not found. Run `npm run build` before starting the server.');
});
}
await initDatabase();
await ensureSavesDir();
const migratedFiles = await migrateSavesToDatabase(SAVES_DIR);
app.listen(PORT, HOST, () => {
console.log(`Kotonoha server listening on http://${HOST}:${PORT}`);
console.log(`Serving frontend from: ${DIST_DIR}`);
console.log(`Using saves directory: ${SAVES_DIR}`);
console.log(`Using SQLite database: ${getDatabasePath()}`);
console.log(`Indexed save files: ${migratedFiles.length}`);
});
+17
View File
@@ -0,0 +1,17 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { getDatabasePath, migrateSavesToDatabase } from './db.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, '..');
const SAVES_DIR = path.resolve(process.env.SAVES_DIR || path.join(PROJECT_ROOT, 'saves'));
try {
const migrated = await migrateSavesToDatabase(SAVES_DIR);
console.log(`Migrated ${migrated.length} save files into SQLite.`);
console.log(`SQLite database: ${getDatabasePath()}`);
} catch (error) {
console.error('Failed to migrate saves into SQLite.', error);
process.exitCode = 1;
}