263 lines
9.0 KiB
JavaScript
263 lines
9.0 KiB
JavaScript
// 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}`);
|
||
});
|