Files
MajoorWaldi-ComfyUI-Majoor-…/ui/app/settings/SettingsPanel.ts
T

517 lines
20 KiB
TypeScript

/**
* Majoor settings integration with ComfyUI settings panel.
*
* This module now has two separate responsibilities:
* - initialize runtime settings side effects once
* - build the static `settings:` extension payload used by ComfyUI_frontend
*/
import { safeDispatchCustomEvent } from "../../utils/events.js";
import { initI18n, setFollowComfyLanguage, startComfyLanguageSync } from "../i18n.js";
import { debounce } from "../../utils/debounce.js";
import { getWatcherStatus, toggleWatcher } from "../../api/client.js";
import { getSettingForApp, setSettingForApp } from "../hostAdapter.js";
import {
DEFAULT_SETTINGS,
loadMajoorSettings,
saveMajoorSettings,
applySettingsToConfig,
syncBackendSecuritySettings,
syncBackendVectorSearchSettings,
syncBackendExecutionGroupingSettings,
} from "./settingsCore.js";
import { startRuntimeStatusDashboard } from "./settingsRuntime.js";
import { registerGridSettings } from "./settingsGrid.js";
import { registerViewerSettings } from "./settingsViewer.js";
import { registerScanningSettings } from "./settingsScanning.js";
import { registerFeedSettings } from "./settingsFeed.js";
import { registerSecuritySettings } from "./settingsSecurity.js";
import { registerAdvancedSettings } from "./settingsAdvanced.js";
import { registerSearchSettings } from "./settingsSearch.js";
import { SETTINGS_KEY } from "../settingsStore.js";
const SETTINGS_CATEGORY = "Majoor Assets Manager";
const SETTINGS_NAME_PREFIX_RE = /^\s*Majoor:\s*/i;
const SETTINGS_NATIVE_SECTIONS = Object.freeze({
ASSETS_PANEL: "Assets Panel",
GENERATED_FEED: "Generated Feed",
VIEWER: "Viewer & Floating Viewer",
INDEXING: "Indexing & Watcher",
SEARCH_AI: "Search & AI",
GENERAL: "General",
ADVANCED: "Advanced",
SECURITY: "Security",
});
const COLOR_SETTING_KEYS = new Set([
"grid.starColor",
"grid.badgeImageColor",
"grid.badgeVideoColor",
"grid.badgeAudioColor",
"grid.badgeModel3dColor",
"grid.badgeDuplicateAlertColor",
"ui.cardHoverColor",
"ui.cardSelectionColor",
"ui.ratingColor",
"ui.tagColor",
]);
const GLOBAL_RESET_SETTING_ID = "Majoor.General.ResetAllSettings";
const RESET_BUTTON_CLASS = "mjr-settings-reset-btn";
let _settingsResetObserver: MutationObserver | null = null;
let _settingsResetBindings: any = null;
function settingSectionForId(id: any): string {
const safeId = String(id || "").trim();
if (!safeId) return SETTINGS_NATIVE_SECTIONS.GENERAL;
if (safeId === GLOBAL_RESET_SETTING_ID || safeId === "Majoor.Language") {
return SETTINGS_NATIVE_SECTIONS.GENERAL;
}
if (/^Majoor\.(Safety|Security)\./.test(safeId)) {
return SETTINGS_NATIVE_SECTIONS.SECURITY;
}
if (
/^Majoor\.(Paths|Db|ProbeBackend|MetadataFallback|Observability)\./.test(safeId) ||
safeId === "Majoor.EnvVars.Reference"
) {
return SETTINGS_NATIVE_SECTIONS.ADVANCED;
}
if (/^Majoor\.(Viewer|WorkflowMinimap)\./.test(safeId)) {
return SETTINGS_NATIVE_SECTIONS.VIEWER;
}
if (/^Majoor\.Feed\./.test(safeId)) {
return SETTINGS_NATIVE_SECTIONS.GENERATED_FEED;
}
if (/^Majoor\.(AutoScan|Scan|Watcher|ExecutionGrouping|RatingTagsSync)\./.test(safeId)) {
return SETTINGS_NATIVE_SECTIONS.INDEXING;
}
if (safeId === "Majoor.RtHydrate.Concurrency") {
return SETTINGS_NATIVE_SECTIONS.INDEXING;
}
if (
/^Majoor\.AI\.(HuggingFaceTokenVisible|HuggingFaceToken|VerboseLogs|VectorStats|VectorBackfillAction|VectorSearchEnabled|VectorCaptionOnIndex|VectorIndexOnScan|VectorConcurrency|VectorUnloadAfterUse|VectorUnloadNow)$/.test(safeId) ||
/^Majoor\.Search\./.test(safeId)
) {
return SETTINGS_NATIVE_SECTIONS.SEARCH_AI;
}
if (
/^Majoor\.(Grid|Cards|Badges|Sidebar|InfiniteScroll|General)\./.test(safeId)
) {
return SETTINGS_NATIVE_SECTIONS.ASSETS_PANEL;
}
if (safeId === "Majoor.Language") {
return SETTINGS_NATIVE_SECTIONS.GENERAL;
}
return SETTINGS_NATIVE_SECTIONS.GENERAL;
}
function normalizeComfyCategory(payload: any): string[] {
const raw = Array.isArray(payload?.category) ? payload.category.filter(Boolean) : [];
const section = settingSectionForId(payload?.id);
const priorSection = String(raw[1] || raw[0] || "").trim();
const priorLabel = String(raw[2] || "").trim();
const name = String(payload?.name || "").replace(SETTINGS_NAME_PREFIX_RE, "").trim();
const label = priorLabel || priorSection || name || section;
return [SETTINGS_CATEGORY, section, label];
}
let _settingsStorageListenerBound = false;
let _settingsStorageListenerRef: any = null;
let _settingsContext: any = null;
let _settingsRuntimeInitialized = false;
const _syncingComfySettingIds = new Set();
function normalizeSettingPayload(payload: any) {
if (!payload || typeof payload !== "object") return null;
const normalized = { ...payload };
try {
if (typeof normalized.name === "string") {
normalized.name = normalized.name.replace(SETTINGS_NAME_PREFIX_RE, "").trim();
}
} catch (e) {
console.debug?.(e);
}
try {
normalized.category = normalizeComfyCategory(normalized);
} catch {
normalized.category = [SETTINGS_CATEGORY, SETTINGS_NATIVE_SECTIONS.GENERAL];
}
if (!normalized.tooltip && typeof normalized.name === "string" && normalized.name.trim()) {
normalized.tooltip = normalized.name.trim();
}
return normalized;
}
function syncComfySettingValue(app: any, definition: any, value: any) {
const id = String(definition?.id || "").trim();
if (!id) return false;
if (_syncingComfySettingIds.has(id)) return false;
_syncingComfySettingIds.add(id);
try {
return setSettingForApp(app, id, value);
} finally {
_syncingComfySettingIds.delete(id);
}
}
function wrapSettingForComfySync(app: any, definition: any) {
if (!definition || typeof definition !== "object") return definition;
const wrapped = { ...definition };
syncComfySettingValue(app, wrapped, wrapped.defaultValue);
const originalOnChange = wrapped.onChange;
wrapped.onChange = (value: any, ...args: any[]) => {
syncComfySettingValue(app, wrapped, value);
if (typeof originalOnChange === "function") {
return originalOnChange(value, ...args);
}
wrapped.defaultValue = value;
return undefined;
};
return wrapped;
}
function cloneSettings(settings: any): Record<string, any> {
try {
return JSON.parse(JSON.stringify(settings || {}));
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function buildSettingsDefinitionsFromSnapshot(
settingsSnapshot: Record<string, any>,
app: any,
notifyApplied: (...args: any[]) => void,
{ wrapForComfy = true } = {},
) {
const definitions: any[] = [];
const addSetting = (payload: any) => {
const normalized = normalizeSettingPayload(payload);
if (normalized) {
definitions.push(wrapForComfy ? wrapSettingForComfySync(app, normalized) : normalized);
}
};
registerGridSettings(addSetting, settingsSnapshot, notifyApplied);
registerFeedSettings(addSetting, settingsSnapshot, notifyApplied);
registerViewerSettings(addSetting, settingsSnapshot, notifyApplied);
registerScanningSettings(addSetting, settingsSnapshot, notifyApplied);
registerSecuritySettings(addSetting, settingsSnapshot, notifyApplied);
registerAdvancedSettings(addSetting, settingsSnapshot, notifyApplied, app);
registerSearchSettings(addSetting, settingsSnapshot, notifyApplied);
return definitions;
}
function sameSettingValue(a: any, b: any): boolean {
if (a === b) return true;
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
function findSettingInputContainer(row: Element | null): HTMLElement | null {
if (!row) return null;
return (
row.querySelector<HTMLElement>(".form-input") ||
row.querySelector<HTMLElement>(".p-inputgroup") ||
row.querySelector<HTMLElement>(".setting-input") ||
row.querySelector<HTMLElement>("[class*='input']")
);
}
function createResetButton(label: string, title: string): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = RESET_BUTTON_CLASS;
button.textContent = label;
button.title = title;
button.style.marginLeft = "8px";
button.style.minWidth = label.length > 2 ? "auto" : "24px";
button.style.height = "24px";
button.style.padding = label.length > 2 ? "0 10px" : "0";
button.style.borderRadius = "6px";
button.style.border = "1px solid var(--border-color, #555)";
button.style.background = "var(--comfy-input-bg, #2b2b2b)";
button.style.color = "var(--input-text, inherit)";
button.style.cursor = "pointer";
button.style.fontSize = "12px";
button.style.lineHeight = "22px";
button.style.flexShrink = "0";
return button;
}
function applySettingDefault(definition: any, defaultValue: any, app: any): void {
const id = String(definition?.id || "").trim();
if (!id) return;
syncComfySettingValue(app, definition, defaultValue);
if (typeof definition?.onChange === "function") {
definition.onChange(defaultValue);
}
}
function updateResetButtonState(button: HTMLButtonElement, definition: any, defaultValue: any, app: any): void {
const currentValue = getSettingForApp(app, definition.id, definition.defaultValue);
const changed = !sameSettingValue(currentValue, defaultValue);
button.disabled = !changed;
button.style.opacity = changed ? "1" : "0.45";
}
function injectSettingsResetButtons() {
if (typeof document === "undefined" || !_settingsResetBindings) return;
const { app, definitions, defaultValues } = _settingsResetBindings;
const globalRow = document.querySelector(`[data-setting-id="${GLOBAL_RESET_SETTING_ID}"]`);
const globalContainer = findSettingInputContainer(globalRow);
if (globalRow && globalContainer && !globalRow.getAttribute("data-mjr-reset-injected")) {
globalRow.setAttribute("data-mjr-reset-injected", "true");
globalContainer.innerHTML = "";
const resetAllButton = createResetButton("Reset all settings", "Reset all Majoor settings to defaults");
resetAllButton.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
for (const definition of definitions) {
if (definition.id === GLOBAL_RESET_SETTING_ID) continue;
if (!defaultValues.has(definition.id)) continue;
applySettingDefault(definition, defaultValues.get(definition.id), app);
}
injectSettingsResetButtons();
};
globalContainer.appendChild(resetAllButton);
}
for (const definition of definitions) {
if (!definition?.id || definition.id === GLOBAL_RESET_SETTING_ID) continue;
if (!defaultValues.has(definition.id)) continue;
const row = document.querySelector(`[data-setting-id="${definition.id}"]`);
if (!row || row.getAttribute("data-mjr-reset-injected")) continue;
const inputContainer = findSettingInputContainer(row);
if (!inputContainer) continue;
row.setAttribute("data-mjr-reset-injected", "true");
const resetButton = createResetButton("Reset", "Reset this setting to default");
updateResetButtonState(resetButton, definition, defaultValues.get(definition.id), app);
resetButton.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
const defaultValue = defaultValues.get(definition.id);
applySettingDefault(definition, defaultValue, app);
updateResetButtonState(resetButton, definition, defaultValue, app);
};
inputContainer.appendChild(resetButton);
}
}
function ensureSettingsResetObserver(app: any, definitions: any[], defaultDefinitions: any[]) {
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return;
const defaultValues = new Map(
defaultDefinitions
.filter((definition) => definition?.id && definition.id !== GLOBAL_RESET_SETTING_ID)
.map((definition) => [definition.id, definition.defaultValue]),
);
_settingsResetBindings = { app, definitions, defaultValues };
injectSettingsResetButtons();
if (_settingsResetObserver) return;
_settingsResetObserver = new MutationObserver(() => injectSettingsResetButtons());
_settingsResetObserver.observe(document.body, { childList: true, subtree: true });
}
function ensureMajoorSettingsContext(app: any, onApplied: any, { initRuntime = false } = {}) {
if (_settingsContext) {
if (typeof onApplied === "function") {
_settingsContext.onAppliedListeners.add(onApplied);
}
if (app && !_settingsContext.app) {
_settingsContext.app = app;
}
} else {
const settings = loadMajoorSettings();
settings.i18n = settings.i18n || {};
if (typeof settings.i18n.followComfyLanguage !== "boolean") {
settings.i18n.followComfyLanguage = true;
setFollowComfyLanguage(true);
saveMajoorSettings(settings);
} else {
setFollowComfyLanguage(!!settings.i18n.followComfyLanguage);
}
const onAppliedListeners = new Set<(...args: any[]) => void>();
if (typeof onApplied === "function") {
onAppliedListeners.add(onApplied);
}
const pendingKeys = new Set();
const pendingColorKeys = new Set();
const COLOR_NOTIFY_DEBOUNCE_MS = 450;
const flushNotify = () => {
if (!pendingKeys.size) return;
const keys = Array.from(pendingKeys);
pendingKeys.clear();
for (const pendingKey of keys) {
safeDispatchCustomEvent(
"mjr-settings-changed",
{ key: pendingKey },
{ warnPrefix: "[Majoor]" },
);
}
};
const flushColorNotify = () => {
if (!pendingColorKeys.size) return;
const keys = Array.from(pendingColorKeys);
pendingColorKeys.clear();
for (const pendingKey of keys) {
safeDispatchCustomEvent(
"mjr-settings-changed",
{ key: pendingKey },
{ warnPrefix: "[Majoor]" },
);
}
};
const scheduleNotifyFlush = debounce(flushNotify, 120);
const scheduleColorNotifyFlush = debounce(flushColorNotify, COLOR_NOTIFY_DEBOUNCE_MS);
const scheduleNotify = (key: any) => {
if (typeof key === "string") pendingKeys.add(key);
scheduleNotifyFlush();
};
const scheduleColorNotify = (key: any) => {
if (typeof key === "string") pendingColorKeys.add(key);
scheduleColorNotifyFlush();
};
const refreshFromStorage = () => {
const latest = loadMajoorSettings();
Object.assign(settings, latest);
applySettingsToConfig(settings);
scheduleNotify("storage");
};
const storageListener = (event: any) => {
if (!event || event.key !== SETTINGS_KEY) return;
if (event.newValue === event.oldValue) return;
refreshFromStorage();
};
if (!_settingsStorageListenerBound) {
// Remove stale listener from a previous init (hot-reload safety)
if (_settingsStorageListenerRef && typeof window !== "undefined") {
try {
window.removeEventListener("storage", _settingsStorageListenerRef);
} catch (e) {
console.debug?.(e);
}
}
try {
window.addEventListener("storage", storageListener);
_settingsStorageListenerBound = true;
_settingsStorageListenerRef = storageListener;
} catch (e) {
console.debug?.(e);
}
}
const notifyApplied = (key: any) => {
for (const listener of onAppliedListeners) {
try {
listener(settings, key);
} catch (e) {
console.debug?.(e);
}
}
if (COLOR_SETTING_KEYS.has(String(key || ""))) {
scheduleColorNotify(key);
} else {
scheduleNotify(key);
}
};
_settingsContext = {
app,
notifyApplied,
onAppliedListeners,
refreshFromStorage,
settings,
};
}
if (initRuntime && !_settingsRuntimeInitialized) {
const runtimeApp = app || _settingsContext.app;
const settings = _settingsContext.settings;
initI18n(runtimeApp);
applySettingsToConfig(settings);
startComfyLanguageSync(runtimeApp);
void syncBackendSecuritySettings();
void syncBackendVectorSearchSettings();
void syncBackendExecutionGroupingSettings();
if (settings?.watcher && typeof settings.watcher.enabled === "boolean") {
void toggleWatcher(!!settings.watcher.enabled).catch(() => {});
}
startRuntimeStatusDashboard();
_settingsRuntimeInitialized = true;
}
return _settingsContext;
}
export const registerMajoorSettings = (app: any | null | undefined, onApplied?: () => void): void => {
const context = ensureMajoorSettingsContext(app, onApplied, { initRuntime: true });
return context.settings;
};
export const buildMajoorSettings = (app: any | null | undefined, onApplied?: () => void): any[] => {
const context = ensureMajoorSettingsContext(app, onApplied, { initRuntime: false });
Object.assign(context.settings, loadMajoorSettings());
const runtimeApp = app || context.app;
const settingsDefinitions = buildSettingsDefinitionsFromSnapshot(
context.settings,
runtimeApp,
context.notifyApplied,
);
const defaultDefinitions = buildSettingsDefinitionsFromSnapshot(
cloneSettings(DEFAULT_SETTINGS),
runtimeApp,
() => {},
{ wrapForComfy: false },
);
settingsDefinitions.unshift(
wrapSettingForComfySync(runtimeApp, {
id: GLOBAL_RESET_SETTING_ID,
category: [SETTINGS_CATEGORY, SETTINGS_NATIVE_SECTIONS.GENERAL, "Reset"],
name: "Reset all settings to defaults",
tooltip: "Reset every Majoor Assets Manager setting to its default value.",
type: "text",
defaultValue: "",
}),
);
ensureSettingsResetObserver(runtimeApp, settingsDefinitions, defaultDefinitions);
return settingsDefinitions;
};
// Best-effort backend sync (watcher status) at startup.
try {
const settings = loadMajoorSettings();
if (settings?.watcher && typeof settings.watcher.enabled === "boolean") {
getWatcherStatus()
.then((res) => {
const enabled = !!res?.ok && !!res?.data?.enabled;
const latest = loadMajoorSettings();
latest.watcher = latest.watcher || {};
if (typeof enabled === "boolean" && enabled !== !!latest.watcher.enabled) {
latest.watcher.enabled = enabled;
saveMajoorSettings(latest);
safeDispatchCustomEvent(
"mjr-settings-changed",
{ key: "watcher.enabled" },
{ warnPrefix: "[Majoor]" },
);
}
})
.catch(() => {});
}
} catch (e) {
console.debug?.(e);
}