feat(ui): add project status management and dropdown rendering

- Implemented project status functionality in `project_status.ts` to provide visual feedback based on the current project state.
- Created a new `project_dropdowns.ts` file to handle rendering of project dropdowns with appropriate options based on project existence and status.
- Added utility functions in `settings.js` for managing application settings, including boolean and number conversions.
- Introduced unit tests for route utilities in `test_route_utils.py` to ensure proper authentication and rate limiting behavior.
- Defined constants for UI panel settings in `panel_constants.ts` to standardize configuration options across the UI.
- Enhanced UI helper functions in `panel_helpers.ts` for consistent styling and layout of form elements.
This commit is contained in:
Ewald
2026-05-31 17:33:30 +02:00
parent 2d74d9c51b
commit a0dad24a76
43 changed files with 2095 additions and 680 deletions
+31 -2
View File
@@ -9,11 +9,16 @@ This extension turns ComfyUI into a lightweight “mini pipeline”: you pick a
## Status & empty workflows
- The Project Settings status bar watches your canvas and turns red when the current graph is empty while prompting you (via dialog) to create or assign a project instead of flooding toasts.
- Toggle the `MajoorPS: Black status when no project` setting (in the MajoorPS settings category) if you'd rather keep a neutral indicator while you experiment without an active project.
- `MajoorPS: Legacy Ctrl+S hook` is off by default because ComfyUI already owns `Ctrl+S` via `Comfy.SaveWorkflow`. Enable it only if you explicitly need the old capture-phase fallback.
- `MajoorPS: Prompt on empty workflow` is off by default; the status badge remains passive unless you opt into modal prompts.
---
## Key Features
- **Project management** inside ComfyUI (create / select / list).
- **Project Health panel** showing active folder, workflow assignment, output target, patchable save nodes, missing-model scan state, and workflow save state.
- **Topbar Project button**, registered with the same ComfyUI button component pattern used by ComfyUI-Manager when available.
- **ComfyUI command palette integration** for opening the panel, assigning the active project, saving workflows, scanning missing models, and patching save nodes.
- **Standardized folder structure** per project under `output/PROJECTS/<project>/...`.
- **Auto-patch “save/export” nodes**:
- sets a **safe relative output folder** (always under `output/`)
@@ -77,6 +82,29 @@ Depending on your ComfyUI version:
- If sidebar tabs are supported, you’ll see a **Project Settings** tab.
- Otherwise, the UI falls back to a **floating panel**.
The first section of the panel is **Project Health**. It tracks the state that matters
before queuing a prompt: project folder, workflow signature, current output target,
patchable save nodes, missing-model scan status, and workflow save name.
Recent ComfyUI frontends also get a compact **Project** button near the menu controls. It
uses ComfyUI's `ComfyButton` / `ComfyButtonGroup` component pattern, matching
ComfyUI-Manager's modern button integration, and falls back to the legacy `.comfy-menu`
button location on older frontends.
## ComfyUI Commands
The extension registers ComfyUI commands so recent ComfyUI frontends can expose them in
the command palette and topbar menu:
- `MajoorPS: Open Project Settings`
- `MajoorPS: Assign workflow to active project`
- `MajoorPS: Save workflow to project`
- `MajoorPS: Scan missing models`
- `MajoorPS: Patch save nodes now`
No default `Ctrl+S` keybinding is registered by this extension, because ComfyUI already
uses it for `Comfy.SaveWorkflow`. The legacy capture-phase browser shortcut is disabled
by default and remains available only through the `MajoorPS: Legacy Ctrl+S hook` setting.
## Debugging logs 🐞
- To enable verbose debug logs in the UI, set `window.MJR_DEBUG = true` in the browser dev console before interacting with the extension. When enabled, the UI will emit developer messages via `console.debug()`.
@@ -274,7 +302,7 @@ Rules:
- Default download size cap is 10GB per file; tune via `MJR_MODEL_DOWNLOAD_MAX_BYTES` or `MJR_MODEL_DOWNLOAD_MAX_GB`.
- **All public hosts are allowed by default** for maximum compatibility. To restrict hosts, set `MJR_MODEL_DOWNLOAD_ALLOW_ANY_HOST=0` / `mjr_project.download_allow_any_host=false` and configure `MJR_MODEL_DOWNLOAD_ALLOWED_HOSTS` (comma-separated list).
- Private/local IPs are blocked by default (SSRF guard); override with `MJR_MODEL_DOWNLOAD_BLOCK_PRIVATE_IPS=0` / `mjr_project.download_block_private_ips=false`.
- POST endpoints enforce same-origin when the browser sends an `Origin` header (basic CSRF mitigation).
- POST endpoints enforce same-origin and CSRF token checks for browser requests.
- Timeout can be tuned via `MJR_MODEL_DOWNLOAD_TIMEOUT` (seconds).
- Online search endpoint is rate-limited per client IP via `MJR_MODEL_SEARCH_RATE_PER_MIN` (default: 30/min).
@@ -406,7 +434,8 @@ So your settings survive browser refreshes and ComfyUI restarts (unless the brow
- This is by design: the extension is strict so it doesn’t become a foot-gun.
### "Ctrl+S conflicts"
- If you hook workflow saving, other extensions might also capture Ctrl+S.
- ComfyUI owns `Ctrl+S` through `Comfy.SaveWorkflow`; this extension does not register a default `Ctrl+S` keybinding.
- Only enable `MajoorPS: Legacy Ctrl+S hook` if you accept that it can override core/browser save behavior.
- Prefer capture-phase + stopping propagation (or disable your hotkey if exposed).
### "Model download fails with permission error"
+7 -6
View File
@@ -5,12 +5,13 @@ Frontend + API only (no nodes).
WEB_DIRECTORY = "./web/js"
from .server import ( # noqa: F401
model_downloader_routes,
model_fixer_routes,
model_registry_routes,
project_routes,
)
if __package__:
from .server import ( # noqa: F401
model_downloader_routes,
model_fixer_routes,
model_registry_routes,
project_routes,
)
# Required to keep ComfyUI from skipping this extension (no nodes on purpose).
NODE_CLASS_MAPPINGS = {}
+237
View File
@@ -0,0 +1,237 @@
# ComfyUI Core Alignment Improvement Audit
Date: 2026-05-27
## Sources Checked
- ComfyUI JavaScript commands/keybindings docs: https://docs.comfy.org/custom-nodes/js/javascript_commands_keybindings
- ComfyUI JavaScript settings docs: https://docs.comfy.org/custom-nodes/js/javascript_settings
- ComfyUI JavaScript extension overview: https://docs.comfy.org/custom-nodes/js/javascript_overview
- ComfyUI server routes docs: https://docs.comfy.org/development/comfyui-server/comms_routes
- ComfyUI server overview: https://docs.comfy.org/development/comfyui-server/comms_overview
- ComfyUI Registry overview: https://docs.comfy.org/registry/overview
- ComfyUI pyproject registry spec: https://docs.comfy.org/registry/specifications
## Current Strong Alignment
- Frontend assets are now under `web/js/` and exported through `WEB_DIRECTORY = "./web/js"`, matching current ComfyUI custom-node guidance.
- The extension registers through `app.registerExtension`.
- Settings are declared in the extension metadata.
- A command and keybinding are registered for project workflow save.
- Sidebar integration uses `registerSidebarTab` with a fallback floating panel.
- Python routes use `PromptServer.instance.routes`.
- `pyproject.toml` now has registry-oriented `[project]`, `[project.urls]`, and `[tool.comfy]` sections.
- TypeScript source is strict and compiles to committed runtime JavaScript.
## Highest Priority Improvements
### 1. Re-enable server protection helpers
Status: fixed in the current working tree; keep tests green before commit.
The audit found that `server/route_utils.py` disabled same-origin checks, API-key/loopback auth, and rate limiting. That conflicted with the README security claims and was risky because this extension can write workflows, mutate project indexes, scan local model folders, and start downloads.
Implemented fix:
- Restore same-origin validation for mutating routes.
- Require loopback access by default when no `MJR_API_KEY` is set.
- Honor `MJR_API_KEY` / `Authorization: Bearer`.
- Restore per-IP/per-bucket rate limiting.
- Validate CSRF cookie/header for browser-origin mutating requests.
- Keep explicit env overrides for trusted local debugging.
- Added tests for allowed loopback, rejected non-loopback, accepted API key, rejected origin, CSRF, and rate limit behavior.
### 2. Stop using raw `Ctrl+S` interception when modern command keybindings exist
Status: fixed in the current working tree; modern ComfyUI command/keybinding is primary.
`src/majoor_project_settings.ts` registers a ComfyUI command/keybinding, but still installs a capture-phase `window.addEventListener("keydown", ...)` that calls `stopImmediatePropagation()`.
ComfyUI docs note that extension keybinding collisions are undefined and core keybindings cannot be overwritten. The raw capture listener can still interfere with core or other extension behavior.
Implemented fix:
- Prefer the registered command/keybinding.
- Keep raw `keydown` only as an opt-in legacy fallback for older ComfyUI builds.
- Added `MajoorPS: Legacy Ctrl+S hook`.
- Defaulted that setting to `false`.
### 3. Unify setting reads through the modern settings API
Status: fixed in the current working tree.
`src/majoor_project_settings.ts` correctly prefers `app.extensionManager.setting.get(...)`, but `src/ui_components.ts` still reads only `app.ui.settings.settingsValues`.
Implemented fix:
- Move setting access into a shared frontend settings adapter.
- Use this order everywhere:
1. `app.extensionManager.setting.get(id)`
2. `app.ui.settings.getSettingValue(id)`
3. `app.ui.settings.settingsValues[id]`
4. fallback
- Add typed setting IDs and remove duplicate `toBool` helpers.
### 4. Reduce broad auto-open / auto-prompt behavior
Status: fixed in the current working tree.
The extension previously prompted on empty workflow transitions and auto-opened the Project Settings tab when missing models were detected by default. This was useful, but could feel intrusive inside ComfyUI workflows.
Implemented fix:
- Empty-graph modal prompting is now behind `MajoorPS: Prompt on empty workflow`, default `false`.
- Auto-open on missing models defaults to `false`.
- Passive status remains available through badge/toast/panel state.
### 5. Use ComfyUI-friendly names and registry metadata consistently
Status: medium.
The registry spec recommends short node IDs that do not include `ComfyUI`. The current `pyproject.toml` uses `majoor-project-settings`, which is good. The GitHub repo name can stay as-is, but docs should call the registry/package name `majoor-project-settings`.
Recommended fix:
- Add classifiers once license/support matrix is decided.
- Add `requires-comfyui` under `[tool.comfy]` after verifying the minimum ComfyUI version needed for sidebar/settings/commands.
- Add icon/banner URLs if publishing to registry.
- Decide license before registry publication; current `LicenseRef-Proprietary` is honest but limits adoption.
## Product Improvements For Project Settings
### 6. Project status should become a first-class ComfyUI command surface
Recommended features:
- Command: `MajoorPS: Open Project Settings`.
- Command: `MajoorPS: Assign active project to workflow`.
- Command: `MajoorPS: Patch save nodes now`.
- Command: `MajoorPS: Scan missing models`.
- Optional keybindings only for non-conflicting actions.
Why: ComfyUI command palette and keybindings are the current extension integration path.
### 7. Persist project metadata in workflow with a versioned schema
Current workflow signature uses `graph.extra.mjr_project`.
Recommended schema:
```json
{
"schema_version": 1,
"project_id": "...",
"project_folder": "...",
"template": "...",
"updated_at": "..."
}
```
Status: fixed in the current working tree with `schema_version: 1`.
### 8. Add a project health panel
Recommended checks:
- Project folder exists.
- Workflow mirror status.
- Missing model count.
- Save nodes already patched vs patchable.
- Last workflow save path.
- Output template preview validity.
Why: this turns the extension from a set of buttons into a reliable project state tool.
### 9. Add dry-run output patching
Status: fixed in the current working tree for the manual "Patch Now" path.
Implemented behavior:
- Show a list of nodes that would be changed.
- Show old path/prefix and new path/prefix.
- Let the user apply all or selected changes.
Why: patching graph nodes is visible state mutation; dry-run makes it predictable.
### 10. Make model download policy explicit in UI
Status: partially fixed in the current working tree.
Current README states all public hosts are allowed by default. The UI now surfaces a concise server-side policy note near the download controls.
Recommended follow-up UI:
- Show current host policy.
- Show private-IP blocking status.
- Show max size.
- Require explicit confirmation for unknown hosts.
- Add per-host allow/deny list editing if settings are available.
Why: download functionality is high trust and should expose its guardrails.
## Engineering Improvements
### 11. Add backend tests for routes, not only validators
Recommended tests:
- Auth/origin/rate limit helpers.
- Project create/list/resolve happy path.
- Template preview rejects traversal and absolute paths.
- Workflow save rejects invalid project/workflow names.
- Archive/unarchive/delete index-only behavior.
- Download URL validation and private IP blocking.
### 12. Split `ui_components.ts` by feature area
Suggested modules:
- `project_panel.ts`
- `workflow_panel.ts`
- `output_template_panel.ts`
- `missing_models_panel.ts`
- `model_registry_panel.ts`
- `project_status.ts`
Why: this is the remaining frontend file with broad responsibility and highest future regression risk.
### 13. Replace repeated ad hoc DOM styling with small helpers
`src/mjr/dom.ts` exists now. Expand it gradually:
- `button()`
- `field()`
- `section()`
- `detailsSection()`
- `statusText()`
Why: keeps vanilla JS/TS while reducing UI duplication. Vue is still not necessary unless the panel becomes much more stateful.
### 14. Add generated file policy
Recommended:
- Keep `src/` as source of truth.
- Keep `web/js/` committed for ComfyUI/Manager install compatibility.
- Optionally stop committing `.map` files if package size/noise matters.
- Keep `.d.ts` only if useful for downstream tooling; otherwise disable declaration emit.
## Suggested Implementation Order
1. Restore `route_utils.py` security helpers and tests.
2. Convert raw Ctrl+S hook into legacy fallback setting.
3. Centralize frontend settings access.
4. Add workflow signature `schema_version`.
5. Add backend route tests for project/template/workflow operations.
6. Add commands for open panel, scan missing models, patch save nodes.
7. Split `ui_components.ts` into feature modules.
8. Add patch dry-run UI.
9. Surface download policy in UI.
10. Prepare registry polish: `requires-comfyui`, license, icon/banner.
## Decision On Vue
Do not migrate to Vue yet.
ComfyUI frontend uses Vue internally, but custom-node extensions are still loaded as browser JS modules through `WEB_DIRECTORY`. The current panel can be improved more safely by splitting modules and using ComfyUI commands/settings/sidebar APIs. Revisit Vue only if the UI becomes a multi-view application with complex reactive state.
+90 -5
View File
@@ -9,6 +9,7 @@ import os
import secrets
import time
import ipaddress
import threading
from urllib.parse import urlparse
from typing import Any, Tuple
@@ -62,8 +63,50 @@ def _parse_host_port(value: str, default_port: int | None = None) -> Tuple[str,
def require_same_origin(request: web.Request) -> web.Response | None:
"""
Temporary override: Security disabled.
Require mutating browser requests to come from the same origin as ComfyUI.
Requests without an Origin header are allowed because same-origin fetches,
CLI clients, and some desktop contexts may omit it. Set
MJR_DISABLE_SAME_ORIGIN=1 only for trusted local debugging.
"""
if to_bool(os.environ.get("MJR_DISABLE_SAME_ORIGIN")):
return None
origin = (request.headers.get("Origin") or "").strip()
if not origin:
return None
host_header = (request.headers.get("Host") or "").strip()
if not host_header:
return json_error("missing Host header", status=403)
origin_host, origin_port = _parse_host_port(origin)
host, host_port = _parse_host_port(host_header)
if not origin_host or not host:
return json_error("invalid origin", status=403)
origin_scheme = urlparse(origin).scheme.lower()
default_port = _default_port_for_scheme(origin_scheme)
origin_port = origin_port or default_port
host_port = host_port or default_port
if origin_host != host or origin_port != host_port:
return json_error("cross-origin request blocked", status=403)
if to_bool(os.environ.get("MJR_DISABLE_CSRF")):
return None
expected_key = (os.environ.get("MJR_API_KEY") or "").strip()
provided_key = _api_key_from_request(request)
if expected_key and secrets.compare_digest(provided_key, expected_key):
return None
cookie_token = (request.cookies.get("mjr_csrf") or "").strip()
header_token = (request.headers.get("X-CSRF-Token") or "").strip()
if not cookie_token or not header_token:
return json_error("missing CSRF token", status=403)
if not secrets.compare_digest(cookie_token, header_token):
return json_error("invalid CSRF token", status=403)
return None
@@ -80,7 +123,12 @@ def _get_client_ip(request: web.Request) -> str:
def _is_loopback_ip(ip: str) -> bool:
return ip in ("127.0.0.1", "::1")
if ip in ("127.0.0.1", "::1", "localhost"):
return True
try:
return ipaddress.ip_address(ip).is_loopback
except ValueError:
return False
def _is_private_ip(ip_str: str) -> bool:
@@ -106,19 +154,56 @@ def _api_key_from_request(request: web.Request) -> str:
def require_auth(request: web.Request) -> web.Response | None:
"""
Temporary override: Security disabled.
Require an API key for non-loopback clients.
If MJR_API_KEY is set, every client must provide it as either
Authorization: Bearer <key> or X-MJR-API-Key. If no key is configured,
only loopback clients are allowed by default.
"""
if to_bool(os.environ.get("MJR_INSECURE_NO_AUTH")):
return None
expected_key = (os.environ.get("MJR_API_KEY") or "").strip()
provided_key = _api_key_from_request(request)
if expected_key:
if secrets.compare_digest(provided_key, expected_key):
return None
return json_error("unauthorized", status=401)
client_ip = _get_client_ip(request)
if _is_loopback_ip(client_ip):
return None
return json_error("unauthorized: configure MJR_API_KEY for remote access", status=401)
return None
_rate_lock = None
_rate_lock = threading.Lock()
_rate_state: dict[tuple[str, str], list[float]] = {}
def require_rate_limit(request: web.Request, bucket: str) -> web.Response | None:
"""
Temporary override: Security disabled.
Apply a simple per-client, per-bucket rate limit.
"""
raw_limit = os.environ.get("MJR_RATE_LIMIT_PER_MIN", "120")
try:
limit = int(raw_limit)
except (TypeError, ValueError):
limit = 120
if limit <= 0:
return None
now = time.monotonic()
cutoff = now - 60.0
key = (_get_client_ip(request), bucket)
with _rate_lock:
hits = [ts for ts in _rate_state.get(key, []) if ts >= cutoff]
if len(hits) >= limit:
_rate_state[key] = hits
return json_error("rate limit exceeded", status=429)
hits.append(now)
_rate_state[key] = hits
return None
+152 -32
View File
@@ -22,10 +22,12 @@ import {
isSaveLikeNode,
loadConfig,
patchSaveNodes,
previewPatchSaveNodes,
patchSingleNode,
stampGraphProjectSignature,
} from "./mjr/patch.js";
import { buildPanel } from "./ui_components.js";
import { updateMenuStatusBadge } from "./mjr/topbar_badge.js";
import {
createRuntimeState,
loadState,
@@ -35,10 +37,15 @@ import {
} from "./state_manager.js";
import { scanMissingModelsWithStats } from "./mjr/model_fixer.js";
import type { RuntimeState, SettingDefinition, SettingId, SidebarRenderOptions, ComfyNode } from "./types/domain.js";
import { getSettingBool, toBool, toNumber } from "./mjr/settings.js";
let workflowShortcutRegistered = false;
const COMMAND_SAVE_WORKFLOW_TO_PROJECT = "mjr.projectSettings.saveWorkflowToProject";
const COMMAND_OPEN_PANEL = "mjr.projectSettings.openPanel";
const COMMAND_ASSIGN_ACTIVE_PROJECT = "mjr.projectSettings.assignActiveProject";
const COMMAND_SCAN_MISSING_MODELS = "mjr.projectSettings.scanMissingModels";
const COMMAND_PATCH_SAVE_NODES = "mjr.projectSettings.patchSaveNodes";
const NODE_PATCH_RETRY_DELAY_MS = 60;
const NODE_PATCH_MAX_RETRIES = 1;
const SETTINGS_CATEGORY = "MajoorPS";
@@ -47,13 +54,17 @@ const DEFAULT_DOWNLOAD_MAX_GB = 50;
const AUTO_FIX_AFTER_DOWNLOAD_SETTING: SettingId = "mjr_project.auto_fix_after_download";
const AUTO_OPEN_PANEL_ON_MISSING_SETTING: SettingId = "mjr_project.auto_open_panel_on_missing";
const AUTO_SCAN_MISSING_ON_OPEN_SETTING: SettingId = "mjr_project.auto_scan_missing_on_open";
const PROMPT_EMPTY_GRAPH_SETTING: SettingId = "mjr_project.prompt_empty_graph";
const AUTO_REMEMBER_NOTE_SOURCES_SETTING: SettingId = "mjr_project.auto_remember_note_sources";
const NO_PROJECT_STATUS_BLACK_SETTING: SettingId = "mjr_project.status_black_no_project";
const LEGACY_CTRL_S_HOOK_SETTING: SettingId = "mjr_project.legacy_ctrl_s_hook";
const DEFAULT_AUTO_FIX_AFTER_DOWNLOAD = true;
const DEFAULT_AUTO_OPEN_PANEL = true;
const DEFAULT_AUTO_OPEN_PANEL = false;
const DEFAULT_AUTO_SCAN_MISSING_ON_OPEN = true;
const DEFAULT_PROMPT_EMPTY_GRAPH = false;
const DEFAULT_AUTO_REMEMBER_NOTE_SOURCES = true;
const DEFAULT_NO_PROJECT_STATUS_BLACK = false;
const DEFAULT_LEGACY_CTRL_S_HOOK = false;
const GRAPH_EMPTY_POLL_INTERVAL_MS = 900;
let graphEmptyWatcherId: ReturnType<typeof setInterval> | null = null;
@@ -90,32 +101,6 @@ function _rollOutputDateIfNeeded(state: RuntimeState): boolean {
return true;
}
const toNumber = (value: unknown, fallback: number): number => {
if (value == null) return fallback;
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
};
const toBool = (value: unknown, fallback: boolean): boolean => {
if (value == null) return fallback;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(v)) return true;
if (["false", "0", "no", "off"].includes(v)) return false;
}
return fallback;
};
const getSettingBool = (id: SettingId, fallback: boolean): boolean => {
const raw =
app?.extensionManager?.setting?.get?.(id) ??
app?.ui?.settings?.getSettingValue?.(id) ??
app?.ui?.settings?.settingsValues?.[id];
return toBool(raw, fallback);
};
function updateUI(state: RuntimeState): void {
const safe = (fn: (() => unknown) | undefined, label: string) => {
try {
@@ -297,7 +282,7 @@ function handleGraphEmptyTransition(state: RuntimeState, runToken: number | null
state.graphIsEmpty = currentEmpty;
if (!currentEmpty) {
state._emptyGraphPromptToken = null;
} else {
} else if (getSettingBool(PROMPT_EMPTY_GRAPH_SETTING, DEFAULT_PROMPT_EMPTY_GRAPH)) {
void promptEmptyGraphProject(state, runToken ?? state.graphLoadToken);
}
return true;
@@ -537,8 +522,102 @@ function saveActiveWorkflowToProjectCommand() {
void saveWorkflowToProject(state);
}
function openProjectSettingsCommand() {
const sidebar = app?.extensionManager?.sidebar;
if (sidebar && typeof sidebar.setTab === "function") {
sidebar.setTab("mjr_project_settings");
return;
}
const state = runtimeState;
if (state) {
state.workflowPanelOpen = true;
saveState(state);
updateUI(state);
}
}
function assignActiveProjectCommand() {
const state = runtimeState;
if (!state?.projectId || !state.projectFolder) {
toast("error", "No active project", "Set a project first.");
return;
}
stampGraphProjectSignature(app, state);
state.workflowHasSignature = true;
state.workflowChecked = true;
state.detected = null;
state.lastError = "";
saveState(state);
updateUI(state);
toast("success", "Workflow assigned", state.projectFolder);
}
function scanMissingModelsCommand() {
const state = runtimeState;
if (!state) return;
try {
const { missing } = scanMissingModelsWithStats();
state._ui?.refreshMissingStatus?.();
toast(
missing?.length ? "warn" : "success",
"Missing Models",
`${missing?.length || 0} missing model${missing?.length === 1 ? "" : "s"} detected`,
{ life: 4000 }
);
} catch (error) {
console.error("[mjr] Error scanning missing models:", error);
toast("error", "Missing Models", "Scan failed.");
}
}
async function patchSaveNodesCommand() {
const state = runtimeState;
if (!state?.projectId) {
toast("warn", "Patch skipped", "Set a project first.");
return;
}
_rollOutputDateIfNeeded(state);
let relDir = state.lastRelDir;
const prefix = state.lastPrefix || "";
if (!relDir && state.projectFolder) {
relDir = joinRel(`PROJECTS/${state.projectFolder}`, mediaDir(state.media || "images"));
}
if (!relDir) {
toast("warn", "Patch skipped", "Create an output target first.");
return;
}
const preview = previewPatchSaveNodes(app, relDir, prefix, state.media);
const nodeCount = new Set(preview.map((item) => item.node)).size;
if (nodeCount === 0) {
toast("warn", "No nodes patched", "No save-like nodes were found.");
return;
}
const previewLines = preview
.slice(0, 8)
.map((item) => `${item.nodeLabel}.${item.widget}: ${item.oldValue || "-"} -> ${item.newValue || "-"}`);
const overflow = preview.length > previewLines.length ? `\n...and ${preview.length - previewLines.length} more change(s)` : "";
const confirmed = await psConfirm({
title: "Patch save paths?",
message:
`Update ${nodeCount} save node${nodeCount === 1 ? "" : "s"} to use the project path?\n\n` +
`${previewLines.join("\n")}${overflow}\n\n` +
"This can be undone with Ctrl+Z.",
});
if (!confirmed) {
toast("info", "Patch cancelled", "No nodes were modified.");
return;
}
const patched = await patchSaveNodes(app, relDir, prefix, state.media);
toast(
patched ? "success" : "warn",
patched ? "Patch applied" : "No nodes patched",
patched ? `Updated ${patched} node${patched === 1 ? "" : "s"}.` : "No save-like nodes were updated."
);
}
function registerWorkflowShortcut() {
if (workflowShortcutRegistered) return;
if (!getSettingBool(LEGACY_CTRL_S_HOOK_SETTING, DEFAULT_LEGACY_CTRL_S_HOOK)) return;
workflowShortcutRegistered = true;
window.addEventListener(
"keydown",
@@ -590,6 +669,13 @@ const EXTENSION_SETTINGS: SettingDefinition[] = [
defaultValue: DEFAULT_AUTO_SCAN_MISSING_ON_OPEN,
category: SETTINGS_CATEGORY,
},
{
id: PROMPT_EMPTY_GRAPH_SETTING,
name: "MajoorPS: Prompt on empty workflow",
type: "boolean",
defaultValue: DEFAULT_PROMPT_EMPTY_GRAPH,
category: SETTINGS_CATEGORY,
},
{
id: AUTO_REMEMBER_NOTE_SOURCES_SETTING,
name: "MajoorPS: Auto-remember note sources",
@@ -604,6 +690,13 @@ const EXTENSION_SETTINGS: SettingDefinition[] = [
defaultValue: DEFAULT_NO_PROJECT_STATUS_BLACK,
category: SETTINGS_CATEGORY,
},
{
id: LEGACY_CTRL_S_HOOK_SETTING,
name: "MajoorPS: Legacy Ctrl+S hook",
type: "boolean",
defaultValue: DEFAULT_LEGACY_CTRL_S_HOOK,
category: SETTINGS_CATEGORY,
},
];
function registerLegacyExtensionSettings() {
@@ -633,11 +726,37 @@ app.registerExtension({
label: "MajoorPS: Save workflow to project",
function: saveActiveWorkflowToProjectCommand,
},
],
keybindings: [
{
combo: { key: "s", ctrl: true },
commandId: COMMAND_SAVE_WORKFLOW_TO_PROJECT,
id: COMMAND_OPEN_PANEL,
label: "MajoorPS: Open Project Settings",
function: openProjectSettingsCommand,
},
{
id: COMMAND_ASSIGN_ACTIVE_PROJECT,
label: "MajoorPS: Assign workflow to active project",
function: assignActiveProjectCommand,
},
{
id: COMMAND_SCAN_MISSING_MODELS,
label: "MajoorPS: Scan missing models",
function: scanMissingModelsCommand,
},
{
id: COMMAND_PATCH_SAVE_NODES,
label: "MajoorPS: Patch save nodes now",
function: patchSaveNodesCommand,
},
],
menuCommands: [
{
path: ["Extensions", "Majoor Project Settings"],
commands: [
COMMAND_OPEN_PANEL,
COMMAND_ASSIGN_ACTIVE_PROJECT,
COMMAND_SAVE_WORKFLOW_TO_PROJECT,
COMMAND_SCAN_MISSING_MODELS,
COMMAND_PATCH_SAVE_NODES,
],
},
],
@@ -691,6 +810,7 @@ app.registerExtension({
}
setRuntimeState(state);
_rollOutputDateIfNeeded(state);
updateMenuStatusBadge(state);
registerWorkflowShortcut();
state._emptyGraphPromptToken = null;
startGraphEmptyWatcher(state);
+80
View File
@@ -77,6 +77,85 @@ export function patchSingleNode(node: ComfyNode | null | undefined, relDir: unkn
return false;
}
export interface PatchPreviewItem {
node: ComfyNode;
nodeLabel: string;
widget: string;
oldValue: string;
newValue: string;
}
export function previewPatchSingleNode(
node: ComfyNode | null | undefined,
relDir: unknown,
filenamePrefix: unknown
): PatchPreviewItem[] {
if (!isSaveLikeNode(node)) return [];
if (!node) return [];
const rel = safeRel(relDir);
const prefix = safeRel(filenamePrefix);
const widgets = node?.widgets || [];
const filenameWidget = widgets.find((w) => w?.name === "filename_prefix");
const pathWidget = widgets.find((w) => w?.name && PATH_WIDGETS.includes(w.name));
const nodeLabel = String(node?.title || node?.comfyClass || node?.type || "Save node");
const items: PatchPreviewItem[] = [];
if (pathWidget && filenameWidget) {
items.push({
node,
nodeLabel,
widget: String(pathWidget.name),
oldValue: String(pathWidget.value ?? ""),
newValue: rel,
});
items.push({
node,
nodeLabel,
widget: String(filenameWidget.name),
oldValue: String(filenameWidget.value ?? ""),
newValue: prefix,
});
return items;
}
if (filenameWidget) {
items.push({
node,
nodeLabel,
widget: String(filenameWidget.name),
oldValue: String(filenameWidget.value ?? ""),
newValue: prefix ? joinRel(rel, prefix) : rel,
});
return items;
}
if (pathWidget) {
items.push({
node,
nodeLabel,
widget: String(pathWidget.name),
oldValue: String(pathWidget.value ?? ""),
newValue: rel,
});
}
return items;
}
export function previewPatchSaveNodes(
app: { graph?: ComfyGraph },
relDir: unknown,
filenamePrefix: unknown,
targetMedia?: ProjectMedia
): PatchPreviewItem[] {
const nodes = app?.graph?._nodes || [];
const items: PatchPreviewItem[] = [];
for (const node of nodes) {
const nm = detectNodeMedia(node);
if (targetMedia && nm && nm !== targetMedia) continue;
items.push(...previewPatchSingleNode(node, relDir, filenamePrefix));
}
return items;
}
export async function patchSaveNodes(
app: { graph?: ComfyGraph },
relDir: unknown,
@@ -105,6 +184,7 @@ export function stampGraphProjectSignature(app: { graph?: ComfyGraph }, state: R
if (!g) return;
g.extra = g.extra || {};
g.extra.mjr_project = {
schema_version: 1,
project_id: state.projectId,
project_folder: state.projectFolder,
template: state.pathTemplate || "",
+32
View File
@@ -0,0 +1,32 @@
import { app } from "../../../scripts/app.js";
import type { SettingId } from "../types/domain.js";
export function toBool(value: unknown, fallback: boolean): boolean {
if (value == null) return fallback;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(v)) return true;
if (["false", "0", "no", "off"].includes(v)) return false;
}
return fallback;
}
export function toNumber(value: unknown, fallback: number): number {
if (value == null) return fallback;
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
export function getSettingValue<T = unknown>(id: SettingId | string): T | undefined {
return (
app?.extensionManager?.setting?.get?.(id) ??
app?.ui?.settings?.getSettingValue?.(id) ??
app?.ui?.settings?.settingsValues?.[id]
) as T | undefined;
}
export function getSettingBool(id: SettingId | string, fallback: boolean): boolean {
return toBool(getSettingValue(id), fallback);
}
+172 -86
View File
@@ -1,100 +1,186 @@
import { applyStyles, createElement } from "./dom.js";
import { app } from "../../../scripts/app.js";
import type { RuntimeState } from "../types/domain.js";
const MENU_BADGE_ID = "mjr-project-status-badge";
const MENU_ACTION_SELECTORS = [
".comfy-menu-actions",
".comfy-menu-no-drag",
".comfy-topbar-actions",
".comfy-menu",
".workflow-tabs-container",
".workflow-tabs-container .flex",
".menu-actions",
];
const BUTTON_ID = "mjr-project-health-button";
const LEGACY_BUTTON_ID = "mjr-project-health-button-legacy";
let menuBadgeInterval: ReturnType<typeof setInterval> | null = null;
let menuBadgePendingState: RuntimeState | null = null;
let menuBadgeLogTimestamp = 0;
let buttonElement: HTMLElement | null = null;
let buttonGroupElement: HTMLElement | null = null;
let pendingState: RuntimeState | null = null;
let createPromise: Promise<void> | null = null;
function findMenuActionsContainer(): Element | null {
for (const selector of MENU_ACTION_SELECTORS) {
const candidate = document.querySelector(selector);
if (candidate) {
return candidate;
}
function openProjectSettings(): void {
const sidebar = app?.extensionManager?.sidebar;
if (sidebar && typeof sidebar.setTab === "function") {
sidebar.setTab("mjr_project_settings");
return;
}
return null;
try {
const state = pendingState;
if (state) {
state.workflowPanelOpen = true;
state._ui?.updateWorkflowBlock?.();
}
} catch (_) {}
}
function ensureMenuStatusBadge(actions: Element, text: string, color: string): HTMLElement {
let badge = document.getElementById(MENU_BADGE_ID);
if (!badge) {
badge = createElement("div", {
id: MENU_BADGE_ID,
styles: {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "4px 10px",
borderRadius: "999px",
fontSize: "11px",
fontWeight: "600",
border: "1px solid transparent",
marginLeft: "8px",
gap: "4px",
transition: "background 0.3s ease, border 0.3s ease",
cursor: "default",
},
function getHealth(state: RuntimeState): {
text: string;
background: string;
border: string;
title: string;
} {
const project = state.projectFolder || state.projectId || "No project";
const output = state.lastRelDir || state.lastPrefix
? `${state.lastRelDir || "-"} | ${state.lastPrefix || "-"}`
: "No output target";
if (state.lastError) {
return {
text: "Project: Error",
background: "#7f1d1d",
border: "#ef4444",
title: `Project Health\nError: ${state.lastError}\nProject: ${project}\nOutput: ${output}`,
};
}
if (state.graphIsEmpty) {
return {
text: "Project: Empty",
background: "#7f1d1d",
border: "#ef4444",
title: `Project Health\nGraph is empty\nProject: ${project}\nOutput: ${output}`,
};
}
if (!state.projectId) {
return {
text: "Project: None",
background: "#3f3f46",
border: "#71717a",
title: `Project Health\nNo active project\nOutput: ${output}`,
};
}
if (state.projectExists === false) {
return {
text: "Project: Missing",
background: "#7f1d1d",
border: "#ef4444",
title: `Project Health\nMissing folder: ${project}\nOutput: ${output}`,
};
}
if (state.workflowChecked && !state.workflowHasSignature) {
return {
text: `Project: ${project}`,
background: "#7c2d12",
border: "#f97316",
title: `Project Health\nWorkflow is not assigned\nProject: ${project}\nOutput: ${output}`,
};
}
const det = state.detected;
const mismatch =
det &&
det.mode !== "none" &&
!(
(det.project_id && det.project_id === state.projectId) ||
(det.project_folder && det.project_folder === state.projectFolder)
) &&
(det.project_id || det.project_folder);
if (mismatch) {
return {
text: "Project: Mismatch",
background: "#7c2d12",
border: "#f97316",
title: `Project Health\nWorkflow: ${det.project_folder || det.project_id}\nActive: ${project}\nOutput: ${output}`,
};
}
return {
text: `Project: ${project}`,
background: "#166534",
border: "#22c55e",
title: `Project Health\nActive project: ${project}\nWorkflow: assigned\nOutput: ${output}`,
};
}
function styleProjectButton(el: HTMLElement): void {
el.id = BUTTON_ID;
el.classList.add("comfyui-button", "comfyui-menu-mobile-collapse");
el.style.maxWidth = "260px";
el.style.overflow = "hidden";
el.style.textOverflow = "ellipsis";
el.style.whiteSpace = "nowrap";
}
async function ensureNewStyleButton(): Promise<boolean> {
if (buttonElement && document.body.contains(buttonElement)) return true;
if (buttonGroupElement && document.body.contains(buttonGroupElement)) return true;
if (!app?.menu?.settingsGroup?.element) return false;
try {
const [{ ComfyButton }, { ComfyButtonGroup }] = await Promise.all([
import("../../../scripts/ui/components/button.js"),
import("../../../scripts/ui/components/buttonGroup.js"),
]);
const button = new ComfyButton({
icon: "briefcase",
action: openProjectSettings,
tooltip: "Majoor Project Health",
content: "Project",
classList: "comfyui-button comfyui-menu-mobile-collapse",
});
actions.appendChild(badge);
buttonElement = button.element as HTMLElement;
styleProjectButton(buttonElement);
const group = new ComfyButtonGroup(buttonElement);
buttonGroupElement = group.element as HTMLElement;
app.menu.settingsGroup.element.before(buttonGroupElement);
return true;
} catch (error) {
console.warn("[mjr] New style ComfyUI button unavailable", error);
return false;
}
}
function ensureLegacyButton(): boolean {
let button = document.getElementById(LEGACY_BUTTON_ID) as HTMLButtonElement | null;
if (!button) {
const menu = document.querySelector(".comfy-menu");
if (!menu) return false;
button = document.createElement("button");
button.id = LEGACY_BUTTON_ID;
button.type = "button";
button.onclick = openProjectSettings;
menu.append(button);
}
buttonElement = button;
return true;
}
function applyHealth(state: RuntimeState): void {
if (!buttonElement) return;
const health = getHealth(state);
buttonElement.textContent = health.text;
buttonElement.title = health.title;
buttonElement.style.background = health.background;
buttonElement.style.borderColor = health.border;
buttonElement.style.color = "white";
}
async function ensureAndUpdate(): Promise<void> {
if (!pendingState) return;
const hasButton = (await ensureNewStyleButton()) || ensureLegacyButton();
if (hasButton && pendingState) {
applyHealth(pendingState);
}
badge.textContent = text;
applyStyles(badge, {
background: color,
borderColor: color,
});
return badge;
}
export function updateMenuStatusBadge(state: RuntimeState): void {
menuBadgePendingState = state;
const actions = findMenuActionsContainer();
if (!actions) {
if (!menuBadgeInterval) {
menuBadgeInterval = setInterval(() => {
if (menuBadgePendingState) {
updateMenuStatusBadge(menuBadgePendingState);
}
}, 500);
}
const now = Date.now();
if (now - menuBadgeLogTimestamp > 5000) {
console.info(
`[mjr] Waiting for topbar badge target (${MENU_ACTION_SELECTORS.join(
", "
)}) project=${state?.projectId || "none"} folder=${state?.projectFolder || "none"}`
);
menuBadgeLogTimestamp = now;
}
console.debug("[mjr] comfy menu actions not found yet, retrying badge soon", state);
return;
pendingState = state;
if (!createPromise) {
createPromise = ensureAndUpdate().finally(() => {
createPromise = null;
});
} else {
void createPromise.then(ensureAndUpdate);
}
if (menuBadgeInterval) {
clearInterval(menuBadgeInterval);
menuBadgeInterval = null;
}
const badgeText = state.projectId
? state.projectExists === false
? "Projet introuvable"
: `Projet: ${state.projectFolder || state.projectId}`
: "Pas de projet actif";
const badgeColor = state.projectId
? state.projectExists === false
? "rgba(179, 58, 58, 0.9)"
: "rgba(47, 143, 78, 0.95)"
: "rgba(102, 102, 102, 0.8)";
ensureMenuStatusBadge(actions, badgeText, badgeColor);
menuBadgePendingState = null;
}
+40
View File
@@ -10,6 +10,46 @@ declare module "*/scripts/app.js" {
export const app: any;
}
declare module "../../../scripts/ui/components/button.js" {
export class ComfyButton {
element: HTMLElement;
constructor(options: {
icon?: string;
action?: () => void;
tooltip?: string;
content?: string;
classList?: string;
});
}
}
declare module "*/scripts/ui/components/button.js" {
export class ComfyButton {
element: HTMLElement;
constructor(options: {
icon?: string;
action?: () => void;
tooltip?: string;
content?: string;
classList?: string;
});
}
}
declare module "../../../scripts/ui/components/buttonGroup.js" {
export class ComfyButtonGroup {
element: HTMLElement;
constructor(...elements: HTMLElement[]);
}
}
declare module "*/scripts/ui/components/buttonGroup.js" {
export class ComfyButtonGroup {
element: HTMLElement;
constructor(...elements: HTMLElement[]);
}
}
interface Window {
MJR_DEBUG?: boolean;
}
+4 -1
View File
@@ -135,8 +135,10 @@ export type SettingId =
| "mjr_project.auto_fix_after_download"
| "mjr_project.auto_open_panel_on_missing"
| "mjr_project.auto_scan_missing_on_open"
| "mjr_project.prompt_empty_graph"
| "mjr_project.auto_remember_note_sources"
| "mjr_project.status_black_no_project";
| "mjr_project.status_black_no_project"
| "mjr_project.legacy_ctrl_s_hook";
export type SettingDefinition =
| {
@@ -236,6 +238,7 @@ export interface ComfyNode {
}
export interface GraphProjectSignature {
schema_version?: number;
project_id?: string;
project_folder?: string;
template?: string;
+8
View File
@@ -0,0 +1,8 @@
export const TEMPLATE_TOKENS = ["{BASE}", "{MEDIA}", "{DATE}", "{MODEL}", "{NAME}", "{KIND}"];
export const PROJECT_REFRESH_THROTTLE_MS = 5000;
export const PROJECT_REFRESH_ERROR_BACKOFF_MS = 10000;
export const SETTING_AUTO_FIX_AFTER_DOWNLOAD = "mjr_project.auto_fix_after_download";
export const SETTING_AUTO_SCAN_MISSING_ON_OPEN = "mjr_project.auto_scan_missing_on_open";
export const SETTING_AUTO_REMEMBER_NOTE_SOURCES = "mjr_project.auto_remember_note_sources";
+124
View File
@@ -0,0 +1,124 @@
export type DetailsWithSummary = HTMLDetailsElement & { _summary?: HTMLElement };
export interface PanelHelpers {
section: (label: string) => HTMLDivElement;
divider: (thick?: boolean) => HTMLDivElement;
detailsSection: (title: string) => DetailsWithSummary;
makeLabel: (labelText: string) => HTMLLabelElement;
styleField: (inputEl: HTMLElement) => void;
row: (labelText: string, inputEl: HTMLElement) => HTMLDivElement;
groupRow: (labelText: string, contentEl: HTMLElement) => HTMLDivElement;
btn: (text: string) => HTMLButtonElement;
}
export function createPanelHelpers(gap: string): PanelHelpers {
const section = (label: string): HTMLDivElement => {
const h = document.createElement("div");
h.textContent = label;
h.style.fontWeight = "600";
h.style.marginTop = gap;
h.style.marginBottom = gap;
h.style.paddingLeft = "2px";
h.style.textTransform = "uppercase";
h.style.letterSpacing = "0.6px";
h.style.color = "#8fb9ff";
return h;
};
const divider = (thick = false): HTMLDivElement => {
const d = document.createElement("div");
d.style.height = thick ? "3px" : "1px";
d.style.background = "rgba(255,255,255,0.08)";
d.style.margin = "0";
return d;
};
const detailsSection = (title: string): DetailsWithSummary => {
const details = document.createElement("details") as DetailsWithSummary;
details.style.marginTop = "0";
details.style.display = "flex";
details.style.flexDirection = "column";
details.style.gap = gap;
const summary = document.createElement("summary");
summary.textContent = title;
summary.style.cursor = "pointer";
summary.style.opacity = "0.9";
summary.style.textTransform = "uppercase";
summary.style.letterSpacing = "0.6px";
summary.style.color = "#8fb9ff";
summary.style.fontWeight = "600";
summary.style.paddingLeft = "2px";
details.appendChild(summary);
details._summary = summary;
return details;
};
const makeLabel = (labelText: string): HTMLLabelElement => {
const label = document.createElement("label");
label.textContent = labelText;
label.style.opacity = "0.85";
label.style.marginLeft = "2px";
label.style.fontSize = "11px";
label.style.lineHeight = "1.2";
return label;
};
const styleField = (inputEl: HTMLElement): void => {
inputEl.style.width = "100%";
inputEl.style.boxSizing = "border-box";
inputEl.style.padding = "6px 8px";
inputEl.style.borderRadius = "8px";
inputEl.style.border = "1px solid rgba(255,255,255,0.12)";
inputEl.style.background = "rgba(0,0,0,0.25)";
inputEl.style.color = "#eee";
inputEl.style.outline = "none";
};
const row = (labelText: string, inputEl: HTMLElement): HTMLDivElement => {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = gap;
wrap.appendChild(makeLabel(labelText));
styleField(inputEl);
wrap.appendChild(inputEl);
return wrap;
};
const groupRow = (labelText: string, contentEl: HTMLElement): HTMLDivElement => {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = gap;
wrap.appendChild(makeLabel(labelText));
wrap.appendChild(contentEl);
return wrap;
};
const btn = (text: string): HTMLButtonElement => {
const b = document.createElement("button");
b.textContent = text;
b.className = "mjr-ps-btn";
b.style.padding = "7px 8px";
b.style.borderRadius = "8px";
b.style.border = "1px solid rgba(46, 136, 255, 0.65)";
b.style.background = "rgba(46, 136, 255, 0.25)";
b.style.color = "#eee";
b.style.cursor = "pointer";
b.style.margin = "0";
b.style.lineHeight = "1.2";
b.style.whiteSpace = "nowrap";
return b;
};
return {
section,
divider,
detailsSection,
makeLabel,
styleField,
row,
groupRow,
btn,
};
}
+36
View File
@@ -0,0 +1,36 @@
import type { ProjectListEntry } from "../types/domain.js";
export interface ProjectDropdownTargets {
pendingSelect: HTMLSelectElement;
}
export function renderProjectDropdowns(
targets: ProjectDropdownTargets,
projects: ProjectListEntry[]
): void {
const { pendingSelect } = targets;
const currentPending = pendingSelect.value;
pendingSelect.innerHTML = "";
const emptyOpt = document.createElement("option");
emptyOpt.value = "";
emptyOpt.textContent = "-";
pendingSelect.appendChild(emptyOpt);
for (const project of projects) {
const projectId = project.project_id || "";
if (!projectId) continue;
const opt = document.createElement("option");
opt.value = projectId;
opt.dataset.folder = project.folder || "";
opt.dataset.exists = project.exists === false ? "false" : "true";
const missingTag = project.exists === false ? " (missing)" : "";
const archivedTag = project.archived ? " [archived]" : "";
opt.textContent = `${project.folder || project.project_id} (${project.project_id})${missingTag}${archivedTag}`;
pendingSelect.appendChild(opt);
}
if (currentPending) pendingSelect.value = currentPending;
}
+81
View File
@@ -0,0 +1,81 @@
import type { RuntimeState } from "../types/domain.js";
import { getSettingBool } from "../mjr/settings.js";
const SETTING_NO_PROJECT_STATUS_BLACK = "mjr_project.status_black_no_project";
export interface ProjectStatus {
color: string;
text: string;
level: string;
}
function parseHexColor(hex: unknown): [number, number, number] | null {
const m = String(hex || "").trim().match(/^#([0-9a-fA-F]{6})$/);
if (!m) return null;
const v = m[1];
return [
parseInt(v.slice(0, 2), 16),
parseInt(v.slice(2, 4), 16),
parseInt(v.slice(4, 6), 16),
];
}
export function colorWithAlpha(hex: unknown, alpha: unknown): string {
const rgb = parseHexColor(hex);
if (!rgb) return String(hex);
const a = Math.max(0, Math.min(1, Number(alpha)));
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})`;
}
export function makeStatus(state: RuntimeState): ProjectStatus {
const det = state.detected;
const sameProject =
det &&
det.mode !== "none" &&
((det.project_id && det.project_id === state.projectId) ||
(det.project_folder && det.project_folder === state.projectFolder));
const mismatch =
det &&
det.mode !== "none" &&
!sameProject &&
(det.project_id || det.project_folder);
if (state.lastError) {
return { color: "#b33a3a", text: `Error: ${state.lastError}`, level: "red" };
}
if (state.graphIsEmpty) {
return { color: "#b33a3a", text: "Empty graph, set or create a project", level: "red" };
}
if (!state.projectId) {
if (getSettingBool(SETTING_NO_PROJECT_STATUS_BLACK, false)) {
return { color: "#000000", text: "No project (test)", level: "black" };
}
return { color: "#666666", text: "No active project", level: "gray" };
}
if (state.projectExists === false) {
return {
color: "#b33a3a",
text: `Missing project folder: ${state.projectFolder || state.projectId}`,
level: "red",
};
}
if (state.workflowChecked && !state.workflowHasSignature) {
return {
color: "#c77c2a",
text: "Active project, workflow unassigned",
level: "orange",
};
}
if (mismatch) {
return {
color: "#c77c2a",
text: `Project mismatch: ${det.project_folder || det.project_id}`,
level: "orange",
};
}
return {
color: "#2f8f4e",
text: `Active: ${state.projectFolder} (${state.projectId})`,
level: "green",
};
}
+129 -218
View File
@@ -21,6 +21,7 @@ import {
detectNodeMedia,
isSaveLikeNode,
patchSaveNodes,
previewPatchSaveNodes,
patchSingleNode,
stampGraphProjectSignature,
} from "./mjr/patch.js";
@@ -40,103 +41,18 @@ import { getSerializedWorkflow } from "./mjr/graph.js";
import { extractNoteTexts } from "./mjr/workflow_note_recipes.js";
import { debug } from "./mjr/log.js";
import { updateMenuStatusBadge } from "./mjr/topbar_badge.js";
const TEMPLATE_TOKENS = ["{BASE}", "{MEDIA}", "{DATE}", "{MODEL}", "{NAME}", "{KIND}"];
const PROJECT_REFRESH_THROTTLE_MS = 5000;
const PROJECT_REFRESH_ERROR_BACKOFF_MS = 10000;
const SETTING_AUTO_FIX_AFTER_DOWNLOAD = "mjr_project.auto_fix_after_download";
const SETTING_AUTO_SCAN_MISSING_ON_OPEN = "mjr_project.auto_scan_missing_on_open";
const SETTING_AUTO_REMEMBER_NOTE_SOURCES = "mjr_project.auto_remember_note_sources";
const SETTING_NO_PROJECT_STATUS_BLACK = "mjr_project.status_black_no_project";
const toBool = (value: unknown, fallback: boolean): boolean => {
if (value == null) return fallback;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(v)) return true;
if (["false", "0", "no", "off"].includes(v)) return false;
}
return fallback;
};
const getSettingBool = (id: string, fallback: boolean): boolean => {
const raw = app?.ui?.settings?.settingsValues?.[id];
return toBool(raw, fallback);
};
function parseHexColor(hex: unknown): [number, number, number] | null {
const m = String(hex || "").trim().match(/^#([0-9a-fA-F]{6})$/);
if (!m) return null;
const v = m[1];
return [
parseInt(v.slice(0, 2), 16),
parseInt(v.slice(2, 4), 16),
parseInt(v.slice(4, 6), 16),
];
}
function colorWithAlpha(hex: unknown, alpha: unknown): string {
const rgb = parseHexColor(hex);
if (!rgb) return String(hex);
const a = Math.max(0, Math.min(1, Number(alpha)));
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})`;
}
function makeStatus(state: RuntimeState): { color: string; text: string; level: string } {
const det = state.detected;
const sameProject =
det &&
det.mode !== "none" &&
((det.project_id && det.project_id === state.projectId) ||
(det.project_folder && det.project_folder === state.projectFolder));
const mismatch =
det &&
det.mode !== "none" &&
!sameProject &&
(det.project_id || det.project_folder);
if (state.lastError) {
return { color: "#b33a3a", text: `Error: ${state.lastError}`, level: "red" };
}
if (state.graphIsEmpty) {
return { color: "#b33a3a", text: "Empty graph, set or create a project", level: "red" };
}
if (!state.projectId) {
if (getSettingBool(SETTING_NO_PROJECT_STATUS_BLACK, false)) {
return { color: "#000000", text: "No project (test)", level: "black" };
}
return { color: "#666666", text: "No active project", level: "gray" };
}
if (state.projectExists === false) {
return {
color: "#b33a3a",
text: `Missing project folder: ${state.projectFolder || state.projectId}`,
level: "red",
};
}
if (state.workflowChecked && !state.workflowHasSignature) {
return {
color: "#c77c2a",
text: "Active project, workflow unassigned",
level: "orange",
};
}
if (mismatch) {
return {
color: "#c77c2a",
text: `Project mismatch: ${det.project_folder || det.project_id}`,
level: "orange",
};
}
return {
color: "#2f8f4e",
text: `Active: ${state.projectFolder} (${state.projectId})`,
level: "green",
};
}
import { getSettingBool } from "./mjr/settings.js";
import { createPanelHelpers } from "./ui/panel_helpers.js";
import { colorWithAlpha, makeStatus } from "./ui/project_status.js";
import { renderProjectDropdowns } from "./ui/project_dropdowns.js";
import {
PROJECT_REFRESH_ERROR_BACKOFF_MS,
PROJECT_REFRESH_THROTTLE_MS,
SETTING_AUTO_FIX_AFTER_DOWNLOAD,
SETTING_AUTO_REMEMBER_NOTE_SOURCES,
SETTING_AUTO_SCAN_MISSING_ON_OPEN,
TEMPLATE_TOKENS,
} from "./ui/panel_constants.js";
export function buildPanel(el: HTMLElement, state: RuntimeState, actions: SidebarRenderOptions): void {
const { createAndActivateProject, setActiveProjectById, saveWorkflowToProject } = actions || {};
@@ -154,106 +70,16 @@ export function buildPanel(el: HTMLElement, state: RuntimeState, actions: Sideba
container.style.fontFamily = "inherit";
container.style.padding = "8px 14px 16px";
type DetailsWithSummary = HTMLDetailsElement & { _summary?: HTMLElement };
const section = (label: string): HTMLDivElement => {
const h = document.createElement("div");
h.textContent = label;
h.style.fontWeight = "600";
h.style.marginTop = UI_GAP;
h.style.marginBottom = UI_GAP;
h.style.paddingLeft = "2px";
h.style.textTransform = "uppercase";
h.style.letterSpacing = "0.6px";
h.style.color = "#8fb9ff";
return h;
};
const divider = (thick = false) => {
const d = document.createElement("div");
d.style.height = thick ? "3px" : "1px";
d.style.background = "rgba(255,255,255,0.08)";
d.style.margin = "0";
return d;
};
const detailsSection = (title: string): DetailsWithSummary => {
const details = document.createElement("details") as DetailsWithSummary;
details.style.marginTop = "0";
details.style.display = "flex";
details.style.flexDirection = "column";
details.style.gap = UI_GAP;
const summary = document.createElement("summary");
summary.textContent = title;
summary.style.cursor = "pointer";
summary.style.opacity = "0.9";
summary.style.textTransform = "uppercase";
summary.style.letterSpacing = "0.6px";
summary.style.color = "#8fb9ff";
summary.style.fontWeight = "600";
summary.style.paddingLeft = "2px";
details.appendChild(summary);
details._summary = summary;
return details;
};
const makeLabel = (labelText: string): HTMLLabelElement => {
const label = document.createElement("label");
label.textContent = labelText;
label.style.opacity = "0.85";
label.style.marginLeft = "2px";
label.style.fontSize = "11px";
label.style.lineHeight = "1.2";
return label;
};
const styleField = (inputEl: HTMLElement): void => {
inputEl.style.width = "100%";
inputEl.style.boxSizing = "border-box";
inputEl.style.padding = "6px 8px";
inputEl.style.borderRadius = "8px";
inputEl.style.border = "1px solid rgba(255,255,255,0.12)";
inputEl.style.background = "rgba(0,0,0,0.25)";
inputEl.style.color = "#eee";
inputEl.style.outline = "none";
};
const row = (labelText: string, inputEl: HTMLElement): HTMLDivElement => {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = UI_GAP;
wrap.appendChild(makeLabel(labelText));
styleField(inputEl);
wrap.appendChild(inputEl);
return wrap;
};
const groupRow = (labelText: string, contentEl: HTMLElement): HTMLDivElement => {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = UI_GAP;
wrap.appendChild(makeLabel(labelText));
wrap.appendChild(contentEl);
return wrap;
};
const btn = (text: string): HTMLButtonElement => {
const b = document.createElement("button");
b.textContent = text;
b.className = "mjr-ps-btn";
b.style.padding = "7px 8px";
b.style.borderRadius = "8px";
b.style.border = "1px solid rgba(46, 136, 255, 0.65)";
b.style.background = "rgba(46, 136, 255, 0.25)";
b.style.color = "#eee";
b.style.cursor = "pointer";
b.style.margin = "0";
b.style.lineHeight = "1.2";
b.style.whiteSpace = "nowrap";
return b;
};
const {
section,
divider,
detailsSection,
makeLabel,
styleField,
row,
groupRow,
btn,
} = createPanelHelpers(UI_GAP);
// Status bar
const statusBar = document.createElement("div");
@@ -306,6 +132,92 @@ export function buildPanel(el: HTMLElement, state: RuntimeState, actions: Sideba
updateWorkflowBlock();
});
const healthSection = detailsSection("Project Health");
healthSection.open = true;
const healthList = document.createElement("div");
healthList.style.display = "grid";
healthList.style.gridTemplateColumns = "auto minmax(0, 1fr)";
healthList.style.columnGap = "8px";
healthList.style.rowGap = "6px";
healthList.style.alignItems = "start";
healthList.style.padding = "8px 0 0";
healthSection.appendChild(healthList);
const healthRows: Record<string, HTMLDivElement> = {};
const addHealthRow = (key: string, labelText: string): void => {
const label = document.createElement("div");
label.textContent = labelText;
label.style.opacity = "0.7";
label.style.whiteSpace = "nowrap";
const value = document.createElement("div");
value.style.minWidth = "0";
value.style.overflowWrap = "anywhere";
healthRows[key] = value;
healthList.appendChild(label);
healthList.appendChild(value);
};
addHealthRow("folder", "Folder");
addHealthRow("workflow", "Workflow");
addHealthRow("target", "Output");
addHealthRow("patch", "Patch");
addHealthRow("models", "Models");
addHealthRow("save", "Last save");
const setHealth = (key: string, text: string, color = "#ddd"): void => {
const rowEl = healthRows[key];
if (!rowEl) return;
rowEl.textContent = text;
rowEl.style.color = color;
};
const updateHealth = (): void => {
const okColor = "#9dd7a5";
const warnColor = "#f2d98a";
const errorColor = "#ff9a9a";
const mutedColor = "#aaa";
setHealth(
"folder",
state.projectId
? state.projectExists === false
? `Missing: ${state.projectFolder || state.projectId}`
: state.projectFolder || state.projectId
: "No active project",
!state.projectId ? mutedColor : state.projectExists === false ? errorColor : okColor
);
setHealth(
"workflow",
state.workflowChecked
? state.workflowHasSignature
? "Assigned"
: "Unassigned"
: "Not checked yet",
state.workflowChecked && !state.workflowHasSignature ? warnColor : okColor
);
setHealth(
"target",
state.lastRelDir || state.lastPrefix ? `${state.lastRelDir || "-"} | ${state.lastPrefix || "-"}` : "No output target",
state.lastRelDir || state.lastPrefix ? okColor : warnColor
);
const patchPreview = state.lastRelDir || state.lastPrefix
? previewPatchSaveNodes(app, state.lastRelDir, state.lastPrefix, state.media)
: [];
const patchNodes = new Set(patchPreview.map((item) => item.node)).size;
setHealth(
"patch",
patchNodes > 0 ? `${patchNodes} save node${patchNodes === 1 ? "" : "s"} patchable` : "No patchable save nodes",
patchNodes > 0 ? warnColor : mutedColor
);
const missingText = fixerState.scanned
? `${fixerState.missing} missing / ${fixerState.total} checked`
: "Not scanned";
setHealth("models", missingText, fixerState.scanned && fixerState.missing > 0 ? warnColor : mutedColor);
setHealth(
"save",
state.workflowName || state.workflowLastBase || "No workflow save yet",
state.workflowName || state.workflowLastBase ? okColor : mutedColor
);
};
const updateResolve = () => {
const det = state.detected;
let show = true;
@@ -851,6 +763,12 @@ export function buildPanel(el: HTMLElement, state: RuntimeState, actions: Sideba
const downloadStatusText = document.createElement("div");
downloadStatus.appendChild(downloadStatusText);
const downloadPolicyText = document.createElement("div");
downloadPolicyText.textContent =
"Download policy: direct http/https URLs, extension allowlist, size cap, host policy, and private-IP guard are enforced server-side.";
downloadPolicyText.style.opacity = "0.75";
downloadStatus.appendChild(downloadPolicyText);
const downloadProgressWrap = document.createElement("div");
downloadProgressWrap.style.height = "6px";
downloadProgressWrap.style.width = "100%";
@@ -888,6 +806,7 @@ export function buildPanel(el: HTMLElement, state: RuntimeState, actions: Sideba
workflowUtilsWrap.appendChild(workflowUtilsContent);
container.appendChild(statusBar);
container.appendChild(healthSection);
container.appendChild(workflowWrap);
container.appendChild(resolveWrap);
container.appendChild(divider());
@@ -1096,6 +1015,7 @@ export function buildPanel(el: HTMLElement, state: RuntimeState, actions: Sideba
}
workflowUtilsWrap.style.border = `1px solid ${colorWithAlpha(color, 0.4)}`;
workflowUtilsWrap.style.background = colorWithAlpha(color, 0.12);
updateHealth();
};
const updateDownloadStatus = () => {
@@ -1318,6 +1238,7 @@ export function buildPanel(el: HTMLElement, state: RuntimeState, actions: Sideba
if (!template) {
previewLabel.textContent = "Preview: -";
updateHealth();
return;
}
@@ -1327,10 +1248,12 @@ export function buildPanel(el: HTMLElement, state: RuntimeState, actions: Sideba
if (requestToken !== previewToken) return;
const preview = resp?.preview || "";
previewLabel.textContent = preview ? `Preview: ${preview}` : "Preview: -";
updateHealth();
} catch (err) {
if (requestToken !== previewToken) return;
const previewError = String(err instanceof Error ? err.message : err || "Invalid template");
previewLabel.textContent = `Preview: ${previewError}`;
updateHealth();
}
};
@@ -1425,22 +1348,7 @@ export function buildPanel(el: HTMLElement, state: RuntimeState, actions: Sideba
let projectRefreshErrorCount = 0;
const updateProjectDropdowns = (filteredList: ProjectListEntry[]): void => {
pendingSelect.innerHTML = "";
const pendingEmpty = document.createElement("option");
pendingEmpty.value = "";
pendingEmpty.textContent = "-";
pendingSelect.appendChild(pendingEmpty);
for (const p of filteredList) {
const opt = document.createElement("option");
opt.value = p.project_id;
opt.dataset.folder = p.folder || "";
opt.dataset.exists = p.exists === false ? "false" : "true";
const missingTag = p.exists === false ? " (missing)" : "";
const archivedTag = p.archived ? " [archived]" : "";
opt.textContent = `${p.folder || p.project_id} (${p.project_id})${missingTag}${archivedTag}`;
pendingSelect.appendChild(opt);
}
renderProjectDropdowns({ pendingSelect }, filteredList);
};
refreshProjectsList = async (force = false) => {
@@ -2261,22 +2169,25 @@ export function buildPanel(el: HTMLElement, state: RuntimeState, actions: Sideba
return;
}
// Count save nodes first
const saveNodes = ((app.graph?._nodes || []) as (ComfyNode | null)[]).filter(n => isSaveLikeNode(n));
const nodeCount = saveNodes.length;
const preview = previewPatchSaveNodes(app, state.lastRelDir, state.lastPrefix, state.media);
const nodeCount = new Set(preview.map((item) => item.node)).size;
if (nodeCount === 0) {
toast("warn", "No nodes to patch", "No save-like nodes found in workflow.");
return;
}
const previewLines = preview
.slice(0, 8)
.map((item) => `${item.nodeLabel}.${item.widget}: ${item.oldValue || "-"} -> ${item.newValue || "-"}`);
const overflow = preview.length > previewLines.length ? `\n...and ${preview.length - previewLines.length} more change(s)` : "";
// Ask for confirmation
const confirmed = await psConfirm({
title: "Patch save paths?",
message:
`Update ${nodeCount} save node${nodeCount !== 1 ? "s" : ""} to use the project path?\n\n` +
`Path: ${state.lastRelDir}\n` +
`Prefix: ${state.lastPrefix}\n\n` +
`${previewLines.join("\n")}${overflow}\n\n` +
`This can be undone with Ctrl+Z.`,
});
+21
View File
@@ -7,6 +7,7 @@ import {
detectNodeMedia,
isSaveLikeNode,
patchSingleNode,
previewPatchSingleNode,
} from "../../web/js/mjr/patch.js";
test("patchSingleNode patches split path and filename widgets", () => {
@@ -25,6 +26,26 @@ test("patchSingleNode patches split path and filename widgets", () => {
assert.equal(node.widgets[1].value, "260526_Test");
});
test("previewPatchSingleNode shows old and new widget values", () => {
PATH_WIDGETS.splice(0, PATH_WIDGETS.length, "output_path");
const node = {
title: "Preview Save",
type: "SaveImage",
widgets: [
{ name: "output_path", value: "old/path" },
{ name: "filename_prefix", value: "old_prefix" },
],
};
const preview = previewPatchSingleNode(node, "PROJECTS/Demo/02_OUT/IMAGES", "260526_Test");
assert.equal(preview.length, 2);
assert.equal(preview[0].nodeLabel, "Preview Save");
assert.equal(preview[0].oldValue, "old/path");
assert.equal(preview[0].newValue, "PROJECTS/Demo/02_OUT/IMAGES");
assert.equal(preview[1].oldValue, "old_prefix");
assert.equal(preview[1].newValue, "260526_Test");
});
test("patchSingleNode falls back to filename_prefix only nodes", () => {
PATH_WIDGETS.splice(0, PATH_WIDGETS.length, "output_path");
const node = {
+94
View File
@@ -0,0 +1,94 @@
import importlib.util
import os
import unittest
from pathlib import Path
from types import SimpleNamespace
def _load_module(relpath: str):
root = Path(__file__).resolve().parents[1]
path = root / relpath
spec = importlib.util.spec_from_file_location(relpath.replace("/", "_"), path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
route_utils = _load_module("server/route_utils.py")
class EnvGuard:
def __enter__(self):
self.original = os.environ.copy()
return self
def __exit__(self, exc_type, exc, tb):
os.environ.clear()
os.environ.update(self.original)
def request(headers=None, remote="127.0.0.1", cookies=None):
return SimpleNamespace(headers=headers or {}, remote=remote, cookies=cookies or {})
class TestRouteUtils(unittest.TestCase):
def setUp(self):
route_utils._rate_state.clear()
def test_auth_allows_loopback_without_api_key(self):
with EnvGuard():
os.environ.pop("MJR_API_KEY", None)
self.assertIsNone(route_utils.require_auth(request(remote="127.0.0.1")))
def test_auth_rejects_remote_without_api_key(self):
with EnvGuard():
os.environ.pop("MJR_API_KEY", None)
resp = route_utils.require_auth(request(remote="203.0.113.10"))
self.assertEqual(resp.status, 401)
def test_auth_accepts_matching_api_key(self):
with EnvGuard():
os.environ["MJR_API_KEY"] = "secret"
resp = route_utils.require_auth(
request(headers={"Authorization": "Bearer secret"}, remote="203.0.113.10")
)
self.assertIsNone(resp)
def test_same_origin_rejects_mismatched_origin(self):
resp = route_utils.require_same_origin(
request(headers={"Origin": "http://evil.example", "Host": "127.0.0.1:8188"})
)
self.assertEqual(resp.status, 403)
def test_same_origin_accepts_matching_origin(self):
resp = route_utils.require_same_origin(
request(
headers={
"Origin": "http://127.0.0.1:8188",
"Host": "127.0.0.1:8188",
"X-CSRF-Token": "token",
},
cookies={"mjr_csrf": "token"},
)
)
self.assertIsNone(resp)
def test_same_origin_rejects_missing_csrf(self):
resp = route_utils.require_same_origin(
request(headers={"Origin": "http://127.0.0.1:8188", "Host": "127.0.0.1:8188"})
)
self.assertEqual(resp.status, 403)
def test_rate_limit_blocks_after_limit(self):
with EnvGuard():
os.environ["MJR_RATE_LIMIT_PER_MIN"] = "2"
req = request(remote="127.0.0.1")
self.assertIsNone(route_utils.require_rate_limit(req, "bucket"))
self.assertIsNone(route_utils.require_rate_limit(req, "bucket"))
resp = route_utils.require_rate_limit(req, "bucket")
self.assertEqual(resp.status, 429)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -19,6 +19,7 @@
"src/mjr/dom.ts",
"src/mjr/graph.ts",
"src/mjr/patch.ts",
"src/mjr/settings.ts",
"src/mjr/toast.ts",
"src/mjr/topbar_badge.ts",
"src/state_manager.ts",
+141 -35
View File
@@ -9,12 +9,18 @@ import { toast } from "./mjr/toast.js";
import { fetchJSON, saveWorkflow, tryResolveProjectId } from "./mjr/api.js";
import { psConfirm, psPrompt } from "./mjr/dialog.js";
import { detectModelFromGraph, getSerializedWorkflow, inferProjectFolderFromGraph, readGraphSignature, } from "./mjr/graph.js";
import { PATH_WIDGETS, alreadyProjectPathed, detectNodeMedia, isSaveLikeNode, loadConfig, patchSaveNodes, patchSingleNode, stampGraphProjectSignature, } from "./mjr/patch.js";
import { PATH_WIDGETS, alreadyProjectPathed, detectNodeMedia, isSaveLikeNode, loadConfig, patchSaveNodes, previewPatchSaveNodes, patchSingleNode, stampGraphProjectSignature, } from "./mjr/patch.js";
import { buildPanel } from "./ui_components.js";
import { updateMenuStatusBadge } from "./mjr/topbar_badge.js";
import { createRuntimeState, loadState, saveState, runtimeState, setRuntimeState, } from "./state_manager.js";
import { scanMissingModelsWithStats } from "./mjr/model_fixer.js";
import { getSettingBool, toBool, toNumber } from "./mjr/settings.js";
let workflowShortcutRegistered = false;
const COMMAND_SAVE_WORKFLOW_TO_PROJECT = "mjr.projectSettings.saveWorkflowToProject";
const COMMAND_OPEN_PANEL = "mjr.projectSettings.openPanel";
const COMMAND_ASSIGN_ACTIVE_PROJECT = "mjr.projectSettings.assignActiveProject";
const COMMAND_SCAN_MISSING_MODELS = "mjr.projectSettings.scanMissingModels";
const COMMAND_PATCH_SAVE_NODES = "mjr.projectSettings.patchSaveNodes";
const NODE_PATCH_RETRY_DELAY_MS = 60;
const NODE_PATCH_MAX_RETRIES = 1;
const SETTINGS_CATEGORY = "MajoorPS";
@@ -23,13 +29,17 @@ const DEFAULT_DOWNLOAD_MAX_GB = 50;
const AUTO_FIX_AFTER_DOWNLOAD_SETTING = "mjr_project.auto_fix_after_download";
const AUTO_OPEN_PANEL_ON_MISSING_SETTING = "mjr_project.auto_open_panel_on_missing";
const AUTO_SCAN_MISSING_ON_OPEN_SETTING = "mjr_project.auto_scan_missing_on_open";
const PROMPT_EMPTY_GRAPH_SETTING = "mjr_project.prompt_empty_graph";
const AUTO_REMEMBER_NOTE_SOURCES_SETTING = "mjr_project.auto_remember_note_sources";
const NO_PROJECT_STATUS_BLACK_SETTING = "mjr_project.status_black_no_project";
const LEGACY_CTRL_S_HOOK_SETTING = "mjr_project.legacy_ctrl_s_hook";
const DEFAULT_AUTO_FIX_AFTER_DOWNLOAD = true;
const DEFAULT_AUTO_OPEN_PANEL = true;
const DEFAULT_AUTO_OPEN_PANEL = false;
const DEFAULT_AUTO_SCAN_MISSING_ON_OPEN = true;
const DEFAULT_PROMPT_EMPTY_GRAPH = false;
const DEFAULT_AUTO_REMEMBER_NOTE_SOURCES = true;
const DEFAULT_NO_PROJECT_STATUS_BLACK = false;
const DEFAULT_LEGACY_CTRL_S_HOOK = false;
const GRAPH_EMPTY_POLL_INTERVAL_MS = 900;
let graphEmptyWatcherId = null;
function _extractYYMMDDFromPrefix(prefix) {
@@ -62,34 +72,6 @@ function _rollOutputDateIfNeeded(state) {
updateUI(state);
return true;
}
const toNumber = (value, fallback) => {
if (value == null)
return fallback;
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
};
const toBool = (value, fallback) => {
if (value == null)
return fallback;
if (typeof value === "boolean")
return value;
if (typeof value === "number")
return value !== 0;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(v))
return true;
if (["false", "0", "no", "off"].includes(v))
return false;
}
return fallback;
};
const getSettingBool = (id, fallback) => {
const raw = app?.extensionManager?.setting?.get?.(id) ??
app?.ui?.settings?.getSettingValue?.(id) ??
app?.ui?.settings?.settingsValues?.[id];
return toBool(raw, fallback);
};
function updateUI(state) {
const safe = (fn, label) => {
try {
@@ -258,7 +240,7 @@ function handleGraphEmptyTransition(state, runToken, currentEmpty) {
if (!currentEmpty) {
state._emptyGraphPromptToken = null;
}
else {
else if (getSettingBool(PROMPT_EMPTY_GRAPH_SETTING, DEFAULT_PROMPT_EMPTY_GRAPH)) {
void promptEmptyGraphProject(state, runToken ?? state.graphLoadToken);
}
return true;
@@ -477,9 +459,92 @@ function saveActiveWorkflowToProjectCommand() {
}
void saveWorkflowToProject(state);
}
function openProjectSettingsCommand() {
const sidebar = app?.extensionManager?.sidebar;
if (sidebar && typeof sidebar.setTab === "function") {
sidebar.setTab("mjr_project_settings");
return;
}
const state = runtimeState;
if (state) {
state.workflowPanelOpen = true;
saveState(state);
updateUI(state);
}
}
function assignActiveProjectCommand() {
const state = runtimeState;
if (!state?.projectId || !state.projectFolder) {
toast("error", "No active project", "Set a project first.");
return;
}
stampGraphProjectSignature(app, state);
state.workflowHasSignature = true;
state.workflowChecked = true;
state.detected = null;
state.lastError = "";
saveState(state);
updateUI(state);
toast("success", "Workflow assigned", state.projectFolder);
}
function scanMissingModelsCommand() {
const state = runtimeState;
if (!state)
return;
try {
const { missing } = scanMissingModelsWithStats();
state._ui?.refreshMissingStatus?.();
toast(missing?.length ? "warn" : "success", "Missing Models", `${missing?.length || 0} missing model${missing?.length === 1 ? "" : "s"} detected`, { life: 4000 });
}
catch (error) {
console.error("[mjr] Error scanning missing models:", error);
toast("error", "Missing Models", "Scan failed.");
}
}
async function patchSaveNodesCommand() {
const state = runtimeState;
if (!state?.projectId) {
toast("warn", "Patch skipped", "Set a project first.");
return;
}
_rollOutputDateIfNeeded(state);
let relDir = state.lastRelDir;
const prefix = state.lastPrefix || "";
if (!relDir && state.projectFolder) {
relDir = joinRel(`PROJECTS/${state.projectFolder}`, mediaDir(state.media || "images"));
}
if (!relDir) {
toast("warn", "Patch skipped", "Create an output target first.");
return;
}
const preview = previewPatchSaveNodes(app, relDir, prefix, state.media);
const nodeCount = new Set(preview.map((item) => item.node)).size;
if (nodeCount === 0) {
toast("warn", "No nodes patched", "No save-like nodes were found.");
return;
}
const previewLines = preview
.slice(0, 8)
.map((item) => `${item.nodeLabel}.${item.widget}: ${item.oldValue || "-"} -> ${item.newValue || "-"}`);
const overflow = preview.length > previewLines.length ? `\n...and ${preview.length - previewLines.length} more change(s)` : "";
const confirmed = await psConfirm({
title: "Patch save paths?",
message: `Update ${nodeCount} save node${nodeCount === 1 ? "" : "s"} to use the project path?\n\n` +
`${previewLines.join("\n")}${overflow}\n\n` +
"This can be undone with Ctrl+Z.",
});
if (!confirmed) {
toast("info", "Patch cancelled", "No nodes were modified.");
return;
}
const patched = await patchSaveNodes(app, relDir, prefix, state.media);
toast(patched ? "success" : "warn", patched ? "Patch applied" : "No nodes patched", patched ? `Updated ${patched} node${patched === 1 ? "" : "s"}.` : "No save-like nodes were updated.");
}
function registerWorkflowShortcut() {
if (workflowShortcutRegistered)
return;
if (!getSettingBool(LEGACY_CTRL_S_HOOK_SETTING, DEFAULT_LEGACY_CTRL_S_HOOK))
return;
workflowShortcutRegistered = true;
window.addEventListener("keydown", (event) => {
const key = String(event.key || "").toLowerCase();
@@ -531,6 +596,13 @@ const EXTENSION_SETTINGS = [
defaultValue: DEFAULT_AUTO_SCAN_MISSING_ON_OPEN,
category: SETTINGS_CATEGORY,
},
{
id: PROMPT_EMPTY_GRAPH_SETTING,
name: "MajoorPS: Prompt on empty workflow",
type: "boolean",
defaultValue: DEFAULT_PROMPT_EMPTY_GRAPH,
category: SETTINGS_CATEGORY,
},
{
id: AUTO_REMEMBER_NOTE_SOURCES_SETTING,
name: "MajoorPS: Auto-remember note sources",
@@ -545,6 +617,13 @@ const EXTENSION_SETTINGS = [
defaultValue: DEFAULT_NO_PROJECT_STATUS_BLACK,
category: SETTINGS_CATEGORY,
},
{
id: LEGACY_CTRL_S_HOOK_SETTING,
name: "MajoorPS: Legacy Ctrl+S hook",
type: "boolean",
defaultValue: DEFAULT_LEGACY_CTRL_S_HOOK,
category: SETTINGS_CATEGORY,
},
];
function registerLegacyExtensionSettings() {
if (app?.extensionManager?.setting)
@@ -574,11 +653,37 @@ app.registerExtension({
label: "MajoorPS: Save workflow to project",
function: saveActiveWorkflowToProjectCommand,
},
],
keybindings: [
{
combo: { key: "s", ctrl: true },
commandId: COMMAND_SAVE_WORKFLOW_TO_PROJECT,
id: COMMAND_OPEN_PANEL,
label: "MajoorPS: Open Project Settings",
function: openProjectSettingsCommand,
},
{
id: COMMAND_ASSIGN_ACTIVE_PROJECT,
label: "MajoorPS: Assign workflow to active project",
function: assignActiveProjectCommand,
},
{
id: COMMAND_SCAN_MISSING_MODELS,
label: "MajoorPS: Scan missing models",
function: scanMissingModelsCommand,
},
{
id: COMMAND_PATCH_SAVE_NODES,
label: "MajoorPS: Patch save nodes now",
function: patchSaveNodesCommand,
},
],
menuCommands: [
{
path: ["Extensions", "Majoor Project Settings"],
commands: [
COMMAND_OPEN_PANEL,
COMMAND_ASSIGN_ACTIVE_PROJECT,
COMMAND_SAVE_WORKFLOW_TO_PROJECT,
COMMAND_SCAN_MISSING_MODELS,
COMMAND_PATCH_SAVE_NODES,
],
},
],
async setup() {
@@ -632,6 +737,7 @@ app.registerExtension({
}
setRuntimeState(state);
_rollOutputDateIfNeeded(state);
updateMenuStatusBadge(state);
registerWorkflowShortcut();
state._emptyGraphPromptToken = null;
startGraphEmptyWatcher(state);
File diff suppressed because one or more lines are too long
+11
View File
@@ -5,6 +5,17 @@ export declare function detectNodeMedia(node: ComfyNode | null | undefined): Pro
export declare function isSaveLikeNode(node: ComfyNode | null | undefined): boolean;
export declare function alreadyProjectPathed(value: unknown): boolean;
export declare function patchSingleNode(node: ComfyNode | null | undefined, relDir: unknown, filenamePrefix: unknown): boolean;
export interface PatchPreviewItem {
node: ComfyNode;
nodeLabel: string;
widget: string;
oldValue: string;
newValue: string;
}
export declare function previewPatchSingleNode(node: ComfyNode | null | undefined, relDir: unknown, filenamePrefix: unknown): PatchPreviewItem[];
export declare function previewPatchSaveNodes(app: {
graph?: ComfyGraph;
}, relDir: unknown, filenamePrefix: unknown, targetMedia?: ProjectMedia): PatchPreviewItem[];
export declare function patchSaveNodes(app: {
graph?: ComfyGraph;
}, relDir: unknown, filenamePrefix: unknown, targetMedia?: ProjectMedia): Promise<number>;
+62
View File
@@ -70,6 +70,67 @@ export function patchSingleNode(node, relDir, filenamePrefix) {
}
return false;
}
export function previewPatchSingleNode(node, relDir, filenamePrefix) {
if (!isSaveLikeNode(node))
return [];
if (!node)
return [];
const rel = safeRel(relDir);
const prefix = safeRel(filenamePrefix);
const widgets = node?.widgets || [];
const filenameWidget = widgets.find((w) => w?.name === "filename_prefix");
const pathWidget = widgets.find((w) => w?.name && PATH_WIDGETS.includes(w.name));
const nodeLabel = String(node?.title || node?.comfyClass || node?.type || "Save node");
const items = [];
if (pathWidget && filenameWidget) {
items.push({
node,
nodeLabel,
widget: String(pathWidget.name),
oldValue: String(pathWidget.value ?? ""),
newValue: rel,
});
items.push({
node,
nodeLabel,
widget: String(filenameWidget.name),
oldValue: String(filenameWidget.value ?? ""),
newValue: prefix,
});
return items;
}
if (filenameWidget) {
items.push({
node,
nodeLabel,
widget: String(filenameWidget.name),
oldValue: String(filenameWidget.value ?? ""),
newValue: prefix ? joinRel(rel, prefix) : rel,
});
return items;
}
if (pathWidget) {
items.push({
node,
nodeLabel,
widget: String(pathWidget.name),
oldValue: String(pathWidget.value ?? ""),
newValue: rel,
});
}
return items;
}
export function previewPatchSaveNodes(app, relDir, filenamePrefix, targetMedia) {
const nodes = app?.graph?._nodes || [];
const items = [];
for (const node of nodes) {
const nm = detectNodeMedia(node);
if (targetMedia && nm && nm !== targetMedia)
continue;
items.push(...previewPatchSingleNode(node, relDir, filenamePrefix));
}
return items;
}
export async function patchSaveNodes(app, relDir, filenamePrefix, targetMedia) {
await loadConfig();
const nodes = app?.graph?._nodes || [];
@@ -96,6 +157,7 @@ export function stampGraphProjectSignature(app, state) {
return;
g.extra = g.extra || {};
g.extra.mjr_project = {
schema_version: 1,
project_id: state.projectId,
project_folder: state.projectFolder,
template: state.pathTemplate || "",
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
import type { SettingId } from "../types/domain.js";
export declare function toBool(value: unknown, fallback: boolean): boolean;
export declare function toNumber(value: unknown, fallback: number): number;
export declare function getSettingValue<T = unknown>(id: SettingId | string): T | undefined;
export declare function getSettingBool(id: SettingId | string, fallback: boolean): boolean;
+32
View File
@@ -0,0 +1,32 @@
import { app } from "../../../scripts/app.js";
export function toBool(value, fallback) {
if (value == null)
return fallback;
if (typeof value === "boolean")
return value;
if (typeof value === "number")
return value !== 0;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(v))
return true;
if (["false", "0", "no", "off"].includes(v))
return false;
}
return fallback;
}
export function toNumber(value, fallback) {
if (value == null)
return fallback;
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
export function getSettingValue(id) {
return (app?.extensionManager?.setting?.get?.(id) ??
app?.ui?.settings?.getSettingValue?.(id) ??
app?.ui?.settings?.settingsValues?.[id]);
}
export function getSettingBool(id, fallback) {
return toBool(getSettingValue(id), fallback);
}
//# sourceMappingURL=settings.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"settings.js","sourceRoot":"","sources":["../../../src/mjr/settings.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,yBAAyB,CAAC;AAG9C,MAAM,UAAU,MAAM,CAAC,KAAc,EAAE,QAAiB;IACtD,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,QAAQ,CAAC;IACnC,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAC7C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,KAAK,CAAC,CAAC;IAClD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;QACrC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;QACxD,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,OAAO,KAAK,CAAC;IAC5D,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,KAAc,EAAE,QAAgB;IACvD,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,QAAQ,CAAC;IACnC,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC1B,OAAO,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC;AAC/C,CAAC;AAED,MAAM,UAAU,eAAe,CAAc,EAAsB;IACjE,OAAO,CACL,GAAG,EAAE,gBAAgB,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC;QACzC,GAAG,EAAE,EAAE,EAAE,QAAQ,EAAE,eAAe,EAAE,CAAC,EAAE,CAAC;QACxC,GAAG,EAAE,EAAE,EAAE,QAAQ,EAAE,cAAc,EAAE,CAAC,EAAE,CAAC,CACvB,CAAC;AACrB,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,EAAsB,EAAE,QAAiB;IACtE,OAAO,MAAM,CAAC,eAAe,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC;AAC/C,CAAC"}
+167 -84
View File
@@ -1,89 +1,172 @@
import { applyStyles, createElement } from "./dom.js";
const MENU_BADGE_ID = "mjr-project-status-badge";
const MENU_ACTION_SELECTORS = [
".comfy-menu-actions",
".comfy-menu-no-drag",
".comfy-topbar-actions",
".comfy-menu",
".workflow-tabs-container",
".workflow-tabs-container .flex",
".menu-actions",
];
let menuBadgeInterval = null;
let menuBadgePendingState = null;
let menuBadgeLogTimestamp = 0;
function findMenuActionsContainer() {
for (const selector of MENU_ACTION_SELECTORS) {
const candidate = document.querySelector(selector);
if (candidate) {
return candidate;
}
}
return null;
}
function ensureMenuStatusBadge(actions, text, color) {
let badge = document.getElementById(MENU_BADGE_ID);
if (!badge) {
badge = createElement("div", {
id: MENU_BADGE_ID,
styles: {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "4px 10px",
borderRadius: "999px",
fontSize: "11px",
fontWeight: "600",
border: "1px solid transparent",
marginLeft: "8px",
gap: "4px",
transition: "background 0.3s ease, border 0.3s ease",
cursor: "default",
},
});
actions.appendChild(badge);
}
badge.textContent = text;
applyStyles(badge, {
background: color,
borderColor: color,
});
return badge;
}
export function updateMenuStatusBadge(state) {
menuBadgePendingState = state;
const actions = findMenuActionsContainer();
if (!actions) {
if (!menuBadgeInterval) {
menuBadgeInterval = setInterval(() => {
if (menuBadgePendingState) {
updateMenuStatusBadge(menuBadgePendingState);
}
}, 500);
}
const now = Date.now();
if (now - menuBadgeLogTimestamp > 5000) {
console.info(`[mjr] Waiting for topbar badge target (${MENU_ACTION_SELECTORS.join(", ")}) project=${state?.projectId || "none"} folder=${state?.projectFolder || "none"}`);
menuBadgeLogTimestamp = now;
}
console.debug("[mjr] comfy menu actions not found yet, retrying badge soon", state);
import { app } from "../../../scripts/app.js";
const BUTTON_ID = "mjr-project-health-button";
const LEGACY_BUTTON_ID = "mjr-project-health-button-legacy";
let buttonElement = null;
let buttonGroupElement = null;
let pendingState = null;
let createPromise = null;
function openProjectSettings() {
const sidebar = app?.extensionManager?.sidebar;
if (sidebar && typeof sidebar.setTab === "function") {
sidebar.setTab("mjr_project_settings");
return;
}
if (menuBadgeInterval) {
clearInterval(menuBadgeInterval);
menuBadgeInterval = null;
try {
const state = pendingState;
if (state) {
state.workflowPanelOpen = true;
state._ui?.updateWorkflowBlock?.();
}
}
catch (_) { }
}
function getHealth(state) {
const project = state.projectFolder || state.projectId || "No project";
const output = state.lastRelDir || state.lastPrefix
? `${state.lastRelDir || "-"} | ${state.lastPrefix || "-"}`
: "No output target";
if (state.lastError) {
return {
text: "Project: Error",
background: "#7f1d1d",
border: "#ef4444",
title: `Project Health\nError: ${state.lastError}\nProject: ${project}\nOutput: ${output}`,
};
}
if (state.graphIsEmpty) {
return {
text: "Project: Empty",
background: "#7f1d1d",
border: "#ef4444",
title: `Project Health\nGraph is empty\nProject: ${project}\nOutput: ${output}`,
};
}
if (!state.projectId) {
return {
text: "Project: None",
background: "#3f3f46",
border: "#71717a",
title: `Project Health\nNo active project\nOutput: ${output}`,
};
}
if (state.projectExists === false) {
return {
text: "Project: Missing",
background: "#7f1d1d",
border: "#ef4444",
title: `Project Health\nMissing folder: ${project}\nOutput: ${output}`,
};
}
if (state.workflowChecked && !state.workflowHasSignature) {
return {
text: `Project: ${project}`,
background: "#7c2d12",
border: "#f97316",
title: `Project Health\nWorkflow is not assigned\nProject: ${project}\nOutput: ${output}`,
};
}
const det = state.detected;
const mismatch = det &&
det.mode !== "none" &&
!((det.project_id && det.project_id === state.projectId) ||
(det.project_folder && det.project_folder === state.projectFolder)) &&
(det.project_id || det.project_folder);
if (mismatch) {
return {
text: "Project: Mismatch",
background: "#7c2d12",
border: "#f97316",
title: `Project Health\nWorkflow: ${det.project_folder || det.project_id}\nActive: ${project}\nOutput: ${output}`,
};
}
return {
text: `Project: ${project}`,
background: "#166534",
border: "#22c55e",
title: `Project Health\nActive project: ${project}\nWorkflow: assigned\nOutput: ${output}`,
};
}
function styleProjectButton(el) {
el.id = BUTTON_ID;
el.classList.add("comfyui-button", "comfyui-menu-mobile-collapse");
el.style.maxWidth = "260px";
el.style.overflow = "hidden";
el.style.textOverflow = "ellipsis";
el.style.whiteSpace = "nowrap";
}
async function ensureNewStyleButton() {
if (buttonElement && document.body.contains(buttonElement))
return true;
if (buttonGroupElement && document.body.contains(buttonGroupElement))
return true;
if (!app?.menu?.settingsGroup?.element)
return false;
try {
const [{ ComfyButton }, { ComfyButtonGroup }] = await Promise.all([
import("../../../scripts/ui/components/button.js"),
import("../../../scripts/ui/components/buttonGroup.js"),
]);
const button = new ComfyButton({
icon: "briefcase",
action: openProjectSettings,
tooltip: "Majoor Project Health",
content: "Project",
classList: "comfyui-button comfyui-menu-mobile-collapse",
});
buttonElement = button.element;
styleProjectButton(buttonElement);
const group = new ComfyButtonGroup(buttonElement);
buttonGroupElement = group.element;
app.menu.settingsGroup.element.before(buttonGroupElement);
return true;
}
catch (error) {
console.warn("[mjr] New style ComfyUI button unavailable", error);
return false;
}
}
function ensureLegacyButton() {
let button = document.getElementById(LEGACY_BUTTON_ID);
if (!button) {
const menu = document.querySelector(".comfy-menu");
if (!menu)
return false;
button = document.createElement("button");
button.id = LEGACY_BUTTON_ID;
button.type = "button";
button.onclick = openProjectSettings;
menu.append(button);
}
buttonElement = button;
return true;
}
function applyHealth(state) {
if (!buttonElement)
return;
const health = getHealth(state);
buttonElement.textContent = health.text;
buttonElement.title = health.title;
buttonElement.style.background = health.background;
buttonElement.style.borderColor = health.border;
buttonElement.style.color = "white";
}
async function ensureAndUpdate() {
if (!pendingState)
return;
const hasButton = (await ensureNewStyleButton()) || ensureLegacyButton();
if (hasButton && pendingState) {
applyHealth(pendingState);
}
}
export function updateMenuStatusBadge(state) {
pendingState = state;
if (!createPromise) {
createPromise = ensureAndUpdate().finally(() => {
createPromise = null;
});
}
else {
void createPromise.then(ensureAndUpdate);
}
const badgeText = state.projectId
? state.projectExists === false
? "Projet introuvable"
: `Projet: ${state.projectFolder || state.projectId}`
: "Pas de projet actif";
const badgeColor = state.projectId
? state.projectExists === false
? "rgba(179, 58, 58, 0.9)"
: "rgba(47, 143, 78, 0.95)"
: "rgba(102, 102, 102, 0.8)";
ensureMenuStatusBadge(actions, badgeText, badgeColor);
menuBadgePendingState = null;
}
//# sourceMappingURL=topbar_badge.js.map
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -118,7 +118,7 @@ export interface RuntimeState {
_ui?: RuntimeUiCallbacks;
_emptyGraphPromptToken?: number | null;
}
export type SettingId = "mjr_project.download_max_gb" | "mjr_project.auto_fix_after_download" | "mjr_project.auto_open_panel_on_missing" | "mjr_project.auto_scan_missing_on_open" | "mjr_project.auto_remember_note_sources" | "mjr_project.status_black_no_project";
export type SettingId = "mjr_project.download_max_gb" | "mjr_project.auto_fix_after_download" | "mjr_project.auto_open_panel_on_missing" | "mjr_project.auto_scan_missing_on_open" | "mjr_project.prompt_empty_graph" | "mjr_project.auto_remember_note_sources" | "mjr_project.status_black_no_project" | "mjr_project.legacy_ctrl_s_hook";
export type SettingDefinition = {
id: SettingId;
name: string;
@@ -162,6 +162,7 @@ export interface ComfyNode {
[key: string]: unknown;
}
export interface GraphProjectSignature {
schema_version?: number;
project_id?: string;
project_folder?: string;
template?: string;
+6
View File
@@ -0,0 +1,6 @@
export declare const TEMPLATE_TOKENS: string[];
export declare const PROJECT_REFRESH_THROTTLE_MS = 5000;
export declare const PROJECT_REFRESH_ERROR_BACKOFF_MS = 10000;
export declare const SETTING_AUTO_FIX_AFTER_DOWNLOAD = "mjr_project.auto_fix_after_download";
export declare const SETTING_AUTO_SCAN_MISSING_ON_OPEN = "mjr_project.auto_scan_missing_on_open";
export declare const SETTING_AUTO_REMEMBER_NOTE_SOURCES = "mjr_project.auto_remember_note_sources";
+7
View File
@@ -0,0 +1,7 @@
export const TEMPLATE_TOKENS = ["{BASE}", "{MEDIA}", "{DATE}", "{MODEL}", "{NAME}", "{KIND}"];
export const PROJECT_REFRESH_THROTTLE_MS = 5000;
export const PROJECT_REFRESH_ERROR_BACKOFF_MS = 10000;
export const SETTING_AUTO_FIX_AFTER_DOWNLOAD = "mjr_project.auto_fix_after_download";
export const SETTING_AUTO_SCAN_MISSING_ON_OPEN = "mjr_project.auto_scan_missing_on_open";
export const SETTING_AUTO_REMEMBER_NOTE_SOURCES = "mjr_project.auto_remember_note_sources";
//# sourceMappingURL=panel_constants.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"panel_constants.js","sourceRoot":"","sources":["../../../src/ui/panel_constants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;AAE9F,MAAM,CAAC,MAAM,2BAA2B,GAAG,IAAI,CAAC;AAChD,MAAM,CAAC,MAAM,gCAAgC,GAAG,KAAK,CAAC;AAEtD,MAAM,CAAC,MAAM,+BAA+B,GAAG,qCAAqC,CAAC;AACrF,MAAM,CAAC,MAAM,iCAAiC,GAAG,uCAAuC,CAAC;AACzF,MAAM,CAAC,MAAM,kCAAkC,GAAG,wCAAwC,CAAC"}
+14
View File
@@ -0,0 +1,14 @@
export type DetailsWithSummary = HTMLDetailsElement & {
_summary?: HTMLElement;
};
export interface PanelHelpers {
section: (label: string) => HTMLDivElement;
divider: (thick?: boolean) => HTMLDivElement;
detailsSection: (title: string) => DetailsWithSummary;
makeLabel: (labelText: string) => HTMLLabelElement;
styleField: (inputEl: HTMLElement) => void;
row: (labelText: string, inputEl: HTMLElement) => HTMLDivElement;
groupRow: (labelText: string, contentEl: HTMLElement) => HTMLDivElement;
btn: (text: string) => HTMLButtonElement;
}
export declare function createPanelHelpers(gap: string): PanelHelpers;
+104
View File
@@ -0,0 +1,104 @@
export function createPanelHelpers(gap) {
const section = (label) => {
const h = document.createElement("div");
h.textContent = label;
h.style.fontWeight = "600";
h.style.marginTop = gap;
h.style.marginBottom = gap;
h.style.paddingLeft = "2px";
h.style.textTransform = "uppercase";
h.style.letterSpacing = "0.6px";
h.style.color = "#8fb9ff";
return h;
};
const divider = (thick = false) => {
const d = document.createElement("div");
d.style.height = thick ? "3px" : "1px";
d.style.background = "rgba(255,255,255,0.08)";
d.style.margin = "0";
return d;
};
const detailsSection = (title) => {
const details = document.createElement("details");
details.style.marginTop = "0";
details.style.display = "flex";
details.style.flexDirection = "column";
details.style.gap = gap;
const summary = document.createElement("summary");
summary.textContent = title;
summary.style.cursor = "pointer";
summary.style.opacity = "0.9";
summary.style.textTransform = "uppercase";
summary.style.letterSpacing = "0.6px";
summary.style.color = "#8fb9ff";
summary.style.fontWeight = "600";
summary.style.paddingLeft = "2px";
details.appendChild(summary);
details._summary = summary;
return details;
};
const makeLabel = (labelText) => {
const label = document.createElement("label");
label.textContent = labelText;
label.style.opacity = "0.85";
label.style.marginLeft = "2px";
label.style.fontSize = "11px";
label.style.lineHeight = "1.2";
return label;
};
const styleField = (inputEl) => {
inputEl.style.width = "100%";
inputEl.style.boxSizing = "border-box";
inputEl.style.padding = "6px 8px";
inputEl.style.borderRadius = "8px";
inputEl.style.border = "1px solid rgba(255,255,255,0.12)";
inputEl.style.background = "rgba(0,0,0,0.25)";
inputEl.style.color = "#eee";
inputEl.style.outline = "none";
};
const row = (labelText, inputEl) => {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = gap;
wrap.appendChild(makeLabel(labelText));
styleField(inputEl);
wrap.appendChild(inputEl);
return wrap;
};
const groupRow = (labelText, contentEl) => {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = gap;
wrap.appendChild(makeLabel(labelText));
wrap.appendChild(contentEl);
return wrap;
};
const btn = (text) => {
const b = document.createElement("button");
b.textContent = text;
b.className = "mjr-ps-btn";
b.style.padding = "7px 8px";
b.style.borderRadius = "8px";
b.style.border = "1px solid rgba(46, 136, 255, 0.65)";
b.style.background = "rgba(46, 136, 255, 0.25)";
b.style.color = "#eee";
b.style.cursor = "pointer";
b.style.margin = "0";
b.style.lineHeight = "1.2";
b.style.whiteSpace = "nowrap";
return b;
};
return {
section,
divider,
detailsSection,
makeLabel,
styleField,
row,
groupRow,
btn,
};
}
//# sourceMappingURL=panel_helpers.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"panel_helpers.js","sourceRoot":"","sources":["../../../src/ui/panel_helpers.ts"],"names":[],"mappings":"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"}
+5
View File
@@ -0,0 +1,5 @@
import type { ProjectListEntry } from "../types/domain.js";
export interface ProjectDropdownTargets {
pendingSelect: HTMLSelectElement;
}
export declare function renderProjectDropdowns(targets: ProjectDropdownTargets, projects: ProjectListEntry[]): void;
+25
View File
@@ -0,0 +1,25 @@
export function renderProjectDropdowns(targets, projects) {
const { pendingSelect } = targets;
const currentPending = pendingSelect.value;
pendingSelect.innerHTML = "";
const emptyOpt = document.createElement("option");
emptyOpt.value = "";
emptyOpt.textContent = "-";
pendingSelect.appendChild(emptyOpt);
for (const project of projects) {
const projectId = project.project_id || "";
if (!projectId)
continue;
const opt = document.createElement("option");
opt.value = projectId;
opt.dataset.folder = project.folder || "";
opt.dataset.exists = project.exists === false ? "false" : "true";
const missingTag = project.exists === false ? " (missing)" : "";
const archivedTag = project.archived ? " [archived]" : "";
opt.textContent = `${project.folder || project.project_id} (${project.project_id})${missingTag}${archivedTag}`;
pendingSelect.appendChild(opt);
}
if (currentPending)
pendingSelect.value = currentPending;
}
//# sourceMappingURL=project_dropdowns.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"project_dropdowns.js","sourceRoot":"","sources":["../../../src/ui/project_dropdowns.ts"],"names":[],"mappings":"AAMA,MAAM,UAAU,sBAAsB,CACpC,OAA+B,EAC/B,QAA4B;IAE5B,MAAM,EAAE,aAAa,EAAE,GAAG,OAAO,CAAC;IAClC,MAAM,cAAc,GAAG,aAAa,CAAC,KAAK,CAAC;IAE3C,aAAa,CAAC,SAAS,GAAG,EAAE,CAAC;IAE7B,MAAM,QAAQ,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;IAClD,QAAQ,CAAC,KAAK,GAAG,EAAE,CAAC;IACpB,QAAQ,CAAC,WAAW,GAAG,GAAG,CAAC;IAC3B,aAAa,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;IAEpC,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC;QAC3C,IAAI,CAAC,SAAS;YAAE,SAAS;QAEzB,MAAM,GAAG,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QAC7C,GAAG,CAAC,KAAK,GAAG,SAAS,CAAC;QACtB,GAAG,CAAC,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,EAAE,CAAC;QAC1C,GAAG,CAAC,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;QACjE,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC;QAChE,MAAM,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1D,GAAG,CAAC,WAAW,GAAG,GAAG,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,UAAU,KAAK,OAAO,CAAC,UAAU,IAAI,UAAU,GAAG,WAAW,EAAE,CAAC;QAC/G,aAAa,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IACjC,CAAC;IAED,IAAI,cAAc;QAAE,aAAa,CAAC,KAAK,GAAG,cAAc,CAAC;AAC3D,CAAC"}
+8
View File
@@ -0,0 +1,8 @@
import type { RuntimeState } from "../types/domain.js";
export interface ProjectStatus {
color: string;
text: string;
level: string;
}
export declare function colorWithAlpha(hex: unknown, alpha: unknown): string;
export declare function makeStatus(state: RuntimeState): ProjectStatus;
+70
View File
@@ -0,0 +1,70 @@
import { getSettingBool } from "../mjr/settings.js";
const SETTING_NO_PROJECT_STATUS_BLACK = "mjr_project.status_black_no_project";
function parseHexColor(hex) {
const m = String(hex || "").trim().match(/^#([0-9a-fA-F]{6})$/);
if (!m)
return null;
const v = m[1];
return [
parseInt(v.slice(0, 2), 16),
parseInt(v.slice(2, 4), 16),
parseInt(v.slice(4, 6), 16),
];
}
export function colorWithAlpha(hex, alpha) {
const rgb = parseHexColor(hex);
if (!rgb)
return String(hex);
const a = Math.max(0, Math.min(1, Number(alpha)));
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})`;
}
export function makeStatus(state) {
const det = state.detected;
const sameProject = det &&
det.mode !== "none" &&
((det.project_id && det.project_id === state.projectId) ||
(det.project_folder && det.project_folder === state.projectFolder));
const mismatch = det &&
det.mode !== "none" &&
!sameProject &&
(det.project_id || det.project_folder);
if (state.lastError) {
return { color: "#b33a3a", text: `Error: ${state.lastError}`, level: "red" };
}
if (state.graphIsEmpty) {
return { color: "#b33a3a", text: "Empty graph, set or create a project", level: "red" };
}
if (!state.projectId) {
if (getSettingBool(SETTING_NO_PROJECT_STATUS_BLACK, false)) {
return { color: "#000000", text: "No project (test)", level: "black" };
}
return { color: "#666666", text: "No active project", level: "gray" };
}
if (state.projectExists === false) {
return {
color: "#b33a3a",
text: `Missing project folder: ${state.projectFolder || state.projectId}`,
level: "red",
};
}
if (state.workflowChecked && !state.workflowHasSignature) {
return {
color: "#c77c2a",
text: "Active project, workflow unassigned",
level: "orange",
};
}
if (mismatch) {
return {
color: "#c77c2a",
text: `Project mismatch: ${det.project_folder || det.project_id}`,
level: "orange",
};
}
return {
color: "#2f8f4e",
text: `Active: ${state.projectFolder} (${state.projectId})`,
level: "green",
};
}
//# sourceMappingURL=project_status.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"project_status.js","sourceRoot":"","sources":["../../../src/ui/project_status.ts"],"names":[],"mappings":"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"}
+88 -206
View File
@@ -3,7 +3,7 @@ import { joinRel, makeKindToken, mediaDir, titlePathJS, token3Tag, yymmddJS } fr
import { ensureStyles, toast } from "./mjr/toast.js";
import { buildFingerprintCache, downloadModels, fetchJSON, getDownloadStatus, getFingerprintCacheStatus, getModels, listExistingNames, previewTemplate, resolveModelRecipes, saveModelRecipes, scanModelCandidates, searchRegistry, } from "./mjr/api.js";
import { psConfirm, psPrompt } from "./mjr/dialog.js";
import { isSaveLikeNode, patchSaveNodes, stampGraphProjectSignature, } from "./mjr/patch.js";
import { patchSaveNodes, previewPatchSaveNodes, stampGraphProjectSignature, } from "./mjr/patch.js";
import { applyFixesToGraph, scanMissingModelsWithStats, } from "./mjr/model_fixer.js";
import { collectNoteRecipes, getKindOptions, normalizeKey, typeHintToKind } from "./mjr/model_downloader.js";
import { showModelFixerDialog } from "./mjr/ui_model_fixer_dialog.js";
@@ -14,100 +14,11 @@ import { getSerializedWorkflow } from "./mjr/graph.js";
import { extractNoteTexts } from "./mjr/workflow_note_recipes.js";
import { debug } from "./mjr/log.js";
import { updateMenuStatusBadge } from "./mjr/topbar_badge.js";
const TEMPLATE_TOKENS = ["{BASE}", "{MEDIA}", "{DATE}", "{MODEL}", "{NAME}", "{KIND}"];
const PROJECT_REFRESH_THROTTLE_MS = 5000;
const PROJECT_REFRESH_ERROR_BACKOFF_MS = 10000;
const SETTING_AUTO_FIX_AFTER_DOWNLOAD = "mjr_project.auto_fix_after_download";
const SETTING_AUTO_SCAN_MISSING_ON_OPEN = "mjr_project.auto_scan_missing_on_open";
const SETTING_AUTO_REMEMBER_NOTE_SOURCES = "mjr_project.auto_remember_note_sources";
const SETTING_NO_PROJECT_STATUS_BLACK = "mjr_project.status_black_no_project";
const toBool = (value, fallback) => {
if (value == null)
return fallback;
if (typeof value === "boolean")
return value;
if (typeof value === "number")
return value !== 0;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(v))
return true;
if (["false", "0", "no", "off"].includes(v))
return false;
}
return fallback;
};
const getSettingBool = (id, fallback) => {
const raw = app?.ui?.settings?.settingsValues?.[id];
return toBool(raw, fallback);
};
function parseHexColor(hex) {
const m = String(hex || "").trim().match(/^#([0-9a-fA-F]{6})$/);
if (!m)
return null;
const v = m[1];
return [
parseInt(v.slice(0, 2), 16),
parseInt(v.slice(2, 4), 16),
parseInt(v.slice(4, 6), 16),
];
}
function colorWithAlpha(hex, alpha) {
const rgb = parseHexColor(hex);
if (!rgb)
return String(hex);
const a = Math.max(0, Math.min(1, Number(alpha)));
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})`;
}
function makeStatus(state) {
const det = state.detected;
const sameProject = det &&
det.mode !== "none" &&
((det.project_id && det.project_id === state.projectId) ||
(det.project_folder && det.project_folder === state.projectFolder));
const mismatch = det &&
det.mode !== "none" &&
!sameProject &&
(det.project_id || det.project_folder);
if (state.lastError) {
return { color: "#b33a3a", text: `Error: ${state.lastError}`, level: "red" };
}
if (state.graphIsEmpty) {
return { color: "#b33a3a", text: "Empty graph, set or create a project", level: "red" };
}
if (!state.projectId) {
if (getSettingBool(SETTING_NO_PROJECT_STATUS_BLACK, false)) {
return { color: "#000000", text: "No project (test)", level: "black" };
}
return { color: "#666666", text: "No active project", level: "gray" };
}
if (state.projectExists === false) {
return {
color: "#b33a3a",
text: `Missing project folder: ${state.projectFolder || state.projectId}`,
level: "red",
};
}
if (state.workflowChecked && !state.workflowHasSignature) {
return {
color: "#c77c2a",
text: "Active project, workflow unassigned",
level: "orange",
};
}
if (mismatch) {
return {
color: "#c77c2a",
text: `Project mismatch: ${det.project_folder || det.project_id}`,
level: "orange",
};
}
return {
color: "#2f8f4e",
text: `Active: ${state.projectFolder} (${state.projectId})`,
level: "green",
};
}
import { getSettingBool } from "./mjr/settings.js";
import { createPanelHelpers } from "./ui/panel_helpers.js";
import { colorWithAlpha, makeStatus } from "./ui/project_status.js";
import { renderProjectDropdowns } from "./ui/project_dropdowns.js";
import { PROJECT_REFRESH_ERROR_BACKOFF_MS, PROJECT_REFRESH_THROTTLE_MS, SETTING_AUTO_FIX_AFTER_DOWNLOAD, SETTING_AUTO_REMEMBER_NOTE_SOURCES, SETTING_AUTO_SCAN_MISSING_ON_OPEN, TEMPLATE_TOKENS, } from "./ui/panel_constants.js";
export function buildPanel(el, state, actions) {
const { createAndActivateProject, setActiveProjectById, saveWorkflowToProject } = actions || {};
el.innerHTML = "";
@@ -121,97 +32,7 @@ export function buildPanel(el, state, actions) {
container.style.color = "#ddd";
container.style.fontFamily = "inherit";
container.style.padding = "8px 14px 16px";
const section = (label) => {
const h = document.createElement("div");
h.textContent = label;
h.style.fontWeight = "600";
h.style.marginTop = UI_GAP;
h.style.marginBottom = UI_GAP;
h.style.paddingLeft = "2px";
h.style.textTransform = "uppercase";
h.style.letterSpacing = "0.6px";
h.style.color = "#8fb9ff";
return h;
};
const divider = (thick = false) => {
const d = document.createElement("div");
d.style.height = thick ? "3px" : "1px";
d.style.background = "rgba(255,255,255,0.08)";
d.style.margin = "0";
return d;
};
const detailsSection = (title) => {
const details = document.createElement("details");
details.style.marginTop = "0";
details.style.display = "flex";
details.style.flexDirection = "column";
details.style.gap = UI_GAP;
const summary = document.createElement("summary");
summary.textContent = title;
summary.style.cursor = "pointer";
summary.style.opacity = "0.9";
summary.style.textTransform = "uppercase";
summary.style.letterSpacing = "0.6px";
summary.style.color = "#8fb9ff";
summary.style.fontWeight = "600";
summary.style.paddingLeft = "2px";
details.appendChild(summary);
details._summary = summary;
return details;
};
const makeLabel = (labelText) => {
const label = document.createElement("label");
label.textContent = labelText;
label.style.opacity = "0.85";
label.style.marginLeft = "2px";
label.style.fontSize = "11px";
label.style.lineHeight = "1.2";
return label;
};
const styleField = (inputEl) => {
inputEl.style.width = "100%";
inputEl.style.boxSizing = "border-box";
inputEl.style.padding = "6px 8px";
inputEl.style.borderRadius = "8px";
inputEl.style.border = "1px solid rgba(255,255,255,0.12)";
inputEl.style.background = "rgba(0,0,0,0.25)";
inputEl.style.color = "#eee";
inputEl.style.outline = "none";
};
const row = (labelText, inputEl) => {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = UI_GAP;
wrap.appendChild(makeLabel(labelText));
styleField(inputEl);
wrap.appendChild(inputEl);
return wrap;
};
const groupRow = (labelText, contentEl) => {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = UI_GAP;
wrap.appendChild(makeLabel(labelText));
wrap.appendChild(contentEl);
return wrap;
};
const btn = (text) => {
const b = document.createElement("button");
b.textContent = text;
b.className = "mjr-ps-btn";
b.style.padding = "7px 8px";
b.style.borderRadius = "8px";
b.style.border = "1px solid rgba(46, 136, 255, 0.65)";
b.style.background = "rgba(46, 136, 255, 0.25)";
b.style.color = "#eee";
b.style.cursor = "pointer";
b.style.margin = "0";
b.style.lineHeight = "1.2";
b.style.whiteSpace = "nowrap";
return b;
};
const { section, divider, detailsSection, makeLabel, styleField, row, groupRow, btn, } = createPanelHelpers(UI_GAP);
// Status bar
const statusBar = document.createElement("div");
statusBar.style.display = "flex";
@@ -261,6 +82,69 @@ export function buildPanel(el, state, actions) {
saveState(state);
updateWorkflowBlock();
});
const healthSection = detailsSection("Project Health");
healthSection.open = true;
const healthList = document.createElement("div");
healthList.style.display = "grid";
healthList.style.gridTemplateColumns = "auto minmax(0, 1fr)";
healthList.style.columnGap = "8px";
healthList.style.rowGap = "6px";
healthList.style.alignItems = "start";
healthList.style.padding = "8px 0 0";
healthSection.appendChild(healthList);
const healthRows = {};
const addHealthRow = (key, labelText) => {
const label = document.createElement("div");
label.textContent = labelText;
label.style.opacity = "0.7";
label.style.whiteSpace = "nowrap";
const value = document.createElement("div");
value.style.minWidth = "0";
value.style.overflowWrap = "anywhere";
healthRows[key] = value;
healthList.appendChild(label);
healthList.appendChild(value);
};
addHealthRow("folder", "Folder");
addHealthRow("workflow", "Workflow");
addHealthRow("target", "Output");
addHealthRow("patch", "Patch");
addHealthRow("models", "Models");
addHealthRow("save", "Last save");
const setHealth = (key, text, color = "#ddd") => {
const rowEl = healthRows[key];
if (!rowEl)
return;
rowEl.textContent = text;
rowEl.style.color = color;
};
const updateHealth = () => {
const okColor = "#9dd7a5";
const warnColor = "#f2d98a";
const errorColor = "#ff9a9a";
const mutedColor = "#aaa";
setHealth("folder", state.projectId
? state.projectExists === false
? `Missing: ${state.projectFolder || state.projectId}`
: state.projectFolder || state.projectId
: "No active project", !state.projectId ? mutedColor : state.projectExists === false ? errorColor : okColor);
setHealth("workflow", state.workflowChecked
? state.workflowHasSignature
? "Assigned"
: "Unassigned"
: "Not checked yet", state.workflowChecked && !state.workflowHasSignature ? warnColor : okColor);
setHealth("target", state.lastRelDir || state.lastPrefix ? `${state.lastRelDir || "-"} | ${state.lastPrefix || "-"}` : "No output target", state.lastRelDir || state.lastPrefix ? okColor : warnColor);
const patchPreview = state.lastRelDir || state.lastPrefix
? previewPatchSaveNodes(app, state.lastRelDir, state.lastPrefix, state.media)
: [];
const patchNodes = new Set(patchPreview.map((item) => item.node)).size;
setHealth("patch", patchNodes > 0 ? `${patchNodes} save node${patchNodes === 1 ? "" : "s"} patchable` : "No patchable save nodes", patchNodes > 0 ? warnColor : mutedColor);
const missingText = fixerState.scanned
? `${fixerState.missing} missing / ${fixerState.total} checked`
: "Not scanned";
setHealth("models", missingText, fixerState.scanned && fixerState.missing > 0 ? warnColor : mutedColor);
setHealth("save", state.workflowName || state.workflowLastBase || "No workflow save yet", state.workflowName || state.workflowLastBase ? okColor : mutedColor);
};
const updateResolve = () => {
const det = state.detected;
let show = true;
@@ -736,6 +620,11 @@ export function buildPanel(el, state, actions) {
downloadStatus.style.marginLeft = "2px";
const downloadStatusText = document.createElement("div");
downloadStatus.appendChild(downloadStatusText);
const downloadPolicyText = document.createElement("div");
downloadPolicyText.textContent =
"Download policy: direct http/https URLs, extension allowlist, size cap, host policy, and private-IP guard are enforced server-side.";
downloadPolicyText.style.opacity = "0.75";
downloadStatus.appendChild(downloadPolicyText);
const downloadProgressWrap = document.createElement("div");
downloadProgressWrap.style.height = "6px";
downloadProgressWrap.style.width = "100%";
@@ -766,6 +655,7 @@ export function buildPanel(el, state, actions) {
workflowUtilsContent.appendChild(fingerprintDetails);
workflowUtilsWrap.appendChild(workflowUtilsContent);
container.appendChild(statusBar);
container.appendChild(healthSection);
container.appendChild(workflowWrap);
container.appendChild(resolveWrap);
container.appendChild(divider());
@@ -954,6 +844,7 @@ export function buildPanel(el, state, actions) {
}
workflowUtilsWrap.style.border = `1px solid ${colorWithAlpha(color, 0.4)}`;
workflowUtilsWrap.style.background = colorWithAlpha(color, 0.12);
updateHealth();
};
const updateDownloadStatus = () => {
const rawState = downloadState.state || "idle";
@@ -1164,6 +1055,7 @@ export function buildPanel(el, state, actions) {
modelSelect.style.opacity = modelSelect.disabled ? "0.6" : "1";
if (!template) {
previewLabel.textContent = "Preview: -";
updateHealth();
return;
}
previewLabel.textContent = "Preview: ...";
@@ -1173,12 +1065,14 @@ export function buildPanel(el, state, actions) {
return;
const preview = resp?.preview || "";
previewLabel.textContent = preview ? `Preview: ${preview}` : "Preview: -";
updateHealth();
}
catch (err) {
if (requestToken !== previewToken)
return;
const previewError = String(err instanceof Error ? err.message : err || "Invalid template");
previewLabel.textContent = `Preview: ${previewError}`;
updateHealth();
}
};
const updateState = () => {
@@ -1266,21 +1160,7 @@ export function buildPanel(el, state, actions) {
let lastProjectsRefreshAt = 0;
let projectRefreshErrorCount = 0;
const updateProjectDropdowns = (filteredList) => {
pendingSelect.innerHTML = "";
const pendingEmpty = document.createElement("option");
pendingEmpty.value = "";
pendingEmpty.textContent = "-";
pendingSelect.appendChild(pendingEmpty);
for (const p of filteredList) {
const opt = document.createElement("option");
opt.value = p.project_id;
opt.dataset.folder = p.folder || "";
opt.dataset.exists = p.exists === false ? "false" : "true";
const missingTag = p.exists === false ? " (missing)" : "";
const archivedTag = p.archived ? " [archived]" : "";
opt.textContent = `${p.folder || p.project_id} (${p.project_id})${missingTag}${archivedTag}`;
pendingSelect.appendChild(opt);
}
renderProjectDropdowns({ pendingSelect }, filteredList);
};
refreshProjectsList = async (force = false) => {
const now = Date.now();
@@ -2057,19 +1937,21 @@ export function buildPanel(el, state, actions) {
toast("warn", "No target", "Create an output first.");
return;
}
// Count save nodes first
const saveNodes = (app.graph?._nodes || []).filter(n => isSaveLikeNode(n));
const nodeCount = saveNodes.length;
const preview = previewPatchSaveNodes(app, state.lastRelDir, state.lastPrefix, state.media);
const nodeCount = new Set(preview.map((item) => item.node)).size;
if (nodeCount === 0) {
toast("warn", "No nodes to patch", "No save-like nodes found in workflow.");
return;
}
const previewLines = preview
.slice(0, 8)
.map((item) => `${item.nodeLabel}.${item.widget}: ${item.oldValue || "-"} -> ${item.newValue || "-"}`);
const overflow = preview.length > previewLines.length ? `\n...and ${preview.length - previewLines.length} more change(s)` : "";
// Ask for confirmation
const confirmed = await psConfirm({
title: "Patch save paths?",
message: `Update ${nodeCount} save node${nodeCount !== 1 ? "s" : ""} to use the project path?\n\n` +
`Path: ${state.lastRelDir}\n` +
`Prefix: ${state.lastPrefix}\n\n` +
`${previewLines.join("\n")}${overflow}\n\n` +
`This can be undone with Ctrl+Z.`,
});
if (!confirmed) {
File diff suppressed because one or more lines are too long