197 lines
6.6 KiB
TypeScript
197 lines
6.6 KiB
TypeScript
import tailwindcss from '@tailwindcss/vite';
|
|
import react from '@vitejs/plugin-react';
|
|
import fs from 'fs/promises';
|
|
import { existsSync } from 'fs';
|
|
import path from 'path';
|
|
import { defineConfig, Plugin } from 'vite';
|
|
import {
|
|
deleteDocumentByFileName,
|
|
getDocumentByFileName,
|
|
getDocumentById,
|
|
getVocabCards,
|
|
initDatabase,
|
|
listDocuments,
|
|
migrateSavesToDatabase,
|
|
saveDocument,
|
|
} from './server/db.js';
|
|
|
|
const SAVES_DIR = path.resolve(__dirname, 'saves');
|
|
|
|
const ensureSavesDir = async () => {
|
|
await fs.mkdir(SAVES_DIR, { recursive: true });
|
|
};
|
|
|
|
const sanitizeSaveName = (name: string) => {
|
|
const cleaned = name
|
|
.replace(/\.json$/i, '')
|
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
|
.trim();
|
|
return `${cleaned || `save-${Date.now()}`}.json`;
|
|
};
|
|
|
|
const readJsonBody = async (req: any) => {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
return raw ? JSON.parse(raw) : {};
|
|
};
|
|
|
|
const sendJson = (res: any, statusCode: number, payload: unknown) => {
|
|
res.statusCode = statusCode;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(JSON.stringify(payload));
|
|
};
|
|
|
|
const localSavesPlugin = (): Plugin => ({
|
|
name: 'local-saves-api',
|
|
configureServer(server) {
|
|
const ready = (async () => {
|
|
await initDatabase();
|
|
await ensureSavesDir();
|
|
await migrateSavesToDatabase(SAVES_DIR);
|
|
})();
|
|
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (!req.url?.startsWith('/api/saves') && !req.url?.startsWith('/api/documents') && !req.url?.startsWith('/api/vocab')) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await ready;
|
|
await ensureSavesDir();
|
|
const url = new URL(req.url, 'http://localhost');
|
|
const fileNameFromPath = decodeURIComponent(url.pathname.replace(/^\/api\/saves\/?/, ''));
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/saves') {
|
|
const files = await listDocuments();
|
|
sendJson(res, 200, { files });
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname.startsWith('/api/saves/') && fileNameFromPath) {
|
|
const fileName = sanitizeSaveName(fileNameFromPath);
|
|
const document = await getDocumentByFileName(fileName);
|
|
if (document) {
|
|
sendJson(res, 200, document);
|
|
return;
|
|
}
|
|
|
|
const filePath = path.join(SAVES_DIR, fileName);
|
|
if (!existsSync(filePath)) {
|
|
sendJson(res, 404, { error: 'Save file not found' });
|
|
return;
|
|
}
|
|
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
sendJson(res, 200, JSON.parse(content));
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && url.pathname === '/api/saves') {
|
|
const body = await readJsonBody(req);
|
|
const fileName = sanitizeSaveName(String(body.name || 'current'));
|
|
const document = body.document;
|
|
if (!document || typeof document !== 'object') {
|
|
sendJson(res, 400, { 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 });
|
|
sendJson(res, 200, { file });
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'DELETE' && url.pathname.startsWith('/api/saves/') && fileNameFromPath) {
|
|
const fileName = sanitizeSaveName(fileNameFromPath);
|
|
const filePath = path.join(SAVES_DIR, fileName);
|
|
if (!existsSync(filePath)) {
|
|
sendJson(res, 404, { error: 'Save file not found' });
|
|
return;
|
|
}
|
|
await fs.unlink(filePath);
|
|
await deleteDocumentByFileName(fileName);
|
|
sendJson(res, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/documents') {
|
|
const files = await listDocuments();
|
|
sendJson(res, 200, { files });
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname.startsWith('/api/documents/')) {
|
|
const id = decodeURIComponent(url.pathname.replace(/^\/api\/documents\/?/, ''));
|
|
const document = await getDocumentById(id);
|
|
if (!document) {
|
|
sendJson(res, 404, { error: 'Document not found' });
|
|
return;
|
|
}
|
|
sendJson(res, 200, document);
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && url.pathname === '/api/documents') {
|
|
const body = await readJsonBody(req);
|
|
const document = body.document;
|
|
if (!document || typeof document !== 'object') {
|
|
sendJson(res, 400, { error: 'Missing document payload' });
|
|
return;
|
|
}
|
|
const file = await saveDocument({ name: String(body.name || document.title || 'current'), document });
|
|
await fs.writeFile(path.join(SAVES_DIR, file.name), JSON.stringify(document, null, 2), 'utf-8');
|
|
sendJson(res, 200, { file });
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/vocab') {
|
|
const cards = await getVocabCards();
|
|
sendJson(res, 200, { cards });
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname.startsWith('/api/vocab/')) {
|
|
const term = decodeURIComponent(url.pathname.replace(/^\/api\/vocab\/?/, ''));
|
|
const cards = await getVocabCards();
|
|
const card = cards.find((item: any) => item.normalizedText === term || item.text === term);
|
|
if (!card) {
|
|
sendJson(res, 404, { error: 'Vocab card not found' });
|
|
return;
|
|
}
|
|
sendJson(res, 200, { card });
|
|
return;
|
|
}
|
|
|
|
sendJson(res, 405, { error: 'Unsupported saves API request' });
|
|
} catch (error) {
|
|
console.error(error);
|
|
sendJson(res, 500, { error: 'Local saves API failed' });
|
|
}
|
|
});
|
|
},
|
|
});
|
|
|
|
export default defineConfig(() => {
|
|
return {
|
|
base: './',
|
|
plugins: [react(), tailwindcss(), localSavesPlugin()],
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(__dirname, '.'),
|
|
},
|
|
},
|
|
server: {
|
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
|
hmr: process.env.DISABLE_HMR !== 'true',
|
|
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
|
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
|
},
|
|
};
|
|
});
|