// server.js const express = require('express'); const bodyParser = require('body-parser'); const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const http = require('http'); const { Server } = require('socket.io'); const fs = require('fs'); const app = express(); const server = http.createServer(app); const io = new Server(server); app.use(bodyParser.json()); app.use(express.static(path.join(__dirname, 'public'))); // open (or create) the SQLite database file const db = new sqlite3.Database('positions.db', err => { if (err) { console.error('Could not open DB', err); process.exit(1); } }); // create table if not exists db.run( `CREATE TABLE IF NOT EXISTS positions ( square_id TEXT PRIMARY KEY, fruit TEXT )`, (err) => { if (err) console.error('Could not ensure table', err); } ); // Add a table for history (revision log) db.run( `CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, action TEXT, positions TEXT -- JSON string of all positions )`, (err) => { if (err) console.error('Could not ensure history table', err); } ); // Helper to save a revision to history function saveHistory(action, cb) { db.all('SELECT square_id, fruit FROM positions', (err, rows) => { if (err) return cb && cb(err); const mapping = {}; rows.forEach(r => (mapping[r.square_id] = r.fruit)); db.run( 'INSERT INTO history (action, positions) VALUES (?, ?)', [action, JSON.stringify(mapping)], cb ); }); } // get all saved positions app.get('/api/positions', (req, res) => { db.all('SELECT square_id, fruit FROM positions', (err, rows) => { if (err) return res.status(500).json({ error: err.message }); // convert to an object. should look like this: { "1": "Apple", "2": "Banana", … } const mapping = {}; rows.forEach(r => (mapping[r.square_id] = r.fruit)); res.json(mapping); }); }); // save (or update) a single square’s item app.post('/api/positions', (req, res) => { const { squareId, fruit } = req.body; if (!squareId || typeof fruit !== 'string') { return res.status(400).json({ error: 'squareId and fruit required' }); } db.run( `INSERT INTO positions (square_id, fruit) VALUES (?, ?) ON CONFLICT(square_id) DO UPDATE SET fruit=excluded.fruit`, [squareId, fruit], function (err) { if (err) return res.status(500).json({ error: err.message }); // Save to history saveHistory(`Moved ${fruit} to '${squareId}'`, () => {}); // broadcast update via Socket.io io.emit('update', { squareId, fruit }); res.json({ success: true }); } ); }); // Serve categories and fruits from a JSON file app.get('/api/categories', (req, res) => { const categoriesPath = path.join(__dirname, 'categories.json'); fs.readFile(categoriesPath, 'utf8', (err, data) => { if (err) return res.status(500).json({ error: 'Could not load categories' }); try { const categories = JSON.parse(data); res.json(categories); } catch (e) { res.status(500).json({ error: 'Invalid categories file' }); } }); }); // Add a fruit to a category in categories.json app.post('/api/add-fruit', (req, res) => { const { category, fruit } = req.body; if (!category || !fruit) { return res.status(400).json({ error: 'category and fruit required' }); } const categoriesPath = path.join(__dirname, 'categories.json'); fs.readFile(categoriesPath, 'utf8', (err, data) => { if (err) return res.status(500).json({ error: 'Could not load categories' }); let categories; try { categories = JSON.parse(data); } catch (e) { return res.status(500).json({ error: 'Invalid categories file' }); } const cat = categories.find(c => c.name === category); if (!cat) return res.status(404).json({ error: 'Category not found' }); // Prevent duplicates if (cat.fruits.includes(fruit)) { return res.status(400).json({ error: 'Fruit already exists in category' }); } cat.fruits.push(fruit); fs.writeFile(categoriesPath, JSON.stringify(categories, null, 2), err2 => { if (err2) return res.status(500).json({ error: 'Could not save categories' }); res.json({ success: true }); }); }); }); // Delete a fruit from a category in categories.json app.post('/api/delete-fruit', (req, res) => { const { category, fruit } = req.body; if (!category || !fruit) { return res.status(400).json({ error: 'category and fruit required' }); } const categoriesPath = path.join(__dirname, 'categories.json'); fs.readFile(categoriesPath, 'utf8', (err, data) => { if (err) return res.status(500).json({ error: 'Could not load categories' }); let categories; try { categories = JSON.parse(data); } catch (e) { return res.status(500).json({ error: 'Invalid categories file' }); } const cat = categories.find(c => c.name === category); if (!cat) return res.status(404).json({ error: 'Category not found' }); const idx = cat.fruits.indexOf(fruit); if (idx === -1) return res.status(404).json({ error: 'Fruit not found in category' }); cat.fruits.splice(idx, 1); fs.writeFile(categoriesPath, JSON.stringify(categories, null, 2), err2 => { if (err2) return res.status(500).json({ error: 'Could not save categories' }); res.json({ success: true }); }); }); }); // API to get history app.get('/api/history', (req, res) => { db.all('SELECT id, timestamp, action FROM history ORDER BY id DESC', (err, rows) => { if (err) return res.status(500).json({ error: err.message }); res.json(rows); }); }); // API to get a specific revision's positions app.get('/api/history/:id', (req, res) => { db.get('SELECT positions FROM history WHERE id = ?', [req.params.id], (err, row) => { if (err) return res.status(500).json({ error: err.message }); if (!row) return res.status(404).json({ error: 'Not found' }); res.json(JSON.parse(row.positions)); }); }); // API to revert to a specific revision app.post('/api/revert', (req, res) => { const { id } = req.body; if (!id) { // No id: revert to empty board db.run('DELETE FROM positions', [], (err2) => { if (err2) return res.status(500).json({ error: err2.message }); saveHistory('Reverted to empty board', () => {}); io.emit('update', { revert: true }); return res.json({ success: true }); }); return; } db.get('SELECT positions FROM history WHERE id = ?', [id], (err, row) => { if (err) return res.status(500).json({ error: err.message }); if (!row) return res.status(404).json({ error: 'Not found' }); const positions = JSON.parse(row.positions); // Clear all positions db.run('DELETE FROM positions', [], (err2) => { if (err2) return res.status(500).json({ error: err2.message }); // Insert all positions from the revision const entries = Object.entries(positions); let done = 0; if (entries.length === 0) { saveHistory(`Reverted to revision ${id}`, () => {}); io.emit('update', { revert: true }); return res.json({ success: true }); } entries.forEach(([squareId, fruit]) => { db.run( `INSERT INTO positions (square_id, fruit) VALUES (?, ?)`, [squareId, fruit], (err3) => { done++; if (done === entries.length) { saveHistory(`Reverted to revision ${id}`, () => {}); io.emit('update', { revert: true }); res.json({ success: true }); } } ); }); }); }); }); // Move a fruit from one square to another in a single revision app.post('/api/move', (req, res) => { const { fromSquareId, toSquareId, fruit } = req.body; if (!fromSquareId || !toSquareId || !fruit) { return res.status(400).json({ error: 'fromSquareId, toSquareId, and fruit required' }); } db.serialize(() => { db.run( `UPDATE positions SET fruit='' WHERE square_id=?`, [fromSquareId], (err1) => { if (err1) return res.status(500).json({ error: err1.message }); db.run( `INSERT INTO positions (square_id, fruit) VALUES (?, ?) ON CONFLICT(square_id) DO UPDATE SET fruit=excluded.fruit`, [toSquareId, fruit], (err2) => { if (err2) return res.status(500).json({ error: err2.message }); saveHistory(`Moved ${fruit} from ${fromSquareId} to ${toSquareId}`, () => {}); io.emit('update', { fromSquareId, toSquareId, fruit }); res.json({ success: true }); } ); } ); }); }); // start server const PORT = process.env.PORT || 3085; server.listen(PORT, () => { console.log(`Listening on http://localhost:${PORT}`); });