19 Commits

Author SHA1 Message Date
Ethanfel ba8ce45846 fix: make snapshot list fill available viewport height
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 18:23:56 +01:00
Ethanfel 1ec3abf17a feat: replace Git-DAG timeline with flat snapshot browser
Replace HistoryTree (DAG with branches, Graphviz rendering) with a flat
chronological SnapshotTimeline. New UI features: split-view layout,
snapshot compare/diff, cherry-pick restore of individual sequences or
fields, auto-snapshots with debounce, and pin/filter support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 18:14:22 +01:00
Ethanfel 686d4687c3 fix: cast sequence_number to int in fetch_key
Hidden widget sync passes sequence_number as string, causing format
code errors downstream. Cast to int before use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:42:19 +01:00
Ethanfel a37dd82ae3 fix: parse file list API response format correctly
API returns {"files": [{"name": "...", "data_type": "..."}]}, not a
plain array. Extract file names from the nested structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:26:39 +01:00
Ethanfel 3b11a4e974 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 <noreply@anthropic.com>
2026-03-21 11:20:41 +01:00
Ethanfel 5eb82f8ff6 fix: preserve saved combo value across load-order race
When ProjectKey configures before ProjectSource, _getSourceLabels
returns empty. replaceWithCombo now always keeps the saved value in
the options list so it survives the race condition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:24:17 +01:00
Ethanfel bf598ebf80 fix: make key_name selection sticky, never auto-reset
The key_name combo now only updates its dropdown options from the API
but never changes the user's selected value. Only the output value
refreshes on execution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:01:03 +01:00
Ethanfel 6e232da193 fix: remove _refreshKeys from onMouseDown to prevent key reset
The async _refreshKeys call on every mouse click caused a race condition
where clicking the key_name dropdown would trigger a re-fetch that
overwrote the user's selection. Keys are now only refreshed on source
label change and workflow load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:00:19 +01:00
Ethanfel ff5802ab63 fix: don't crash on 404, add sync debug logging
ProjectKey.fetch_key now returns empty defaults instead of raising
RuntimeError on API errors. Added logging to _syncFromSource and
fetch_key to trace the seq=4001 vs seq=4 mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 20:58:42 +01:00
Ethanfel 413e1c09e9 fix: ProjectKey pulls fresh data on click, add debug logging
ProjectKey onMouseDown now triggers _syncFromSource + _refreshKeys so
clicking a relay always picks up source config changes. Added console
logs to notifyRelays and _refreshKeys for diagnosing sync issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 20:46:50 +01:00
Ethanfel 672b28e27f feat: split lora name and strength into separate keys
Reverses the previous merge migration. Lora data is now stored as
separate keys: 'lora 1 high' (STRING name) and 'lora 1 high strength'
(FLOAT). This allows ProjectKey relay nodes to output name and strength
as properly typed separate values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:18:33 +01:00
Ethanfel 3dc91319a2 fix: notify relay nodes when source config changes
When ProjectSource widgets (url, project, file, sequence_number)
change, all ProjectKey nodes referencing that source now re-sync
and refresh their key dropdown immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:43:05 +01:00
Ethanfel bd36b4b725 fix: prevent undefined in combo widgets with empty values
Combo widgets show "undefined" when values list is empty. Now ensures
at least one entry (empty string placeholder) and picks a valid default.
Also populates source labels immediately on node creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 15:34:01 +01:00
Ethanfel 77eb3473ab fix: properly hide INT widgets and create real combo dropdowns
- INT widgets (sequence_number) now properly hidden using origType +
  hidden flag pattern from fast_saver.js
- source_label and key_name are now replaced with real combo widgets
  via addWidget("combo") instead of just setting type="combo" on
  STRING widgets, which didn't produce working dropdowns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 15:27:52 +01:00
