From 30733a1dad57c9522a13f533c540f3391d143d71 Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Wed, 18 Mar 2026 21:47:54 +0000 Subject: [PATCH 1/5] fix: metadata IDs, save feedback, acquisition info alert, mag labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Acq_ID supports leading zeros (text input + smart increment) (#903) - Sample_ID as string + save confirmation snackbar (#889-893) - Show project/sample/operator/gear in acquisition page (#894) - Add flowcell size (µm) to magnification button labels (#901) --- flows.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flows.json b/flows.json index 4c696ba..692de4c 100644 --- a/flows.json +++ b/flows.json @@ -3174,7 +3174,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3686,7 +3686,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, From 106d8a9372ae3b777da61366e6629226d58b2b97 Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Wed, 18 Mar 2026 21:48:07 +0000 Subject: [PATCH 2/5] fix: unique EcoTaxa naming and path sanitization - Compose fully-qualified project_sample_acq IDs in update_config (#882) - Space-to-underscore sanitization in IDs - Gallery + Explorer URL path sanitization for legacy TSVs --- flows.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flows.json b/flows.json index 692de4c..88c990d 100644 --- a/flows.json +++ b/flows.json @@ -3844,7 +3844,7 @@ "type": "function", "z": "71ede8b7dd88d90e", "name": "update_config", - "func": "\n\nconst keys = global.keys(); \nlet config = {}; \n\nkeys.forEach(key => {\n if (!key.startsWith('$')) {\n if (\n key.startsWith('sample_') ||\n key.startsWith('acq_') ||\n key.startsWith('object_') ||\n key.startsWith('process_') ||\n key.startsWith('img_')\n ) {\n config[key] = global.get(key);\n }\n }\n});\n\nmsg.topic = \"imager/image\";\n\nmsg.payload = {\n action: \"update_config\",\n config: config\n};\n\nreturn msg;", + "func": "const keys = global.keys();\nlet config = {};\n\nkeys.forEach(key => {\n if (!key.startsWith('$')) {\n if (\n key.startsWith('sample_') ||\n key.startsWith('acq_') ||\n key.startsWith('object_') ||\n key.startsWith('process_') ||\n key.startsWith('img_')\n ) {\n config[key] = global.get(key);\n }\n }\n});\n\n// Compose fully-qualified IDs for unique EcoTaxa naming\nconst project = config.sample_project || \"\";\nconst sampleRaw = String(config.sample_id || \"\");\nconst acqRaw = String(config.acq_id || \"\");\n\nif (project && !sampleRaw.startsWith(project + \"_\")) {\n config.sample_id = project + \"_\" + sampleRaw;\n}\nconst finalSample = String(config.sample_id || \"\");\nif (finalSample && !acqRaw.startsWith(finalSample + \"_\")) {\n config.acq_id = finalSample + \"_\" + acqRaw;\n}\n\n// Sanitize spaces in IDs to match filesystem paths\n// (the Python imager replaces spaces with underscores in directory names)\nconfig.sample_id = String(config.sample_id || \"\").replace(/ /g, \"_\");\nconfig.acq_id = String(config.acq_id || \"\").replace(/ /g, \"_\");\n\nmsg.topic = \"imager/image\";\n\nmsg.payload = {\n action: \"update_config\",\n config: config\n};\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5083,7 +5083,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "EXPLORER", - "func": "// Explorer Function Node\n// TSV → {meta, keys, data: [{id, url, ...keys}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\n// 1. Parse Headers\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const i = headers.indexOf(col);\n return i >= 0 ? row[i] : null;\n};\n\n// 2. Extract Meta from first valid data row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n // Skip rows starting with '[' (metadata comments)\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\n\n// Default meta\nconst meta = {\n sample_id: \"\",\n project: \"\",\n acq_id: \"\",\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = val(firstRow, \"sample_id\") || \"\";\n meta.project = val(firstRow, \"sample_project\") || \"\";\n meta.acq_id = val(firstRow, \"acq_id\") || \"\";\n \n // CRITICAL: Extract pixel size (microns per pixel)\n // If process_pixel is missing or 0, default to 1 to prevent division by zero\n const px = parseFloat(val(firstRow, \"process_pixel\"));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 3. Explorer Keys to extract\nconst explorerKeys = [\n \"object_area\", \"object_equivalent_diameter\", \"object_perim.\",\n \"object_major\", \"object_minor\", \"object_width\", \"object_height\",\n \"object_circ.\", \"object_elongation\", \"object_solidity\",\n \"object_eccentricity\", \"object_MeanHue\", \"object_MeanSaturation\",\n \"object_MeanValue\", \"object_StdValue\", \"object_blur_laplacian\"\n];\n\nlet seq = 0;\nconst data = [];\n\n// 4. Process Data Lines\nfor (let i = 1; i < lines.length; i++) {\n const line = lines[i];\n if (line.trim().startsWith(\"[\")) continue;\n\n const row = line.split(\"\\t\");\n\n const item = {\n id: val(row, \"object_id\"),\n // Construct URL\n url: `/ps/node-red-v2/my-images/${val(row,\"object_date\")}/${val(row,\"sample_id\")}/${val(row,\"acq_id\")}/${val(row,\"img_file_name\")}`,\n sequence_index: seq++\n };\n\n // Parse numeric keys\n explorerKeys.forEach(k => {\n let v = parseFloat(val(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n // Custom calc for greenness (example)\n const hue = item.object_MeanHue || 0;\n const dist = Math.abs(hue - 80);\n item.custom_greenness = Math.max(0, 100 - dist);\n\n data.push(item);\n}\n\nmsg.payload = {\n meta,\n keys: explorerKeys,\n data\n};\n\nreturn msg;", + "func": "// Explorer Function Node\n// TSV → {meta, keys, data: [{id, url, ...keys}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\n// 1. Parse Headers\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const i = headers.indexOf(col);\n return i >= 0 ? row[i] : null;\n};\n\n// 2. Extract Meta from first valid data row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n // Skip rows starting with '[' (metadata comments)\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\n\n// Default meta\nconst meta = {\n sample_id: \"\",\n project: \"\",\n acq_id: \"\",\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = val(firstRow, \"sample_id\") || \"\";\n meta.project = val(firstRow, \"sample_project\") || \"\";\n meta.acq_id = val(firstRow, \"acq_id\") || \"\";\n \n // CRITICAL: Extract pixel size (microns per pixel)\n // If process_pixel is missing or 0, default to 1 to prevent division by zero\n const px = parseFloat(val(firstRow, \"process_pixel\"));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 3. Explorer Keys to extract\nconst explorerKeys = [\n \"object_area\", \"object_equivalent_diameter\", \"object_perim.\",\n \"object_major\", \"object_minor\", \"object_width\", \"object_height\",\n \"object_circ.\", \"object_elongation\", \"object_solidity\",\n \"object_eccentricity\", \"object_MeanHue\", \"object_MeanSaturation\",\n \"object_MeanValue\", \"object_StdValue\", \"object_blur_laplacian\"\n];\n\nlet seq = 0;\nconst data = [];\n\n// 4. Process Data Lines\nfor (let i = 1; i < lines.length; i++) {\n const line = lines[i];\n if (line.trim().startsWith(\"[\")) continue;\n\n const row = line.split(\"\\t\");\n\n const item = {\n id: val(row, \"object_id\"),\n // Construct URL\n url: `/ps/node-red-v2/my-images/${(val(row,\"object_date\")||\"\").replace(/ /g,\"_\")}/${(val(row,\"sample_id\")||\"\").replace(/ /g,\"_\")}/${(val(row,\"acq_id\")||\"\").replace(/ /g,\"_\")}/${val(row,\"img_file_name\")}`,\n sequence_index: seq++\n };\n\n // Parse numeric keys\n explorerKeys.forEach(k => {\n let v = parseFloat(val(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n // Custom calc for greenness (example)\n const hue = item.object_MeanHue || 0;\n const dist = Math.abs(hue - 80);\n item.custom_greenness = Math.max(0, 100 - dist);\n\n data.push(item);\n}\n\nmsg.payload = {\n meta,\n keys: explorerKeys,\n data\n};\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5158,7 +5158,7 @@ "type": "function", "z": "4fdbd7bacb797c5a", "name": "GALLERY", - "func": "// GALLERY Function Node\n// TSV → { data: [], meta: { resolution: ... }, keys: ... }\n\n// 1. Handle Clear Signals\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 2. Validate Payload\nif (!msg.payload) return null;\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 3. Parse Headers\nconst headers = lines[0].split('\\t');\nconst get = (row, key) => {\n const idx = headers.indexOf(key);\n return idx >= 0 ? row[idx] : null;\n};\n\n// 4. Extract Meta (Resolution is key here)\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n const L = lines[i].trim();\n if (!L.startsWith('[') && L !== '') {\n firstRow = lines[i].split('\\t');\n break;\n }\n}\n\nconst meta = {\n sample_id: 'N/A',\n project: 'N/A',\n acq_id: 'N/A',\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = get(firstRow, 'sample_id') || \"\";\n meta.project = get(firstRow, 'sample_project') || \"\";\n meta.acq_id = get(firstRow, 'acq_id') || \"\";\n\n // CRITICAL: Extract microns per pixel\n const px = parseFloat(get(firstRow, 'process_pixel'));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 5. Select Keys to Display\nconst usefulKeys = [\n \"object_area\", \"object_width\", \"object_height\",\n \"object_equivalent_diameter\", \"object_major\", \"object_minor\",\n \"object_MeanHue\", \"object_elongation\", \"object_blur_laplacian\"\n];\n\nconst data = [];\n\n// 6. Process Rows\nfor (let i = 1; i < lines.length; i++) {\n const rowLine = lines[i].trim();\n if (rowLine.startsWith('[') || rowLine === '') continue;\n\n const row = rowLine.split('\\t');\n if (row.length !== headers.length) continue;\n\n const item = {\n id: get(row, 'object_id'),\n // Construct Image URL\n url: `/ps/data/browse/api/preview/big/objects/${get(row, 'object_date')}/${get(row, 'sample_id')}/${get(row, 'acq_id')}/${get(row, 'img_file_name')}`\n };\n\n // Parse numeric values\n usefulKeys.forEach(k => {\n let v = parseFloat(get(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n data.push(item);\n}\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta\n};\n\nreturn msg;", + "func": "// GALLERY Function Node\n// TSV → { data: [], meta: { resolution: ... }, keys: ... }\n\n// 1. Handle Clear Signals\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 2. Validate Payload\nif (!msg.payload) return null;\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 3. Parse Headers\nconst headers = lines[0].split('\\t');\nconst get = (row, key) => {\n const idx = headers.indexOf(key);\n return idx >= 0 ? row[idx] : null;\n};\n\n// 4. Extract Meta (Resolution is key here)\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n const L = lines[i].trim();\n if (!L.startsWith('[') && L !== '') {\n firstRow = lines[i].split('\\t');\n break;\n }\n}\n\nconst meta = {\n sample_id: 'N/A',\n project: 'N/A',\n acq_id: 'N/A',\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = get(firstRow, 'sample_id') || \"\";\n meta.project = get(firstRow, 'sample_project') || \"\";\n meta.acq_id = get(firstRow, 'acq_id') || \"\";\n\n // CRITICAL: Extract microns per pixel\n const px = parseFloat(get(firstRow, 'process_pixel'));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 5. Select Keys to Display\nconst usefulKeys = [\n \"object_area\", \"object_width\", \"object_height\",\n \"object_equivalent_diameter\", \"object_major\", \"object_minor\",\n \"object_MeanHue\", \"object_elongation\", \"object_blur_laplacian\"\n];\n\nconst data = [];\n\n// 6. Process Rows\nfor (let i = 1; i < lines.length; i++) {\n const rowLine = lines[i].trim();\n if (rowLine.startsWith('[') || rowLine === '') continue;\n\n const row = rowLine.split('\\t');\n if (row.length !== headers.length) continue;\n\n const item = {\n id: get(row, 'object_id'),\n // Construct Image URL\n url: `/ps/data/browse/api/preview/big/objects/${(get(row, 'object_date')||'').replace(/ /g,'_')}/${(get(row, 'sample_id')||'').replace(/ /g,'_')}/${(get(row, 'acq_id')||'').replace(/ /g,'_')}/${get(row, 'img_file_name')}`\n };\n\n // Parse numeric values\n usefulKeys.forEach(k => {\n let v = parseFloat(get(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n data.push(item);\n}\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta\n};\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, From 5336f068069b41688b4525c110315c7d17a06057 Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Wed, 18 Mar 2026 21:48:18 +0000 Subject: [PATCH 3/5] fix: add confirmation dialog before deleting acquisitions (#904) - Replace direct deleteItem with confirmDelete + dialog - Show acquisition and sample ID in confirmation prompt - Warning that deletion cannot be undone --- flows.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flows.json b/flows.json index 88c990d..5ef8e8b 100644 --- a/flows.json +++ b/flows.json @@ -4333,7 +4333,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n", + "format": "\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, From 86049e1cb3c09ba093d72ade17d06c05c03816ba Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Wed, 18 Mar 2026 21:48:32 +0000 Subject: [PATCH 4/5] fix: cross-persist lat/lon and date/time in metadata setters (#907) - set object_datetime also persists lat/lon if present - set object_latlon also persists date/time if present --- flows.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flows.json b/flows.json index 5ef8e8b..3e324c9 100644 --- a/flows.json +++ b/flows.json @@ -3394,7 +3394,7 @@ "type": "function", "z": "f7ff9c0e54da4b8e", "name": "set object_latlon", - "func": "if (msg.topic) {\n global.set(\"object_lat\", msg.payload.object_lat);\n global.set(\"object_lon\", msg.payload.object_lon);\n}\nreturn msg;\n", + "func": "if (msg.topic) {\n global.set(\"object_lat\", msg.payload.object_lat);\n global.set(\"object_lon\", msg.payload.object_lon);\n if (msg.payload.object_date !== undefined) {\n global.set(\"object_date\", msg.payload.object_date);\n }\n if (msg.payload.object_time !== undefined) {\n global.set(\"object_time\", msg.payload.object_time);\n }\n}\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, @@ -3412,7 +3412,7 @@ "type": "function", "z": "f7ff9c0e54da4b8e", "name": "set object_datetime", - "func": "if (msg.topic) {\n global.set(\"object_date\", msg.payload.object_date);\n global.set(\"object_time\", msg.payload.object_time);\n}\nreturn msg;\n", + "func": "if (msg.topic) {\n global.set(\"object_date\", msg.payload.object_date);\n global.set(\"object_time\", msg.payload.object_time);\n if (msg.payload.object_lat !== undefined) {\n global.set(\"object_lat\", msg.payload.object_lat);\n }\n if (msg.payload.object_lon !== undefined) {\n global.set(\"object_lon\", msg.payload.object_lon);\n }\n}\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, From 8d051249bffbeda1b2809ca9487db3cefeb035c3 Mon Sep 17 00:00:00 2001 From: Adam Larson Date: Wed, 18 Mar 2026 22:31:27 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20acquisition=20race=20condition=20?= =?UTF-8?q?=E2=80=94=20stale=20nb=5Fframe=20on=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The start acquisition function read acq_nb_frame and acq_interframe_volume from Node-RED globals, but the globals were written by a parallel message path. When the user changed form values and clicked Start, the read happened before the write, causing the acquisition to use stale values. Fix: start acquisition now reads from msg.payload first (carried with the command), falling back to globals for backwards compatibility. handleAcqStatusChange explicitly passes acq values in the start message. --- flows.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flows.json b/flows.json index 3e324c9..b4f9b0a 100644 --- a/flows.json +++ b/flows.json @@ -3686,7 +3686,7 @@ "width": 0, "height": 0, "head": "", - "format": "\n\n\n\n", + "format": "\n\n\n\n", "storeOutMessages": true, "passthru": true, "resendOnRefresh": true, @@ -3864,7 +3864,7 @@ "type": "function", "z": "71ede8b7dd88d90e", "name": "start acquisition", - "func": "\n\nconst acq_interframe_volume = global.get(\"acq_interframe_volume\") || 0;\nconst acq_nb_frame = global.get(\"acq_nb_frame\") || 0;\nconst acq_stabilization_delay = global.get(\"acq_stabilization_delay\") || 0;\nconst acq_interframe_flowrate = global.get(\"acq_interframe_flowrate\") || 0;\n\nmsg.payload = {\n action: \"image\",\n pump_direction: \"FORWARD\",\n volume: acq_interframe_volume,\n nb_frame: acq_nb_frame,\n sleep: acq_stabilization_delay,\n flowrate: acq_interframe_flowrate,\n};\n\nmsg.topic = \"imager/image\";\n\nreturn msg;", + "func": "const acq_interframe_volume = msg.payload.acq_interframe_volume\n || global.get(\"acq_interframe_volume\") || 0;\nconst acq_nb_frame = msg.payload.acq_nb_frame\n || global.get(\"acq_nb_frame\") || 0;\nconst acq_stabilization_delay = msg.payload.acq_stabilization_delay\n || global.get(\"acq_stabilization_delay\") || 0;\nconst acq_interframe_flowrate = msg.payload.acq_interframe_flowrate\n || global.get(\"acq_interframe_flowrate\") || 0;\n\nmsg.payload = {\n action: \"image\",\n pump_direction: \"FORWARD\",\n volume: acq_interframe_volume,\n nb_frame: acq_nb_frame,\n sleep: acq_stabilization_delay,\n flowrate: acq_interframe_flowrate,\n};\n\nmsg.topic = \"imager/image\";\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0,