Fix 25+ bugs across rounds 4-8 of comprehensive code review
history_tree.py:
- Cycle protection in generate_graph() parent walk
- KeyError → .get() for malformed node data in commit() and generate_graph()
- UUID collision check with for/else raise in commit() and _migrate_legacy()
- RuntimeError → ValueError for consistent exception handling
tab_timeline_ng.py:
- Re-parent children walks to surviving ancestor for batch deletes
- Branch tip deletion re-points to parent instead of removing branch
- Cycle protection in _walk_branch_nodes and _find_branch_for_node
- Full data.clear() restore instead of merge in _restore_node
- Safe .get('data', {}) in restore and preview
- Reset stale branch selection after node deletion
- json.dumps for safe JS string escaping in graphviz renderer
tab_batch_ng.py:
- NaN/inf rejection in dict_number with math.isfinite()
- _safe_int used in recalc_vace, update_mode_label, frame_to_skip
- Uncaught ValueError from htree.commit() caught with user notification
tab_comfy_ng.py:
- asyncio.get_event_loop() → get_running_loop()
utils.py:
- Atomic writes for save_config and save_snippets
- save_config extra_data can't override explicit last_dir/favorites
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,10 @@ class HistoryTree:
|
|||||||
def _migrate_legacy(self, old_list: list[dict[str, Any]]) -> None:
|
def _migrate_legacy(self, old_list: list[dict[str, Any]]) -> None:
|
||||||
parent = None
|
parent = None
|
||||||
for item in reversed(old_list):
|
for item in reversed(old_list):
|
||||||
node_id = str(uuid.uuid4())[:8]
|
for _ in range(10):
|
||||||
|
node_id = str(uuid.uuid4())[:8]
|
||||||
|
if node_id not in self.nodes:
|
||||||
|
break
|
||||||
self.nodes[node_id] = {
|
self.nodes[node_id] = {
|
||||||
"id": node_id, "parent": parent, "timestamp": time.time(),
|
"id": node_id, "parent": parent, "timestamp": time.time(),
|
||||||
"data": item, "note": item.get("note", "Legacy Import")
|
"data": item, "note": item.get("note", "Legacy Import")
|
||||||
@@ -28,7 +31,13 @@ class HistoryTree:
|
|||||||
self.head_id = parent
|
self.head_id = parent
|
||||||
|
|
||||||
def commit(self, data: dict[str, Any], note: str = "Snapshot") -> str:
|
def commit(self, data: dict[str, Any], note: str = "Snapshot") -> str:
|
||||||
new_id = str(uuid.uuid4())[:8]
|
# Generate unique node ID with collision check
|
||||||
|
for _ in range(10):
|
||||||
|
new_id = str(uuid.uuid4())[:8]
|
||||||
|
if new_id not in self.nodes:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError("Failed to generate unique node ID after 10 attempts")
|
||||||
|
|
||||||
# Cycle detection: walk parent chain from head to verify no cycle
|
# Cycle detection: walk parent chain from head to verify no cycle
|
||||||
if self.head_id:
|
if self.head_id:
|
||||||
@@ -39,7 +48,7 @@ class HistoryTree:
|
|||||||
raise ValueError(f"Cycle detected in history tree at node {current}")
|
raise ValueError(f"Cycle detected in history tree at node {current}")
|
||||||
visited.add(current)
|
visited.add(current)
|
||||||
node = self.nodes.get(current)
|
node = self.nodes.get(current)
|
||||||
current = node["parent"] if node else None
|
current = node.get("parent") if node else None
|
||||||
|
|
||||||
active_branch = None
|
active_branch = None
|
||||||
for b_name, tip_id in self.branches.items():
|
for b_name, tip_id in self.branches.items():
|
||||||
@@ -115,8 +124,12 @@ class HistoryTree:
|
|||||||
# Build reverse lookup: node_id -> branch name (walk each branch ancestry)
|
# Build reverse lookup: node_id -> branch name (walk each branch ancestry)
|
||||||
node_to_branch: dict[str, str] = {}
|
node_to_branch: dict[str, str] = {}
|
||||||
for b_name, tip_id in self.branches.items():
|
for b_name, tip_id in self.branches.items():
|
||||||
|
visited = set()
|
||||||
current = tip_id
|
current = tip_id
|
||||||
while current and current in self.nodes:
|
while current and current in self.nodes:
|
||||||
|
if current in visited:
|
||||||
|
break
|
||||||
|
visited.add(current)
|
||||||
if current not in node_to_branch:
|
if current not in node_to_branch:
|
||||||
node_to_branch[current] = b_name
|
node_to_branch[current] = b_name
|
||||||
current = self.nodes[current].get('parent')
|
current = self.nodes[current].get('parent')
|
||||||
@@ -192,11 +205,18 @@ class HistoryTree:
|
|||||||
+ '</TABLE>>'
|
+ '</TABLE>>'
|
||||||
)
|
)
|
||||||
|
|
||||||
safe_tooltip = full_note.replace('\\', '\\\\').replace('"', '\\"')
|
safe_tooltip = (full_note
|
||||||
dot.append(f' "{nid}" [label={label}, tooltip="{safe_tooltip}"];')
|
.replace('\\', '\\\\')
|
||||||
|
.replace('"', '\\"')
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.replace('\r', '')
|
||||||
|
.replace(']', ']'))
|
||||||
|
safe_nid = nid.replace('"', '_')
|
||||||
|
dot.append(f' "{safe_nid}" [label={label}, tooltip="{safe_tooltip}"];')
|
||||||
|
|
||||||
if n["parent"] and n["parent"] in self.nodes:
|
if n.get("parent") and n["parent"] in self.nodes:
|
||||||
dot.append(f' "{n["parent"]}" -> "{nid}";')
|
safe_parent = n["parent"].replace('"', '_')
|
||||||
|
dot.append(f' "{safe_parent}" -> "{safe_nid}";')
|
||||||
|
|
||||||
dot.append("}")
|
dot.append("}")
|
||||||
return "\n".join(dot)
|
return "\n".join(dot)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import random
|
import random
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -143,6 +144,8 @@ def dict_number(label, seq, key, default=0, **kwargs):
|
|||||||
try:
|
try:
|
||||||
# Try float first to handle "1.5" strings, then check if it's a clean int
|
# Try float first to handle "1.5" strings, then check if it's a clean int
|
||||||
fval = float(val)
|
fval = float(val)
|
||||||
|
if not math.isfinite(fval):
|
||||||
|
fval = float(default)
|
||||||
val = int(fval) if fval == int(fval) else fval
|
val = int(fval) if fval == int(fval) else fval
|
||||||
except (ValueError, TypeError, OverflowError):
|
except (ValueError, TypeError, OverflowError):
|
||||||
val = default
|
val = default
|
||||||
@@ -153,10 +156,13 @@ def dict_number(label, seq, key, default=0, **kwargs):
|
|||||||
if v is None:
|
if v is None:
|
||||||
v = d
|
v = d
|
||||||
elif isinstance(v, float):
|
elif isinstance(v, float):
|
||||||
try:
|
if not math.isfinite(v):
|
||||||
v = int(v) if v == int(v) else v
|
|
||||||
except (OverflowError, ValueError):
|
|
||||||
v = d
|
v = d
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
v = int(v) if v == int(v) else v
|
||||||
|
except (OverflowError, ValueError):
|
||||||
|
v = d
|
||||||
seq[k] = v
|
seq[k] = v
|
||||||
|
|
||||||
el.on('blur', lambda _: _sync())
|
el.on('blur', lambda _: _sync())
|
||||||
@@ -336,7 +342,11 @@ def render_batch_processor(state: AppState):
|
|||||||
snapshot_payload = copy.deepcopy(data)
|
snapshot_payload = copy.deepcopy(data)
|
||||||
snapshot_payload.pop(KEY_HISTORY_TREE, None)
|
snapshot_payload.pop(KEY_HISTORY_TREE, None)
|
||||||
note = commit_input.value if commit_input.value else _auto_change_note(htree, batch_list)
|
note = commit_input.value if commit_input.value else _auto_change_note(htree, batch_list)
|
||||||
htree.commit(snapshot_payload, note=note)
|
try:
|
||||||
|
htree.commit(snapshot_payload, note=note)
|
||||||
|
except ValueError as e:
|
||||||
|
ui.notify(f'Save failed: {e}', type='negative')
|
||||||
|
return
|
||||||
data[KEY_HISTORY_TREE] = htree.to_dict()
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
if state.db_enabled and state.current_project and state.db:
|
if state.db_enabled and state.current_project and state.db:
|
||||||
@@ -635,18 +645,18 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li
|
|||||||
fts_input = dict_number('Frame to Skip', seq, 'frame_to_skip').classes(
|
fts_input = dict_number('Frame to Skip', seq, 'frame_to_skip').classes(
|
||||||
'col').props('outlined')
|
'col').props('outlined')
|
||||||
|
|
||||||
_original_fts = int(seq.get('frame_to_skip', FRAME_TO_SKIP_DEFAULT))
|
_original_fts = _safe_int(seq.get('frame_to_skip', FRAME_TO_SKIP_DEFAULT), FRAME_TO_SKIP_DEFAULT)
|
||||||
|
|
||||||
def shift_fts(idx=i, orig=_original_fts):
|
def shift_fts(idx=i, orig=_original_fts):
|
||||||
new_fts = int(fts_input.value) if fts_input.value is not None else orig
|
new_fts = _safe_int(fts_input.value, orig)
|
||||||
delta = new_fts - orig
|
delta = new_fts - orig
|
||||||
if delta == 0:
|
if delta == 0:
|
||||||
ui.notify('No change to shift', type='info')
|
ui.notify('No change to shift', type='info')
|
||||||
return
|
return
|
||||||
shifted = 0
|
shifted = 0
|
||||||
for j in range(idx + 1, len(batch_list)):
|
for j in range(idx + 1, len(batch_list)):
|
||||||
batch_list[j]['frame_to_skip'] = int(
|
batch_list[j]['frame_to_skip'] = _safe_int(
|
||||||
batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) + delta
|
batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT), FRAME_TO_SKIP_DEFAULT) + delta
|
||||||
shifted += 1
|
shifted += 1
|
||||||
data[KEY_BATCH_DATA] = batch_list
|
data[KEY_BATCH_DATA] = batch_list
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
@@ -670,7 +680,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li
|
|||||||
ui.button(icon='help', on_click=ref_dlg.open).props('flat dense round')
|
ui.button(icon='help', on_click=ref_dlg.open).props('flat dense round')
|
||||||
|
|
||||||
def update_mode_label(e):
|
def update_mode_label(e):
|
||||||
idx = int(e.sender.value) if e.sender.value is not None else 0
|
idx = _safe_int(e.sender.value, 0)
|
||||||
idx = max(0, min(idx, len(VACE_MODES) - 1))
|
idx = max(0, min(idx, len(VACE_MODES) - 1))
|
||||||
mode_label.set_text(VACE_MODES[idx])
|
mode_label.set_text(VACE_MODES[idx])
|
||||||
|
|
||||||
@@ -706,10 +716,10 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, state, refresh_li
|
|||||||
|
|
||||||
# Recalculate VACE output when any input changes
|
# Recalculate VACE output when any input changes
|
||||||
def recalc_vace(*_args):
|
def recalc_vace(*_args):
|
||||||
mi = int(vs_input.value) if vs_input.value is not None else 0
|
mi = _safe_int(vs_input.value, 0)
|
||||||
ia = int(ia_input.value) if ia_input.value is not None else 16
|
ia = _safe_int(ia_input.value, 16)
|
||||||
ib = int(ib_input.value) if ib_input.value is not None else 16
|
ib = _safe_int(ib_input.value, 16)
|
||||||
nb = int(vl_input.value) if vl_input.value is not None else 1
|
nb = _safe_int(vl_input.value, 1)
|
||||||
|
|
||||||
if mi == 0:
|
if mi == 0:
|
||||||
raw = nb + ia
|
raw = nb + ia
|
||||||
@@ -794,7 +804,11 @@ def _render_mass_update(batch_list, data, file_path, state: AppState, refresh_li
|
|||||||
htree = HistoryTree(data.get(KEY_HISTORY_TREE, {}))
|
htree = HistoryTree(data.get(KEY_HISTORY_TREE, {}))
|
||||||
snapshot = copy.deepcopy(data)
|
snapshot = copy.deepcopy(data)
|
||||||
snapshot.pop(KEY_HISTORY_TREE, None)
|
snapshot.pop(KEY_HISTORY_TREE, None)
|
||||||
htree.commit(snapshot, f"Mass update: {', '.join(selected_keys)}")
|
try:
|
||||||
|
htree.commit(snapshot, f"Mass update: {', '.join(selected_keys)}")
|
||||||
|
except ValueError as e:
|
||||||
|
ui.notify(f'Mass update failed: {e}', type='negative')
|
||||||
|
return
|
||||||
data[KEY_HISTORY_TREE] = htree.to_dict()
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
if state.db_enabled and state.current_project and state.db:
|
if state.db_enabled and state.current_project and state.db:
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
|||||||
|
|
||||||
async def refresh_status():
|
async def refresh_status():
|
||||||
status_container.clear()
|
status_container.clear()
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
res, err = await loop.run_in_executor(
|
res, err = await loop.run_in_executor(
|
||||||
None, lambda: _fetch_blocking(f'{comfy_url}/queue'))
|
None, lambda: _fetch_blocking(f'{comfy_url}/queue'))
|
||||||
with status_container:
|
with status_container:
|
||||||
@@ -237,7 +237,7 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
|||||||
|
|
||||||
async def check_image():
|
async def check_image():
|
||||||
img_container.clear()
|
img_container.clear()
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
res, err = await loop.run_in_executor(
|
res, err = await loop.run_in_executor(
|
||||||
None, lambda: _fetch_blocking(f'{comfy_url}/history', timeout=2))
|
None, lambda: _fetch_blocking(f'{comfy_url}/history', timeout=2))
|
||||||
with img_container:
|
with img_container:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
@@ -9,16 +10,37 @@ from utils import save_json, sync_to_db, KEY_BATCH_DATA, KEY_HISTORY_TREE
|
|||||||
|
|
||||||
|
|
||||||
def _delete_nodes(htree, data, file_path, node_ids):
|
def _delete_nodes(htree, data, file_path, node_ids):
|
||||||
"""Delete nodes with backup, branch cleanup, and head fallback."""
|
"""Delete nodes with backup, branch cleanup, re-parenting, and head fallback."""
|
||||||
if 'history_tree_backup' not in data:
|
if 'history_tree_backup' not in data:
|
||||||
data['history_tree_backup'] = []
|
data['history_tree_backup'] = []
|
||||||
data['history_tree_backup'].append(copy.deepcopy(htree.to_dict()))
|
data['history_tree_backup'].append(copy.deepcopy(htree.to_dict()))
|
||||||
data['history_tree_backup'] = data['history_tree_backup'][-10:]
|
data['history_tree_backup'] = data['history_tree_backup'][-10:]
|
||||||
|
# Save deleted node parents before removal (needed for branch re-pointing)
|
||||||
|
deleted_parents = {}
|
||||||
|
for nid in node_ids:
|
||||||
|
deleted_node = htree.nodes.get(nid)
|
||||||
|
if deleted_node:
|
||||||
|
deleted_parents[nid] = deleted_node.get('parent')
|
||||||
|
# Re-parent children of deleted nodes — walk up to find a surviving ancestor
|
||||||
|
for nid in node_ids:
|
||||||
|
surviving_parent = deleted_parents.get(nid)
|
||||||
|
while surviving_parent in node_ids:
|
||||||
|
surviving_parent = deleted_parents.get(surviving_parent)
|
||||||
|
for child in htree.nodes.values():
|
||||||
|
if child.get('parent') == nid:
|
||||||
|
child['parent'] = surviving_parent
|
||||||
for nid in node_ids:
|
for nid in node_ids:
|
||||||
htree.nodes.pop(nid, None)
|
htree.nodes.pop(nid, None)
|
||||||
|
# Re-point branches whose tip was deleted to a surviving ancestor
|
||||||
for b, tip in list(htree.branches.items()):
|
for b, tip in list(htree.branches.items()):
|
||||||
if tip in node_ids:
|
if tip in node_ids:
|
||||||
del htree.branches[b]
|
new_tip = deleted_parents.get(tip)
|
||||||
|
while new_tip in node_ids:
|
||||||
|
new_tip = deleted_parents.get(new_tip)
|
||||||
|
if new_tip and new_tip in htree.nodes:
|
||||||
|
htree.branches[b] = new_tip
|
||||||
|
else:
|
||||||
|
del htree.branches[b]
|
||||||
if htree.head_id in node_ids:
|
if htree.head_id in node_ids:
|
||||||
if htree.nodes:
|
if htree.nodes:
|
||||||
htree.head_id = sorted(htree.nodes.values(),
|
htree.head_id = sorted(htree.nodes.values(),
|
||||||
@@ -153,8 +175,12 @@ def _render_batch_delete(htree, data, file_path, state, refresh_fn):
|
|||||||
def _walk_branch_nodes(htree, tip_id):
|
def _walk_branch_nodes(htree, tip_id):
|
||||||
"""Walk parent pointers from tip, returning nodes newest-first."""
|
"""Walk parent pointers from tip, returning nodes newest-first."""
|
||||||
nodes = []
|
nodes = []
|
||||||
|
visited = set()
|
||||||
current = tip_id
|
current = tip_id
|
||||||
while current and current in htree.nodes:
|
while current and current in htree.nodes:
|
||||||
|
if current in visited:
|
||||||
|
break
|
||||||
|
visited.add(current)
|
||||||
nodes.append(htree.nodes[current])
|
nodes.append(htree.nodes[current])
|
||||||
current = htree.nodes[current].get('parent')
|
current = htree.nodes[current].get('parent')
|
||||||
return nodes
|
return nodes
|
||||||
@@ -173,10 +199,14 @@ def _find_active_branch(htree):
|
|||||||
def _find_branch_for_node(htree, node_id):
|
def _find_branch_for_node(htree, node_id):
|
||||||
"""Return the branch name whose ancestry contains node_id, or None."""
|
"""Return the branch name whose ancestry contains node_id, or None."""
|
||||||
for b_name, tip_id in htree.branches.items():
|
for b_name, tip_id in htree.branches.items():
|
||||||
|
visited = set()
|
||||||
current = tip_id
|
current = tip_id
|
||||||
while current and current in htree.nodes:
|
while current and current in htree.nodes:
|
||||||
|
if current in visited:
|
||||||
|
break
|
||||||
if current == node_id:
|
if current == node_id:
|
||||||
return b_name
|
return b_name
|
||||||
|
visited.add(current)
|
||||||
current = htree.nodes[current].get('parent')
|
current = htree.nodes[current].get('parent')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -311,6 +341,10 @@ def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_
|
|||||||
_delete_nodes(htree, data, file_path, {sel_id})
|
_delete_nodes(htree, data, file_path, {sel_id})
|
||||||
if state and state.db_enabled and state.current_project and state.db:
|
if state and state.db_enabled and state.current_project and state.db:
|
||||||
sync_to_db(state.db, state.current_project, file_path, data)
|
sync_to_db(state.db, state.current_project, file_path, data)
|
||||||
|
# Reset selection if branch was removed
|
||||||
|
if selected['branch'] not in htree.branches:
|
||||||
|
selected['branch'] = next(iter(htree.branches), None)
|
||||||
|
selected['node_id'] = htree.head_id
|
||||||
ui.notify('Node Deleted', type='positive')
|
ui.notify('Node Deleted', type='positive')
|
||||||
refresh_fn()
|
refresh_fn()
|
||||||
|
|
||||||
@@ -434,7 +468,7 @@ def _render_graphviz(dot_source: str, selected_node_id: str | None = None):
|
|||||||
src = graphviz.Source(dot_source)
|
src = graphviz.Source(dot_source)
|
||||||
svg = src.pipe(format='svg').decode('utf-8')
|
svg = src.pipe(format='svg').decode('utf-8')
|
||||||
|
|
||||||
sel_escaped = selected_node_id.replace("'", "\\'") if selected_node_id else ''
|
sel_escaped = json.dumps(selected_node_id or '')[1:-1] # strip quotes, get JS-safe content
|
||||||
|
|
||||||
# CSS inline (allowed), JS via run_javascript (script tags blocked)
|
# CSS inline (allowed), JS via run_javascript (script tags blocked)
|
||||||
css = '''<style>
|
css = '''<style>
|
||||||
@@ -491,11 +525,18 @@ def _render_graphviz(dot_source: str, selected_node_id: str | None = None):
|
|||||||
|
|
||||||
|
|
||||||
def _restore_node(data, node, htree, file_path, state: AppState):
|
def _restore_node(data, node, htree, file_path, state: AppState):
|
||||||
"""Restore a history node as the current version."""
|
"""Restore a history node as the current version (full replace, not merge)."""
|
||||||
node_data = copy.deepcopy(node['data'])
|
node_data = copy.deepcopy(node.get('data', {}))
|
||||||
if KEY_BATCH_DATA not in node_data and KEY_BATCH_DATA in data:
|
# Preserve the history tree before clearing
|
||||||
del data[KEY_BATCH_DATA]
|
preserved_tree = data.get(KEY_HISTORY_TREE)
|
||||||
|
preserved_backup = data.get('history_tree_backup')
|
||||||
|
data.clear()
|
||||||
data.update(node_data)
|
data.update(node_data)
|
||||||
|
# Re-attach history tree (not part of snapshot data)
|
||||||
|
if preserved_tree is not None:
|
||||||
|
data[KEY_HISTORY_TREE] = preserved_tree
|
||||||
|
if preserved_backup is not None:
|
||||||
|
data['history_tree_backup'] = preserved_backup
|
||||||
htree.head_id = node['id']
|
htree.head_id = node['id']
|
||||||
data[KEY_HISTORY_TREE] = htree.to_dict()
|
data[KEY_HISTORY_TREE] = htree.to_dict()
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
@@ -512,7 +553,7 @@ def _render_data_preview(nid, htree):
|
|||||||
ui.label('No node selected.').classes('text-caption')
|
ui.label('No node selected.').classes('text-caption')
|
||||||
return
|
return
|
||||||
|
|
||||||
node_data = htree.nodes[nid]['data']
|
node_data = htree.nodes[nid].get('data', {})
|
||||||
batch_list = node_data.get(KEY_BATCH_DATA, [])
|
batch_list = node_data.get(KEY_BATCH_DATA, [])
|
||||||
|
|
||||||
if batch_list and isinstance(batch_list, list) and len(batch_list) > 0:
|
if batch_list and isinstance(batch_list, list) and len(batch_list) > 0:
|
||||||
|
|||||||
15
utils.py
15
utils.py
@@ -114,14 +114,17 @@ def save_config(current_dir, favorites, extra_data=None):
|
|||||||
existing = load_config()
|
existing = load_config()
|
||||||
data.update(existing)
|
data.update(existing)
|
||||||
|
|
||||||
data["last_dir"] = str(current_dir)
|
|
||||||
data["favorites"] = favorites
|
|
||||||
|
|
||||||
if extra_data:
|
if extra_data:
|
||||||
data.update(extra_data)
|
data.update(extra_data)
|
||||||
|
|
||||||
with open(CONFIG_FILE, 'w') as f:
|
# Force-set explicit params last so extra_data can't override them
|
||||||
|
data["last_dir"] = str(current_dir)
|
||||||
|
data["favorites"] = favorites
|
||||||
|
|
||||||
|
tmp = CONFIG_FILE.with_suffix('.json.tmp')
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
json.dump(data, f, indent=4)
|
json.dump(data, f, indent=4)
|
||||||
|
os.replace(tmp, CONFIG_FILE)
|
||||||
|
|
||||||
def load_snippets():
|
def load_snippets():
|
||||||
if SNIPPETS_FILE.exists():
|
if SNIPPETS_FILE.exists():
|
||||||
@@ -133,8 +136,10 @@ def load_snippets():
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def save_snippets(snippets):
|
def save_snippets(snippets):
|
||||||
with open(SNIPPETS_FILE, 'w') as f:
|
tmp = SNIPPETS_FILE.with_suffix('.json.tmp')
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
json.dump(snippets, f, indent=4)
|
json.dump(snippets, f, indent=4)
|
||||||
|
os.replace(tmp, SNIPPETS_FILE)
|
||||||
|
|
||||||
def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
|
def load_json(path: str | Path) -> tuple[dict[str, Any], float]:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
|
|||||||
Reference in New Issue
Block a user