From bb2d1da9c018f790e804f234a12d83cc0faaf16a Mon Sep 17 00:00:00 2001 From: MajoorWaldi <129623145+MajoorWaldi@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:16:02 +0100 Subject: [PATCH] routes: Add workflow reconstruction and logging features --- js/ui_grid.js | 68 +----------------------- js/ui_sidebar.js | 33 +++++++++--- server/routes.py | 134 +++++++++++++++++++++++++++-------------------- 3 files changed, 104 insertions(+), 131 deletions(-) diff --git a/js/ui_grid.js b/js/ui_grid.js index 54aca2d2..f5565a49 100644 --- a/js/ui_grid.js +++ b/js/ui_grid.js @@ -496,70 +496,6 @@ app.registerExtension({ if (!window.__MajoorAssetsManagerHotkeysInitialized) { window.__MajoorAssetsManagerHotkeysInitialized = true; - const toggleAssetsManager = () => { - const SIDEBAR_ID = "majoorAssetsManagerSidebar"; - const BOTTOM_ID = "majoorAssetsManagerBottom"; - - try { - const em = app?.extensionManager; - const sidebar = em?.sidebarTab; - const bottom = em?.bottomPanel; - - const sidebarActive = sidebar && sidebar.activeSidebarTabId === SIDEBAR_ID; - const bottomActive = - bottom && - bottom.bottomPanelVisible === true && - bottom.activeBottomPanelTabId === BOTTOM_ID; - - if (sidebarActive) { - sidebar.activeSidebarTabId = null; - return true; - } - if (bottomActive) { - bottom.bottomPanelVisible = false; - return true; - } - - const sidebarRegistered = - sidebar && - Array.isArray(sidebar.sidebarTabs) && - sidebar.sidebarTabs.some((t) => t && t.id === SIDEBAR_ID); - if (sidebarRegistered) { - sidebar.activeSidebarTabId = SIDEBAR_ID; - return true; - } - - const bottomRegistered = - bottom && - Array.isArray(bottom.bottomPanelTabs) && - bottom.bottomPanelTabs.some((t) => t && t.id === BOTTOM_ID); - if (bottomRegistered) { - bottom.activeBottomPanelTabId = BOTTOM_ID; - bottom.bottomPanelVisible = true; - return true; - } - } catch (_) { - // fall through to DOM-based toggle - } - - const selectors = [ - `[data-sidebar-tab-id="majoorAssetsManagerSidebar"]`, - `[data-sidebar-tab="majoorAssetsManagerSidebar"]`, - `[data-tab-id="majoorAssetsManagerSidebar"]`, - `[data-extension-id="majoorAssetsManagerSidebar"]`, - `[data-tab-id="majoorAssetsManagerBottom"]`, - `[data-bottom-tab-id="majoorAssetsManagerBottom"]`, - ]; - for (const sel of selectors) { - const el = document.querySelector(sel); - if (el && typeof el.click === "function") { - el.click(); - return true; - } - } - return false; - }; - const onKeyDown = (ev) => { if (!mjrSettings.hotkeys.enabled) return; if (ev.isComposing) return; @@ -573,9 +509,7 @@ if (!window.__MajoorAssetsManagerHotkeysInitialized) { if (isTyping) return; let handled = false; - if (ev.key === "Tab" && !ev.ctrlKey && !ev.altKey && !ev.metaKey) { - handled = toggleAssetsManager(); - } else if (mjrSettings.hotkeys.rating && /^[0-5]$/.test(ev.key) && !ev.ctrlKey && !ev.altKey && !ev.metaKey) { + if (mjrSettings.hotkeys.rating && /^[0-5]$/.test(ev.key) && !ev.ctrlKey && !ev.altKey && !ev.metaKey) { mjrGlobalState.instances.forEach(inst => { if (!inst?.state) return; const rate = parseInt(ev.key, 10); diff --git a/js/ui_sidebar.js b/js/ui_sidebar.js index 8b6c9c00..4a702d56 100644 --- a/js/ui_sidebar.js +++ b/js/ui_sidebar.js @@ -567,14 +567,31 @@ export function createMetadataSidebar(options) { showPanel(file, fallbackMeta, cachedEntry ? null : "Loading metadata..."); - try { - const res = await api.fetchApi(`/mjr/filemanager/metadata?${params.toString()}`); - const data = await res.json(); - if (seq !== metaRequestSeq) return; - if (data.ok && data.generation) { - const meta = data.generation || {}; - if (meta.has_workflow === undefined) meta.has_workflow = !!meta.workflow; - if (meta.has_workflow && file) file.hasWorkflow = true; + try { + const res = await api.fetchApi(`/mjr/filemanager/metadata?${params.toString()}`); + if (seq !== metaRequestSeq) return; + if (!res.ok) { + let extra = ""; + try { + const txt = await res.text(); + if (txt) extra = `: ${String(txt).slice(0, 160)}`; + } catch (_) {} + showPanel(file, null, `Error while loading metadata (HTTP ${res.status}${extra})`); + return; + } + + let data = null; + try { + data = await res.json(); + } catch (e) { + showPanel(file, null, "Error while loading metadata (invalid server response)."); + return; + } + if (seq !== metaRequestSeq) return; + if (data.ok && data.generation) { + const meta = data.generation || {}; + if (meta.has_workflow === undefined) meta.has_workflow = !!meta.workflow; + if (meta.has_workflow && file) file.hasWorkflow = true; if (meta.rating === undefined && file.rating !== undefined) meta.rating = file.rating; if (!meta.tags && file.tags) meta.tags = file.tags; const sourceMtime = meta.mtime ?? file.mtime; diff --git a/server/routes.py b/server/routes.py index eb8b5de6..72a1f2d0 100644 --- a/server/routes.py +++ b/server/routes.py @@ -8,6 +8,7 @@ import shutil import platform import concurrent.futures import json +import math from pathlib import Path from typing import Any, Dict, List, Optional @@ -45,6 +46,45 @@ from .mjr_collections import ( log = get_logger(__name__) + +def _json_sanitize(obj: Any) -> Any: + """ + Ensure objects are JSON-safe and strict-JSON compliant. + Python's json.dumps allows NaN/Infinity by default, but browsers reject them. + """ + if obj is None: + return None + if isinstance(obj, float): + return obj if math.isfinite(obj) else None + if isinstance(obj, (bytes, bytearray)): + try: + return bytes(obj).decode("utf-8", errors="replace") + except Exception: + return repr(obj) + if isinstance(obj, Path): + return str(obj) + if isinstance(obj, dict): + out: Dict[str, Any] = {} + for k, v in obj.items(): + try: + key = str(k) + except Exception: + key = repr(k) + out[key] = _json_sanitize(v) + return out + if isinstance(obj, (list, tuple, set)): + return [_json_sanitize(v) for v in obj] + return obj + + +def _json_response(data: Any, status: int = 200) -> web.Response: + cleaned = _json_sanitize(data) + return web.json_response( + cleaned, + status=status, + dumps=lambda x: json.dumps(x, ensure_ascii=False, allow_nan=False), + ) + # --------------------------------------------------------------------------- # In-memory cache for output listing # Avoids redoing a full os.scandir on every auto-refresh. @@ -553,7 +593,7 @@ async def list_files(request: web.Request) -> web.Response: loop = asyncio.get_running_loop() loop.run_in_executor(None, _prefetch) - return web.json_response( + return _json_response( { "files": items, "total": total, @@ -574,7 +614,7 @@ async def batch_metadata(request: web.Request) -> web.Response: except ConnectionResetError: return web.Response(status=499) except Exception: - return web.json_response({"ok": False, "error": "Invalid JSON"}, status=400) + return _json_response({"ok": False, "error": "Invalid JSON"}, status=400) items = payload.get("items") or [] loop = asyncio.get_running_loop() @@ -641,7 +681,7 @@ async def batch_metadata(request: web.Request) -> web.Response: continue try: - return web.json_response({"ok": True, "metadatas": results, "errors": errors}) + return _json_response({"ok": True, "metadatas": results, "errors": errors}) except ConnectionResetError: return web.Response(status=499) @@ -653,9 +693,7 @@ async def delete_files(request: web.Request) -> web.Response: try: payload = await request.json() except Exception: - return web.json_response( - {"ok": False, "error": "Invalid JSON body"}, status=400 - ) + return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) items = payload.get("items") or [] deleted = [] @@ -701,7 +739,7 @@ async def delete_files(request: web.Request) -> web.Response: {"filename": filename, "subfolder": subfolder, "error": str(exc)} ) - return web.json_response({"ok": True, "deleted": deleted, "errors": errors}) + return _json_response({"ok": True, "deleted": deleted, "errors": errors}) @PromptServer.instance.routes.post("/mjr/filemanager/open_explorer") @@ -715,28 +753,28 @@ async def open_folder(request: web.Request) -> web.Response: try: payload = await request.json() except Exception: - return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400) + return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) filename = payload.get("filename") subfolder = payload.get("subfolder", "") if not filename: - return web.json_response({"ok": False, "error": "Missing filename"}, status=400) + return _json_response({"ok": False, "error": "Missing filename"}, status=400) try: target = _safe_target(root, subfolder, filename) except ValueError: - return web.json_response({"ok": False, "error": "File is outside output directory"}, status=400) + return _json_response({"ok": False, "error": "File is outside output directory"}, status=400) if not target.exists(): - return web.json_response({"ok": False, "error": "File not found"}, status=404) + return _json_response({"ok": False, "error": "File not found"}, status=404) ok, warning = _open_in_file_manager(target) if ok: resp = {"ok": True} if warning: resp["warning"] = warning - return web.json_response(resp, status=200) - return web.json_response({"ok": False, "error": warning or "Failed to open folder"}, status=500) + return _json_response(resp, status=200) + return _json_response({"ok": False, "error": warning or "Failed to open folder"}, status=500) # --------------------------------------------------------------------------- @@ -751,21 +789,15 @@ async def get_metadata(request: web.Request) -> web.Response: subfolder = request.query.get("subfolder", "") if not filename: - return web.json_response( - {"ok": False, "error": "missing filename"}, status=400 - ) + return _json_response({"ok": False, "error": "missing filename"}, status=400) try: target = _safe_target(root, subfolder, filename) except ValueError: - return web.json_response( - {"ok": False, "error": "File is outside output directory"}, status=400 - ) + return _json_response({"ok": False, "error": "File is outside output directory"}, status=400) if not target.exists(): - return web.json_response( - {"ok": False, "error": "File not found"}, status=404 - ) + return _json_response({"ok": False, "error": "File not found"}, status=404) def _extract_generation(path: Path) -> Dict[str, Any]: def _extract_one(p: Path) -> Dict[str, Any]: @@ -831,9 +863,7 @@ async def get_metadata(request: web.Request) -> web.Response: rating, tags = _rating_tags_with_fallback(target, kind) try: - return web.json_response( - {"ok": True, "generation": params, "rating": rating, "tags": tags} - ) + return _json_response({"ok": True, "generation": params, "rating": rating, "tags": tags}) except ConnectionResetError: return web.Response(status=499) @@ -842,7 +872,7 @@ async def get_metadata(request: web.Request) -> web.Response: async def get_capabilities(request: web.Request) -> web.Response: os_name = platform.system().lower() exiftool_available = _get_exiftool_path() is not None - return web.json_response( + return _json_response( { "ok": True, "os": os_name, @@ -859,9 +889,7 @@ async def update_metadata(request: web.Request) -> web.Response: try: payload = await request.json() except Exception: - return web.json_response( - {"ok": False, "error": "Invalid JSON body"}, status=400 - ) + return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) filename = payload.get("filename") subfolder = payload.get("subfolder", "") @@ -871,22 +899,16 @@ async def update_metadata(request: web.Request) -> web.Response: tags = payload.get("tags") if tags_provided else None if not filename: - return web.json_response( - {"ok": False, "error": "Missing filename"}, status=400 - ) + return _json_response({"ok": False, "error": "Missing filename"}, status=400) try: target = _safe_target(root, subfolder, filename) except ValueError: - return web.json_response( - {"ok": False, "error": "File is outside output directory"}, status=400 - ) + return _json_response({"ok": False, "error": "File is outside output directory"}, status=400) kind = classify_ext(filename.lower()) if not target.exists(): - return web.json_response( - {"ok": False, "error": "File not found"}, status=404 - ) + return _json_response({"ok": False, "error": "File not found"}, status=404) updates = {} if rating_provided: @@ -897,7 +919,7 @@ async def update_metadata(request: web.Request) -> web.Response: try: meta = update_metadata_with_windows(str(target), updates) except Exception as exc: - return web.json_response( + return _json_response( { "ok": False, "file": {"filename": filename, "subfolder": subfolder}, @@ -906,7 +928,7 @@ async def update_metadata(request: web.Request) -> web.Response: status=500, ) - return web.json_response({"ok": True, "rating": meta.get("rating", 0), "tags": meta.get("tags", [])}) + return _json_response({"ok": True, "rating": meta.get("rating", 0), "tags": meta.get("tags", [])}) @PromptServer.instance.routes.post("/mjr/filemanager/generation/update") @@ -924,14 +946,14 @@ async def update_generation_sidecar(request: web.Request) -> web.Response: try: payload = await request.json() except Exception: - return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400) + return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) files = payload.get("files") or [] prompt = payload.get("prompt") workflow = payload.get("workflow") if not isinstance(files, list) or not files: - return web.json_response({"ok": False, "error": "No files provided"}, status=400) + return _json_response({"ok": False, "error": "No files provided"}, status=400) updated: List[Dict[str, Any]] = [] errors: List[Dict[str, Any]] = [] @@ -976,7 +998,7 @@ async def update_generation_sidecar(request: web.Request) -> web.Response: except Exception as exc: errors.append({"file": {"filename": filename, "subfolder": subfolder}, "error": str(exc)}) - return web.json_response({"ok": True, "updated": updated, "errors": errors}) + return _json_response({"ok": True, "updated": updated, "errors": errors}) @PromptServer.instance.routes.post("/mjr/filemanager/sidecar/update") @@ -988,32 +1010,32 @@ async def update_sidecar(request: web.Request) -> web.Response: try: payload = await request.json() except Exception: - return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400) + return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) filename = payload.get("filename") subfolder = payload.get("subfolder", "") meta_updates = payload.get("meta") if not filename or not isinstance(meta_updates, dict): - return web.json_response({"ok": False, "error": "Missing filename or invalid meta"}, status=400) + return _json_response({"ok": False, "error": "Missing filename or invalid meta"}, status=400) try: target = _safe_target(root, subfolder, filename) except ValueError: - return web.json_response({"ok": False, "error": "File is outside output directory"}, status=400) + return _json_response({"ok": False, "error": "File is outside output directory"}, status=400) if not target.exists(): - return web.json_response({"ok": False, "error": "File not found"}, status=404) + return _json_response({"ok": False, "error": "File not found"}, status=404) meta_path = metadata_path(str(target)) if not meta_path: - return web.json_response({"ok": False, "error": "Sidecar disabled"}, status=400) + return _json_response({"ok": False, "error": "Sidecar disabled"}, status=400) try: merged = deep_merge_metadata(str(target), meta_updates) - return web.json_response({"ok": True, "meta": merged}) + return _json_response({"ok": True, "meta": merged}) except Exception as exc: - return web.json_response({"ok": False, "error": str(exc)}, status=500) + return _json_response({"ok": False, "error": str(exc)}, status=500) # --------------------------------------------------------------------------- @@ -1023,14 +1045,14 @@ async def update_sidecar(request: web.Request) -> web.Response: @PromptServer.instance.routes.get("/mjr/collections/list") async def list_collections_route(request: web.Request) -> web.Response: names = await asyncio.to_thread(get_collections) - return web.json_response({"collections": names}) + return _json_response({"collections": names}) @PromptServer.instance.routes.get("/mjr/collections/{name}") async def get_collection_route(request: web.Request) -> web.Response: name = request.match_info["name"] files = await asyncio.to_thread(load_collection, name) - return web.json_response({"files": files}) + return _json_response({"files": files}) @PromptServer.instance.routes.post("/mjr/collections/add") @@ -1038,9 +1060,9 @@ async def add_to_collection_route(request: web.Request) -> web.Response: try: data = await request.json() await asyncio.to_thread(add_to_collection, data["name"], data["path"]) - return web.json_response({"ok": True}) + return _json_response({"ok": True}) except Exception as e: - return web.json_response({"ok": False, "error": str(e)}, status=500) + return _json_response({"ok": False, "error": str(e)}, status=500) @PromptServer.instance.routes.post("/mjr/collections/remove") @@ -1048,6 +1070,6 @@ async def remove_from_collection_route(request: web.Request) -> web.Response: try: data = await request.json() await asyncio.to_thread(remove_from_collection, data["name"], data["path"]) - return web.json_response({"ok": True}) + return _json_response({"ok": True}) except Exception as e: - return web.json_response({"ok": False, "error": str(e)}, status=500) + return _json_response({"ok": False, "error": str(e)}, status=500)