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

224 lines
6.4 KiB
JavaScript

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}`);
});