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