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