mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Phase 2: export v2 format with playlists, backward-compat v1 import
Export now includes playlists, playlist_items, and device.playlist_id. Format bumped to screentinker-export-v2. Schedules include playlist_id. V1 import converts old assignments array into per-device playlists inline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
19a08ef5bc
commit
33a5be39ed
|
|
@ -84,8 +84,12 @@ router.get('/export', (req, res) => {
|
||||||
const layoutPlaceholders = layoutIds.map(() => '?').join(',') || "'__none__'";
|
const layoutPlaceholders = layoutIds.map(() => '?').join(',') || "'__none__'";
|
||||||
const layoutZones = layoutIds.length ? db.prepare(`SELECT * FROM layout_zones WHERE layout_id IN (${layoutPlaceholders})`).all(...layoutIds) : [];
|
const layoutZones = layoutIds.length ? db.prepare(`SELECT * FROM layout_zones WHERE layout_id IN (${layoutPlaceholders})`).all(...layoutIds) : [];
|
||||||
|
|
||||||
const assignments = deviceIds.length ? db.prepare(`SELECT id, device_id, content_id, widget_id, zone_id, sort_order, duration_sec, enabled FROM assignments WHERE device_id IN (${devicePlaceholders})`).all(...deviceIds) : [];
|
const playlists = db.prepare('SELECT id, name, description, is_auto_generated, created_at, updated_at FROM playlists WHERE user_id = ?').all(userId);
|
||||||
const schedules = db.prepare('SELECT id, device_id, zone_id, content_id, widget_id, layout_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at FROM schedules WHERE user_id = ?').all(userId);
|
const playlistIds = playlists.map(p => p.id);
|
||||||
|
const playlistPlaceholders = playlistIds.map(() => '?').join(',') || "'__none__'";
|
||||||
|
const playlistItems = playlistIds.length ? db.prepare(`SELECT id, playlist_id, content_id, widget_id, sort_order, duration_sec FROM playlist_items WHERE playlist_id IN (${playlistPlaceholders})`).all(...playlistIds) : [];
|
||||||
|
|
||||||
|
const schedules = db.prepare('SELECT id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at FROM schedules WHERE user_id = ?').all(userId);
|
||||||
const videoWalls = db.prepare('SELECT * FROM video_walls WHERE user_id = ?').all(userId);
|
const videoWalls = db.prepare('SELECT * FROM video_walls WHERE user_id = ?').all(userId);
|
||||||
const wallIds = videoWalls.map(w => w.id);
|
const wallIds = videoWalls.map(w => w.id);
|
||||||
const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'";
|
const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'";
|
||||||
|
|
@ -100,15 +104,19 @@ router.get('/export', (req, res) => {
|
||||||
const whiteLabel = db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(userId);
|
const whiteLabel = db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(userId);
|
||||||
|
|
||||||
const exportData = {
|
const exportData = {
|
||||||
format: 'screentinker-export-v1',
|
format: 'screentinker-export-v2',
|
||||||
exported_at: new Date().toISOString(),
|
exported_at: new Date().toISOString(),
|
||||||
user,
|
user,
|
||||||
devices,
|
devices: devices.map(d => {
|
||||||
|
const dev = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(d.id);
|
||||||
|
return { ...d, playlist_id: dev?.playlist_id || null };
|
||||||
|
}),
|
||||||
content,
|
content,
|
||||||
widgets: widgets.map(w => ({ ...w, config: JSON.parse(w.config || '{}') })),
|
widgets: widgets.map(w => ({ ...w, config: JSON.parse(w.config || '{}') })),
|
||||||
layouts,
|
layouts,
|
||||||
layout_zones: layoutZones,
|
layout_zones: layoutZones,
|
||||||
assignments,
|
playlists,
|
||||||
|
playlist_items: playlistItems,
|
||||||
schedules,
|
schedules,
|
||||||
video_walls: videoWalls,
|
video_walls: videoWalls,
|
||||||
video_wall_devices: wallDevices,
|
video_wall_devices: wallDevices,
|
||||||
|
|
@ -240,11 +248,12 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
|
||||||
return res.status(400).json({ error: 'Invalid export file. Must be a ScreenTinker export JSON.' });
|
return res.status(400).json({ error: 'Invalid export file. Must be a ScreenTinker export JSON.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isV2 = data.format === 'screentinker-export-v2';
|
||||||
const uuid = require('uuid');
|
const uuid = require('uuid');
|
||||||
const stats = { devices: 0, content: 0, widgets: 0, layouts: 0, schedules: 0, video_walls: 0, kiosk_pages: 0, device_groups: 0 };
|
const stats = { devices: 0, content: 0, widgets: 0, layouts: 0, playlists: 0, schedules: 0, video_walls: 0, kiosk_pages: 0, device_groups: 0 };
|
||||||
|
|
||||||
// Map old IDs to new IDs
|
// Map old IDs to new IDs
|
||||||
const idMap = { devices: {}, content: {}, widgets: {}, layouts: {}, zones: {}, groups: {}, walls: {}, kiosk: {} };
|
const idMap = { devices: {}, content: {}, widgets: {}, layouts: {}, zones: {}, playlists: {}, groups: {}, walls: {}, kiosk: {} };
|
||||||
|
|
||||||
const importDb = db.transaction(() => {
|
const importDb = db.transaction(() => {
|
||||||
// Import devices (as offline, unlinked - they'll need re-pairing)
|
// Import devices (as offline, unlinked - they'll need re-pairing)
|
||||||
|
|
@ -317,15 +326,50 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
|
||||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, newLayoutId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent, z.z_index || 0, z.zone_type || 'content', z.fit_mode || 'cover', z.background_color || '#000000', z.sort_order || 0);
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, newLayoutId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent, z.z_index || 0, z.zone_type || 'content', z.fit_mode || 'cover', z.background_color || '#000000', z.sort_order || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import assignments
|
// Import playlists (v2) or convert assignments to playlists (v1)
|
||||||
for (const a of (data.assignments || [])) {
|
if (isV2) {
|
||||||
const devId = idMap.devices[a.device_id];
|
for (const p of (data.playlists || [])) {
|
||||||
if (!devId) continue;
|
const newId = uuid.v4();
|
||||||
const contentId = a.content_id ? idMap.content[a.content_id] : null;
|
idMap.playlists[p.id] = newId;
|
||||||
const widgetId = a.widget_id ? idMap.widgets[a.widget_id] : null;
|
db.prepare('INSERT INTO playlists (id, user_id, name, description, is_auto_generated, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(newId, userId, p.name, p.description || '', p.is_auto_generated || 0, p.created_at || Math.floor(Date.now() / 1000), p.updated_at || Math.floor(Date.now() / 1000));
|
||||||
const zoneId = a.zone_id ? (idMap.zones[a.zone_id] || null) : null;
|
stats.playlists++;
|
||||||
if (!contentId && !widgetId) continue;
|
}
|
||||||
db.prepare(`INSERT INTO assignments (device_id, content_id, widget_id, zone_id, sort_order, duration_sec, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(devId, contentId, widgetId, zoneId, a.sort_order || 0, a.duration_sec || 10, a.enabled !== undefined ? a.enabled : 1);
|
for (const pi of (data.playlist_items || [])) {
|
||||||
|
const playlistId = idMap.playlists[pi.playlist_id];
|
||||||
|
if (!playlistId) continue;
|
||||||
|
const contentId = pi.content_id ? idMap.content[pi.content_id] : null;
|
||||||
|
const widgetId = pi.widget_id ? idMap.widgets[pi.widget_id] : null;
|
||||||
|
if (!contentId && !widgetId) continue;
|
||||||
|
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)').run(playlistId, contentId, widgetId, pi.sort_order || 0, pi.duration_sec || 10);
|
||||||
|
}
|
||||||
|
// Set device playlist_id references
|
||||||
|
for (const d of (data.devices || [])) {
|
||||||
|
if (d.playlist_id && idMap.playlists[d.playlist_id]) {
|
||||||
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(idMap.playlists[d.playlist_id], idMap.devices[d.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// v1: convert assignments to per-device playlists
|
||||||
|
const assignmentsByDevice = {};
|
||||||
|
for (const a of (data.assignments || [])) {
|
||||||
|
if (!assignmentsByDevice[a.device_id]) assignmentsByDevice[a.device_id] = [];
|
||||||
|
assignmentsByDevice[a.device_id].push(a);
|
||||||
|
}
|
||||||
|
for (const [oldDevId, assignments] of Object.entries(assignmentsByDevice)) {
|
||||||
|
const devId = idMap.devices[oldDevId];
|
||||||
|
if (!devId) continue;
|
||||||
|
const devName = (data.devices || []).find(d => d.id === oldDevId)?.name || 'Display';
|
||||||
|
const playlistId = uuid.v4();
|
||||||
|
db.prepare('INSERT INTO playlists (id, user_id, name, description, is_auto_generated) VALUES (?, ?, ?, ?, 1)').run(playlistId, userId, `${devName} (imported)`, 'Converted from v1 assignments');
|
||||||
|
for (const a of assignments) {
|
||||||
|
const contentId = a.content_id ? idMap.content[a.content_id] : null;
|
||||||
|
const widgetId = a.widget_id ? idMap.widgets[a.widget_id] : null;
|
||||||
|
if (!contentId && !widgetId) continue;
|
||||||
|
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)').run(playlistId, contentId, widgetId, a.sort_order || 0, a.duration_sec || 10);
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, devId);
|
||||||
|
stats.playlists++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import schedules
|
// Import schedules
|
||||||
|
|
@ -333,7 +377,8 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
|
||||||
const devId = idMap.devices[s.device_id];
|
const devId = idMap.devices[s.device_id];
|
||||||
if (!devId) continue;
|
if (!devId) continue;
|
||||||
const newId = uuid.v4();
|
const newId = uuid.v4();
|
||||||
db.prepare(`INSERT INTO schedules (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000));
|
const playlistId = s.playlist_id ? (idMap.playlists[s.playlist_id] || null) : null;
|
||||||
|
db.prepare(`INSERT INTO schedules (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000));
|
||||||
stats.schedules++;
|
stats.schedules++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue