From 516b74a85f83419b1e14ca795154a477b12f7ffc Mon Sep 17 00:00:00 2001 From: Aarnav Koushik Date: Sat, 21 Mar 2026 20:51:48 -0700 Subject: [PATCH 01/83] fixed deleting nodes and added strict modification logic --- Web/.gitignore | 2 + Web/candoDocument.js | 854 ++++++++++++++++++++++++++++++++++++++++++ Web/editor.js | 6 + Web/formMessageDef.js | 38 +- Web/formNodeEdit.js | 16 +- Web/formRoutingAdd.js | 114 ++---- Web/formSuperAdd.js | 55 ++- Web/index.html | 9 + Web/logic.js | 15 + Web/viewer.js | 83 +++- 10 files changed, 1028 insertions(+), 164 deletions(-) create mode 100644 Web/.gitignore create mode 100644 Web/candoDocument.js diff --git a/Web/.gitignore b/Web/.gitignore new file mode 100644 index 000000000..274164774 --- /dev/null +++ b/Web/.gitignore @@ -0,0 +1,2 @@ +WEB_HANDOFF_FULL_SUMMARY.txt +tests/ diff --git a/Web/candoDocument.js b/Web/candoDocument.js new file mode 100644 index 000000000..e4eae18fc --- /dev/null +++ b/Web/candoDocument.js @@ -0,0 +1,854 @@ +// Purpose: Semantic document model for the CANdo format. +// Parses the full file into typed data structures, enforces all cross-section +// invariants (I1–I5 per IMPLEMENTATION_PLAN.md), and exposes atomic operations +// that keep routing + GR ID + Message ID consistent with each other. +// After every mutation, serializes the model back to canonical text and calls +// editor.updateRawText() so editor.js remains the single source of raw text truth. +// +// Sections owned (regenerated on serialize): routing, Message ID, GR ID +// Sections preserved verbatim: Bus ID, byte order, Custom CAN ID +// +// Dual-mode: browser (window.GrcanDocument) or Node.js (module.exports) for tests. + +/* global window, module */ +(function (factory) { + if (typeof module !== "undefined" && module.exports) { + module.exports = factory(); + } else { + window.GrcanDocument = factory(); + } +})(function () { + "use strict"; + + // ==================== Module-level model state ==================== + // All mutable; reset by _parse() at the start of every operation. + + let _devices = new Map(); // Map + let _grIds = new Map(); // Map + let _messageIds = new Map(); // Map + let _busIdsText = ""; // verbatim Bus ID section text + let _byteOrderText = ""; // verbatim byte order line text + let _customCanIdsText = ""; // verbatim Custom CAN ID section text + + // ==================== Parser ==================== + + function _parse(rawText) { + _devices = new Map(); + _grIds = new Map(); + _messageIds = new Map(); + _busIdsText = ""; + _byteOrderText = ""; + _customCanIdsText = ""; + + if (!rawText) return; + const lines = rawText.split("\n"); + + // Find 0-indexed start line of each top-level section. + let routingStart = -1, + byteOrderStart = -1, + msgIdStart = -1, + customCanIdStart = -1, + grIdStart = -1; + + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (l.startsWith("routing:")) routingStart = i; + else if (l.startsWith("byte order:")) byteOrderStart = i; + else if (l.startsWith("Message ID:")) msgIdStart = i; + else if (l.startsWith("Custom CAN ID:")) customCanIdStart = i; + else if (l.startsWith("GR ID:")) grIdStart = i; + } + + // Verbatim: Bus ID = everything before routing section header. + if (routingStart > 0) { + _busIdsText = lines + .slice(0, routingStart) + .join("\n") + .replace(/\n+$/, ""); + } + + // Verbatim: byte order line through the blank line before Message ID. + if (byteOrderStart > -1) { + const end = msgIdStart > -1 ? msgIdStart : lines.length; + _byteOrderText = lines + .slice(byteOrderStart, end) + .join("\n") + .replace(/\n+$/, ""); + } + + // Verbatim: Custom CAN ID section through the blank line before GR ID. + if (customCanIdStart > -1) { + const end = grIdStart > -1 ? grIdStart : lines.length; + _customCanIdsText = lines + .slice(customCanIdStart, end) + .join("\n") + .replace(/\n+$/, ""); + } + + // Owned sections: parse into model. + if (routingStart > -1) { + const end = + byteOrderStart > -1 + ? byteOrderStart + : msgIdStart > -1 + ? msgIdStart + : lines.length; + _parseRouting(lines, routingStart, end); + } + if (msgIdStart > -1) { + const end = customCanIdStart > -1 ? customCanIdStart : lines.length; + _parseMsgIds(lines, msgIdStart, end); + } + if (grIdStart > -1) { + _parseGrIds(lines, grIdStart, lines.length); + } + } + + function _parseRouting(lines, start, end) { + let curDevice = null, + curBus = null, + curReceiver = null; + + for (let i = start; i < end; i++) { + const line = lines[i]; + if (!line.trim()) continue; + const indent = line.search(/\S/); + const content = line.trim(); + + if (indent === 4 && content.endsWith(":")) { + // Device name. Skip the "messages:" sub-key which is at indent 2. + const name = content.slice(0, -1); + curDevice = { deviceName: name, buses: new Map() }; + _devices.set(name, curDevice); + curBus = null; + curReceiver = null; + } else if (indent === 6 && content.endsWith(":")) { + if (!curDevice) continue; + const busPort = content.slice(0, -1); + curBus = { busPort, receivers: new Map() }; + curDevice.buses.set(busPort, curBus); + curReceiver = null; + } else if (indent === 8 && content.endsWith(":")) { + if (!curBus) continue; + const recName = content.slice(0, -1); + curReceiver = { receiverName: recName, routes: [] }; + curBus.receivers.set(recName, curReceiver); + } else if (indent === 10 && content.startsWith("- msg:")) { + if (!curReceiver) continue; + const msgName = content.slice("- msg:".length).trim(); + let canIdOverride = null; + if (i + 1 < end) { + const next = lines[i + 1]; + const ni = next.search(/\S/); + if (ni === 12 && next.trim().startsWith("can_id_override:")) { + canIdOverride = next.trim().slice("can_id_override:".length).trim(); + i++; + } + } + curReceiver.routes.push({ msgName, canIdOverride }); + } + } + } + + function _parseMsgIds(lines, start, end) { + let curMsg = null, + curField = null; + + function flushField() { + if (curField && curMsg) { + curMsg.fields.push(curField); + } + curField = null; + } + function flushMsg() { + flushField(); + if (curMsg) _messageIds.set(curMsg.name, curMsg); + curMsg = null; + } + + for (let i = start; i < end; i++) { + const line = lines[i]; + if (!line.trim()) continue; + const indent = line.search(/\S/); + const content = line.trim(); + + if (indent === 2 && content.endsWith(":")) { + flushMsg(); + curMsg = { + name: content.slice(0, -1), + msgId: "", + msgLength: "", + fields: [], + }; + } else if (indent === 4 && curMsg) { + if (content.startsWith("MSG ID:")) { + flushField(); + curMsg.msgId = content.slice("MSG ID:".length).trim(); + } else if (content.startsWith("MSG LENGTH:")) { + flushField(); + curMsg.msgLength = content.slice("MSG LENGTH:".length).trim(); + } else if (content.endsWith(":")) { + flushField(); + curField = { + name: content.slice(0, -1), + bitStart: "", + comment: null, + dataType: null, + units: null, + scaledMin: null, + scaledMax: null, + mapEquation: null, + }; + } + } else if (indent >= 6 && curField) { + if (content.startsWith("bit_start:")) { + curField.bitStart = content.slice("bit_start:".length).trim(); + } else if (content.startsWith("#")) { + const t = content.slice(1).trim(); + curField.comment = + curField.comment ? curField.comment + "\n" + t : t; + } else if (content.startsWith("data type:")) { + curField.dataType = content.slice("data type:".length).trim(); + } else if (content.startsWith("units:")) { + curField.units = content.slice("units:".length).trim(); + } else if (content.startsWith("scaled min:")) { + curField.scaledMin = content.slice("scaled min:".length).trim(); + } else if (content.startsWith("scaled max:")) { + curField.scaledMax = content.slice("scaled max:".length).trim(); + } else if (content.startsWith("map equation:")) { + curField.mapEquation = content + .slice("map equation:".length) + .trim() + .replace(/^["']|["']$/g, ""); + } + } + } + flushMsg(); + } + + function _parseGrIds(lines, start, end) { + for (let i = start + 1; i < end; i++) { + const line = lines[i]; + if (!line.trim()) continue; + const indent = line.search(/\S/); + if (indent !== 2) continue; + const colonIdx = line.indexOf(":"); + if (colonIdx <= 0) continue; + const name = line.slice(0, colonIdx).trim(); + if (!name) continue; + const rawVal = line.slice(colonIdx + 1).trim(); + const hexId = rawVal.replace(/^["']|["']$/g, ""); + _grIds.set(name, hexId); + } + } + + // ==================== Serializer ==================== + + function _serializeRouting() { + let out = "routing:\n messages:\n"; + for (const device of _devices.values()) { + if (device.buses.size === 0) continue; + out += " " + device.deviceName + ":\n"; + for (const bus of device.buses.values()) { + out += " " + bus.busPort + ":\n"; + for (const receiver of bus.receivers.values()) { + out += " " + receiver.receiverName + ":\n"; + for (const route of receiver.routes) { + out += " - msg: " + route.msgName + "\n"; + if (route.canIdOverride) { + out += + " can_id_override: " + route.canIdOverride + "\n"; + } + } + } + } + } + return out; + } + + function _serializeMessageIds() { + let out = "Message ID:\n"; + for (const msg of _messageIds.values()) { + out += " " + msg.name + ":\n"; + out += " MSG ID: " + msg.msgId + "\n"; + out += " MSG LENGTH: " + msg.msgLength + "\n"; + for (const field of msg.fields) { + out += " " + field.name + ":\n"; + out += " bit_start: " + field.bitStart + "\n"; + if (field.comment) { + for (const commentLine of field.comment.split("\n")) { + out += " # " + commentLine + "\n"; + } + } + if (field.dataType !== null) { + out += " data type: " + field.dataType + "\n"; + } + if (field.units) out += " units: " + field.units + "\n"; + if (field.scaledMin !== null && field.scaledMin !== "") + out += " scaled min: " + field.scaledMin + "\n"; + if (field.scaledMax !== null && field.scaledMax !== "") + out += " scaled max: " + field.scaledMax + "\n"; + if (field.mapEquation) + out += ' map equation: "' + field.mapEquation + '"\n'; + } + } + return out; + } + + function _serializeGrIds() { + let out = "GR ID:\n\n"; + for (const [name, hexId] of _grIds.entries()) { + out += ' ' + name + ': "' + hexId + '"\n'; + } + return out; + } + + function _serialize() { + const parts = [ + _busIdsText, + _serializeRouting(), + _byteOrderText, + _serializeMessageIds(), + _customCanIdsText, + _serializeGrIds(), + ]; + // Strip trailing newlines from each part, join with exactly one blank line, + // add a single trailing newline. + return parts.map((p) => p.replace(/\n+$/, "")).join("\n\n") + "\n"; + } + + // ==================== Validator ==================== + + function _getCustomCanIdNames() { + const names = new Set(); + if (!_customCanIdsText) return names; + for (const line of _customCanIdsText.split("\n")) { + const indent = line.search(/\S/); + const content = line.trim(); + if (indent === 2 && content.endsWith(":")) { + names.add(content.slice(0, -1)); + } + } + return names; + } + + function validate() { + _ensureParsed(); + const results = []; + const customCanIds = _getCustomCanIdNames(); + + // V1: MISSING_GR_ID + for (const name of _devices.keys()) { + if (!_grIds.has(name)) { + results.push({ + severity: "error", + code: "MISSING_GR_ID", + message: `Device "${name}" is in routing but has no GR ID entry`, + context: { device: name }, + }); + } + } + + // V2: ORPHAN_GR_ID + for (const name of _grIds.keys()) { + if (!_devices.has(name)) { + results.push({ + severity: "warning", + code: "ORPHAN_GR_ID", + message: `GR ID entry "${name}" has no corresponding device in routing`, + context: { device: name }, + }); + } + } + + // V3: BROKEN_MSG_REF + for (const device of _devices.values()) { + for (const bus of device.buses.values()) { + for (const receiver of bus.receivers.values()) { + for (const route of receiver.routes) { + if ( + !_messageIds.has(route.msgName) && + !customCanIds.has(route.msgName) + ) { + results.push({ + severity: "error", + code: "BROKEN_MSG_REF", + message: `Route in "${device.deviceName}" > ${bus.busPort} references unknown message "${route.msgName}"`, + context: { + device: device.deviceName, + bus: bus.busPort, + msg: route.msgName, + }, + }); + } + } + } + } + } + + // V4: UNKNOWN_RECEIVER + for (const device of _devices.values()) { + for (const bus of device.buses.values()) { + for (const receiverName of bus.receivers.keys()) { + if (!_grIds.has(receiverName)) { + results.push({ + severity: "warning", + code: "UNKNOWN_RECEIVER", + message: `Receiver "${receiverName}" in "${device.deviceName}" > ${bus.busPort} is not in GR ID`, + context: { + device: device.deviceName, + bus: bus.busPort, + receiver: receiverName, + }, + }); + } + } + } + } + + // V5: DUPLICATE_MSG_ID + const seenMsgIds = new Map(); + for (const msg of _messageIds.values()) { + const norm = msg.msgId.toLowerCase(); + if (seenMsgIds.has(norm)) { + results.push({ + severity: "error", + code: "DUPLICATE_MSG_ID", + message: `MSG ID ${msg.msgId} used by both "${seenMsgIds.get(norm)}" and "${msg.name}"`, + context: { + first: seenMsgIds.get(norm), + second: msg.name, + id: msg.msgId, + }, + }); + } else { + seenMsgIds.set(norm, msg.name); + } + } + + // V6: DUPLICATE_GR_ID + const seenGrIds = new Map(); + for (const [name, hexId] of _grIds.entries()) { + const norm = hexId.toLowerCase(); + if (seenGrIds.has(norm)) { + results.push({ + severity: "warning", + code: "DUPLICATE_GR_ID", + message: `GR ID ${hexId} shared by "${seenGrIds.get(norm)}" and "${name}"`, + context: { first: seenGrIds.get(norm), second: name, id: hexId }, + }); + } else { + seenGrIds.set(norm, name); + } + } + + // V7: EMPTY_DEVICE_BLOCK + for (const device of _devices.values()) { + if (device.buses.size === 0) { + results.push({ + severity: "warning", + code: "EMPTY_DEVICE_BLOCK", + message: `Device "${device.deviceName}" has no bus entries in routing`, + context: { device: device.deviceName }, + }); + } + } + + return results; + } + + // ==================== Editor bridge ==================== + + function _getEditor() { + const g = + typeof window !== "undefined" + ? window + : typeof global !== "undefined" + ? global + : {}; + return g.GrcanEditor || null; + } + + function _ensureParsed() { + const editor = _getEditor(); + if (editor && typeof editor.getRawText === "function") { + _parse(editor.getRawText()); + } + // else: test environment — use state already set by _parseForTest() + } + + // Wraps a mutation fn: parse from editor → run fn → serialize back to editor. + // fn() returns an OpResult. If ok is false, setRawText is NOT called. + function _withEditor(fn) { + const editor = _getEditor(); + if (!editor) { + return { ok: false, error: "GrcanEditor not available" }; + } + _parse(editor.getRawText()); + const result = fn(); + if (result.ok !== false) { + editor.updateRawText(_serialize()); + } + return result; + } + + // ==================== Operations ==================== + + function addDevice(name, grId) { + return _withEditor(() => { + if (!name || !name.trim()) + return { ok: false, error: "Device name is required" }; + if (!grId || !/^0x[0-9a-fA-F]+$/i.test(grId.trim())) + return { + ok: false, + error: 'GR ID must be a hex value (e.g. 0x2B)', + }; + const n = name.trim(), + g = grId.trim(); + if (_devices.has(n)) + return { ok: false, error: `Device "${n}" already exists in routing` }; + if (_grIds.has(n)) + return { + ok: false, + error: `A GR ID entry for "${n}" already exists`, + }; + _devices.set(n, { deviceName: n, buses: new Map() }); + _grIds.set(n, g); + return { ok: true, warnings: [] }; + }); + } + + function deleteDevice(name) { + return _withEditor(() => { + if (!name) return { ok: false, error: "Device name is required" }; + const warnings = []; + _devices.delete(name); + _grIds.delete(name); + + // Receiver ref sweep — snapshot keys first to avoid mutation-during-iteration. + for (const device of [..._devices.values()]) { + for (const [busPort, bus] of [...device.buses.entries()]) { + if (bus.receivers.has(name)) { + bus.receivers.delete(name); + warnings.push( + `Removed receiver reference to "${name}" from ${device.deviceName} > ${busPort}`, + ); + if (bus.receivers.size === 0) { + device.buses.delete(busPort); + } + } + } + } + return { ok: true, warnings }; + }); + } + + function renameDevice(oldName, newName) { + return _withEditor(() => { + if (!oldName || !newName) + return { ok: false, error: "Both names are required" }; + if (oldName === newName) return { ok: true, warnings: [] }; + if (!_devices.has(oldName)) + return { ok: false, error: `Device "${oldName}" not found` }; + if (_devices.has(newName)) + return { + ok: false, + error: `Device "${newName}" already exists`, + }; + if (_grIds.has(newName)) + return { + ok: false, + error: `GR ID entry for "${newName}" already exists`, + }; + + // Rebuild _devices map preserving insertion order. + const newDevices = new Map(); + for (const [k, v] of _devices) { + if (k === oldName) { + v.deviceName = newName; + newDevices.set(newName, v); + } else { + newDevices.set(k, v); + } + } + _devices = newDevices; + + // Rebuild _grIds map preserving insertion order. + const newGrIds = new Map(); + for (const [k, v] of _grIds) { + newGrIds.set(k === oldName ? newName : k, v); + } + _grIds = newGrIds; + + // Receiver ref sweep across all remaining devices. + for (const device of _devices.values()) { + for (const bus of device.buses.values()) { + if (bus.receivers.has(oldName)) { + const block = bus.receivers.get(oldName); + block.receiverName = newName; + const newReceivers = new Map(); + for (const [k, v] of bus.receivers) { + newReceivers.set(k === oldName ? newName : k, v); + } + bus.receivers = newReceivers; + } + } + } + + return { ok: true, warnings: [] }; + }); + } + + function addRoute(deviceName, busPort, receiverName, msgName, canIdOverride) { + return _withEditor(() => { + const warnings = []; + if (!deviceName) + return { ok: false, error: "Device name is required" }; + if (!["CAN1", "CAN2", "CAN3"].includes(busPort)) + return { ok: false, error: "Bus must be CAN1, CAN2, or CAN3" }; + if (!receiverName) + return { ok: false, error: "Receiver name is required" }; + if (!msgName) return { ok: false, error: "Message name is required" }; + + const customCanIds = _getCustomCanIdNames(); + if (!_messageIds.has(msgName) && !customCanIds.has(msgName)) { + return { + ok: false, + error: `Message "${msgName}" not found in Message ID or Custom CAN ID`, + }; + } + if (!_grIds.has(deviceName)) + warnings.push( + `Device "${deviceName}" has no GR ID entry. Use addDevice first.`, + ); + if (!_grIds.has(receiverName)) + warnings.push(`Receiver "${receiverName}" is not a known device.`); + + const ovr = canIdOverride || null; + + // Idempotency check. + const existDev = _devices.get(deviceName); + if (existDev) { + const existBus = existDev.buses.get(busPort); + if (existBus) { + const existRec = existBus.receivers.get(receiverName); + if ( + existRec && + existRec.routes.some( + (r) => r.msgName === msgName && r.canIdOverride === ovr, + ) + ) { + return { ok: true, warnings }; + } + } + } + + if (!_devices.has(deviceName)) { + _devices.set(deviceName, { deviceName, buses: new Map() }); + } + const device = _devices.get(deviceName); + if (!device.buses.has(busPort)) { + device.buses.set(busPort, { busPort, receivers: new Map() }); + } + const bus = device.buses.get(busPort); + if (!bus.receivers.has(receiverName)) { + bus.receivers.set(receiverName, { receiverName, routes: [] }); + } + bus.receivers.get(receiverName).routes.push({ msgName, canIdOverride: ovr }); + + return { ok: true, warnings }; + }); + } + + function deleteRouteEntry(deviceName, busPort, msgName) { + return _withEditor(() => { + const device = _devices.get(deviceName); + if (!device) return { ok: true, warnings: [] }; + const bus = device.buses.get(busPort); + if (!bus) return { ok: true, warnings: [] }; + + for (const [recName, receiver] of [...bus.receivers.entries()]) { + receiver.routes = receiver.routes.filter((r) => r.msgName !== msgName); + if (receiver.routes.length === 0) { + bus.receivers.delete(recName); + } + } + if (bus.receivers.size === 0) { + device.buses.delete(busPort); + } + return { ok: true, warnings: [] }; + }); + } + + function deleteBusBlock(deviceName, busPort) { + return _withEditor(() => { + const device = _devices.get(deviceName); + if (!device) return { ok: true, warnings: [] }; + device.buses.delete(busPort); + return { ok: true, warnings: [] }; + }); + } + + function addMessageDef(def) { + return _withEditor(() => { + if (!def || !def.name) + return { ok: false, error: "Message name is required" }; + if (_messageIds.has(def.name)) + return { + ok: false, + error: `Message "${def.name}" already exists`, + }; + if (!/^0x[0-9a-fA-F]+$/i.test(def.msgId)) + return { ok: false, error: "MSG ID must be hex (e.g. 0x003)" }; + const normId = def.msgId.toLowerCase(); + for (const msg of _messageIds.values()) { + if (msg.msgId.toLowerCase() === normId) { + return { + ok: false, + error: `MSG ID ${def.msgId} is already used by "${msg.name}"`, + }; + } + } + _messageIds.set(def.name, def); + return { ok: true, warnings: [] }; + }); + } + + function updateMessageDef(oldName, def) { + return _withEditor(() => { + if (!_messageIds.has(oldName)) + return { ok: false, error: `Message "${oldName}" not found` }; + if (!def || !def.name) + return { ok: false, error: "Message name is required" }; + if (def.name !== oldName && _messageIds.has(def.name)) + return { + ok: false, + error: `Message "${def.name}" already exists`, + }; + + const normId = def.msgId.toLowerCase(); + for (const [k, msg] of _messageIds) { + if (k !== oldName && msg.msgId.toLowerCase() === normId) { + return { + ok: false, + error: `MSG ID ${def.msgId} is already used by "${k}"`, + }; + } + } + + if (def.name === oldName) { + _messageIds.set(oldName, def); + } else { + // Rename: rebuild map preserving insertion order. + const newMsgIds = new Map(); + for (const [k, v] of _messageIds) { + newMsgIds.set(k === oldName ? def.name : k, k === oldName ? def : v); + } + _messageIds = newMsgIds; + + // Route ref sweep. + for (const device of _devices.values()) { + for (const bus of device.buses.values()) { + for (const receiver of bus.receivers.values()) { + for (const route of receiver.routes) { + if (route.msgName === oldName) route.msgName = def.name; + } + } + } + } + } + + return { ok: true, warnings: [] }; + }); + } + + // ==================== Read-only accessors ==================== + + function deviceExists(name) { + _ensureParsed(); + return _devices.has(name); + } + + function grIdExists(name) { + _ensureParsed(); + return _grIds.has(name); + } + + function getDeviceNames() { + _ensureParsed(); + return [..._devices.keys()]; + } + + function getGrIds() { + _ensureParsed(); + return new Map(_grIds); + } + + function getGrId(name) { + _ensureParsed(); + return _grIds.get(name) || null; + } + + function getMessageDef(name) { + _ensureParsed(); + return _messageIds.get(name) || null; + } + + function getMessageIdNames() { + _ensureParsed(); + return [..._messageIds.keys()]; + } + + function routeEntryExists(device, bus, receiver, msg, canIdOverride) { + _ensureParsed(); + const dev = _devices.get(device); + if (!dev) return false; + const b = dev.buses.get(bus); + if (!b) return false; + const rec = b.receivers.get(receiver); + if (!rec) return false; + return rec.routes.some( + (r) => r.msgName === msg && r.canIdOverride === (canIdOverride || null), + ); + } + + // ==================== Test helpers ==================== + // Exposed only for Node.js test environment. + + function _parseForTest(text) { + _parse(text); + return { devices: _devices, grIds: _grIds, messageIds: _messageIds }; + } + + function _serializeFromState() { + return _serialize(); + } + + // ==================== Public API ==================== + + return { + // Mutations + addDevice, + deleteDevice, + renameDevice, + addRoute, + deleteRouteEntry, + deleteBusBlock, + addMessageDef, + updateMessageDef, + // Validation + validate, + // Read-only + deviceExists, + grIdExists, + getDeviceNames, + getGrIds, + getGrId, + getMessageDef, + getMessageIdNames, + routeEntryExists, + // Test hooks + _parseForTest, + _serializeFromState, + }; +}); diff --git a/Web/editor.js b/Web/editor.js index 9255e969e..dd4cfee42 100644 --- a/Web/editor.js +++ b/Web/editor.js @@ -433,6 +433,12 @@ editedKeys.clear(); newKeys.clear(); }, + // Update working text without resetting original or edit state. + // Used by GrcanDocument after semantic mutations. + updateRawText(text) { + rawCandoText = text; + hasEdits = true; + }, getRawText() { return rawCandoText; }, diff --git a/Web/formMessageDef.js b/Web/formMessageDef.js index 0e961cd40..3f979c624 100644 --- a/Web/formMessageDef.js +++ b/Web/formMessageDef.js @@ -441,38 +441,24 @@ } if (!ok) return; - const yaml = editor.generateMessageIdYaml({ - name, - msgId, - msgLength: parseInt(msgLen, 10), - fields, - }); + const def = { name, msgId, msgLength: msgLen, fields }; if (!isNewMsg && msgName) { - const defRange = editor.findMessageDefRange(msgName); - const changed = - defRange && - editor.getLineRangeText(defRange.startLine, defRange.endLine) !== - yaml; - if (changed) { - editor.replaceLineRange(defRange.startLine, defRange.endLine, yaml); - if (name !== msgName) { - editor.renameRoutingMessageRefs(msgName, name); - } - } - if (changed) { - editor.markEdited("msgDef:" + msgName); - if (name !== msgName) editor.markEdited("msgDef:" + name); + const result = window.GrcanDocument.updateMessageDef(msgName, def); + if (!result.ok) { + nameF.error.textContent = result.error; + return; } + editor.markEdited("msgDef:" + msgName); + if (name !== msgName) editor.markEdited("msgDef:" + name); fu.closeOverlay(overlay, { force: true }); - if (changed) editor.triggerReRender(); + editor.triggerReRender(); return; } else { - const lines = editor.getLines(); - const secStart = editor.findSectionStart(lines, "Message ID"); - if (secStart !== -1) { - const secEnd = editor.findSectionEnd(lines, secStart); - editor.insertAtLine(secEnd, yaml); + const result = window.GrcanDocument.addMessageDef(def); + if (!result.ok) { + nameF.error.textContent = result.error; + return; } editor.markNew("msgDef:" + name); } diff --git a/Web/formNodeEdit.js b/Web/formNodeEdit.js index 839e82ea2..03a0c7edd 100644 --- a/Web/formNodeEdit.js +++ b/Web/formNodeEdit.js @@ -35,8 +35,7 @@ ok = false; } else if ( newName !== oldDeviceName && - (editor.findRoutingDeviceRange(newName) || - (!!editor.grIdNameExists && editor.grIdNameExists(newName))) + window.GrcanDocument.deviceExists(newName) ) { nameF.error.textContent = "Node already exists"; ok = false; @@ -50,14 +49,11 @@ return; } - const range = editor.findRoutingDeviceRange(oldDeviceName); - if (!range) return; - editor.replaceLineRange( - range.startLine, - range.startLine + 1, - " " + newName + ":\n", - ); - if (editor.renameGrIdNode) editor.renameGrIdNode(oldDeviceName, newName); + const result = window.GrcanDocument.renameDevice(oldDeviceName, newName); + if (!result.ok) { + nameF.error.textContent = result.error; + return; + } editor.markEdited("routeNode:" + newName); fu.closeOverlay(overlay, { force: true }); editor.triggerReRender(); diff --git a/Web/formRoutingAdd.js b/Web/formRoutingAdd.js index 232e03772..6979309ab 100644 --- a/Web/formRoutingAdd.js +++ b/Web/formRoutingAdd.js @@ -150,6 +150,25 @@ if (deviceName) devF.input.disabled = true; body.appendChild(devF.row); + // GR ID field: only shown (and required) when the typed device name is new. + const grIdF = fu.makeFormRow( + "GR ID (new device)", + fu.makeInput("text", "", "e.g. 0x2B"), + false, + ); + grIdF.row.style.display = "none"; + body.appendChild(grIdF.row); + + function updateGrIdVisibility() { + const isNew = + !devF.input.disabled && + !window.GrcanDocument.deviceExists(devF.input.value.trim()); + grIdF.row.style.display = isNew && devF.input.value.trim() ? "" : "none"; + } + devF.input.addEventListener("input", updateGrIdVisibility); + // Run once on open in case a device name was pre-filled. + if (!devF.input.disabled) updateGrIdVisibility(); + const busF = fu.makeFormRow( "Bus", fu.makeSelect(["CAN1", "CAN2", "CAN3"], busPort || "CAN1"), @@ -403,86 +422,31 @@ } else ovrF.error.textContent = ""; if (!ok) return; - if (editor.routeEntryExists(dev, bus, rec, msg, ovr || null)) { - fu.closeOverlay(overlay, { force: true }); - return; - } - const lines = editor.getLines(); - const devRange = editor.findRoutingDeviceRange(dev); - let createdNode = false; - let createdBus = false; - - if (!devRange) { - createdNode = true; - createdBus = true; - const rStart = editor.findSectionStart(lines, "routing"); - if (rStart === -1) return; - const rEnd = editor.findSectionEnd(lines, rStart); - editor.insertAtLine( - rEnd, - " " + - dev + - ":\n " + - bus + - ":\n " + - rec + - ":\n" + - editor.generateRoutingMsgYaml(msg, ovr || null), - ); - } else { - const busRange = editor.findRoutingBusRange(dev, bus); - if (!busRange) { - createdBus = true; - editor.insertAtLine( - devRange.endLine, - " " + - bus + - ":\n " + - rec + - ":\n" + - editor.generateRoutingMsgYaml(msg, ovr || null), - ); - } else { - let recFound = false; - const freshLines = editor.getLines(); - for (let i = busRange.startLine + 1; i < busRange.endLine; i++) { - if ( - freshLines[i].search(/\S/) === 8 && - freshLines[i].trim() === rec + ":" - ) { - // findBlockEnd locates the end of this receiver block - // (indent ≤ 8), bounded by the parent bus block end. - const recEnd = editor.findBlockEnd( - freshLines, - i, - busRange.endLine, - 8, - ); - editor.insertAtLine( - recEnd, - editor.generateRoutingMsgYaml(msg, ovr || null), - ); - recFound = true; - break; - } - } - if (!recFound) { - editor.insertAtLine( - busRange.endLine, - " " + - rec + - ":\n" + - editor.generateRoutingMsgYaml(msg, ovr || null), - ); - } + // If device is new, validate and create it with a GR ID first. + const isNewDevice = !window.GrcanDocument.deviceExists(dev); + if (isNewDevice) { + const grId = grIdF.input.value.trim(); + if (!grId || !/^0x[0-9a-fA-F]+$/i.test(grId)) { + grIdF.error.textContent = "Required for new device (hex, e.g. 0x2B)"; + return; + } + const addResult = window.GrcanDocument.addDevice(dev, grId); + if (!addResult.ok) { + devF.error.textContent = addResult.error; + return; } } - if (createdNode) editor.markNew("routeNode:" + dev); + const routeResult = window.GrcanDocument.addRoute(dev, bus, rec, msg, ovr || null); + if (!routeResult.ok) { + msgF.error.textContent = routeResult.error; + return; + } + + if (isNewDevice) editor.markNew("routeNode:" + dev); else editor.markEdited("routeNode:" + dev); - if (createdBus) editor.markNew("routeBus:" + dev + "|" + bus); - else editor.markEdited("routeBus:" + dev + "|" + bus); + editor.markNew("routeBus:" + dev + "|" + bus); editor.markNew("routeMsg:" + dev + "|" + bus + "|" + msg); fu.closeOverlay(overlay, { force: true }); diff --git a/Web/formSuperAdd.js b/Web/formSuperAdd.js index b02b73c52..53839f76a 100644 --- a/Web/formSuperAdd.js +++ b/Web/formSuperAdd.js @@ -429,25 +429,26 @@ if (!ok) return; let changed = false; + const doc = window.GrcanDocument; - if (createSender && insertGrIdEntry(editor, sender, senderId)) { + if (createSender && !doc.deviceExists(sender)) { + const r = doc.addDevice(sender, senderId); + if (!r.ok) { senderF.error.textContent = r.error; return; } editor.markNew("routeNode:" + sender); changed = true; } - if ( - createReceiver && - receiver !== sender && - insertGrIdEntry(editor, receiver, receiverId) - ) { + if (createReceiver && receiver !== sender && !doc.deviceExists(receiver)) { + const r = doc.addDevice(receiver, receiverId); + if (!r.ok) { receiverF.error.textContent = r.error; return; } editor.markNew("routeNode:" + receiver); changed = true; } if (creatingMsg) { - const msgYaml = editor.generateMessageIdYaml({ + const msgDef = { name: msgName, msgId: msgIdF.input.value.trim(), - msgLength: parseInt(msgLenF.input.value.trim(), 10), + msgLength: msgLenF.input.value.trim(), fields: [ { name: fieldNameF.input.value.trim(), @@ -460,35 +461,21 @@ mapEquation: "", }, ], - }); - const lines = editor.getLines(); - const msgStart = editor.findSectionStart(lines, "Message ID"); - if (msgStart !== -1) { - const msgEnd = editor.findSectionEnd(lines, msgStart); - editor.insertAtLine(msgEnd, msgYaml); - editor.markNew("msgDef:" + msgName); - changed = true; - } + }; + const r = doc.addMessageDef(msgDef); + if (!r.ok) { msgNameF.error.textContent = r.error; return; } + editor.markNew("msgDef:" + msgName); + changed = true; } if (routeOn) { - const routeResult = appendRoute( - editor, - sender, - bus, - receiver, - msgName, - overrideId || null, - ); - if (routeResult.changed) { - if (routeResult.createdNode) editor.markNew("routeNode:" + sender); - else editor.markEdited("routeNode:" + sender); - if (routeResult.createdBus) - editor.markNew("routeBus:" + sender + "|" + bus); - else editor.markEdited("routeBus:" + sender + "|" + bus); - editor.markNew("routeMsg:" + sender + "|" + bus + "|" + msgName); - changed = true; - } + const r = doc.addRoute(sender, bus, receiver, msgName, overrideId || null); + if (!r.ok) { msgNameF.error.textContent = r.error; return; } + if (!doc.deviceExists(sender)) editor.markNew("routeNode:" + sender); + else editor.markEdited("routeNode:" + sender); + editor.markNew("routeBus:" + sender + "|" + bus); + editor.markNew("routeMsg:" + sender + "|" + bus + "|" + msgName); + changed = true; } fu.closeOverlay(overlay, { force: true }); diff --git a/Web/index.html b/Web/index.html index 4d1457491..a187f128d 100644 --- a/Web/index.html +++ b/Web/index.html @@ -31,6 +31,14 @@

