Merge pull request #53 from screentinker/fix/template-zone-duplication

fix(layouts): atomic zone save — stop template zone duplication
This commit is contained in:
screentinker 2026-06-09 10:16:06 -05:00 committed by GitHub
commit 3f429aec85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 74 additions and 12 deletions

View file

@ -293,15 +293,21 @@ async function renderEditor(container, layoutId) {
document.getElementById('saveLayoutBtn').onclick = async () => {
try {
for (const z of layout.zones || []) {
await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' });
}
for (const z of zones) {
await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) });
}
// Single atomic update: send the full zone set and the server replaces them
// exactly. The old per-zone delete-then-add loop could accumulate zones
// (and regenerated every zone id each save). Keep each zone's id so
// device->zone assignments survive.
const updated = await API(`/layouts/${layoutId}`, {
method: 'PUT',
body: JSON.stringify({ zones }),
});
if (updated && updated.error) { showToast(updated.error, 'error'); return; }
layout = updated;
zones = layout.zones || [];
selectedZone = null;
showToast(t('layout.toast.saved'), 'success');
layout = await API(`/layouts/${layoutId}`);
zones = layout.zones;
renderZones();
updateProperties();
} catch (err) {
showToast(err.message, 'error');
}

View file

@ -719,6 +719,40 @@ function pruneScreenshots(deviceId) {
`).run(deviceId, deviceId);
}
// De-duplicate built-in template zones. A prior layout-editor save regenerated
// every zone id on save; schema.sql's INSERT OR IGNORE then re-seeded the
// canonical zone on the next boot, so template layouts accumulated positional
// duplicates (e.g. a 2-zone split template grew to 4+). For each position in a
// template, keep ONE zone, preferring the canonical seeded id (the built-in
// template zones use 'z-...' ids; bug copies are uuids) so schema.sql's re-seed
// stays an idempotent no-op; tiebreak by earliest rowid. One-time; the atomic
// id-preserving save prevents recurrence.
try {
const DEDUPE_ID = 'dedupe_template_zones_v1';
if (!db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(DEDUPE_ID)) {
const removed = db.prepare(`
DELETE FROM layout_zones WHERE id IN (
SELECT z.id FROM layout_zones z
JOIN layouts l ON l.id = z.layout_id
WHERE l.is_template = 1 AND EXISTS (
SELECT 1 FROM layout_zones z2
WHERE z2.layout_id = z.layout_id AND z2.id != z.id
AND z2.x_percent = z.x_percent AND z2.y_percent = z.y_percent
AND z2.width_percent = z.width_percent AND z2.height_percent = z.height_percent
AND (
-- z2 is canonical and z is not -> keep z2, drop z
(z2.id LIKE 'z-%' AND z.id NOT LIKE 'z-%')
-- same canonical-ness -> keep the earliest, drop the rest
OR ((CASE WHEN z2.id LIKE 'z-%' THEN 1 ELSE 0 END) = (CASE WHEN z.id LIKE 'z-%' THEN 1 ELSE 0 END) AND z2.rowid < z.rowid)
)
)
)
`).run().changes;
if (removed > 0) console.log(`[migrate] removed ${removed} duplicate template zone(s)`);
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(DEDUPE_ID);
}
} catch (e) { console.error('[migrate] template-zone dedupe failed:', e.message); }
// #37: fail fast (loud) if migrations left the DB missing schema the code needs.
const { verifyAndRepairSchema } = require('../lib/schema-check');
verifyAndRepairSchema(db);

View file

@ -116,10 +116,32 @@ router.put('/:id', (req, res) => {
if (!layout) return;
if (layout.is_template && !PLATFORM_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' });
const { name, width, height } = req.body;
if (name) db.prepare('UPDATE layouts SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id);
if (width) db.prepare('UPDATE layouts SET width = ? WHERE id = ?').run(width, req.params.id);
if (height) db.prepare('UPDATE layouts SET height = ? WHERE id = ?').run(height, req.params.id);
const { name, width, height, zones } = req.body;
const txn = db.transaction(() => {
if (name) db.prepare('UPDATE layouts SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id);
if (width) db.prepare('UPDATE layouts SET width = ? WHERE id = ?').run(width, req.params.id);
if (height) db.prepare('UPDATE layouts SET height = ? WHERE id = ?').run(height, req.params.id);
// Atomic zone replace: the editor sends the FULL desired set, so the layout
// ends up with EXACTLY those zones - no accumulation from a per-zone
// delete/add loop. Reuse each zone's id when supplied so device->zone
// assignments survive an edit (a fresh uuid per save would orphan them).
if (Array.isArray(zones)) {
db.prepare('DELETE FROM layout_zones WHERE layout_id = ?').run(req.params.id);
const stmt = db.prepare(`
INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
zones.forEach((z, i) => {
stmt.run(z.id || uuidv4(), req.params.id, z.name || `Zone ${i + 1}`,
z.x_percent || 0, z.y_percent || 0, z.width_percent || 100, z.height_percent || 100,
z.z_index || 0, z.zone_type || 'content', z.fit_mode || 'contain',
z.background_color || '#000000', i);
});
db.prepare('UPDATE layouts SET updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(req.params.id);
}
});
txn();
const updated = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
updated.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(req.params.id);