/* =================================================================== KabelFlux v5 — Live-backend hydration PROJECTS is loaded from the backend on boot. Per-project detail (wires / connectors / nodes / bundles) is loaded on demand by kfxLoadAndMapProject() and held as React state in app.jsx — the old WIRES / CONNECTORS / NODES / BUNDLES globals are gone. =================================================================== */ /* Start empty so the shell renders during auth bootstrap. app.jsx swaps these for React state once kfxBootstrap() resolves. */ let PROJECTS = []; /* IEC 60757 color codes for wire colors — real lookup tables, keep static */ const COLOR_HEX = { Red:'#cc2200', Black:'#222', White:'#dde2ee', Blue:'#1e4dcc', Green:'#2a8a2a', Yellow:'#e6c200', Orange:'#e07820', Brown:'#7a4220', Violet:'#7a1aaa', Gray:'#9aa3b8', Pink:'#e060a0', Drainwire:'#aaa' }; const COLOR_CODE = { Red:'RD', Black:'BK', White:'WH', Blue:'BU', Green:'GN', Yellow:'YE', Orange:'OG', Brown:'BN', Violet:'VT', Gray:'GY', Pink:'PK', Drainwire:'DR' }; /* Per-project content is loaded from the backend by kfxLoadAndMapProject() (see below) and threaded as props through app.jsx. No module-level WIRES / CONNECTORS / NODES / BUNDLES. */ /* ── Auth + fetch helpers (legacy iat_token key — same as /legacy) ── */ const KFX_API = `${window.location.protocol}//${window.location.host}/api`; function kfxGetToken() { try { return localStorage.getItem('iat_token') || ''; } catch (e) { return ''; } } function kfxSetAuth(token, user) { try { localStorage.setItem('iat_token', token); localStorage.setItem('iat_user', JSON.stringify(user)); } catch (e) {} } function kfxClearAuth() { try { localStorage.removeItem('iat_token'); localStorage.removeItem('iat_user'); } catch (e) {} } async function kfxFetch(path, opts = {}) { const token = kfxGetToken(); const headers = Object.assign({}, opts.headers || {}); if (token) headers['Authorization'] = 'Bearer ' + token; const hasBody = opts.body !== undefined && opts.body !== null; const isForm = hasBody && opts.body instanceof FormData; if (hasBody && !isForm && !headers['Content-Type']) { headers['Content-Type'] = 'application/json'; } const r = await fetch(KFX_API + path, { method: opts.method || 'GET', headers, body: hasBody ? (isForm ? opts.body : JSON.stringify(opts.body)) : undefined, }); const ct = (r.headers.get('content-type') || '').toLowerCase(); let payload = null; if (ct.includes('application/json')) { try { payload = await r.json(); } catch (e) { payload = null; } } else { try { payload = await r.text(); } catch (e) { payload = null; } } if (!r.ok) { const message = (payload && (payload.detail || payload.message)) || (typeof payload === 'string' && payload) || r.statusText; const err = new Error(message); err.status = r.status; err.message = message; throw err; } return payload; } /* Relative-time formatter for project.modified — minimal, no deps. */ function kfxRelativeTime(iso) { if (!iso) return ''; const t = Date.parse(iso); if (isNaN(t)) return String(iso); const diffSec = Math.max(0, (Date.now() - t) / 1000); if (diffSec < 60) return Math.floor(diffSec) + 's ago'; if (diffSec < 3600) return Math.floor(diffSec / 60) + ' min ago'; if (diffSec < 86400) return Math.floor(diffSec / 3600) + ' h ago'; if (diffSec < 172800) return 'Yesterday'; if (diffSec < 604800) return Math.floor(diffSec / 86400) + ' d ago'; return Math.floor(diffSec / 604800) + ' wk ago'; } /* Map a backend project row onto the bundle's project shape. */ function kfxMapProject(p) { const blob = String((p.name || '') + ' ' + (p.description || '')); const m = blob.match(/HA\d{7}/i); const code = m ? m[0].toUpperCase() : ('P' + String(p.id).padStart(6, '0')); return { id: 'p' + p.id, realId: p.id, code, name: p.harness_name || p.name, revision: p.revision || 'A', wires: p.wires_count || 0, conns: p.connectors_count || 0, nodes: p.nodes_count || 0, modified: kfxRelativeTime(p.updated_at), owner: p.owner_name || '', pinned: false, status: p.shared ? 'active' : 'draft', company_name: p.company_name || '', _raw: p, }; } async function kfxLoadAuthMe() { try { return await kfxFetch('/auth/me'); } catch (e) { if (e.status === 401) return null; throw e; } } async function kfxLoadProjects() { const rows = await kfxFetch('/projects'); return (rows || []).map(kfxMapProject); } /* ── Project detail (wires / connectors / nodes) loader + mappers ── The backend returns each row in its raw schema (snake_case, all strings for boolean-ish fields). These mappers translate to the shape the bundle's views expect (camelCase, real booleans). */ async function kfxLoadProject(realId) { return await kfxFetch('/projects/' + realId); } /* Connector library — the global catalog seeded by the backend (180 built-in parts + any user-added custom entries). Returned shape per backend row: { id, part_number, manufacturer, series, description, connector_type, pin_count, gender, symbol, termination, datasheet_url, mating_part_number, is_builtin, notes, created_at } */ async function kfxLoadConnectorLibrary() { return await kfxFetch('/connlib'); } /* Map a backend connlib row to the shape the bundle's SymbolLibrary view expects: { id, family, name, pins, gender, shell, used, updated, tags, manufacturer, series, symbol, partNumber, _raw } */ function kfxMapLibraryRow(row) { const gender = (row.gender || '').toLowerCase(); return { id: row.part_number || ('LIB' + row.id), partNumber: row.part_number || '', manufacturer: row.manufacturer || 'Other', series: row.series || '', /* family key — lowercased + dashes — drives the existing tab filter. The user wanted manufacturer-grouped headers; we use manufacturer as the family axis so the existing kf-library-fam tabs become manufacturer tabs (AMASS / AMPHENOL / ...). */ family: (row.manufacturer || 'other').toLowerCase().replace(/\s+/g, '-'), name: row.part_number || row.description || '(unnamed)', description: row.description || '', connectorType:row.connector_type || '', pins: row.pin_count || 0, /* The bundle's existing grid filter uses 'plug' / 'recep' / '—'. Backend uses 'male' / 'female'. Map across. */ gender: gender === 'male' ? 'plug' : gender === 'female' ? 'recep' : '—', shell: row.series || '—', used: 0, /* per-project usage — TODO once we add it server-side */ updated: (row.created_at || '').slice(0, 10), symbol: row.symbol || 'rectangular', termination: row.termination || '', isBuiltin: !!row.is_builtin, tags: [row.series, row.termination].filter(Boolean), _raw: row, }; } /* kfxAuditProject moved into views-layout.jsx — see Analyse chip there. */ function kfxMapWire(w, idx) { const color = w.color || ''; const code = COLOR_CODE[color] || color.slice(0, 2).toUpperCase(); return { id: 'w' + w.id, realId: w.id, cavity: idx + 1, fromConn: w.from_connector_name || '', fromPin: w.from_pin || '', // When a wire terminates at a node, the backend's data model fills // EITHER to_connector_name with the node name OR (in older data) leaves // to_connector_name blank and only sets node_ref. Fall back to node_ref // so wires-through-nodes still draw a line. Same idea for the from side // (rare, but possible if a continuing segment originates from a node). toConn: w.to_connector_name || w.node_ref || '', toPin: w.to_pin || '', node_ref: w.node_ref || '', color, code, gauge: w.gauge || '', spec: w.wire_spec || '', length: parseInt(w.length, 10) || 0, shield: !!w.shield && w.shield !== '', twisted: !!w.twisted_pair && w.twisted_pair !== '', cable: w.cable_type || 'Single core', net: w.net || '', notes: w.notes || '', _raw: w, }; } function kfxMapConnector(c) { const name = c.connector_name || ('J' + c.id); const gender = c.gender === 'male' ? 'M' : (c.gender === 'female' ? 'F' : ''); return { id: name, realId: c.id, type: c.connector_type || '', gender, pins: c.pin_count || 0, x: c.x || 0, y: c.y || 0, label: name + (c.description ? ' — ' + c.description : ''), _raw: c, }; } function kfxMapNode(n) { /* Backend columns are `label` and `node_type` — NOT `name`/`type`. The id MUST be the label so that wires referencing a node by name ("NODE_W2A") can resolve it via nodes.find(n => n.id === w.fromConn) in views-layout.jsx + views-other.jsx wire-path lookups. Earlier versions used n.name (which doesn't exist) and fell back to 'N', which made every wire-through-node silently fail to draw. */ const label = n.label || n.name || ''; return { id: label || ('N' + n.id), realId: n.id, type: n.node_type || n.type || 'splice', x: n.x || 0, y: n.y || 0, label: label, value: n.value || '', _raw: n, }; } /* Backend has no geometric bundles — length_defs are length variants, not visual harness bundles. Until the backend ships real bundle geometry we return []; the layout view skips bundle rendering and the BOM "Length by bundle" chart just shows an empty bar list. */ function kfxMapBundles() { return []; } async function kfxLoadAndMapProject(realId) { const raw = await kfxLoadProject(realId); return { raw, wires: (raw.wires || []).map(kfxMapWire), connectors: (raw.connectors || []).map(kfxMapConnector), nodes: (raw.nodes || []).map(kfxMapNode), bundles: kfxMapBundles(raw), }; } async function kfxLogin(username, password) { const d = await kfxFetch('/auth/login', { method: 'POST', body: { username, password }, }); const user = { username: d.username, full_name: d.full_name, role: d.role, uid: d.uid || 0, company_id: d.company_id, company_name: d.company_name || '', logo_data: d.logo_data || '', email: d.email || '', job_title: d.job_title || '', }; kfxSetAuth(d.token, user); return user; } async function kfxLogout() { try { await kfxFetch('/auth/logout', { method: 'POST' }); } catch (e) { /* best-effort */ } kfxClearAuth(); } /* ── CRUD helpers: convert bundle-shape → backend-shape and call API. Used by views to add/edit/delete wires, connectors and nodes. After any successful mutation, the caller should re-run kfxLoadAndMapProject() so the UI stays consistent. ── */ function kfxWireToBackend(wire) { return { from_connector_name: wire.fromConn || '', from_pin: wire.fromPin !== undefined && wire.fromPin !== null ? String(wire.fromPin) : '', to_connector_name: wire.toConn || '', to_pin: wire.toPin !== undefined && wire.toPin !== null ? String(wire.toPin) : '', connection_id: wire.connection_id || '', net: wire.net || '', color: wire.color || 'Black', gauge: wire.gauge || '', wire_spec: wire.spec || '', length: wire.length !== undefined && wire.length !== null ? String(wire.length) : '', cable_type: wire.cable || 'Single core', shield: wire.shield ? 'Y' : '', twisted_pair: wire.twisted ? 'Y' : '', notes: wire.notes || '', }; } function kfxConnectorToBackend(conn) { const gender = conn.gender === 'M' ? 'male' : conn.gender === 'F' ? 'female' : (conn.gender || 'male'); return { connector_name: conn.id || conn.name || '', connector_type: conn.type || '', description: conn.description || '', pin_count: conn.pins || 0, gender, x: conn.x || 100, y: conn.y || 100, }; } function kfxNodeToBackend(node) { /* Length goes to the backend as a plain string (e.g. "250" — unit is ignored server-side; the model column is TEXT). If the user picked inches we still send the numeric so the formboard render math works, and append a hint to description if it's not already there. */ let length = node.length || ''; return { node_type: node.type || 'splice', label: node.label || node.id || '', value: node.value || '', part_number: node.part_number || '', description: node.description || '', length: length, x: node.x || 300, y: node.y || 200, }; } async function kfxCreateWire(realPid, wire) { return await kfxFetch('/projects/' + realPid + '/wires', { method: 'POST', body: kfxWireToBackend(wire), }); } async function kfxUpdateWire(wid, patch) { return await kfxFetch('/wires/' + wid, { method: 'PUT', body: kfxWireToBackend(patch), }); } async function kfxDeleteWire(wid) { return await kfxFetch('/wires/' + wid, { method: 'DELETE' }); } async function kfxCreateConnector(realPid, conn) { return await kfxFetch('/projects/' + realPid + '/connectors', { method: 'POST', body: kfxConnectorToBackend(conn), }); } async function kfxUpdateConnector(cid, patch) { return await kfxFetch('/connectors/' + cid, { method: 'PUT', body: kfxConnectorToBackend(patch), }); } async function kfxDeleteConnector(cid) { return await kfxFetch('/connectors/' + cid, { method: 'DELETE' }); } async function kfxCreateNode(realPid, node) { return await kfxFetch('/projects/' + realPid + '/nodes', { method: 'POST', body: kfxNodeToBackend(node), }); } async function kfxUpdateNode(nid, patch) { return await kfxFetch('/nodes/' + nid, { method: 'PUT', body: kfxNodeToBackend(patch), }); } async function kfxDeleteNode(nid) { return await kfxFetch('/nodes/' + nid, { method: 'DELETE' }); } async function kfxCreateProject(payload) { return await kfxFetch('/projects', { method: 'POST', body: { name: payload.name || 'New Harness', harness_name: payload.harness_name || payload.name || '', description: payload.description || '', }, }); } async function kfxImportCsv(realPid, file) { const fd = new FormData(); fd.append('file', file); return await kfxFetch('/projects/' + realPid + '/import-csv', { method: 'POST', body: fd, }); } /* ── Export helpers ── Backend exports return binary content with Content-Disposition: attachment. For GET endpoints, build a URL with ?token=… (backend accepts query-param auth — see _get_session in backend/main.py) and trigger a download via window.location.assign. For POST endpoints (export-formboard / export-combined), submit a hidden form so the response becomes a download. */ function kfxExportUrl(realPid, name) { return KFX_API + '/projects/' + realPid + '/export-' + name + '?token=' + encodeURIComponent(kfxGetToken()); } function kfxDownload(realPid, name) { // POST-only export endpoints. Submit a hidden form so the browser handles // the binary response as a file download. if (name === 'combined' || name === 'formboard') { const form = document.createElement('form'); form.method = 'POST'; form.action = KFX_API + '/projects/' + realPid + '/export-' + name + '?token=' + encodeURIComponent(kfxGetToken()); form.style.display = 'none'; document.body.appendChild(form); form.submit(); setTimeout(() => form.remove(), 1000); return; } // GET endpoints — simplest possible trigger. const a = document.createElement('a'); a.href = kfxExportUrl(realPid, name); a.rel = 'noopener'; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000); } /* ── Admin helpers — Backups / Users / Companies / Share-links / Health ── Thin wrappers over the /api/admin/* + /api/users + /api/companies endpoints. Each returns the raw backend payload; the admin panels are responsible for mapping/rendering. After mutations, callers should re- fetch the matching list. */ /* Backups */ async function kfxListBackups() { return await kfxFetch('/admin/backups'); } async function kfxTriggerBackup() { return await kfxFetch('/admin/backup', { method: 'POST' }); } async function kfxRestoreBackup(filename) { return await kfxFetch('/admin/backup/' + encodeURIComponent(filename) + '/restore', { method: 'POST' }); } async function kfxDeleteBackupFile(filename) { return await kfxFetch('/admin/backup/' + encodeURIComponent(filename), { method: 'DELETE' }); } async function kfxImportLegacyDb(file) { const fd = new FormData(); fd.append('file', file); return await kfxFetch('/admin/backup/import', { method: 'POST', body: fd }); } async function kfxGetBackupSettings() { // Backend returns {enabled: bool}. Normalise to a shape with // backups_enabled so the UI doesn't care about wire format. const r = await kfxFetch('/admin/backup-settings'); return { backups_enabled: !!r.enabled }; } async function kfxSetBackupSettings({ backups_enabled }) { const r = await kfxFetch('/admin/backup-settings', { method: 'POST', body: { enabled: !!backups_enabled }, }); return { backups_enabled: !!r.enabled }; } /* Users */ async function kfxListUsers() { return await kfxFetch('/users'); } async function kfxCreateUser(user) { return await kfxFetch('/users', { method: 'POST', body: user }); } async function kfxUpdateUser(uid, patch) { return await kfxFetch('/users/' + uid, { method: 'PUT', body: patch }); } async function kfxDeleteUser(uid) { return await kfxFetch('/users/' + uid, { method: 'DELETE' }); } /* Companies — superadmin view uses /admin/companies (status-aware); POST create uses /companies (no admin/companies POST exists). */ async function kfxListCompanies() { return await kfxFetch('/admin/companies'); } async function kfxCreateCompany(co) { return await kfxFetch('/companies', { method: 'POST', body: co }); } async function kfxUpdateCompany(cid, patch) { return await kfxFetch('/companies/' + cid, { method: 'PUT', body: patch }); } async function kfxApproveCompany(cid, trial_days = 14) { return await kfxFetch('/admin/companies/' + cid + '/approve', { method: 'POST', body: { trial_days }, }); } async function kfxSetCompanyStatus(cid, status) { return await kfxFetch('/admin/companies/' + cid + '/status', { method: 'POST', body: { status }, }); } /* Share links — keyed by the project's real (backend) id. */ async function kfxListShareLinks(realPid) { return await kfxFetch('/projects/' + realPid + '/shares'); } async function kfxCreateShareLink(realPid, { expires_at, note }) { // Backend takes expires_in_days, not an absolute timestamp. Convert // here so the panel can use a date input naturally. let expires_in_days = null; if (expires_at) { const target = Date.parse(expires_at); if (!isNaN(target)) { const diffMs = target - Date.now(); expires_in_days = Math.max(1, Math.ceil(diffMs / 86400000)); } } return await kfxFetch('/projects/' + realPid + '/share', { method: 'POST', body: { expires_in_days, note: note || '' }, }); } async function kfxRevokeShareLink(token) { return await kfxFetch('/share/' + encodeURIComponent(token), { method: 'DELETE' }); } /* Health */ async function kfxGetVersion() { return await kfxFetch('/version'); } async function kfxGetHealth() { return await kfxFetch('/health'); } async function kfxBootstrap() { if (!kfxGetToken()) return { kind: 'guest' }; try { const me = await kfxLoadAuthMe(); if (!me) { kfxClearAuth(); return { kind: 'guest' }; } const projects = await kfxLoadProjects(); return { kind: 'authed', me, projects }; } catch (e) { if (e.status === 401) { kfxClearAuth(); return { kind: 'guest' }; } return { kind: 'error', err: e }; } } Object.assign(window, { PROJECTS, COLOR_HEX, COLOR_CODE, kfxGetToken, kfxSetAuth, kfxClearAuth, kfxFetch, kfxMapProject, kfxRelativeTime, kfxLoadAuthMe, kfxLoadProjects, kfxLogin, kfxLogout, kfxBootstrap, kfxLoadConnectorLibrary, kfxMapLibraryRow, kfxLoadProject, kfxLoadAndMapProject, kfxMapWire, kfxMapConnector, kfxMapNode, kfxMapBundles, kfxWireToBackend, kfxConnectorToBackend, kfxNodeToBackend, kfxCreateWire, kfxUpdateWire, kfxDeleteWire, kfxCreateConnector, kfxUpdateConnector, kfxDeleteConnector, kfxCreateNode, kfxUpdateNode, kfxDeleteNode, kfxCreateProject, kfxImportCsv, kfxExportUrl, kfxDownload, /* Admin */ kfxListBackups, kfxTriggerBackup, kfxRestoreBackup, kfxDeleteBackupFile, kfxImportLegacyDb, kfxGetBackupSettings, kfxSetBackupSettings, kfxListUsers, kfxCreateUser, kfxUpdateUser, kfxDeleteUser, kfxListCompanies, kfxCreateCompany, kfxUpdateCompany, kfxApproveCompany, kfxSetCompanyStatus, kfxListShareLinks, kfxCreateShareLink, kfxRevokeShareLink, kfxGetVersion, kfxGetHealth, });