Ethanfel 2cf8cc1f0a fix: add IS_CHANGED to ProjectKey and document source_label usage
ProjectKey fetches live API data, so it must re-execute on every queue.
Added comment explaining why source_label exists but is unused in Python.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:42:15 +01:00
Ethanfel 545b864c08 feat: add ProjectKey JS extension with source/key dropdowns
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:39:48 +01:00
Ethanfel ad6cd76b08 feat: add ProjectSource JS extension with label title sync
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:36:29 +01:00
Ethanfel bd7d314ae8 feat: add ProjectKey single-output relay node
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:34:22 +01:00
Ethanfel 628b256981 feat: add ProjectSource config-only node
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:32:47 +01:00
13 changed files with 1717 additions and 721 deletions
+59 -31
View File
@@ -81,7 +81,7 @@ class ProjectDB:
self._migrate_all_lora_data()
def _migrate_all_lora_data(self) -> None:
"""One-time bulk migration: merge separate lora strength keys in all stored sequences."""
"""Bulk migration: split combined lora 'name:strength' into separate keys."""
rows = self.conn.execute("SELECT id, data FROM sequences").fetchall()
updated = 0
self.conn.execute("BEGIN")
@@ -244,7 +244,7 @@ class ProjectDB:
@staticmethod
def _migrate_lora_keys(data: dict) -> dict:
"""Merge lora name + strength into single 'name:strength' key, remove separate strength keys."""
"""Split combined lora 'name:strength' into separate name and strength keys."""
for idx in range(1, 4):
for tier in ('high', 'low'):
name_key = f'lora {idx} {tier}'
@@ -252,16 +252,31 @@ class ProjectDB:
raw = str(data.get(name_key, ''))
if raw.startswith('<lora:'):
inner = raw.replace('<lora:', '').replace('>', '')
data[name_key] = inner
if ':' not in inner:
strength = data.pop(str_key, 1.0)
data[name_key] = f'{inner}:{float(strength)}' if inner else ''
if ':' in inner:
parts = inner.rsplit(':', 1)
data[name_key] = parts[0]
try:
data[str_key] = float(parts[1])
except ValueError:
data[str_key] = 1.0
else:
data.pop(str_key, None)
elif str_key in data:
strength = float(data.pop(str_key))
if raw and ':' not in raw:
data[name_key] = f'{raw}:{strength}'
data[name_key] = inner
if str_key not in data:
data[str_key] = 1.0
elif ':' in raw and raw:
parts = raw.rsplit(':', 1)
try:
strength = float(parts[1])
data[name_key] = parts[0]
data[str_key] = strength
except ValueError:
if str_key not in data:
data[str_key] = 1.0
elif raw:
# Name exists without colon, ensure strength key exists
if str_key not in data:
data[str_key] = 1.0
# If name is empty, don't add a strength key
return data
def get_sequence(self, data_file_id: int, sequence_number: int) -> dict | None:
@@ -324,27 +339,34 @@ class ProjectDB:
# ------------------------------------------------------------------
def save_history_tree(self, data_file_id: int, tree_data: dict) -> None:
"""Save history tree, extracting node snapshots into separate table."""
"""Save history tree, extracting snapshot data into separate table.
Supports both new format (snapshots dict) and old format (nodes dict).
"""
now = time.time()
nodes = tree_data.get("nodes", {})
if "snapshots" in tree_data:
entries = tree_data.get("snapshots", {})
entry_key = "snapshots"
else:
entries = tree_data.get("nodes", {})
entry_key = "nodes"
slim_tree = dict(tree_data)
slim_nodes = {}
for nid, node in nodes.items():
slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"}
slim_tree["nodes"] = slim_nodes
slim_entries = {}
for eid, entry in entries.items():
slim_entries[eid] = {k: v for k, v in entry.items() if k != "data"}
slim_tree[entry_key] = slim_entries
self.conn.execute("BEGIN IMMEDIATE")
try:
# Extract snapshot data from nodes into history_snapshots table
for nid, node in nodes.items():
snap = node.get("data")
for eid, entry in entries.items():
snap = entry.get("data")
if snap:
self.conn.execute(
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
(data_file_id, nid, json.dumps(snap), now),
(data_file_id, eid, json.dumps(snap), now),
)
self.conn.execute(
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
@@ -447,24 +469,30 @@ class ProjectDB:
)
# Import history tree (extract snapshots into separate table)
# Supports both new format (snapshots dict) and old format (nodes dict)
history_tree = data.get(KEY_HISTORY_TREE)
if history_tree and isinstance(history_tree, dict):
now = time.time()
nodes = history_tree.get("nodes", {})
if "snapshots" in history_tree:
entries = history_tree.get("snapshots", {})
entry_key = "snapshots"
else:
entries = history_tree.get("nodes", {})
entry_key = "nodes"
slim_tree = dict(history_tree)
slim_nodes = {}
for nid, node in nodes.items():
snap = node.get("data")
slim_entries = {}
for eid, entry in entries.items():
snap = entry.get("data")
if snap:
self.conn.execute(
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
(df_id, nid, json.dumps(snap), now),
(df_id, eid, json.dumps(snap), now),
)
slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"}
slim_tree["nodes"] = slim_nodes
slim_entries[eid] = {k: v for k, v in entry.items() if k != "data"}
slim_tree[entry_key] = slim_entries
self.conn.execute(
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
"VALUES (?, ?, ?) "
@@ -524,9 +552,9 @@ class ProjectDB:
# Load history tree (metadata only, no snapshot data)
tree = self.get_history_tree(df["id"])
if tree:
# Strip any residual snapshot data from nodes
for node in tree.get("nodes", {}).values():
node.pop("data", None)
# Strip any residual snapshot data (supports both formats)
for entry in tree.get("snapshots", tree.get("nodes", {})).values():
entry.pop("data", None)
data["history_tree"] = tree
t3 = time.time()
+8 -8
View File
@@ -295,12 +295,12 @@ def index():
sync_to_db, pane_state.db, pane_state.current_project, fp, data)
tree = data.get('history_tree')
if tree and isinstance(tree, dict):
for node in tree.get('nodes', {}).values():
node.pop('data', None)
for entry in tree.get('snapshots', tree.get('nodes', {})).values():
entry.pop('data', None)
for backup in data.get('history_tree_backup', []):
if isinstance(backup, dict):
for node in backup.get('nodes', {}).values():
node.pop('data', None)
for entry in backup.get('snapshots', backup.get('nodes', {})).values():
entry.pop('data', None)
pane_state.data_cache = data
pane_state.last_mtime = fp.stat().st_mtime if fp.exists() else 0
pane_state.loaded_file = str(fp)
@@ -339,13 +339,13 @@ def index():
sync_to_db, state.db, state.current_project, fp, data)
tree = data.get('history_tree')
if tree and isinstance(tree, dict):
for node in tree.get('nodes', {}).values():
node.pop('data', None)
for entry in tree.get('snapshots', tree.get('nodes', {})).values():
entry.pop('data', None)
# Strip snapshot data from history_tree_backup to prevent RAM/disk bloat
for backup in data.get('history_tree_backup', []):
if isinstance(backup, dict):
for node in backup.get('nodes', {}).values():
node.pop('data', None)
for entry in backup.get('snapshots', backup.get('nodes', {})).values():
entry.pop('data', None)
state.data_cache = data
state.last_mtime = fp.stat().st_mtime if fp.exists() else 0
state.loaded_file = str(fp)
+90
View File
@@ -207,11 +207,101 @@ class ProjectLoaderDynamic:
return (total_sequences,) + tuple(results)
class ProjectSource:
"""Config node — holds project connection settings, outputs sequence_number."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
"project_name": ("STRING", {"default": "", "multiline": False}),
"file_name": ("STRING", {"default": "", "multiline": False}),
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
"label": ("STRING", {"default": "source", "multiline": False}),
},
}
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 (sequence_number,)
class ProjectKey:
"""Single-output relay — fetches one key from a ProjectSource."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"source_label": ("STRING", {"default": "", "multiline": False}),
"key_name": ("STRING", {"default": "", "multiline": False}),
"key_type": ("STRING", {"default": "STRING", "multiline": False}),
},
"optional": {
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
"project_name": ("STRING", {"default": "", "multiline": False}),
"file_name": ("STRING", {"default": "", "multiline": False}),
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
},
}
RETURN_TYPES = (any_type,)
RETURN_NAMES = ("value",)
FUNCTION = "fetch_key"
CATEGORY = "utils/json/project"
OUTPUT_NODE = False
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan") # Always re-fetch from API
def fetch_key(self, source_label, key_name, key_type,
manager_url="http://localhost:8080", project_name="",
file_name="", sequence_number=1):
# source_label is used by JS to identify which ProjectSource to sync
# config from. The actual config arrives via the optional widgets below.
sequence_number = int(sequence_number)
logger.info("ProjectKey.fetch_key: source=%s key=%s url=%s project=%s file=%s seq=%s",
source_label, key_name, manager_url, project_name, file_name, sequence_number)
data = _fetch_data(manager_url, project_name, file_name, sequence_number)
if data.get("error") in ("http_error", "network_error", "parse_error"):
msg = data.get("message", "Unknown error")
logger.warning("ProjectKey.fetch_key failed: %s", msg)
# Return empty/default instead of crashing the workflow
if key_type == "INT":
return (0,)
elif key_type == "FLOAT":
return (0.0,)
else:
return ("",)
val = data.get(key_name, "")
if key_type == "INT":
return (to_int(val),)
elif key_type == "FLOAT":
return (to_float(val),)
elif isinstance(val, bool):
return (str(val).lower(),)
elif isinstance(val, (int, float)):
return (val,)
else:
return (str(val),)
# --- Mappings ---
PROJECT_NODE_CLASS_MAPPINGS = {
"ProjectLoaderDynamic": ProjectLoaderDynamic,
"ProjectSource": ProjectSource,
"ProjectKey": ProjectKey,
}
PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
"ProjectLoaderDynamic": "Project Loader (Dynamic)",
"ProjectSource": "Project Source",
"ProjectKey": "Project Key",
}
+184
View File
@@ -0,0 +1,184 @@
import time
import uuid
from typing import Any
KEY_PROMPT_HISTORY = "prompt_history"
class SnapshotTimeline:
"""Flat chronological snapshot list — replaces the old HistoryTree DAG."""
def __init__(self, raw_data: dict[str, Any]) -> None:
# Detect and migrate old HistoryTree format
if "nodes" in raw_data and "branches" in raw_data:
self._migrate_from_tree(raw_data)
elif KEY_PROMPT_HISTORY in raw_data and isinstance(raw_data[KEY_PROMPT_HISTORY], list):
self._migrate_legacy(raw_data[KEY_PROMPT_HISTORY])
else:
self.snapshots: dict[str, dict[str, Any]] = raw_data.get("snapshots", {})
self.current_id: str | None = raw_data.get("current_id", None)
# ------------------------------------------------------------------
# Migration
# ------------------------------------------------------------------
def _migrate_from_tree(self, raw_data: dict[str, Any]) -> None:
"""Flatten old HistoryTree nodes into snapshot list, discarding DAG info."""
self.snapshots = {}
nodes = raw_data.get("nodes", {})
for nid, node in nodes.items():
self.snapshots[nid] = {
"id": nid,
"timestamp": node.get("timestamp", time.time()),
"note": node.get("note", "Migrated"),
"pinned": False,
"auto": False,
"seq_count": self._count_seqs(node.get("data")),
}
# Preserve snapshot data if present
if "data" in node and node["data"]:
self.snapshots[nid]["data"] = node["data"]
self.current_id = raw_data.get("head_id")
def _migrate_legacy(self, old_list: list[dict[str, Any]]) -> None:
"""Convert ancient prompt_history list into snapshots."""
self.snapshots = {}
self.current_id = None
for item in reversed(old_list):
sid = self._make_id()
self.snapshots[sid] = {
"id": sid,
"timestamp": time.time(),
"note": item.get("note", "Legacy Import"),
"pinned": False,
"auto": False,
"seq_count": self._count_seqs(item),
"data": item,
}
self.current_id = sid
# ------------------------------------------------------------------
# Core operations
# ------------------------------------------------------------------
def record(self, data: dict[str, Any], note: str = "Snapshot",
auto: bool = False) -> str:
"""Create a new snapshot and return its ID."""
sid = self._make_id()
self.snapshots[sid] = {
"id": sid,
"timestamp": time.time(),
"note": note,
"pinned": False,
"auto": auto,
"seq_count": self._count_seqs(data),
"data": data,
}
self.current_id = sid
return sid
def get_snapshot_data(self, snapshot_id: str) -> dict[str, Any] | None:
"""Return the inline snapshot data if present."""
snap = self.snapshots.get(snapshot_id)
if snap:
return snap.get("data")
return None
def toggle_pin(self, snapshot_id: str) -> bool:
"""Toggle pinned state, return new value."""
snap = self.snapshots.get(snapshot_id)
if snap:
snap["pinned"] = not snap.get("pinned", False)
return snap["pinned"]
return False
def delete(self, snapshot_id: str) -> None:
"""Remove a snapshot."""
self.snapshots.pop(snapshot_id, None)
if self.current_id == snapshot_id:
# Fall back to most recent remaining
if self.snapshots:
self.current_id = max(
self.snapshots.values(), key=lambda s: s["timestamp"]
)["id"]
else:
self.current_id = None
def strip_snapshots(self) -> None:
"""Remove inline data from all snapshots (for slim JSON storage)."""
for snap in self.snapshots.values():
snap.pop("data", None)
# ------------------------------------------------------------------
# Serialization
# ------------------------------------------------------------------
def to_dict(self) -> dict[str, Any]:
return {
"snapshots": self.snapshots,
"current_id": self.current_id,
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _make_id(self) -> str:
for _ in range(10):
sid = str(uuid.uuid4())[:8]
if sid not in self.snapshots:
return sid
raise ValueError("Failed to generate unique snapshot ID after 10 attempts")
@staticmethod
def _count_seqs(data: dict | None) -> int:
if not data:
return 0
from utils import KEY_BATCH_DATA
batch = data.get(KEY_BATCH_DATA, [])
return len(batch) if isinstance(batch, list) else 0
# ------------------------------------------------------------------
# Diff function
# ------------------------------------------------------------------
def diff_snapshots(old_batch: list[dict], new_batch: list[dict]) -> list[dict]:
"""Compare two batch lists by sequence_number, return per-sequence diffs.
Returns a list of dicts:
{
"seq_num": int,
"status": "unchanged" | "changed" | "added" | "removed",
"changes": [{"field": str, "old": Any, "new": Any}],
}
"""
from utils import KEY_SEQUENCE_NUMBER
old_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in old_batch}
new_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in new_batch}
all_seqs = sorted(set(old_by_seq) | set(new_by_seq))
result = []
for seq_num in all_seqs:
old_item = old_by_seq.get(seq_num)
new_item = new_by_seq.get(seq_num)
if old_item and not new_item:
result.append({"seq_num": seq_num, "status": "removed", "changes": []})
elif new_item and not old_item:
result.append({"seq_num": seq_num, "status": "added", "changes": []})
else:
# Both exist — field-by-field comparison
all_keys = sorted(set(old_item) | set(new_item))
changes = []
for k in all_keys:
old_val = old_item.get(k)
new_val = new_item.get(k)
if old_val != new_val:
changes.append({"field": k, "old": old_val, "new": new_val})
status = "changed" if changes else "unchanged"
result.append({"seq_num": seq_num, "status": status, "changes": changes})
return result
+1 -1
View File
@@ -13,7 +13,7 @@ class AppState:
snippets: dict = field(default_factory=dict)
file_path: Path | None = None
restored_indicator: str | None = None
timeline_selected_nodes: set = field(default_factory=set)
timeline_selected_id: str | None = None
live_toggles: dict = field(default_factory=dict)
show_comfy_monitor: bool = True
+55 -48
View File
@@ -16,9 +16,11 @@ from utils import (
DEFAULTS, save_json, load_json, sync_to_db,
KEY_BATCH_DATA, KEY_HISTORY_TREE, KEY_PROMPT_HISTORY, KEY_SEQUENCE_NUMBER,
)
from history_tree import HistoryTree
from snapshot_timeline import SnapshotTimeline
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'}
_AUTO_SNAP_DEBOUNCE = 30 # seconds between auto-snapshots
_last_auto_snap: dict[str, float] = {} # file_path -> timestamp
SUB_SEGMENT_MULTIPLIER = 1000
SUB_SEGMENT_NUM_COLORS = 6
FRAME_TO_SKIP_DEFAULT = DEFAULTS['frame_to_skip']
@@ -86,18 +88,18 @@ def find_insert_position(batch_list, parent_index, parent_seq_num):
# --- Auto change note ---
def _auto_change_note(htree, batch_list, state=None, file_path=None):
def _auto_change_note(timeline, batch_list, state=None, file_path=None):
"""Compare current batch_list against last snapshot and describe changes."""
# Get previous batch data from the current head
if not htree.head_id or htree.head_id not in htree.nodes:
# Get previous batch data from the current snapshot
if not timeline.current_id or timeline.current_id not in timeline.snapshots:
return f'Initial save ({len(batch_list)} sequences)'
# Load previous snapshot from DB (nodes no longer hold data in memory)
prev_data = htree.nodes[htree.head_id].get('data')
# Load previous snapshot from inline data or DB
prev_data = timeline.get_snapshot_data(timeline.current_id)
if not prev_data and state and state.db_enabled and state.db and state.current_project and file_path:
df = state.db.get_data_file_by_names(state.current_project, file_path.stem)
if df:
prev_data = state.db.get_node_snapshot(df['id'], htree.head_id)
prev_data = state.db.get_node_snapshot(df['id'], timeline.current_id)
prev_batch = (prev_data or {}).get(KEY_BATCH_DATA, [])
prev_by_seq = {int(s.get(KEY_SEQUENCE_NUMBER, 0)): s for s in prev_batch}
@@ -306,9 +308,9 @@ def render_batch_processor(state: AppState):
ui.button('From Source', icon='file_download', on_click=add_from_source)
# --- Standard / LoRA / VACE key sets ---
lora_keys = ['lora 1 high', 'lora 1 low',
'lora 2 high', 'lora 2 low',
'lora 3 high', 'lora 3 low']
lora_keys = ['lora 1 high', 'lora 1 high strength', 'lora 1 low', 'lora 1 low strength',
'lora 2 high', 'lora 2 high strength', 'lora 2 low', 'lora 2 low strength',
'lora 3 high', 'lora 3 high strength', 'lora 3 low', 'lora 3 low strength']
standard_keys = {
'name', 'mode', 'general_prompt', 'general_negative', 'current_prompt', 'negative', 'prompt',
'seed', 'cfg', 'camera', 'flf', KEY_SEQUENCE_NUMBER,
@@ -363,38 +365,34 @@ def render_batch_processor(state: AppState):
logger.info("save_and_snap START")
data[KEY_BATCH_DATA] = batch_list
tree_data = data.get(KEY_HISTORY_TREE, {})
htree = HistoryTree(tree_data)
note = commit_input.value if commit_input.value else _auto_change_note(htree, batch_list, state=state, file_path=file_path)
timeline = SnapshotTimeline(tree_data)
note = commit_input.value if commit_input.value else _auto_change_note(timeline, batch_list, state=state, file_path=file_path)
# Single serialization: json roundtrip gives us an isolated snapshot
# without the expensive deepcopy
t1 = time.perf_counter()
snapshot_json = json.dumps({k: v for k, v in data.items()
if k != KEY_HISTORY_TREE})
snapshot_payload = json.loads(snapshot_json)
logger.info("save_and_snap snapshot %.3fs", time.perf_counter() - t1)
try:
htree.commit(snapshot_payload, note=note)
timeline.record(snapshot_payload, note=note)
except ValueError as e:
ui.notify(f'Save failed: {e}', type='negative')
return
if state.db_enabled and state.current_project and state.db:
# DB path: sync full tree (with snapshots) to DB, then
# write slim tree (no snapshots) to JSON and memory
full_tree = htree.to_dict()
full_tree = timeline.to_dict()
data[KEY_HISTORY_TREE] = full_tree
t1 = time.perf_counter()
db_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snapshot)
logger.info("save_and_snap sync_to_db %.3fs", time.perf_counter() - t1)
htree.strip_snapshots()
data[KEY_HISTORY_TREE] = htree.to_dict()
timeline.strip_snapshots()
data[KEY_HISTORY_TREE] = timeline.to_dict()
t1 = time.perf_counter()
slim_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, slim_snapshot)
logger.info("save_and_snap save_json %.3fs", time.perf_counter() - t1)
else:
# No DB: write full tree (with snapshots) to JSON
data[KEY_HISTORY_TREE] = htree.to_dict()
data[KEY_HISTORY_TREE] = timeline.to_dict()
t1 = time.perf_counter()
save_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, save_snapshot)
@@ -416,9 +414,30 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
refresh_list):
async def commit(message=None):
data[KEY_BATCH_DATA] = batch_list
# Auto-snapshot with debounce
fp_key = str(file_path)
now = time.time()
did_snap = False
if now - _last_auto_snap.get(fp_key, 0) >= _AUTO_SNAP_DEBOUNCE:
timeline = SnapshotTimeline(data.get(KEY_HISTORY_TREE, {}))
snap_json = json.dumps({k: v for k, v in data.items()
if k != KEY_HISTORY_TREE})
snap_payload = json.loads(snap_json)
try:
timeline.record(snap_payload, note=message or "Auto-save", auto=True)
if state.db_enabled and state.current_project and state.db:
data[KEY_HISTORY_TREE] = timeline.to_dict()
db_snap = json.loads(json.dumps(data))
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snap)
timeline.strip_snapshots()
did_snap = True
data[KEY_HISTORY_TREE] = timeline.to_dict()
_last_auto_snap[fp_key] = now
except ValueError:
pass # Non-critical: skip auto-snapshot on ID collision
snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, snapshot)
if state.db_enabled and state.current_project and state.db:
if state.db_enabled and state.current_project and state.db and not did_snap:
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, snapshot)
if message:
ui.notify(message, type='positive')
@@ -592,23 +611,12 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
for tier, tier_label in [('high', 'High'), ('low', 'Low')]:
lora_key = f'lora {lora_idx} {tier}'
# Parse combined name:strength value
raw = str(seq.get(lora_key, ''))
# Handle legacy <lora:> wrapper
if raw.startswith('<lora:'):
raw = raw.replace('<lora:', '').replace('>', '')
# Also remove stale separate strength key if present
seq.pop(f'lora {lora_idx} {tier} strength', None)
if ':' in raw and raw:
parts = raw.rsplit(':', 1)
lora_name = parts[0]
lora_name = str(seq.get(lora_key, ''))
strength_key = f'lora {lora_idx} {tier} strength'
lora_strength = seq.get(strength_key, 1.0)
try:
lora_strength = float(parts[1])
except ValueError:
lora_strength = 1.0
else:
lora_name = raw
lora_strength = float(lora_strength)
except (ValueError, TypeError):
lora_strength = 1.0
with ui.row().classes('w-full items-center q-gutter-sm'):
@@ -625,10 +633,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
format='%.1f',
).props('outlined dense').style('max-width: 80px')
def _lora_sync(k=lora_key, n_inp=name_input, s_inp=strength_input):
name = n_inp.value or ''
strength = s_inp.value if s_inp.value is not None else 1.0
seq[k] = f'{name}:{strength}' if name else ''
def _lora_sync(k=lora_key, sk=strength_key, n_inp=name_input, s_inp=strength_input):
seq[k] = n_inp.value or ''
seq[sk] = float(s_inp.value) if s_inp.value is not None else 1.0
name_input.on('blur', lambda _, s=_lora_sync: s())
name_input.on('update:model-value', lambda _, s=_lora_sync: s())
@@ -857,26 +864,26 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li
batch_list[idx][key] = copy.deepcopy(source_seq.get(key))
data[KEY_BATCH_DATA] = batch_list
htree = HistoryTree(data.get(KEY_HISTORY_TREE, {}))
timeline = SnapshotTimeline(data.get(KEY_HISTORY_TREE, {}))
snapshot_json = json.dumps({k: v for k, v in data.items()
if k != KEY_HISTORY_TREE})
snapshot = json.loads(snapshot_json)
try:
htree.commit(snapshot, f"Mass update: {', '.join(selected_keys)}")
timeline.record(snapshot, f"Mass update: {', '.join(selected_keys)}")
except ValueError as e:
ui.notify(f'Mass update failed: {e}', type='negative')
return
if state.db_enabled and state.current_project and state.db:
full_tree = htree.to_dict()
full_tree = timeline.to_dict()
data[KEY_HISTORY_TREE] = full_tree
db_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, db_snapshot)
htree.strip_snapshots()
data[KEY_HISTORY_TREE] = htree.to_dict()
timeline.strip_snapshots()
data[KEY_HISTORY_TREE] = timeline.to_dict()
slim_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, slim_snapshot)
else:
data[KEY_HISTORY_TREE] = htree.to_dict()
data[KEY_HISTORY_TREE] = timeline.to_dict()
save_snapshot = json.loads(json.dumps(data))
await asyncio.to_thread(save_json, file_path, save_snapshot)
ui.notify(f'Updated {len(targets)} sequences', type='positive')
+502 -576
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -208,10 +208,10 @@ class TestHistoryTrees:
def test_upsert_updates(self, db):
pid = db.create_project("p1", "/p1")
df_id = db.create_data_file(pid, "batch", "generic")
db.save_history_tree(df_id, {"v": 1})
db.save_history_tree(df_id, {"v": 2})
db.save_history_tree(df_id, {"snapshots": {}, "v": 1})
db.save_history_tree(df_id, {"snapshots": {}, "v": 2})
result = db.get_history_tree(df_id)
assert result == {"v": 2}
assert result == {"snapshots": {}, "v": 2}
def test_get_nonexistent(self, db):
pid = db.create_project("p1", "/p1")
+145 -2
View File
@@ -203,9 +203,152 @@ class TestProjectLoaderDynamic:
assert ProjectLoaderDynamic.CATEGORY == "utils/json/project"
class TestProjectSource:
def test_input_types(self):
from project_loader import ProjectSource
inputs = ProjectSource.INPUT_TYPES()
assert "manager_url" in inputs["required"]
assert "project_name" in inputs["required"]
assert "file_name" in inputs["required"]
assert "sequence_number" in inputs["required"]
assert "label" in inputs["required"]
def test_outputs_sequence_number(self):
from project_loader import ProjectSource
assert ProjectSource.RETURN_TYPES == ("INT",)
assert ProjectSource.RETURN_NAMES == ("sequence_number",)
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=42,
label="my_source"
)
assert result == (42,)
def test_category(self):
from project_loader import ProjectSource
assert ProjectSource.CATEGORY == "utils/json/project"
class TestProjectKey:
def test_input_types(self):
from project_loader import ProjectKey
inputs = ProjectKey.INPUT_TYPES()
assert "source_label" in inputs["required"]
assert "key_name" in inputs["required"]
assert "key_type" in inputs["required"]
def test_single_output(self):
from project_loader import ProjectKey
assert len(ProjectKey.RETURN_TYPES) == 1
assert len(ProjectKey.RETURN_NAMES) == 1
def test_fetch_key_string(self):
from project_loader import ProjectKey
node = ProjectKey()
data = {"prompt": "hello", "seed": 42}
with patch("project_loader._fetch_data", return_value=data):
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 == ("hello",)
def test_fetch_key_int_coercion(self):
from project_loader import ProjectKey
node = ProjectKey()
data = {"seed": "42"}
with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_key(
source_label="my_source",
key_name="seed",
key_type="INT",
manager_url="http://localhost:8080",
project_name="proj1",
file_name="batch_i2v",
sequence_number=1,
)
assert result == (42,)
def test_fetch_key_float_coercion(self):
from project_loader import ProjectKey
node = ProjectKey()
data = {"cfg": "1.5"}
with patch("project_loader._fetch_data", return_value=data):
result = node.fetch_key(
source_label="my_source",
key_name="cfg",
key_type="FLOAT",
manager_url="http://localhost:8080",
project_name="proj1",
file_name="batch_i2v",
sequence_number=1,
)
assert result == (1.5,)
def test_fetch_key_missing_key(self):
from project_loader import ProjectKey
node = ProjectKey()
with patch("project_loader._fetch_data", return_value={}):
result = node.fetch_key(
source_label="my_source",
key_name="nonexistent",
key_type="STRING",
manager_url="http://localhost:8080",
project_name="proj1",
file_name="batch_i2v",
sequence_number=1,
)
assert result == ("",)
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):
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
assert ProjectKey.CATEGORY == "utils/json/project"
class TestNodeMappings:
def test_mappings_exist(self):
from project_loader import PROJECT_NODE_CLASS_MAPPINGS, PROJECT_NODE_DISPLAY_NAME_MAPPINGS
assert "ProjectLoaderDynamic" in PROJECT_NODE_CLASS_MAPPINGS
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 1
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 1
assert "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS
assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 3
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 3
+159
View File
@@ -0,0 +1,159 @@
import pytest
from snapshot_timeline import SnapshotTimeline, diff_snapshots
def test_record_creates_snapshot():
tl = SnapshotTimeline({})
sid = tl.record({"batch_data": [{"seed": 42}]}, note="first")
assert sid in tl.snapshots
assert tl.current_id == sid
assert tl.snapshots[sid]["note"] == "first"
assert tl.snapshots[sid]["auto"] is False
assert tl.snapshots[sid]["seq_count"] == 1
def test_record_auto_flag():
tl = SnapshotTimeline({})
sid = tl.record({"batch_data": []}, note="auto save", auto=True)
assert tl.snapshots[sid]["auto"] is True
def test_multiple_records():
tl = SnapshotTimeline({})
id1 = tl.record({"batch_data": [{"a": 1}]}, note="one")
id2 = tl.record({"batch_data": [{"b": 2}]}, note="two")
assert len(tl.snapshots) == 2
assert tl.current_id == id2
def test_to_dict_roundtrip():
tl = SnapshotTimeline({})
tl.record({"batch_data": [{"x": 1}]}, note="test")
d = tl.to_dict()
tl2 = SnapshotTimeline(d)
assert tl2.current_id == tl.current_id
assert set(tl2.snapshots.keys()) == set(tl.snapshots.keys())
def test_migrate_from_history_tree():
"""Old HistoryTree format should be flattened into snapshots."""
old_data = {
"nodes": {
"aaa": {"id": "aaa", "parent": None, "timestamp": 1000, "note": "First", "data": {"batch_data": [{"seed": 1}]}},
"bbb": {"id": "bbb", "parent": "aaa", "timestamp": 2000, "note": "Second", "data": {"batch_data": [{"seed": 2}]}},
},
"branches": {"main": "bbb"},
"head_id": "bbb",
}
tl = SnapshotTimeline(old_data)
assert len(tl.snapshots) == 2
assert tl.current_id == "bbb"
assert tl.snapshots["aaa"]["note"] == "First"
assert tl.snapshots["bbb"]["note"] == "Second"
# Data should be preserved
assert tl.snapshots["aaa"]["data"]["batch_data"] == [{"seed": 1}]
def test_migrate_from_history_tree_no_data():
"""Slim tree nodes (no inline data) should still migrate."""
old_data = {
"nodes": {
"aaa": {"id": "aaa", "parent": None, "timestamp": 1000, "note": "First"},
},
"branches": {"main": "aaa"},
"head_id": "aaa",
}
tl = SnapshotTimeline(old_data)
assert len(tl.snapshots) == 1
assert tl.snapshots["aaa"]["seq_count"] == 0
def test_migrate_legacy_prompt_history():
legacy = {
"prompt_history": [
{"note": "A", "seed": 1},
{"note": "B", "seed": 2},
]
}
tl = SnapshotTimeline(legacy)
assert len(tl.snapshots) == 2
assert tl.current_id is not None
def test_toggle_pin():
tl = SnapshotTimeline({})
sid = tl.record({"batch_data": []}, note="test")
assert tl.snapshots[sid]["pinned"] is False
result = tl.toggle_pin(sid)
assert result is True
assert tl.snapshots[sid]["pinned"] is True
result = tl.toggle_pin(sid)
assert result is False
def test_delete_snapshot():
tl = SnapshotTimeline({})
id1 = tl.record({"batch_data": []}, note="one")
id2 = tl.record({"batch_data": []}, note="two")
tl.delete(id2)
assert id2 not in tl.snapshots
assert tl.current_id == id1
def test_delete_all_snapshots():
tl = SnapshotTimeline({})
sid = tl.record({"batch_data": []}, note="only")
tl.delete(sid)
assert len(tl.snapshots) == 0
assert tl.current_id is None
def test_strip_snapshots():
tl = SnapshotTimeline({})
tl.record({"batch_data": [{"a": 1}]}, note="test")
tl.strip_snapshots()
for snap in tl.snapshots.values():
assert "data" not in snap
def test_get_snapshot_data():
tl = SnapshotTimeline({})
sid = tl.record({"batch_data": [{"x": 1}]}, note="test")
data = tl.get_snapshot_data(sid)
assert data == {"batch_data": [{"x": 1}]}
assert tl.get_snapshot_data("nonexistent") is None
# --- diff_snapshots tests ---
def test_diff_unchanged():
batch = [{"sequence_number": 1, "seed": 42}]
result = diff_snapshots(batch, batch)
assert len(result) == 1
assert result[0]["status"] == "unchanged"
assert result[0]["changes"] == []
def test_diff_changed():
old = [{"sequence_number": 1, "seed": 42, "cfg": 1.5}]
new = [{"sequence_number": 1, "seed": 99, "cfg": 1.5}]
result = diff_snapshots(old, new)
assert result[0]["status"] == "changed"
assert len(result[0]["changes"]) == 1
assert result[0]["changes"][0]["field"] == "seed"
assert result[0]["changes"][0]["old"] == 42
assert result[0]["changes"][0]["new"] == 99
def test_diff_added_and_removed():
old = [{"sequence_number": 1, "seed": 1}]
new = [{"sequence_number": 2, "seed": 2}]
result = diff_snapshots(old, new)
assert len(result) == 2
statuses = {r["seq_num"]: r["status"] for r in result}
assert statuses[1] == "removed"
assert statuses[2] == "added"
def test_diff_empty():
assert diff_snapshots([], []) == []
+62 -36
View File
@@ -49,13 +49,19 @@ DEFAULTS = {
"reference path": "",
"flf image path": "",
# --- LoRAs (format: "name:strength" or empty) ---
# --- LoRAs (name as STRING, strength as FLOAT) ---
"lora 1 high": "",
"lora 1 high strength": 1.0,
"lora 1 low": "",
"lora 1 low strength": 1.0,
"lora 2 high": "",
"lora 2 high strength": 1.0,
"lora 2 low": "",
"lora 2 low strength": 1.0,
"lora 3 high": "",
"lora 3 low": ""
"lora 3 high strength": 1.0,
"lora 3 low": "",
"lora 3 low strength": 1.0
}
CONFIG_FILE = Path(".editor_config.json")
@@ -145,12 +151,12 @@ def save_snippets(snippets):
os.replace(tmp, SNIPPETS_FILE)
def _migrate_lora_keys(data: dict) -> None:
"""Merge lora name + strength into single 'name:strength' key, remove separate strength keys.
"""Split combined lora 'name:strength' into separate name and strength keys.
Handles three legacy formats:
1. <lora:Name:0.5> → Name:0.5
2. Separate name_key + str_key → name:strength (then delete str_key)
3. Already merged name:strength → no change
Handles legacy formats:
1. <lora:Name:0.5> → name_key='Name', str_key=0.5
2. 'Name:0.5' (merged) → name_key='Name', str_key=0.5
3. Already split (name_key + str_key exist) → no change
"""
for item in data.get(KEY_BATCH_DATA, []):
if not isinstance(item, dict):
@@ -162,24 +168,35 @@ def _migrate_lora_keys(data: dict) -> None:
raw = str(item.get(name_key, ''))
if raw.startswith('<lora:'):
# Legacy <lora:Name:0.5> format → Name:0.5
# Legacy <lora:Name:0.5> format
inner = raw.replace('<lora:', '').replace('>', '')
item[name_key] = inner # already name:strength or just name
if ':' not in inner:
# No strength in the wrapper, check separate key
strength = item.pop(str_key, 1.0)
item[name_key] = f'{inner}:{float(strength)}' if inner else ''
if ':' in inner:
parts = inner.rsplit(':', 1)
item[name_key] = parts[0]
try:
item[str_key] = float(parts[1])
except ValueError:
item[str_key] = 1.0
else:
item.pop(str_key, None)
elif str_key in item:
# Separate strength key exists → merge into name:strength
strength = float(item.pop(str_key))
if raw:
# Avoid double-merging if already has name:strength format
if ':' not in raw:
item[name_key] = f'{raw}:{strength}'
# else: already merged, just remove the stale strength key
# No change needed if already in name:strength format or empty
item[name_key] = inner
if str_key not in item:
item[str_key] = 1.0
elif ':' in raw and raw:
# Combined 'name:strength' format → split
parts = raw.rsplit(':', 1)
try:
strength = float(parts[1])
item[name_key] = parts[0]
item[str_key] = strength
except ValueError:
# Not a valid strength, leave as-is
if str_key not in item:
item[str_key] = 1.0
elif raw:
# Name exists without colon, ensure strength key exists
if str_key not in item:
item[str_key] = 1.0
# If name is empty, don't add a strength key
def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
@@ -288,38 +305,47 @@ def sync_to_db(db, project_name: str, file_path: Path, data: dict) -> None:
else:
db.conn.execute("DELETE FROM sequences WHERE data_file_id = ?", (df_id,))
# Sync history tree (extract node snapshots into separate table)
# Sync history tree (extract snapshot data into separate table)
# Supports both new format (snapshots dict) and old format (nodes dict)
history_tree = data.get(KEY_HISTORY_TREE)
if history_tree and isinstance(history_tree, dict):
nodes = history_tree.get("nodes", {})
# Detect format: new has "snapshots", old has "nodes"
if "snapshots" in history_tree:
entries = history_tree.get("snapshots", {})
else:
entries = history_tree.get("nodes", {})
slim_tree = dict(history_tree)
slim_nodes = {}
for nid, node in nodes.items():
snap = node.get("data")
slim_entries = {}
for eid, entry in entries.items():
snap = entry.get("data")
if snap:
db.conn.execute(
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
(df_id, nid, json.dumps(snap), now),
(df_id, eid, json.dumps(snap), now),
)
slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"}
slim_tree["nodes"] = slim_nodes
slim_entries[eid] = {k: v for k, v in entry.items() if k != "data"}
# Write back slim version using the correct key
if "snapshots" in history_tree:
slim_tree["snapshots"] = slim_entries
else:
slim_tree["nodes"] = slim_entries
db.conn.execute(
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
"VALUES (?, ?, ?) "
"ON CONFLICT(data_file_id) DO UPDATE SET tree_data=excluded.tree_data, updated_at=excluded.updated_at",
(df_id, json.dumps(slim_tree), now),
)
# Clean up orphaned snapshots for nodes no longer in tree
current_node_ids = set(nodes.keys())
if current_node_ids:
placeholders = ",".join("?" for _ in current_node_ids)
# Clean up orphaned snapshots
current_ids = set(entries.keys())
if current_ids:
placeholders = ",".join("?" for _ in current_ids)
db.conn.execute(
f"DELETE FROM history_snapshots WHERE data_file_id = ? "
f"AND node_id NOT IN ({placeholders})",
(df_id, *current_node_ids),
(df_id, *current_ids),
)
else:
db.conn.execute(
+275
View File
@@ -0,0 +1,275 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "json.manager.project.key",
// Re-sync all ProjectKey nodes from their sources before queueing
// This fixes stale config when the user edits a ProjectSource after
// a ProjectKey already selected it.
async beforeQueuePrompt() {
if (!app.graph?._nodes) return;
for (const node of app.graph._nodes) {
if (node.type === "ProjectKey" && node._syncFromSource) {
node._syncFromSource();
}
}
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "ProjectKey") return;
// Helper: properly hide a widget (works for all types including INT)
function hideWidget(widget) {
if (widget.origType === undefined) widget.origType = widget.type;
widget.type = "hidden";
widget.hidden = true;
widget.computeSize = () => [0, -4];
}
// 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 || "";
// Ensure values list is never empty (combo shows undefined otherwise)
const comboValues = values.length > 0 ? values : [""];
// Always preserve saved value — it may not be in the list yet (load-order race)
if (savedValue && !comboValues.includes(savedValue)) {
comboValues.unshift(savedValue);
}
const defaultValue = savedValue || comboValues[0];
// Remove old STRING widget
node.widgets.splice(idx, 1);
// Insert a real combo widget at the same position
const combo = node.addWidget("combo", name, defaultValue, callback, { values: comboValues });
// Move it from the end to the original position
if (node.widgets.length > 1) {
node.widgets.splice(node.widgets.length - 1, 1);
node.widgets.splice(idx, 0, combo);
}
return combo;
}
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origOnNodeCreated?.apply(this, arguments);
this._configured = false;
// Hide the connection-config widgets (synced from source by JS)
for (const name of ["manager_url", "project_name", "file_name", "sequence_number", "key_type"]) {
const w = this.widgets?.find(w => w.name === name);
if (w) hideWidget(w);
}
// Replace source_label STRING with a proper combo widget
const node = this;
const sourceLabels = this._getSourceLabels?.() || [];
const srcCombo = replaceWithCombo(this, "source_label", sourceLabels, function (value) {
node._syncFromSource();
node._refreshKeys();
});
// Set first available source or "none" placeholder
if (srcCombo) srcCombo.value = sourceLabels[0] || "";
// Replace key_name STRING with a proper combo widget
const keyCombo = replaceWithCombo(this, "key_name", [], function (value) {
node._applyKeySelection();
});
if (keyCombo) keyCombo.value = "";
queueMicrotask(() => {
if (!this._configured) {
// New node — set output to a generic slot
if (this.outputs.length === 0) {
this.addOutput("value", "*");
}
this.setSize(this.computeSize());
}
});
};
// --- Find all ProjectSource nodes and their labels (deduplicated) ---
nodeType.prototype._getSourceLabels = function () {
const seen = new Set();
const labels = [];
if (!this.graph) return labels;
for (const node of this.graph._nodes) {
if (node.type === "ProjectSource") {
const lw = node.widgets?.find(w => w.name === "label");
if (lw?.value && !seen.has(lw.value)) {
seen.add(lw.value);
labels.push(lw.value);
} else if (lw?.value && seen.has(lw.value)) {
console.warn(`[ProjectKey] Duplicate source label "${lw.value}" (node ${node.id}) — only first will be used`);
}
}
}
return labels;
};
// --- Find the ProjectSource node matching a label ---
nodeType.prototype._findSource = function (label) {
if (!this.graph || !label) return null;
for (const node of this.graph._nodes) {
if (node.type === "ProjectSource") {
const lw = node.widgets?.find(w => w.name === "label");
if (lw?.value === label) return node;
}
}
return null;
};
// --- Copy config from source node into hidden widgets ---
nodeType.prototype._syncFromSource = function () {
const srcWidget = this.widgets?.find(w => w.name === "source_label");
const source = this._findSource(srcWidget?.value);
if (!source) {
console.log(`[ProjectKey] _syncFromSource id=${this.id}: no source found for label="${srcWidget?.value}"`);
return;
}
for (const name of ["manager_url", "project_name", "file_name", "sequence_number"]) {
const dst = this.widgets?.find(w => w.name === name);
const src = source.widgets?.find(w => w.name === name);
if (dst && src) {
dst.value = src.value;
console.log(`[ProjectKey] _syncFromSource id=${this.id}: ${name}="${src.value}"`);
}
}
};
// --- Fetch keys from API and populate key_name dropdown ---
nodeType.prototype._refreshKeys = async function () {
const urlW = this.widgets?.find(w => w.name === "manager_url");
const projW = this.widgets?.find(w => w.name === "project_name");
const fileW = this.widgets?.find(w => w.name === "file_name");
const seqW = this.widgets?.find(w => w.name === "sequence_number");
console.log(`[ProjectKey] _refreshKeys id=${this.id}: url="${urlW?.value}" project="${projW?.value}" file="${fileW?.value}" seq=${seqW?.value}`);
if (!urlW?.value || !projW?.value || !fileW?.value) {
console.log(`[ProjectKey] _refreshKeys: skipped (missing config)`);
return;
}
try {
const resp = await api.fetchApi(
`/json_manager/get_project_keys?url=${encodeURIComponent(urlW.value)}&project=${encodeURIComponent(projW.value)}&file=${encodeURIComponent(fileW.value)}&seq=${seqW?.value || 1}`
);
if (!resp.ok) return;
const data = await resp.json();
if (data.error || !Array.isArray(data.keys)) return;
// Store keys/types for lookup
this._availableKeys = data.keys;
this._availableTypes = data.types;
// Update key_name combo options only — never change the selection
const keyWidget = this.widgets?.find(w => w.name === "key_name");
if (keyWidget) {
keyWidget.options.values = data.keys;
// Selection is sticky: user must change it manually
this._applyKeySelection();
}
} catch (e) {
console.error("[ProjectKey] Failed to refresh keys:", e);
}
};
// --- Update output slot based on selected key ---
nodeType.prototype._applyKeySelection = function () {
const keyWidget = this.widgets?.find(w => w.name === "key_name");
if (!keyWidget?.value) return;
const keyIdx = (this._availableKeys || []).indexOf(keyWidget.value);
const keyType = keyIdx >= 0 ? (this._availableTypes[keyIdx] || "*") : "*";
// Update hidden key_type widget
const ktWidget = this.widgets?.find(w => w.name === "key_type");
if (ktWidget) ktWidget.value = keyType;
// Update output slot
if (this.outputs.length > 0) {
this.outputs[0].name = keyWidget.value;
this.outputs[0].label = keyWidget.value;
this.outputs[0].type = keyType;
}
this.title = keyWidget.value ? `Key: ${keyWidget.value}` : "Project Key";
this.setSize(this.computeSize());
app.graph?.setDirtyCanvas(true, true);
};
// --- Sync config on click (lazy, no key refresh to avoid race) ---
const origOnMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e, localPos, graphCanvas) {
origOnMouseDown?.apply(this, arguments);
const srcWidget = this.widgets?.find(w => w.name === "source_label");
if (srcWidget) {
srcWidget.options.values = this._getSourceLabels();
}
// Sync config values from source (synchronous, safe)
this._syncFromSource();
};
// --- Restore state on workflow load ---
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
origOnConfigure?.apply(this, arguments);
this._configured = true;
// Hide config widgets
for (const name of ["manager_url", "project_name", "file_name", "sequence_number", "key_type"]) {
const w = this.widgets?.find(w => w.name === name);
if (w) hideWidget(w);
}
// Ensure source_label is a proper combo (may still be STRING from serialization)
const srcWidget = this.widgets?.find(w => w.name === "source_label");
if (srcWidget && srcWidget.type !== "combo") {
const node = this;
replaceWithCombo(this, "source_label", this._getSourceLabels(), function (value) {
node._syncFromSource();
node._refreshKeys();
});
} else if (srcWidget) {
srcWidget.options.values = this._getSourceLabels();
}
// Ensure key_name is a proper combo
const keyWidget = this.widgets?.find(w => w.name === "key_name");
if (keyWidget && keyWidget.type !== "combo") {
const node = this;
replaceWithCombo(this, "key_name", [], function (value) {
node._applyKeySelection();
});
}
// Re-find widgets after possible replacement
const finalKeyWidget = this.widgets?.find(w => w.name === "key_name");
// Update title from saved key
if (finalKeyWidget?.value) {
this.title = `Key: ${finalKeyWidget.value}`;
}
// Restore output slot name from saved key_name
if (finalKeyWidget?.value && this.outputs.length > 0) {
this.outputs[0].name = finalKeyWidget.value;
this.outputs[0].label = finalKeyWidget.value;
const ktWidget = this.widgets?.find(w => w.name === "key_type");
if (ktWidget?.value) this.outputs[0].type = ktWidget.value;
}
this.setSize(this.computeSize());
// Deferred: sync from source and refresh key dropdown once graph is ready
const node = this;
queueMicrotask(() => {
node._syncFromSource();
node._refreshKeys();
});
};
},
});
+158
View File
@@ -0,0 +1,158 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "json.manager.project.source",
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();
const fileList = (data.files || []).map(f => f.name || f);
console.log(`[ProjectSource] refreshFiles: got ${fileList.length} files:`, fileList);
const fileW = node.widgets?.find(w => w.name === "file_name");
if (fileW) {
const currentValue = fileW.value;
fileW.options.values = fileList.length > 0 ? fileList : [""];
// Keep current selection if still valid
if (currentValue && fileList.includes(currentValue)) {
fileW.value = currentValue;
}
}
} 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;
const labelW = sourceNode.widgets?.find(w => w.name === "label");
if (!labelW?.value) return;
console.log(`[ProjectSource] notifyRelays: label="${labelW.value}", scanning ${sourceNode.graph._nodes.length} nodes`);
let matched = 0;
for (const node of sourceNode.graph._nodes) {
if (node.type === "ProjectKey" && node._syncFromSource && node._refreshKeys) {
const srcW = node.widgets?.find(w => w.name === "source_label");
console.log(`[ProjectSource] ProjectKey id=${node.id} source_label="${srcW?.value}"`);
if (srcW?.value === labelW.value) {
matched++;
node._syncFromSource();
node._refreshKeys();
}
}
}
console.log(`[ProjectSource] notifyRelays: matched ${matched} relays`);
}
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origOnNodeCreated?.apply(this, arguments);
const node = this;
// 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) {
const origCallback = labelWidget.callback;
labelWidget.callback = function (...args) {
origCallback?.apply(this, args);
node.title = labelWidget.value
? `Source: ${labelWidget.value}`
: "Project Source";
app.graph?.setDirtyCanvas(true, true);
};
// Set initial title
if (labelWidget.value) {
this.title = `Source: ${labelWidget.value}`;
}
}
};
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);
});
};
},
});