215 lines
6.9 KiB
JavaScript
215 lines
6.9 KiB
JavaScript
import express from 'express';
|
|
import cors from 'cors';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import crypto from 'crypto';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 80;
|
|
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data_store');
|
|
const STATE_FILE = path.join(DATA_DIR, 'wortkraft_state.json');
|
|
const TRANSLATE_CACHE_FILE = path.join(DATA_DIR, 'translations_cache.json');
|
|
const TTS_CACHE_DIR = path.join(DATA_DIR, 'tts_cache');
|
|
|
|
if (!fs.existsSync(DATA_DIR)) {
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
}
|
|
if (!fs.existsSync(TTS_CACHE_DIR)) {
|
|
fs.mkdirSync(TTS_CACHE_DIR, { recursive: true });
|
|
}
|
|
|
|
function readJson(filePath, fallback) {
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
return JSON.parse(raw);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[WORTKRAFT] Error reading ${filePath}:`, err);
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function writeJson(filePath, obj) {
|
|
try {
|
|
fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8');
|
|
return true;
|
|
} catch (err) {
|
|
console.error(`[WORTKRAFT] Error writing ${filePath}:`, err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Split longer German text into sentence/phrase chunks <= 180 chars for TTS
|
|
function splitTextForTts(text, maxLen = 180) {
|
|
const cleaned = text.replace(/\s+/g, ' ').trim();
|
|
if (cleaned.length <= maxLen) return [cleaned];
|
|
|
|
const sentences = cleaned.split(/(?<=[.!?…;:])\s+/);
|
|
const chunks = [];
|
|
let current = '';
|
|
|
|
for (const sentence of sentences) {
|
|
if ((current + ' ' + sentence).trim().length <= maxLen) {
|
|
current = (current + ' ' + sentence).trim();
|
|
} else {
|
|
if (current) chunks.push(current);
|
|
if (sentence.length <= maxLen) {
|
|
current = sentence;
|
|
} else {
|
|
// Split very long sentence by commas or words
|
|
const words = sentence.split(' ');
|
|
current = '';
|
|
for (const w of words) {
|
|
if ((current + ' ' + w).trim().length <= maxLen) {
|
|
current = (current + ' ' + w).trim();
|
|
} else {
|
|
if (current) chunks.push(current);
|
|
current = w;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (current) chunks.push(current);
|
|
return chunks;
|
|
}
|
|
|
|
app.use(cors());
|
|
app.use(express.json({ limit: '10mb' }));
|
|
|
|
// Healthcheck endpoint for Docker & Homepage
|
|
app.get('/api/health', (_req, res) => {
|
|
res.json({
|
|
status: 'ok',
|
|
app: 'wortkraft',
|
|
timestamp: new Date().toISOString(),
|
|
uptimeSeconds: Math.floor(process.uptime())
|
|
});
|
|
});
|
|
|
|
// Native German (de-DE) TTS Audio Endpoint with disk caching
|
|
app.get('/api/tts', async (req, res) => {
|
|
const text = String(req.query.text || '').trim();
|
|
if (!text) {
|
|
return res.status(400).json({ error: 'Missing text parameter' });
|
|
}
|
|
|
|
const hash = crypto.createHash('sha1').update(text).digest('hex');
|
|
const cachedFile = path.join(TTS_CACHE_DIR, `${hash}.mp3`);
|
|
|
|
if (fs.existsSync(cachedFile)) {
|
|
res.setHeader('Content-Type', 'audio/mpeg');
|
|
res.setHeader('Cache-Control', 'public, max-age=604800');
|
|
return fs.createReadStream(cachedFile).pipe(res);
|
|
}
|
|
|
|
try {
|
|
const chunks = splitTextForTts(text, 180);
|
|
const buffers = [];
|
|
|
|
for (const chunk of chunks) {
|
|
const ttsUrl = `https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=de&q=${encodeURIComponent(chunk)}`;
|
|
const response = await fetch(ttsUrl, {
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
}
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`TTS upstream returned ${response.status}`);
|
|
}
|
|
const arrBuf = await response.arrayBuffer();
|
|
buffers.push(Buffer.from(arrBuf));
|
|
}
|
|
|
|
const combined = Buffer.concat(buffers);
|
|
fs.writeFileSync(cachedFile, combined);
|
|
|
|
res.setHeader('Content-Type', 'audio/mpeg');
|
|
res.setHeader('Cache-Control', 'public, max-age=604800');
|
|
return res.send(combined);
|
|
} catch (err) {
|
|
console.error('[WORTKRAFT] TTS error:', err.message);
|
|
return res.status(502).json({ error: 'TTS generation failed' });
|
|
}
|
|
});
|
|
|
|
// Get persisted learning state (SRS deck, streak, custom words & stories)
|
|
app.get('/api/state', (_req, res) => {
|
|
const state = readJson(STATE_FILE, null);
|
|
res.json({ exists: Boolean(state), state });
|
|
});
|
|
|
|
// Save learning state
|
|
app.post('/api/state', (req, res) => {
|
|
const payload = req.body;
|
|
if (!payload || typeof payload !== 'object') {
|
|
return res.status(400).json({ error: 'Invalid state payload' });
|
|
}
|
|
payload.updatedAt = new Date().toISOString();
|
|
const ok = writeJson(STATE_FILE, payload);
|
|
res.json({ success: ok, updatedAt: payload.updatedAt });
|
|
});
|
|
|
|
// On-the-fly German -> English word translation fallback for custom texts
|
|
app.get('/api/translate', async (req, res) => {
|
|
const word = String(req.query.word || '').trim();
|
|
if (!word) {
|
|
return res.status(400).json({ error: 'Missing word parameter' });
|
|
}
|
|
const key = word.toLowerCase();
|
|
const cache = readJson(TRANSLATE_CACHE_FILE, {});
|
|
if (cache[key]) {
|
|
return res.json({ word, english: cache[key], cached: true });
|
|
}
|
|
|
|
try {
|
|
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(word)}&langpair=de|en`;
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const translated = data?.responseData?.translatedText;
|
|
if (translated && translated.toLowerCase() !== key) {
|
|
cache[key] = translated;
|
|
writeJson(TRANSLATE_CACHE_FILE, cache);
|
|
return res.json({ word, english: translated, cached: false });
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[WORTKRAFT] Translation fallback error:', err.message);
|
|
}
|
|
res.json({ word, english: null });
|
|
});
|
|
|
|
// Serve static frontend files from project root with no-cache headers so updates apply immediately
|
|
const PUBLIC_DIR = path.resolve(__dirname, '..');
|
|
app.use((req, res, next) => {
|
|
if (!req.path.startsWith('/api/tts')) {
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Expires', '0');
|
|
}
|
|
next();
|
|
});
|
|
app.use('/data', express.static(path.join(PUBLIC_DIR, 'data')));
|
|
app.get('/icon.svg', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'icon.svg')));
|
|
app.get('/styles.css', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'styles.css')));
|
|
app.get('/app.js', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'app.js')));
|
|
app.get('/', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html')));
|
|
|
|
app.use((req, res, next) => {
|
|
if (req.method === 'GET' && !req.path.startsWith('/api')) {
|
|
return res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
|
}
|
|
next();
|
|
});
|
|
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`[WORTKRAFT] Server listening on port ${PORT}`);
|
|
console.log(`[WORTKRAFT] Persistent data directory: ${DATA_DIR}`);
|
|
});
|