224 lines
6.4 KiB
JavaScript
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}`);
|
|
});
|