From 178af029a4f5eecef5e9fef175a156f7aa1a8bd6 Mon Sep 17 00:00:00 2001 From: screentinker Date: Thu, 16 Jul 2026 13:53:34 -0500 Subject: [PATCH] Directory board: JSON/CSV import + logo-replaces-title + fix images on player (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(widgets): bulk import for the directory board (JSON / CSV / TSV / text) Adds an "Import from JSON / CSV" button to the directory-board editor. Paste JSON (the { company, tenantsByFloor, advertisements, backgroundImages } shape plus categories[]/floors[]/flat-array/bare-floor-map variants), a CSV/TSV/pipe/semicolon table (with or without a header — vacant/yes/1 => available, quoted fields), or a sectioned "room name" text list, and it auto-fills title, footer, floors->categories, rooms/names/details/availability, and background-image URLs. "Replace / append" toggle. Tolerant key matching (room/suite/unit/id, name/tenant/company, details/subtitle, …); warns on things it can't use (bare-filename background images, headerless columns). parseDirectoryImport is pure and was unit-tested in node across every format. Co-Authored-By: Claude Opus 4.8 * fix(widgets): directory board — logo replaces title, and images load on the player Two on-screen bugs on the directory board: 1. A logo did not remove the title text — both rendered, stacking the wordmark over the name. renderDirectoryBoard (and the directory-search header) now gate the title h1 behind !logoSrc, so a logo replaces the title. New render test guards it. 2. Logo + background images did not show on the player (NS_ERROR_DOM_CORP_FAILED, 0 bytes). The player embeds widgets in a sandbox="allow-scripts" (opaque-origin) iframe, so /api/content image requests are cross-origin, and the helmet default Cross-Origin-Resource-Policy: same-origin blocks them. Set CORP: cross-origin (+ ACAO:*) on the content file + thumbnail routes, matching the existing /uploads/content static route. Content already serves publicly, so no new exposure. Verified in a real sandboxed iframe: same-origin blocks, cross-origin loads. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- frontend/js/i18n/en.js | 13 ++ frontend/js/views/widgets.js | 232 +++++++++++++++++++++ server/routes/content.js | 9 + server/routes/widgets.js | 6 +- server/test/directory-board-render.test.js | 43 ++++ 5 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 server/test/directory-board-render.test.js diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 492e540..f32d555 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -773,6 +773,19 @@ export default { 'widget.dir.columns_auto': 'Auto', 'widget.dir.categories': 'Categories', 'widget.dir.add_category': '+ Add Category', + 'widget.dir.import_btn': 'Import from JSON / CSV', + 'widget.dir.import_hint': 'Paste JSON, CSV/TSV, or a simple tenant list to auto-fill the fields below.', + 'widget.dir.import_title': 'Import Directory Data', + 'widget.dir.import_desc': 'Paste JSON, a CSV/TSV table, or a "room name" list. Floors or sections become categories; rooms, names, details and availability are detected automatically.', + 'widget.dir.import_placeholder': 'Paste here — e.g.\n{ "company": "…", "tenantsByFloor": { "First Floor": [ { "room": "110", "name": "…" } ] } }\n\n…or CSV:\nFloor,Room,Name,Details,Available\nFirst Floor,110,Bodied By Rico,,no', + 'widget.dir.import_replace': 'Replace existing categories (uncheck to append)', + 'widget.dir.import_populate': 'Populate Form', + 'widget.dir.import_done': 'Imported {cats} categories and {entries} entries.', + 'widget.dir.import_warn_bg': '{n} background image name(s) were skipped — they are bare filenames, not URLs. Add them via "Add Background Image".', + 'widget.dir.import_warn_noheader': 'No header row detected — columns were guessed by position. Add a header (e.g. Floor,Room,Name,Details) for exact mapping.', + 'widget.dir.import_warn_nosections': 'No sections detected — everything was placed in one category. Use headings like "Second Floor:" to split by floor.', + 'widget.dir.import_err_empty': 'Nothing to import — paste JSON, CSV, or a tenant list.', + 'widget.dir.import_err_norows': 'No rows found. Check the format — JSON, CSV/TSV, or a "room name" list.', 'widget.dir.add_entry': '+ Add Entry', 'widget.dir.empty_categories': 'Add your first floor or department to get started', 'widget.dir.no_entries': 'No entries yet', diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js index c283bfb..dd20fb5 100644 --- a/frontend/js/views/widgets.js +++ b/frontend/js/views/widgets.js @@ -24,6 +24,175 @@ function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } +// --- Directory-board bulk import: tolerant parser for JSON, CSV/TSV/pipe/semicolon +// tables, or a sectioned "room name" text list. Returns { meta, categories, +// background_images, warnings, stats }. Pure (no DOM) — unit-tested in node. --- +function _diPick(o, keys) { + if (!o || typeof o !== 'object') return undefined; + const low = {}; + for (const k of Object.keys(o)) low[k.toLowerCase()] = o[k]; + for (const k of keys) { const v = low[k]; if (v != null && v !== '') return v; } + return undefined; +} +function _diBool(v) { + if (v === true) return true; + const s = String(v == null ? '' : v).trim().toLowerCase(); + return s === 'true' || s === 'yes' || s === 'y' || s === '1' || s === 'available' || s === 'open' || s === 'vacant'; +} +function _diEntry(e) { + if (e == null) return null; + if (typeof e === 'string' || typeof e === 'number') return { identifier: '', name: String(e).trim(), subtitle: '', available: false }; + const id = _diPick(e, ['room', 'identifier', 'id', 'number', 'no', 'suite', 'unit', 'office', 'space']); + const name = _diPick(e, ['name', 'title', 'company', 'tenant', 'business', 'label', 'text']); + const sub = _diPick(e, ['subtitle', 'details', 'detail', 'description', 'desc', 'note', 'notes', 'info']); + const avail = _diPick(e, ['available', 'vacant', 'is_available', 'open', 'status']); + return { + identifier: id == null ? '' : String(id).trim(), + name: name == null ? '' : String(name).trim(), + subtitle: sub == null ? '' : String(sub).trim(), + available: avail == null ? false : _diBool(avail), + }; +} +const _DI_META_ARRAY_KEYS = new Set(['advertisements', 'ads', 'announcements', 'backgroundimages', 'background_images', 'backgrounds']); +function _diNormalizeJson(data) { + const meta = {}; const warnings = []; + const title = _diPick(data, ['company', 'title', 'name', 'building', 'property']); + if (title != null) meta.title = String(title).trim(); + let footer = _diPick(data, ['footer_text', 'footer', 'footertext', 'leasing', 'contact']); + const ads = data.advertisements || data.ads || data.announcements; + if (footer == null && Array.isArray(ads)) footer = ads.map(a => (typeof a === 'string' ? a : _diPick(a, ['text', 'message', 'content']))).filter(Boolean).join(' • '); + if (footer) meta.footer_text = String(footer).trim(); + const theme = _diPick(data, ['theme']); if (theme) meta.theme = String(theme).toLowerCase(); + const speed = _diPick(data, ['scroll_speed', 'scrollspeed', 'speed']); if (speed) meta.scroll_speed = String(speed).toLowerCase(); + const cols = _diPick(data, ['columns', 'cols']); if (cols != null) meta.columns = String(cols).toLowerCase(); + const logo = _diPick(data, ['logo_url', 'logo', 'logourl']); if (logo) meta.logo_url = String(logo); + const bgSrc = data.background_images || data.backgroundImages || data.backgrounds || []; + const background_images = []; let skippedBg = 0; + if (Array.isArray(bgSrc)) for (const b of bgSrc) { + const s = typeof b === 'string' ? b : _diPick(b, ['url', 'src', 'path']); + if (!s) continue; + if (/^(https?:)?\/\//i.test(s) || String(s).startsWith('/')) background_images.push(String(s)); else skippedBg++; + } + if (skippedBg) warnings.push(t('widget.dir.import_warn_bg', { n: skippedBg })); + let categories = []; + const mapCat = (c) => ({ name: String(_diPick(c, ['name', 'title', 'floor', 'category', 'section', 'label']) || '').trim(), entries: (c.entries || c.tenants || c.items || c.rooms || []).map(_diEntry).filter(Boolean) }); + const tbf = data.tenantsByFloor || data.byfloor || data.tenantsbyfloor || data.sections; + if (tbf && typeof tbf === 'object' && !Array.isArray(tbf)) categories = Object.keys(tbf).map(fn => ({ name: fn, entries: (Array.isArray(tbf[fn]) ? tbf[fn] : []).map(_diEntry).filter(Boolean) })); + else if (Array.isArray(data.categories)) categories = data.categories.map(mapCat); + else if (Array.isArray(data.floors)) categories = data.floors.map(mapCat); + else if (Array.isArray(data)) { + if (data.some(x => x && typeof x === 'object' && (x.entries || x.tenants || x.items || x.rooms))) categories = data.map(mapCat); + else categories = [{ name: '', entries: data.map(_diEntry).filter(Boolean) }]; + } else { + const floorKeys = Object.keys(data).filter(k => Array.isArray(data[k]) && !_DI_META_ARRAY_KEYS.has(k.toLowerCase())); + if (floorKeys.length) categories = floorKeys.map(fn => ({ name: fn, entries: data[fn].map(_diEntry).filter(Boolean) })); + } + return { meta, categories, background_images, warnings }; +} +function _diSplit(line, delim) { + if (delim !== ',' && delim !== ';') return line.split(delim); + const out = []; let cur = ''; let q = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; } + else if (ch === '"') q = true; + else if (ch === delim) { out.push(cur); cur = ''; } + else cur += ch; + } + out.push(cur); return out; +} +const _DI_HEAD = { + floor: ['floor', 'category', 'section', 'level', 'wing', 'building', 'group'], + id: ['room', 'suite', 'unit', 'id', 'number', 'no', 'no.', 'office', 'space', '#'], + name: ['name', 'tenant', 'company', 'business', 'title', 'occupant'], + sub: ['subtitle', 'details', 'detail', 'description', 'desc', 'note', 'notes', 'info'], + avail: ['available', 'vacant', 'status', 'open'], +}; +function _diHeaderRole(cell) { + const c = cell.trim().toLowerCase(); + for (const role of Object.keys(_DI_HEAD)) if (_DI_HEAD[role].includes(c)) return role; + return null; +} +function _diParseTable(lines, delim, warnings) { + const rows = lines.map(l => _diSplit(l, delim).map(c => c.trim())); + const first = rows[0] || []; + const roles = first.map(_diHeaderRole); + const hasHeader = roles.filter(Boolean).length >= 2 || (roles.includes('name') && rows.length > 1); + const idx = { floor: -1, id: -1, name: -1, sub: -1, avail: -1 }; + let body = rows; + if (hasHeader) { roles.forEach((r, i) => { if (r && idx[r] === -1) idx[r] = i; }); body = rows.slice(1); } + else { + const n = Math.max(...rows.map(r => r.length)); + if (n <= 2) { idx.id = 0; idx.name = 1; } + else if (n === 3) { idx.id = 0; idx.name = 1; idx.sub = 2; } + else { idx.floor = 0; idx.id = 1; idx.name = 2; idx.sub = 3; } + warnings.push(t('widget.dir.import_warn_noheader')); + } + if (idx.name === -1 && idx.id === -1) { idx.id = 0; idx.name = 1; } + const cats = new Map(); + const getCat = (nm) => { const k = nm || ''; if (!cats.has(k)) cats.set(k, { name: k, entries: [] }); return cats.get(k); }; + for (const r of body) { + if (!r.length || r.every(c => c === '')) continue; + const cell = (i) => (i >= 0 && i < r.length ? r[i] : ''); + const e = { identifier: cell(idx.id), name: idx.name >= 0 ? cell(idx.name) : '', subtitle: idx.sub >= 0 ? cell(idx.sub) : '', available: idx.avail >= 0 ? _diBool(cell(idx.avail)) : false }; + if (!e.identifier && !e.name) continue; + getCat(idx.floor >= 0 ? cell(idx.floor) : '').entries.push(e); + } + return { meta: {}, categories: [...cats.values()], background_images: [], warnings }; +} +function _diHeading(line) { + const l = line.trim(); + if (/:$/.test(l)) return l.replace(/:$/, '').trim(); + if (/^#{1,6}\s+/.test(l)) return l.replace(/^#{1,6}\s+/, '').trim(); + if (/^\[.+\]$/.test(l)) return l.slice(1, -1).trim(); + if (/^=+\s*(.+?)\s*=+$/.test(l)) return l.replace(/^=+\s*/, '').replace(/\s*=+$/, '').trim(); + if (/^-{3,}\s*(.+?)\s*-{3,}$/.test(l)) return l.replace(/^-+\s*/, '').replace(/\s*-+$/, '').trim(); + if (!/^\W*\d/.test(l) && /\b(floor|level|suite|section|wing|building)\b/i.test(l) && l.split(/\s+/).length <= 4) return l; + return null; +} +function _diParseSectioned(lines, warnings) { + const cats = []; let cur = null; + const ensure = () => { if (!cur) { cur = { name: '', entries: [] }; cats.push(cur); } return cur; }; + for (const raw of lines) { + const heading = _diHeading(raw); + if (heading != null) { cur = { name: heading, entries: [] }; cats.push(cur); continue; } + const l = raw.trim(); + const m = l.match(/^(#?\d+[A-Za-z]?)[\s.)\-:–—|]+(.+)$/); + const e = m ? { identifier: m[1].replace(/^#/, ''), name: m[2].trim(), subtitle: '', available: false } + : { identifier: '', name: l, subtitle: '', available: false }; + if (e.identifier || e.name) ensure().entries.push(e); + } + if (!(cats.length > 1 || cats.some(c => c.name))) warnings.push(t('widget.dir.import_warn_nosections')); + return { meta: {}, categories: cats, background_images: [], warnings }; +} +function _diParseDelimited(raw) { + const warnings = []; + const lines = raw.split(/\r?\n/).map(l => l.replace(/\s+$/, '')).filter(l => l.trim() !== ''); + if (!lines.length) throw new Error(t('widget.dir.import_err_empty')); + const sample = lines.slice(0, 12); + let delim = null, best = 1; + for (const d of ['\t', ',', ';', '|']) { + const counts = sample.map(l => _diSplit(l, d).length); + const withCols = counts.filter(c => c >= 2).length; + const avg = counts.reduce((a, b) => a + b, 0) / counts.length; + if (withCols >= Math.ceil(sample.length * 0.6) && avg > best) { best = avg; delim = d; } + } + return delim ? _diParseTable(lines, delim, warnings) : _diParseSectioned(lines, warnings); +} +function parseDirectoryImport(text) { + const raw = String(text == null ? '' : text).trim(); + if (!raw) throw new Error(t('widget.dir.import_err_empty')); + let json = null; + if (/^[[{]/.test(raw)) { try { json = JSON.parse(raw); } catch (e) { /* not JSON */ } } + const res = (json && typeof json === 'object') ? _diNormalizeJson(json) : _diParseDelimited(raw); + res.categories = (res.categories || []) + .map(c => ({ name: String(c.name || '').trim(), entries: (c.entries || []).filter(e => (e.identifier || '').trim() || (e.name || '').trim()) })) + .filter(c => c.name || c.entries.length); + if (!res.categories.length) throw new Error(t('widget.dir.import_err_norows')); + res.stats = { categories: res.categories.length, entries: res.categories.reduce((n, c) => n + c.entries.length, 0) }; + return res; +} + function openContentPicker({ multiple = false, title } = {}) { return new Promise(async (resolve) => { const overlay = document.createElement('div'); @@ -250,6 +419,10 @@ export async function render(container) { break; case 'directory-board': html += ` +
+ + ${t('widget.dir.import_hint')} +
@@ -331,6 +504,7 @@ export async function render(container) { renderDirCategories({ focusCatName: dirState.categories.length - 1 }); }; document.getElementById('wBgAdd').onclick = pickBgImages; + document.getElementById('dbImportData').onclick = openDirImport; } if (type === 'directory-search') { @@ -473,6 +647,64 @@ export async function render(container) { } } + function openDirImport() { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10001;padding:16px'; + const hasExisting = dirState.categories.length > 0; + overlay.innerHTML = ` +
+

${t('widget.dir.import_title')}

+
${t('widget.dir.import_desc')}
+ + + +
+ + +
+
`; + document.body.appendChild(overlay); + const ta = overlay.querySelector('#diText'); + ta.focus(); + const cleanup = () => overlay.remove(); + overlay.querySelector('#diCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.querySelector('#diGo').onclick = () => { + const errBox = overlay.querySelector('#diError'); + let result; + try { result = parseDirectoryImport(ta.value); } + catch (e) { errBox.textContent = e.message || t('widget.dir.import_err_norows'); errBox.style.display = 'block'; return; } + applyDirImport(result, overlay.querySelector('#diReplace').checked); + cleanup(); + }; + } + + function applyDirImport(result, replace) { + const m = result.meta || {}; + const setVal = (id, v) => { const el = document.getElementById(id); if (el && v != null && v !== '') el.value = v; }; + setVal('wTitle', m.title); + setVal('wFooter', m.footer_text); + const setSel = (id, v, allowed) => { if (v == null) return; const el = document.getElementById(id); if (el && allowed.includes(String(v))) el.value = String(v); }; + setSel('wTheme', m.theme, ['dark', 'light']); + setSel('wSpeed', m.scroll_speed, ['slow', 'medium', 'fast']); + setSel('wCols', m.columns, ['auto', '1', '2', '3', '4']); + if (m.logo_url && (/^(https?:)?\/\//i.test(m.logo_url) || m.logo_url.startsWith('/'))) { dirState.logo_url = m.logo_url; renderLogoPicker(); } + if (Array.isArray(result.background_images) && result.background_images.length) { + const seen = new Set(dirState.background_images); + for (const u of result.background_images) if (!seen.has(u)) { dirState.background_images.push(u); seen.add(u); } + renderBgList(); + } + const mapped = result.categories.map(c => ({ + name: c.name || '', _expanded: false, + entries: c.entries.map(e => ({ identifier: e.identifier || '', name: e.name || '', subtitle: e.subtitle || '', available: !!e.available })), + })); + dirState.categories = replace ? mapped : dirState.categories.concat(mapped); + renderDirCategories(); + const warnings = result.warnings || []; + const msg = [t('widget.dir.import_done', { cats: result.stats.categories, entries: result.stats.entries })].concat(warnings).join(' '); + showToast(msg, warnings.length ? 'info' : 'success'); + } + function renderLogoPicker() { const box = document.getElementById('wLogoBox'); if (!box) return; diff --git a/server/routes/content.js b/server/routes/content.js index 5676a41..a9e7880 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -347,6 +347,12 @@ router.get('/:id/file', (req, res) => { // Prevent path traversal const safePath = path.resolve(config.contentDir, path.basename(content.filepath)); if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); + // Widget boards (logo/background images) render inside the player's sandboxed + // (opaque-origin) widget iframe, so these image requests are cross-origin. Without + // CORP: cross-origin the helmet default (same-origin) blocks them (NS_ERROR_DOM_CORP_FAILED, + // 0 bytes). Matches the /uploads/content static route. Content already serves publicly. + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.sendFile(safePath); }); @@ -357,6 +363,9 @@ router.get('/:id/thumbnail', (req, res) => { if (!content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' }); const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path)); if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); + // See /:id/file — cross-origin so sandboxed widget iframes can load it. + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.sendFile(safePath); }); diff --git a/server/routes/widgets.js b/server/routes/widgets.js index 6ce3014..1e0d20d 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -528,7 +528,8 @@ function renderDirectoryBoard(c) { img.alt = ''; header.appendChild(img); } - if (cfg.title) { + // A logo replaces the title text — showing both stacks the wordmark over the name. + if (cfg.title && !logoSrc) { var h1 = document.createElement('h1'); h1.textContent = cfg.title; header.appendChild(h1); @@ -824,7 +825,8 @@ function renderDirectorySearch(c) { img.className = 'logo'; img.src = logoSrc; img.alt = ''; header.appendChild(img); } - if (cfg.title) { + // A logo replaces the title text — showing both stacks the wordmark over the name. + if (cfg.title && !logoSrc) { var h1 = document.createElement('h1'); h1.textContent = cfg.title; header.appendChild(h1); diff --git a/server/test/directory-board-render.test.js b/server/test/directory-board-render.test.js new file mode 100644 index 0000000..7d28743 --- /dev/null +++ b/server/test/directory-board-render.test.js @@ -0,0 +1,43 @@ +'use strict'; + +// Guards the directory-board header behaviour: a logo REPLACES the title text +// (showing both stacked the wordmark over the name). Renders the public widget +// endpoint and inspects the emitted board script. Mirrors widget-render-xss.test.js. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); + +process.env.JWT_SECRET = 'test-secret-dir-board'; + +const db = new Database(':memory:'); +db.exec(`CREATE TABLE widgets (id TEXT PRIMARY KEY, widget_type TEXT, config TEXT, workspace_id TEXT);`); +const dbModulePath = require.resolve('../db/database'); +require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db } }; + +const express = require('express'); +const widgetsRouter = require('../routes/widgets'); +const app = express(); +app.use('/api/widgets', widgetsRouter); +const server = app.listen(0); +let base; +test.before(async () => { await new Promise(r => server.listening ? r() : server.once('listening', r)); base = `http://127.0.0.1:${server.address().port}`; }); +test.after(() => { server.close(); db.close(); }); + +const seed = (id, config) => db.prepare('INSERT INTO widgets (id, widget_type, config, workspace_id) VALUES (?,?,?,?)').run(id, 'directory-board', JSON.stringify(config), 'ws1'); +const render = async (id) => (await fetch(`${base}/api/widgets/${id}/render`)).text(); + +test('directory board: title text is gated behind !logoSrc (logo replaces title)', async () => { + seed('b1', { title: 'LINNcinnati', logo_url: '/api/content/abc/file', categories: [] }); + const html = await render('b1'); + // The title h1 must only be appended when there is no logo. + assert.match(html, /if \(cfg\.title && !logoSrc\)/, 'title render must be guarded by !logoSrc'); + assert.doesNotMatch(html, /if \(cfg\.title\) \{\s*\n\s*var h1/, 'title must not be rendered unconditionally'); +}); + +test('directory board: still embeds title + logo config for the client', async () => { + seed('b2', { title: 'Lincoln Warehouse', logo_url: '/api/content/xyz/file', categories: [] }); + const html = await render('b2'); + assert.match(html, /Lincoln Warehouse/, 'title present in embedded config'); + assert.match(html, /\/api\/content\/xyz\/file/, 'logo url present in embedded config'); +});