GRCAN Viewer

+
+ + +
+ @@ -75,6 +83,7 @@

GRCAN Viewer

+ diff --git a/Web/logic.js b/Web/logic.js index 55effa4b5..6df5565bc 100644 --- a/Web/logic.js +++ b/Web/logic.js @@ -11,6 +11,12 @@ const GR_NAVY = "#195297"; const GR_GRAY = "#9AA3B0"; const GITHUB_API = "https://api.github.com/repos/Gaucho-Racing/Firmware"; + +// Local-file mode: when set, fetchCando returns this content instead of hitting the API. +let _localCandoText = null; +function setLocalCandoText(text) { _localCandoText = text; } +function getLocalCandoText() { return _localCandoText; } +function isLocalMode() { return _localCandoText !== null; } const CANDO_PATH = "Autogen/CAN/Doc/GRCAN.CANdo"; const BUS_ID_PATH = "Autogen/CAN/Inc/GRCAN_BUS_ID.h"; const NODE_ID_PATH = "Autogen/CAN/Inc/GRCAN_NODE_ID.h"; @@ -317,6 +323,9 @@ async function fetchTags() { } async function fetchCando(ref) { + if (_localCandoText !== null) { + return { content: _localCandoText, notFound: false }; + } try { const res = await fetch( `${GITHUB_API}/contents/${CANDO_PATH}?ref=${encodeURIComponent(ref)}`, @@ -566,6 +575,9 @@ function parseMessageByBusFromText(text, busName) { } async function fetchMessageByBus(ref, busName) { + if (_localCandoText !== null) { + return parseMessageByBusFromText(_localCandoText, busName); + } try { const res = await fetch( `${GITHUB_API}/contents/${CANDO_PATH}?ref=${encodeURIComponent(ref)}`, @@ -633,6 +645,9 @@ window.GrcanApi = { fetchBranches, fetchTags, fetchCando, + setLocalCandoText, + getLocalCandoText, + isLocalMode, fetchBus, fetchNodeIds, fetchMessageCatalog, diff --git a/Web/viewer.js b/Web/viewer.js index d9b1f00bc..8ff77b5d1 100644 --- a/Web/viewer.js +++ b/Web/viewer.js @@ -305,17 +305,11 @@ window.addEventListener("DOMContentLoaded", function () { editor.createDeleteBtn(() => { editor.setNavSnapshot(navSnapshot()); editor.confirmAndDelete(msg.msgName, () => { - const entries = editor.findRoutingMsgEntries( + window.GrcanDocument.deleteRouteEntry( currentDeviceName, busPort, msg.msgName, ); - for (let i = entries.length - 1; i >= 0; i--) { - editor.deleteLineRange( - entries[i].startLine, - entries[i].endLine, - ); - } editor.markEdited( "routeMsg:" + (currentDeviceName || "") + @@ -460,9 +454,7 @@ window.addEventListener("DOMContentLoaded", function () { editor.confirmAndDelete( node.name + " on " + currentBusCanonical, () => { - const range = editor.findRoutingBusRange(node.name, busPort); - if (range) - editor.deleteLineRange(range.startLine, range.endLine); + window.GrcanDocument.deleteBusBlock(node.name, busPort); editor.markEdited("routeBus:" + node.name + "|" + busPort); }, ); @@ -629,9 +621,7 @@ window.addEventListener("DOMContentLoaded", function () { editor.confirmAndDelete( deviceName + " > " + entry.busName, () => { - const range = editor.findRoutingBusRange(deviceName, busPort); - if (range) - editor.deleteLineRange(range.startLine, range.endLine); + window.GrcanDocument.deleteBusBlock(deviceName, busPort); editor.markEdited("routeBus:" + deviceName + "|" + busPort); }, ); @@ -771,8 +761,11 @@ window.addEventListener("DOMContentLoaded", function () { editor.createDeleteBtn(() => { editor.setNavSnapshot(navSnapshot()); editor.confirmAndDelete(nodeEntry.name + " (all routes)", () => { - const range = editor.findRoutingDeviceRange(nodeEntry.name); - if (range) editor.deleteLineRange(range.startLine, range.endLine); + const result = window.GrcanDocument.deleteDevice(nodeEntry.name); + if (!result.ok) { + console.error("deleteDevice failed:", result.error); + return; + } editor.markEdited("routeNode:" + nodeEntry.name); }); }), @@ -810,17 +803,32 @@ window.addEventListener("DOMContentLoaded", function () { // ==================== Hierarchy entry points ==================== async function renderHierarchy(ref) { - await loadNodeIds(ref); - const candoResult = await window.GrcanApi.fetchCando(ref); + const localText = window.GrcanApi.isLocalMode() ? candoResult.content : null; + + if (localText) { + loadNodeIdsFromText(localText); + } else { + await loadNodeIds(ref); + } + if (!candoResult.notFound && editor) { editor.setRawText(candoResult.content); + if (window.GrcanDocument) { + const violations = window.GrcanDocument.validate(); + if (violations.length > 0) { + console.warn( + "[GrcanDocument] CANdo validation issues on load:", + violations, + ); + } + } } if (HIERARCHY_MODE === "NODE_BUS") { - await renderNodeBus(ref); + await renderNodeBus(ref, localText); } else { - await renderBusNode(ref); + await renderBusNode(ref, localText); } } @@ -974,5 +982,42 @@ window.addEventListener("DOMContentLoaded", function () { refSelect.addEventListener("change", onRefInputChange); + // ==================== Local-file toggle ==================== + const localToggle = document.getElementById("local-toggle"); + const localFileInput = document.getElementById("local-file-input"); + + if (localToggle && localFileInput) { + localToggle.addEventListener("change", function () { + if (localToggle.checked) { + localFileInput.style.display = "block"; + localFileInput.click(); + } else { + localFileInput.style.display = "none"; + localFileInput.value = ""; + window.GrcanApi.setLocalCandoText(null); + refSelect.disabled = false; + if (currentRef) renderHierarchy(currentRef); + } + }); + + localFileInput.addEventListener("change", function () { + const file = localFileInput.files[0]; + if (!file) { + localToggle.checked = false; + localFileInput.style.display = "none"; + window.GrcanApi.setLocalCandoText(null); + refSelect.disabled = false; + return; + } + const reader = new FileReader(); + reader.onload = function (e) { + window.GrcanApi.setLocalCandoText(e.target.result); + refSelect.disabled = true; + renderHierarchy(currentRef || "local"); + }; + reader.readAsText(file); + }); + } + init(); }); From 2ce4c86f640a7ea3cf0a45af96c20dfc9b9e99f5 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:56:34 +0000 Subject: [PATCH 02/83] Automatic Web Format: Standardized formatting automatically --- Web/candoDocument.js | 19 ++++++++----------- Web/formRoutingAdd.js | 8 +++++++- Web/formSuperAdd.js | 34 ++++++++++++++++++++++++++++------ Web/index.html | 20 +++++++++++++++++--- Web/logic.js | 12 +++++++++--- Web/viewer.js | 4 +++- 6 files changed, 72 insertions(+), 25 deletions(-) diff --git a/Web/candoDocument.js b/Web/candoDocument.js index e4eae18fc..3ace6141a 100644 --- a/Web/candoDocument.js +++ b/Web/candoDocument.js @@ -61,10 +61,7 @@ // Verbatim: Bus ID = everything before routing section header. if (routingStart > 0) { - _busIdsText = lines - .slice(0, routingStart) - .join("\n") - .replace(/\n+$/, ""); + _busIdsText = lines.slice(0, routingStart).join("\n").replace(/\n+$/, ""); } // Verbatim: byte order line through the blank line before Message ID. @@ -205,8 +202,7 @@ curField.bitStart = content.slice("bit_start:".length).trim(); } else if (content.startsWith("#")) { const t = content.slice(1).trim(); - curField.comment = - curField.comment ? curField.comment + "\n" + t : t; + curField.comment = curField.comment ? curField.comment + "\n" + t : t; } else if (content.startsWith("data type:")) { curField.dataType = content.slice("data type:".length).trim(); } else if (content.startsWith("units:")) { @@ -298,7 +294,7 @@ function _serializeGrIds() { let out = "GR ID:\n\n"; for (const [name, hexId] of _grIds.entries()) { - out += ' ' + name + ': "' + hexId + '"\n'; + out += " " + name + ': "' + hexId + '"\n'; } return out; } @@ -501,7 +497,7 @@ if (!grId || !/^0x[0-9a-fA-F]+$/i.test(grId.trim())) return { ok: false, - error: 'GR ID must be a hex value (e.g. 0x2B)', + error: "GR ID must be a hex value (e.g. 0x2B)", }; const n = name.trim(), g = grId.trim(); @@ -602,8 +598,7 @@ function addRoute(deviceName, busPort, receiverName, msgName, canIdOverride) { return _withEditor(() => { const warnings = []; - if (!deviceName) - return { ok: false, error: "Device name is required" }; + if (!deviceName) return { ok: false, error: "Device name is required" }; if (!["CAN1", "CAN2", "CAN3"].includes(busPort)) return { ok: false, error: "Bus must be CAN1, CAN2, or CAN3" }; if (!receiverName) @@ -654,7 +649,9 @@ if (!bus.receivers.has(receiverName)) { bus.receivers.set(receiverName, { receiverName, routes: [] }); } - bus.receivers.get(receiverName).routes.push({ msgName, canIdOverride: ovr }); + bus.receivers + .get(receiverName) + .routes.push({ msgName, canIdOverride: ovr }); return { ok: true, warnings }; }); diff --git a/Web/formRoutingAdd.js b/Web/formRoutingAdd.js index 6979309ab..ce927fec2 100644 --- a/Web/formRoutingAdd.js +++ b/Web/formRoutingAdd.js @@ -438,7 +438,13 @@ } } - const routeResult = window.GrcanDocument.addRoute(dev, bus, rec, msg, ovr || null); + const routeResult = window.GrcanDocument.addRoute( + dev, + bus, + rec, + msg, + ovr || null, + ); if (!routeResult.ok) { msgF.error.textContent = routeResult.error; return; diff --git a/Web/formSuperAdd.js b/Web/formSuperAdd.js index 53839f76a..9b9160cdc 100644 --- a/Web/formSuperAdd.js +++ b/Web/formSuperAdd.js @@ -433,13 +433,23 @@ if (createSender && !doc.deviceExists(sender)) { const r = doc.addDevice(sender, senderId); - if (!r.ok) { senderF.error.textContent = r.error; return; } + if (!r.ok) { + senderF.error.textContent = r.error; + return; + } editor.markNew("routeNode:" + sender); changed = true; } - if (createReceiver && receiver !== sender && !doc.deviceExists(receiver)) { + if ( + createReceiver && + receiver !== sender && + !doc.deviceExists(receiver) + ) { const r = doc.addDevice(receiver, receiverId); - if (!r.ok) { receiverF.error.textContent = r.error; return; } + if (!r.ok) { + receiverF.error.textContent = r.error; + return; + } editor.markNew("routeNode:" + receiver); changed = true; } @@ -463,14 +473,26 @@ ], }; const r = doc.addMessageDef(msgDef); - if (!r.ok) { msgNameF.error.textContent = r.error; return; } + if (!r.ok) { + msgNameF.error.textContent = r.error; + return; + } editor.markNew("msgDef:" + msgName); changed = true; } if (routeOn) { - const r = doc.addRoute(sender, bus, receiver, msgName, overrideId || null); - if (!r.ok) { msgNameF.error.textContent = r.error; return; } + const r = doc.addRoute( + sender, + bus, + receiver, + msgName, + overrideId || null, + ); + if (!r.ok) { + msgNameF.error.textContent = r.error; + return; + } if (!doc.deviceExists(sender)) editor.markNew("routeNode:" + sender); else editor.markEdited("routeNode:" + sender); editor.markNew("routeBus:" + sender + "|" + bus); diff --git a/Web/index.html b/Web/index.html index a187f128d..c2d5c24ab 100644 --- a/Web/index.html +++ b/Web/index.html @@ -31,12 +31,26 @@

GRCAN Viewer

-
-