import express from 'express' import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, readdirSync } from 'fs' import { fileURLToPath } from 'url' import { dirname, join } from 'path' import crypto from 'crypto' import multer from 'multer' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const app = express() const PORT = process.env.PORT || 3090 const SERVER_VERSION = Date.now().toString() const SCHEDULES_DIR = join(__dirname, 'schedules') const SAVED_SCHEDULE_PATH = join(__dirname, 'saved_schedule.json') const DEFAULT_DATA_PATH = join(__dirname, 'src', 'data.json') // Multer config for Excel uploads const upload = multer({ dest: join(__dirname, 'uploads'), limits: { fileSize: 20 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (file.mimetype === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || file.originalname.endsWith('.xlsx')) { cb(null, true) } else { cb(new Error('Only .xlsx files are accepted')) } } }) app.use(express.json({ limit: '10mb' })) // --- Helpers --- function ensureSchedulesDir() { if (!existsSync(SCHEDULES_DIR)) { mkdirSync(SCHEDULES_DIR, { recursive: true }) } } function listScheduleFiles() { ensureSchedulesDir() const files = readdirSync(SCHEDULES_DIR).filter(f => f.endsWith('.json')) const result = [] for (const f of files) { try { const raw = JSON.parse(readFileSync(join(SCHEDULES_DIR, f), 'utf-8')) result.push({ id: raw.id, name: raw.name, createdAt: raw.createdAt, modifiedAt: raw.modifiedAt, }) } catch {} } result.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt)) return result } function loadScheduleFile(id) { const filePath = join(SCHEDULES_DIR, `${id}.json`) if (!existsSync(filePath)) return null return JSON.parse(readFileSync(filePath, 'utf-8')) } function saveScheduleFile(fileObj) { ensureSchedulesDir() writeFileSync(join(SCHEDULES_DIR, `${fileObj.id}.json`), JSON.stringify(fileObj, null, 2), 'utf-8') } function getMostRecentFile() { const files = listScheduleFiles() return files.length > 0 ? files[0] : null } // --- Migration on startup --- function migrateIfNeeded() { ensureSchedulesDir() const existing = readdirSync(SCHEDULES_DIR).filter(f => f.endsWith('.json')) if (existing.length === 0 && existsSync(SAVED_SCHEDULE_PATH)) { console.log('Migrating saved_schedule.json to schedules/ directory...') const data = JSON.parse(readFileSync(SAVED_SCHEDULE_PATH, 'utf-8')) const now = new Date().toISOString() const fileObj = { id: crypto.randomUUID(), name: `Migrated_Schedule_${now.slice(0, 10)}`, createdAt: now, modifiedAt: now, data, } saveScheduleFile(fileObj) console.log(`Migrated as ${fileObj.id} (${fileObj.name})`) } } migrateIfNeeded() // --- Diff computation --- function computeDiff(file1, file2) { const changes = [] // Build lookup: personId -> { dayIdx -> value } function buildMap(data) { const map = {} if (!data) return map const people = data.people || data.stations if (!people) return map for (const person of people) { const cells = {} if (person.data) { for (const [idx, cellData] of Object.entries(person.data)) { if (cellData && cellData.v !== undefined && cellData.v !== null && cellData.v !== '') { cells[idx] = cellData.v } } } map[person.id || person.code] = cells } return map } const map1 = buildMap(file1.data) const map2 = buildMap(file2.data) // All person IDs from both files const allIds = new Set([...Object.keys(map1), ...Object.keys(map2)]) for (const id of allIds) { const cells1 = map1[id] || {} const cells2 = map2[id] || {} const allIdx = new Set([...Object.keys(cells1), ...Object.keys(cells2)]) for (const idx of allIdx) { const v1 = cells1[idx] const v2 = cells2[idx] const dayIdx = parseInt(idx) if (v1 !== undefined && v2 === undefined) { changes.push({ personId: id, dayIdx, type: 'removed', oldValue: v1 }) } else if (v1 === undefined && v2 !== undefined) { changes.push({ personId: id, dayIdx, type: 'added', newValue: v2 }) } else if (v1 !== v2) { changes.push({ personId: id, dayIdx, type: 'changed', oldValue: v1, newValue: v2 }) } } } return { changes } } // --- API: File management --- // List all files app.get('/api/files', (_req, res) => { try { res.json(listScheduleFiles()) } catch (err) { console.error('Error listing files:', err) res.status(500).json({ error: 'Failed to list files' }) } }) // Diff between two files app.get('/api/files/diff/:id1/:id2', (req, res) => { try { const file1 = loadScheduleFile(req.params.id1) const file2 = loadScheduleFile(req.params.id2) if (!file1) return res.status(404).json({ error: `File ${req.params.id1} not found` }) if (!file2) return res.status(404).json({ error: `File ${req.params.id2} not found` }) res.json(computeDiff(file1, file2)) } catch (err) { console.error('Error computing diff:', err) res.status(500).json({ error: 'Failed to compute diff' }) } }) // Import Excel (not yet implemented for TKB format) app.post('/api/files/import-excel', upload.single('file'), (_req, res) => { res.status(501).json({ error: 'Excel import not yet implemented for TKB format' }) }) // Get single file app.get('/api/files/:id', (req, res) => { try { const file = loadScheduleFile(req.params.id) if (!file) return res.status(404).json({ error: 'File not found' }) res.json(file) } catch (err) { console.error('Error loading file:', err) res.status(500).json({ error: 'Failed to load file' }) } }) // Export file as Excel (not yet implemented for TKB format) app.get('/api/files/:id/export-excel', (_req, res) => { res.status(501).json({ error: 'Excel export not yet implemented for TKB format' }) }) // Export docházka Excel for a given month app.get('/api/files/:id/export-dochazka', async (req, res) => { const { id } = req.params const month = parseInt(req.query.month) || (new Date().getMonth() + 1) const year = parseInt(req.query.year) || new Date().getFullYear() const filePath = join(SCHEDULES_DIR, `${id}.json`) if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' }) let fileData try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) } catch { return res.status(500).json({ error: 'Failed to read file' }) } const input = JSON.stringify({ schedule: fileData.data, month, year }) const scriptPath = join(__dirname, 'export_dochazka.py') try { const { spawn } = await import('child_process') await new Promise((resolve, reject) => { const chunks = [] const errChunks = [] const proc = spawn('python3', [scriptPath]) proc.stdout.on('data', d => chunks.push(d)) proc.stderr.on('data', d => errChunks.push(d)) proc.on('close', code => { if (code !== 0) { const errMsg = Buffer.concat(errChunks).toString() console.error('export_dochazka error:', errMsg) reject(new Error(errMsg)) } else { const xlsx = Buffer.concat(chunks) const MONTH_NAMES = ['','Leden','Únor','Březen','Duben','Květen','Červen','Červenec','Srpen','Září','Říjen','Listopad','Prosinec'] const mm = String(month).padStart(2, '0') const fname = `OP2416101755_TKB_${year}_${mm}_${MONTH_NAMES[month]}.xlsx` res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`) res.send(xlsx) resolve() } }) proc.on('error', reject) proc.stdin.write(input) proc.stdin.end() }) } catch (err) { if (!res.headersSent) res.status(500).json({ error: 'Export failed', details: err.message }) } }) // Create new file app.post('/api/files', (req, res) => { try { const { name, data } = req.body if (!data || (!data.people && !data.stations) || !data.dayIndex) { return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' }) } const now = new Date().toISOString() const fileObj = { id: crypto.randomUUID(), name: name || `Schedule_${now.slice(0, 10)}`, createdAt: now, modifiedAt: now, data, } saveScheduleFile(fileObj) res.json({ id: fileObj.id, name: fileObj.name, createdAt: fileObj.createdAt, modifiedAt: fileObj.modifiedAt, }) } catch (err) { console.error('Error creating file:', err) res.status(500).json({ error: 'Failed to create file' }) } }) // Update existing file app.put('/api/files/:id', (req, res) => { try { const file = loadScheduleFile(req.params.id) if (!file) return res.status(404).json({ error: 'File not found' }) const { name, data } = req.body if (name !== undefined) file.name = name if (data) { if ((!data.people && !data.stations) || !data.dayIndex) { return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' }) } file.data = data } file.modifiedAt = new Date().toISOString() saveScheduleFile(file) res.json({ id: file.id, name: file.name, createdAt: file.createdAt, modifiedAt: file.modifiedAt, }) } catch (err) { console.error('Error updating file:', err) res.status(500).json({ error: 'Failed to update file' }) } }) // Delete file app.delete('/api/files/:id', (req, res) => { try { const filePath = join(SCHEDULES_DIR, `${req.params.id}.json`) if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' }) unlinkSync(filePath) res.json({ ok: true }) } catch (err) { console.error('Error deleting file:', err) res.status(500).json({ error: 'Failed to delete file' }) } }) // --- Backward compatible endpoints --- // GET /api/schedule — Load most recently modified file (or fallback to src/data.json) app.get('/api/schedule', (_req, res) => { try { const recent = getMostRecentFile() if (recent) { const file = loadScheduleFile(recent.id) if (file) return res.json(file.data) } // Fallback to old paths if (existsSync(SAVED_SCHEDULE_PATH)) { return res.json(JSON.parse(readFileSync(SAVED_SCHEDULE_PATH, 'utf-8'))) } res.json(JSON.parse(readFileSync(DEFAULT_DATA_PATH, 'utf-8'))) } catch (err) { console.error('Error loading schedule:', err) res.status(500).json({ error: 'Failed to load schedule' }) } }) // POST /api/schedule — Save to most recently modified file (or create new one) app.post('/api/schedule', (req, res) => { try { const data = req.body if (!data || (!data.people && !data.stations) || !data.dayIndex) { return res.status(400).json({ error: 'Invalid schedule data' }) } const recent = getMostRecentFile() const now = new Date().toISOString() if (recent) { const file = loadScheduleFile(recent.id) if (file) { file.data = data file.modifiedAt = now saveScheduleFile(file) console.log(`Schedule saved to ${file.name} (${file.id}) at ${now}`) return res.json({ ok: true, id: file.id }) } } // No existing file — create new const fileObj = { id: crypto.randomUUID(), name: `Schedule_${now.slice(0, 10)}`, createdAt: now, modifiedAt: now, data, } saveScheduleFile(fileObj) console.log(`Schedule saved as new file ${fileObj.name} (${fileObj.id}) at ${now}`) res.json({ ok: true, id: fileObj.id }) } catch (err) { console.error('Error saving schedule:', err) res.status(500).json({ error: 'Failed to save schedule' }) } }) // --- API: Proposals --- const PROPOSALS_PATH = join(__dirname, '..', 'proposals.md') app.get('/api/proposals', (_req, res) => { try { if (existsSync(PROPOSALS_PATH)) { res.json({ content: readFileSync(PROPOSALS_PATH, 'utf-8') }) } else { res.json({ content: '' }) } } catch (err) { console.error('Error reading proposals:', err) res.status(500).json({ error: 'Failed to read proposals' }) } }) app.post('/api/proposals', (req, res) => { try { const { text, author } = req.body if (!text?.trim()) return res.status(400).json({ error: 'Empty proposal' }) const now = new Date().toLocaleString('cs-CZ') const entry = `\n---\n\n**${now}** ${author ? `(${author})` : ''}\n\n${text.trim()}\n` let content = '' if (existsSync(PROPOSALS_PATH)) { content = readFileSync(PROPOSALS_PATH, 'utf-8') } else { content = '# Navrhy na vylepseni TKB Planu sluzeb\n' } content += entry writeFileSync(PROPOSALS_PATH, content, 'utf-8') res.json({ ok: true }) } catch (err) { console.error('Error saving proposal:', err) res.status(500).json({ error: 'Failed to save proposal' }) } }) // API: export as Excel (legacy endpoint — not yet implemented for TKB format) app.get('/api/export-excel', (_req, res) => { res.status(501).json({ error: 'Excel export not yet implemented for TKB format' }) }) // Version endpoint — clients poll this and reload when version changes app.get('/api/version', (_req, res) => { res.json({ version: SERVER_VERSION }) }) // Serve static files from dist/ app.use(express.static(join(__dirname, 'dist'), { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') } })) // SPA fallback app.get('/{*splat}', (_req, res) => { res.sendFile(join(__dirname, 'dist', 'index.html')) }) app.listen(PORT, () => { console.log(`TKB server running on http://localhost:${PORT}`) })