From 3b11a4e97491253b7cd4a2af7ac8a01a8127c675 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 21 Mar 2026 11:20:41 +0100 Subject: [PATCH] feat: file_name combo on ProjectSource, sequence_number output - file_name is now a combo dropdown populated from the API when manager_url and project_name are set - ProjectSource outputs sequence_number (INT) for downstream use - Refreshes file list when project_name or manager_url changes - Updated tests for new output and error-default behavior Co-Authored-By: Claude Opus 4.6 --- project_loader.py | 8 ++-- tests/test_project_loader.py | 46 ++++++++++++------- web/project_source.js | 87 +++++++++++++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 23 deletions(-) diff --git a/project_loader.py b/project_loader.py index ce361d9..388885f 100644 --- a/project_loader.py +++ b/project_loader.py @@ -208,7 +208,7 @@ class ProjectLoaderDynamic: class ProjectSource: - """Config-only node — holds project connection settings, no outputs.""" + """Config node — holds project connection settings, outputs sequence_number.""" @classmethod def INPUT_TYPES(s): return { @@ -221,14 +221,14 @@ class ProjectSource: }, } - RETURN_TYPES = () - RETURN_NAMES = () + RETURN_TYPES = ("INT",) + RETURN_NAMES = ("sequence_number",) FUNCTION = "hold_config" CATEGORY = "utils/json/project" OUTPUT_NODE = True def hold_config(self, manager_url, project_name, file_name, sequence_number, label): - return () + return (sequence_number,) class ProjectKey: diff --git a/tests/test_project_loader.py b/tests/test_project_loader.py index 1caae73..d269a40 100644 --- a/tests/test_project_loader.py +++ b/tests/test_project_loader.py @@ -213,22 +213,22 @@ class TestProjectSource: assert "sequence_number" in inputs["required"] assert "label" in inputs["required"] - def test_no_outputs(self): + def test_outputs_sequence_number(self): from project_loader import ProjectSource - assert ProjectSource.RETURN_TYPES == () - assert ProjectSource.RETURN_NAMES == () + assert ProjectSource.RETURN_TYPES == ("INT",) + assert ProjectSource.RETURN_NAMES == ("sequence_number",) - def test_hold_config_returns_empty(self): + def test_hold_config_returns_sequence_number(self): from project_loader import ProjectSource node = ProjectSource() result = node.hold_config( manager_url="http://localhost:8080", project_name="proj1", file_name="batch_i2v", - sequence_number=1, + sequence_number=42, label="my_source" ) - assert result == () + assert result == (42,) def test_category(self): from project_loader import ProjectSource @@ -311,21 +311,33 @@ class TestProjectKey: ) assert result == ("",) - def test_fetch_key_network_error(self): + def test_fetch_key_network_error_returns_default(self): from project_loader import ProjectKey node = ProjectKey() error_resp = {"error": "network_error", "message": "Connection refused"} with patch("project_loader._fetch_data", return_value=error_resp): - with pytest.raises(RuntimeError, match="Failed to fetch"): - node.fetch_key( - source_label="my_source", - key_name="prompt", - key_type="STRING", - manager_url="http://localhost:8080", - project_name="proj1", - file_name="batch_i2v", - sequence_number=1, - ) + result = node.fetch_key( + source_label="my_source", + key_name="prompt", + key_type="STRING", + manager_url="http://localhost:8080", + project_name="proj1", + file_name="batch_i2v", + sequence_number=1, + ) + assert result == ("",) + + def test_fetch_key_error_returns_int_default(self): + from project_loader import ProjectKey + node = ProjectKey() + error_resp = {"error": "http_error", "status": 404, "message": "Not found"} + with patch("project_loader._fetch_data", return_value=error_resp): + result = node.fetch_key( + source_label="s", key_name="seed", key_type="INT", + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (0,) def test_category(self): from project_loader import ProjectKey diff --git a/web/project_source.js b/web/project_source.js index 8f232cb..3513f8c 100644 --- a/web/project_source.js +++ b/web/project_source.js @@ -1,4 +1,5 @@ import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; app.registerExtension({ name: "json.manager.project.source", @@ -6,6 +7,56 @@ app.registerExtension({ async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name !== "ProjectSource") return; + // Helper: replace a STRING widget with a proper combo widget + function replaceWithCombo(node, name, values, callback) { + const idx = node.widgets?.findIndex(w => w.name === name); + if (idx === -1 || idx === undefined) return null; + const oldWidget = node.widgets[idx]; + const savedValue = oldWidget.value || ""; + const comboValues = values.length > 0 ? values : [""]; + // Always preserve saved value (may not be in list yet) + if (savedValue && !comboValues.includes(savedValue)) { + comboValues.unshift(savedValue); + } + const defaultValue = savedValue || comboValues[0]; + node.widgets.splice(idx, 1); + const combo = node.addWidget("combo", name, defaultValue, callback, { values: comboValues }); + if (node.widgets.length > 1) { + node.widgets.splice(node.widgets.length - 1, 1); + node.widgets.splice(idx, 0, combo); + } + return combo; + } + + // Fetch file list from API and update file_name combo + async function refreshFiles(node) { + const urlW = node.widgets?.find(w => w.name === "manager_url"); + const projW = node.widgets?.find(w => w.name === "project_name"); + if (!urlW?.value || !projW?.value) return; + + try { + const resp = await api.fetchApi( + `/json_manager/list_project_files?url=${encodeURIComponent(urlW.value)}&project=${encodeURIComponent(projW.value)}` + ); + if (!resp.ok) return; + const data = await resp.json(); + if (!Array.isArray(data)) return; + + const fileW = node.widgets?.find(w => w.name === "file_name"); + if (fileW) { + const currentValue = fileW.value; + fileW.options.values = data.length > 0 ? data : [""]; + // Keep current selection if still valid + if (currentValue && data.includes(currentValue)) { + fileW.value = currentValue; + } + console.log(`[ProjectSource] refreshFiles: ${data.length} files loaded`); + } + } catch (e) { + console.error("[ProjectSource] Failed to refresh files:", e); + } + } + // Notify all ProjectKey nodes referencing this source to re-sync function notifyRelays(sourceNode) { if (!sourceNode.graph?._nodes) return; @@ -33,18 +84,34 @@ app.registerExtension({ const node = this; - // Hook all config widgets to notify relays on change - for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) { + // Replace file_name STRING with a combo + replaceWithCombo(this, "file_name", [], function (value) { + notifyRelays(node); + }); + + // Hook manager_url and project_name to refresh file list + notify relays + for (const name of ["manager_url", "project_name"]) { const w = this.widgets?.find(w => w.name === name); if (w) { const origCb = w.callback; w.callback = function (...args) { origCb?.apply(this, args); + refreshFiles(node); notifyRelays(node); }; } } + // Hook sequence_number to notify relays + const seqW = this.widgets?.find(w => w.name === "sequence_number"); + if (seqW) { + const origCb = seqW.callback; + seqW.callback = function (...args) { + origCb?.apply(this, args); + notifyRelays(node); + }; + } + // Update title when label changes const labelWidget = this.widgets?.find(w => w.name === "label"); if (labelWidget) { @@ -66,10 +133,26 @@ app.registerExtension({ const origOnConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function (info) { origOnConfigure?.apply(this, arguments); + + // Ensure file_name is a combo (may be STRING from serialization) + const fileW = this.widgets?.find(w => w.name === "file_name"); + if (fileW && fileW.type !== "combo") { + const node = this; + replaceWithCombo(this, "file_name", [], function (value) { + notifyRelays(node); + }); + } + const labelWidget = this.widgets?.find(w => w.name === "label"); if (labelWidget?.value) { this.title = `Source: ${labelWidget.value}`; } + + // Deferred: refresh file list once graph is ready + const node = this; + queueMicrotask(() => { + refreshFiles(node); + }); }; }, });