- Added new AI_FEATURES.md guide covering all AI-powered capabilities - Updated DOCUMENTATION_INDEX.md to include new AI features guide - Added AI Features section to README.md with overview and models used - Improved backfill warning information in README.md - Updated reset index / delete DB procedures in DB_MAINTENANCE.md - Added configuration options for AI features in README.md
1600 lines
63 KiB
JavaScript
1600 lines
63 KiB
JavaScript
|
|
import { comfyConfirm, comfyPrompt } from "../../app/dialogs.js";
|
|
import { comfyToast } from "../../app/toast.js";
|
|
import { t } from "../../app/i18n.js";
|
|
import {
|
|
startTimer,
|
|
endTimer,
|
|
trackGridRender,
|
|
trackSearchQuery,
|
|
trackApiCall,
|
|
trackError,
|
|
mark,
|
|
measure
|
|
} from "../../app/metrics.js";
|
|
import { createStatusIndicator, setupStatusPolling, triggerScan, updateStatus } from "../status/StatusDot.js";
|
|
import {
|
|
createGridContainer,
|
|
loadAssets,
|
|
loadAssetsFromList,
|
|
prepareGridForScopeSwitch,
|
|
disposeGrid,
|
|
refreshGrid,
|
|
captureAnchor,
|
|
restoreAnchor,
|
|
bindGridScanListeners,
|
|
disposeGridScanListeners,
|
|
} from "../grid/GridView.js";
|
|
import {
|
|
get,
|
|
post,
|
|
getCollectionAssets,
|
|
vectorFindSimilar,
|
|
toggleWatcher,
|
|
setWatcherScope,
|
|
getDuplicateAlerts,
|
|
startDuplicatesAnalysis,
|
|
mergeDuplicateTags,
|
|
deleteAssets
|
|
} from "../../api/client.js";
|
|
import { ENDPOINTS } from "../../api/endpoints.js";
|
|
import { createSidebar, showAssetInSidebar, closeSidebar } from "../../components/SidebarView.js";
|
|
import { createRatingBadge, createTagsBadge } from "../../components/Badges.js";
|
|
import { loadMajoorSettings } from "../../app/settings.js";
|
|
import { createPopoverManager } from "./views/popoverManager.js";
|
|
import { createCollectionsPopoverView } from "../collections/views/collectionsPopoverView.js";
|
|
import { createCollectionsController } from "../collections/controllers/collectionsController.js";
|
|
|
|
import { createPanelState } from "./state/panelState.js";
|
|
import { createHeaderView } from "./views/headerView.js";
|
|
import { createCustomPopoverView } from "./views/customPopoverView.js";
|
|
import { createFilterPopoverView } from "./views/filterPopoverView.js";
|
|
import { createSortPopoverView } from "./views/sortPopoverView.js";
|
|
import { createSearchView } from "./views/searchView.js";
|
|
import { createPinnedFoldersPopoverView } from "./views/pinnedFoldersPopoverView.js";
|
|
import { createMessagePopoverView } from "./views/messagePopoverView.js";
|
|
import { createSummaryBarView } from "./views/summaryBarView.js";
|
|
import { createAgendaCalendar } from "../filters/calendar/AgendaCalendar.js";
|
|
import { bindMessagePopoverController } from "./messages/messagePopoverController.js";
|
|
|
|
import { normalizeQuery } from "./controllers/query.js";
|
|
import { createGridController } from "./controllers/gridController.js";
|
|
import { createBrowserNavigationController } from "./controllers/browserNavigationController.js";
|
|
import { createScopeController } from "./controllers/scopeController.js";
|
|
import { createContextController } from "./controllers/contextController.js";
|
|
import { createCustomRootsController } from "./controllers/customRootsController.js";
|
|
import { bindFilters } from "./controllers/filtersController.js";
|
|
import { createSortController } from "./controllers/sortController.js";
|
|
import { bindSidebarOpen } from "./controllers/sidebarController.js";
|
|
import { createPanelHotkeysController } from "./controllers/panelHotkeysController.js";
|
|
import { createRatingHotkeysController } from "./controllers/ratingHotkeysController.js";
|
|
import { getHotkeysState, setHotkeysScope } from "./controllers/hotkeysState.js";
|
|
import { bindGridContextMenu } from "../contextmenu/GridContextMenu.js";
|
|
import { getViewerInstance } from "../../components/Viewer.js";
|
|
|
|
let _activeGridContainer = null;
|
|
|
|
export function getActiveGridContainer() {
|
|
// First try the cached reference
|
|
if (_activeGridContainer && _activeGridContainer.isConnected) {
|
|
return _activeGridContainer;
|
|
}
|
|
// Fallback: try to find it in the DOM by ID or class
|
|
try {
|
|
const grid = document.getElementById("mjr-assets-grid") || document.querySelector(".mjr-grid");
|
|
if (grid && grid.isConnected) {
|
|
return grid;
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
return null;
|
|
}
|
|
|
|
export async function renderAssetsManager(container, { useComfyThemeUI = true } = {}) {
|
|
// Start performance timing
|
|
startTimer('panelRender');
|
|
mark('panelRender');
|
|
|
|
const panelLifecycleAC = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
if (useComfyThemeUI) {
|
|
container.classList.add("mjr-assets-manager");
|
|
} else {
|
|
container.classList.remove("mjr-assets-manager");
|
|
}
|
|
|
|
// Cleanup previous instance before re-render to prevent listener leaks
|
|
try {
|
|
container._eventCleanup?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
container._mjrPanelState?._mjrDispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
container._mjrPopoverManager?.dispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
container._mjrHotkeys?.dispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
container._mjrRatingHotkeys?.dispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
container._mjrSidebarController?.dispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
container._mjrGridContextMenuUnbind?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
container.replaceChildren();
|
|
container.classList.add("mjr-am-container");
|
|
|
|
const popovers = createPopoverManager(container);
|
|
try {
|
|
container._mjrPopoverManager = popovers;
|
|
} catch (e) { console.debug?.(e); }
|
|
const state = createPanelState();
|
|
try {
|
|
container._mjrPanelState = state;
|
|
} catch (e) { console.debug?.(e); }
|
|
state.viewScope = String(state.viewScope || "").trim().toLowerCase() === "similar" ? "similar" : "";
|
|
state.similarResults = Array.isArray(state.similarResults) ? state.similarResults : [];
|
|
state.similarTitle = String(state.similarTitle || "");
|
|
state.similarSourceAssetId = String(state.similarSourceAssetId || "");
|
|
|
|
const headerView = createHeaderView();
|
|
const { header, headerActions, tabButtons, customMenuBtn, filterBtn, sortBtn, collectionsBtn, pinnedFoldersBtn, messageBtn } = headerView;
|
|
|
|
const { customPopover, customSelect, customAddBtn, customRemoveBtn } = createCustomPopoverView();
|
|
const {
|
|
filterPopover,
|
|
kindSelect,
|
|
wfCheckbox,
|
|
workflowTypeSelect,
|
|
ratingSelect,
|
|
minSizeInput,
|
|
maxSizeInput,
|
|
resolutionPresetSelect,
|
|
minWidthInput,
|
|
minHeightInput,
|
|
maxWidthInput,
|
|
maxHeightInput,
|
|
dateRangeSelect,
|
|
dateExactInput,
|
|
agendaContainer
|
|
} = createFilterPopoverView();
|
|
const { sortPopover, sortMenu } = createSortPopoverView();
|
|
const { collectionsPopover, collectionsMenu } = createCollectionsPopoverView();
|
|
const { pinnedFoldersPopover, pinnedFoldersMenu } = createPinnedFoldersPopoverView();
|
|
const { messagePopover, messageList, markReadBtn } = createMessagePopoverView();
|
|
|
|
headerActions.appendChild(customPopover);
|
|
headerActions.appendChild(messagePopover);
|
|
|
|
const { searchSection, searchInputEl, similarBtn, setSemanticEnabled } = createSearchView({
|
|
filterBtn,
|
|
sortBtn,
|
|
collectionsBtn,
|
|
pinnedFoldersBtn,
|
|
filterPopover,
|
|
sortPopover,
|
|
collectionsPopover,
|
|
pinnedFoldersPopover
|
|
});
|
|
|
|
// Restore persistent search query
|
|
if (state.searchQuery) {
|
|
searchInputEl.value = state.searchQuery;
|
|
}
|
|
searchInputEl.addEventListener("input", (e) => {
|
|
state.searchQuery = e.target.value;
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
|
|
const { bar: summaryBar, update: updateSummaryBar } = createSummaryBarView();
|
|
const folderBreadcrumb = document.createElement("div");
|
|
folderBreadcrumb.className = "mjr-folder-breadcrumb";
|
|
folderBreadcrumb.style.cssText =
|
|
"display:none; align-items:center; gap:4px; padding:3px 6px; margin:1px 0 3px 0; font-size:11px; opacity:0.78; overflow:auto; white-space:nowrap; border-radius:8px; background:color-mix(in srgb, var(--mjr-surface-2, rgba(255,255,255,0.05)) 42%, transparent);";
|
|
|
|
const content = document.createElement("div");
|
|
content.classList.add("mjr-am-content");
|
|
let gridContainer = null;
|
|
let sidebarController = null;
|
|
|
|
container.tabIndex = -1;
|
|
|
|
const statusSection = createStatusIndicator({
|
|
getScanContext: () => ({
|
|
scope: state.scope,
|
|
customRootId: state.customRootId
|
|
})
|
|
});
|
|
const statusDot = statusSection.querySelector("#mjr-status-dot");
|
|
const statusText = statusSection.querySelector("#mjr-status-text");
|
|
const capabilitiesSection = statusSection.querySelector("#mjr-status-capabilities");
|
|
|
|
const browseSection = document.createElement("div");
|
|
browseSection.classList.add("mjr-am-browse");
|
|
browseSection.style.cssText = `
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: row;
|
|
overflow: hidden;
|
|
`;
|
|
|
|
const gridWrapper = document.createElement("div");
|
|
gridWrapper.classList.add("mjr-am-grid-scroll");
|
|
gridWrapper.style.cssText = `
|
|
flex: 1;
|
|
overflow: auto;
|
|
position: relative;
|
|
`;
|
|
|
|
// NOTE: Scroll restoration is done AFTER grid loads (see initialLoadPromise below).
|
|
// Restoring scroll on an empty grid would be useless.
|
|
|
|
let _scrollTimer = null;
|
|
let _lastUserInteractionAt = 0;
|
|
const markUserInteraction = () => {
|
|
try {
|
|
_lastUserInteractionAt = Date.now();
|
|
} catch {
|
|
_lastUserInteractionAt = 0;
|
|
}
|
|
};
|
|
gridWrapper.addEventListener("scroll", () => {
|
|
markUserInteraction();
|
|
if (_scrollTimer) clearTimeout(_scrollTimer);
|
|
_scrollTimer = setTimeout(() => {
|
|
try {
|
|
state.scrollTop = gridWrapper.scrollTop;
|
|
} catch (e) { console.debug?.(e); }
|
|
}, 100);
|
|
}, { passive: true, signal: panelLifecycleAC?.signal });
|
|
|
|
gridContainer = createGridContainer();
|
|
gridWrapper.appendChild(gridContainer);
|
|
_activeGridContainer = gridContainer;
|
|
try { gridContainer.dataset.mjrScope = state.scope; } catch (e) { console.debug?.(e); }
|
|
const summaryBarDisposers = [];
|
|
const registerSummaryDispose = (fn) => {
|
|
if (typeof fn === "function") summaryBarDisposers.push(fn);
|
|
};
|
|
gridContainer._mjrSummaryBarDispose = () => {
|
|
for (const fn of summaryBarDisposers.splice(0)) {
|
|
try {
|
|
fn();
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
};
|
|
try {
|
|
const handler = () => {
|
|
try {
|
|
updateSummaryBar?.({ state, gridContainer });
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
gridContainer.addEventListener("mjr:grid-stats", handler, { signal: panelLifecycleAC?.signal });
|
|
registerSummaryDispose(() => {
|
|
try {
|
|
gridContainer.removeEventListener("mjr:grid-stats", handler);
|
|
} catch (e) { console.debug?.(e); }
|
|
});
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
bindGridScanListeners();
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
// Bind grid context menu
|
|
container._mjrGridContextMenuUnbind = bindGridContextMenu({
|
|
gridContainer,
|
|
getState: () => state,
|
|
onRequestOpenViewer: (asset) => {
|
|
gridContainer?.dispatchEvent?.(new CustomEvent("mjr:open-viewer", { detail: { asset } }));
|
|
},
|
|
getViewer: getViewerInstance,
|
|
});
|
|
|
|
const settings = loadMajoorSettings();
|
|
const sidebar = createSidebar(settings.sidebar?.position || "right");
|
|
const similarEnabledTitle = t("search.findSimilar", "Find Similar");
|
|
const similarDisabledTitle = t("search.similarDisabled", "AI features are disabled in settings");
|
|
|
|
const isAiEnabled = () => {
|
|
try {
|
|
const s = loadMajoorSettings();
|
|
return !!(s?.ai?.vectorSearchEnabled ?? true);
|
|
} catch {
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const applyAiUiState = (enabled) => {
|
|
const aiEnabled = !!enabled;
|
|
try {
|
|
container.classList.toggle("mjr-ai-disabled", !aiEnabled);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
similarBtn.disabled = !aiEnabled;
|
|
similarBtn.title = aiEnabled ? similarEnabledTitle : similarDisabledTitle;
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
setSemanticEnabled?.(aiEnabled);
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
|
|
let _sidebarPosition = "right";
|
|
const applySidebarPosition = (nextPosition) => {
|
|
const pos = String(nextPosition || "right").toLowerCase() === "left" ? "left" : "right";
|
|
if (_sidebarPosition === pos) return;
|
|
_sidebarPosition = pos;
|
|
|
|
// Preserve grid scroll where possible.
|
|
let scrollTop = 0;
|
|
let scrollLeft = 0;
|
|
try {
|
|
scrollTop = Number(gridWrapper?.scrollTop || 0) || 0;
|
|
scrollLeft = Number(gridWrapper?.scrollLeft || 0) || 0;
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
try {
|
|
sidebar.dataset.position = pos;
|
|
const isOpen = sidebar.classList?.contains?.("is-open");
|
|
const borderColor = "var(--mjr-border, rgba(255,255,255,0.12))";
|
|
sidebar.style.borderLeft = pos === "left" ? "none" : (isOpen ? `1px solid ${borderColor}` : "none");
|
|
sidebar.style.borderRight = pos === "left" ? (isOpen ? `1px solid ${borderColor}` : "none") : "none";
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
try {
|
|
if (pos === "left") {
|
|
browseSection.appendChild(sidebar);
|
|
browseSection.appendChild(gridWrapper);
|
|
browseSection.insertBefore(sidebar, browseSection.firstChild);
|
|
} else {
|
|
browseSection.appendChild(gridWrapper);
|
|
browseSection.appendChild(sidebar);
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
try {
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
gridWrapper.scrollTop = scrollTop;
|
|
gridWrapper.scrollLeft = scrollLeft;
|
|
} catch (e) { console.debug?.(e); }
|
|
});
|
|
} catch {
|
|
try {
|
|
gridWrapper.scrollTop = scrollTop;
|
|
gridWrapper.scrollLeft = scrollLeft;
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
};
|
|
|
|
const applyWatcherForScope = async (nextScope) => {
|
|
const scope = String(nextScope || state.scope || "output").toLowerCase();
|
|
const settings = loadMajoorSettings();
|
|
const wantsEnabled = !!settings?.watcher?.enabled;
|
|
// Custom scope is a live filesystem browser; watcher must stay disabled there.
|
|
if (scope === "custom") {
|
|
try {
|
|
await toggleWatcher(false);
|
|
} catch (e) { console.debug?.(e); }
|
|
return;
|
|
}
|
|
const shouldEnable = wantsEnabled;
|
|
|
|
try {
|
|
await toggleWatcher(shouldEnable);
|
|
} catch (e) { console.debug?.(e); }
|
|
if (!shouldEnable) return;
|
|
|
|
try {
|
|
await setWatcherScope({
|
|
scope,
|
|
customRootId: ""
|
|
});
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
|
|
// Initial position (no reload needed).
|
|
try {
|
|
_sidebarPosition = ""; // force apply
|
|
applySidebarPosition(settings.sidebar?.position || "right");
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
// Live updates when ComfyUI settings change.
|
|
const onSettingsChanged = (_e) => {
|
|
const changedKey = String(_e?.detail?.key || "").trim();
|
|
const isInitialSync = changedKey === "__init__";
|
|
const shouldRefreshGrid =
|
|
isInitialSync
|
|
|| changedKey === ""
|
|
|| changedKey.startsWith("grid.")
|
|
|| changedKey.startsWith("infiniteScroll.")
|
|
|| changedKey.startsWith("siblings.")
|
|
|| changedKey.startsWith("search.")
|
|
|| changedKey.startsWith("rtHydrate.")
|
|
|| changedKey.startsWith("workflowMinimap.");
|
|
const shouldRefreshSidebar =
|
|
isInitialSync
|
|
|| changedKey === ""
|
|
|| changedKey.startsWith("sidebar.")
|
|
|| changedKey.startsWith("ai.");
|
|
const shouldApplyWatcher = changedKey === "" || changedKey.startsWith("watcher.");
|
|
try {
|
|
const s = loadMajoorSettings();
|
|
const aiEnabled = !!(s?.ai?.vectorSearchEnabled ?? true);
|
|
const hover = String(s?.ui?.cardHoverColor || "").trim();
|
|
const selected = String(s?.ui?.cardSelectionColor || "").trim();
|
|
const ratingColor = String(s?.ui?.ratingColor || "").trim();
|
|
const tagColor = String(s?.ui?.tagColor || "").trim();
|
|
if (/^#[0-9a-fA-F]{3,8}$/.test(hover)) {
|
|
document.documentElement.style.setProperty("--mjr-card-hover-color", hover);
|
|
}
|
|
if (/^#[0-9a-fA-F]{3,8}$/.test(selected)) {
|
|
document.documentElement.style.setProperty("--mjr-card-selection-color", selected);
|
|
}
|
|
if (/^#[0-9a-fA-F]{3,8}$/.test(ratingColor)) {
|
|
document.documentElement.style.setProperty("--mjr-rating-color", ratingColor);
|
|
}
|
|
if (/^#[0-9a-fA-F]{3,8}$/.test(tagColor)) {
|
|
document.documentElement.style.setProperty("--mjr-tag-color", tagColor);
|
|
}
|
|
|
|
// 1. Sidebar position
|
|
applySidebarPosition(s.sidebar?.position || "right");
|
|
applyAiUiState(aiEnabled);
|
|
|
|
// 2. Grid visuals (badges, details, sizes)
|
|
// Refresh only when a relevant setting changed to avoid reload storms.
|
|
if (shouldRefreshGrid) {
|
|
refreshGrid(gridContainer);
|
|
}
|
|
if (shouldRefreshSidebar && state.sidebarOpen && state.activeAssetId) {
|
|
sidebarController?.refreshActiveAsset?.();
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
if (!shouldApplyWatcher) return;
|
|
applyWatcherForScope(state.scope);
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
try {
|
|
onSettingsChanged({ detail: { key: "__init__" } });
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
window.addEventListener?.("mjr-settings-changed", onSettingsChanged, { signal: panelLifecycleAC?.signal });
|
|
} catch (e) { console.debug?.(e); }
|
|
// Enrichment-complete reload removed: handleCountersUpdate (path B) already
|
|
// detects new assets and triggers queuedReload with proper throttle + anchor
|
|
// preservation. A second unconditional reload here caused systematic double
|
|
// reloads after every watcher flush that triggers metadata enrichment.
|
|
|
|
content.appendChild(statusSection);
|
|
content.appendChild(searchSection);
|
|
content.appendChild(folderBreadcrumb);
|
|
content.appendChild(summaryBar);
|
|
content.appendChild(browseSection);
|
|
container.appendChild(header);
|
|
try {
|
|
container._mjrVersionUpdateCleanup?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
container._mjrVersionUpdateCleanup = headerView.dispose || header._mjrVersionUpdateCleanup;
|
|
container.appendChild(content);
|
|
|
|
const getQuery = () => normalizeQuery(searchInputEl);
|
|
const gridController = createGridController({
|
|
gridContainer,
|
|
loadAssets,
|
|
loadAssetsFromList,
|
|
getCollectionAssets,
|
|
disposeGrid,
|
|
getQuery,
|
|
searchInputEl,
|
|
state
|
|
});
|
|
|
|
let scopeController = null;
|
|
let sortController = null;
|
|
let contextController = null;
|
|
let _duplicatesAlert = null;
|
|
let _dupPollTimer = null;
|
|
let _autoLoadTimer = null;
|
|
let browserNav = null;
|
|
let _unbindBrowserFolderNav = null;
|
|
|
|
const notifyContextChanged = () => {
|
|
try {
|
|
contextController?.update?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
browserNav?.renderBreadcrumb?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
browserNav = createBrowserNavigationController({
|
|
state,
|
|
gridContainer,
|
|
folderBreadcrumb,
|
|
customSelect,
|
|
reloadGrid: () => gridController.reloadGrid(),
|
|
onContextChanged: () => {
|
|
try {
|
|
contextController?.update?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
},
|
|
lifecycleSignal: panelLifecycleAC?.signal || null,
|
|
});
|
|
try {
|
|
_unbindBrowserFolderNav = browserNav.bindGridFolderNavigation();
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
const refreshDuplicateAlerts = async () => {
|
|
const scope = String(state.scope || "output").toLowerCase();
|
|
const customRootId = String(state.customRootId || "").trim();
|
|
if (scope === "custom" && !customRootId) {
|
|
_duplicatesAlert = null;
|
|
notifyContextChanged();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await getDuplicateAlerts({
|
|
scope,
|
|
customRootId,
|
|
maxGroups: 4,
|
|
maxPairs: 4
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
if (!result?.ok) return;
|
|
const data = result.data || {};
|
|
_duplicatesAlert = {
|
|
exactCount: Array.isArray(data.exact_groups) ? data.exact_groups.length : 0,
|
|
similarCount: Array.isArray(data.similar_pairs) ? data.similar_pairs.length : 0,
|
|
firstGroup: Array.isArray(data.exact_groups) ? data.exact_groups[0] : null,
|
|
firstPair: Array.isArray(data.similar_pairs) ? data.similar_pairs[0] : null
|
|
};
|
|
notifyContextChanged();
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
|
|
// Allow context menus to request a refresh (e.g. after "Remove from collection").
|
|
let _reloadGridHandler = null;
|
|
let _globalReloadGridHandler = null;
|
|
let _lastReloadRequestAt = 0;
|
|
const requestQueuedReload = () => {
|
|
try {
|
|
const now = Date.now();
|
|
if (now - Number(_lastReloadRequestAt || 0) < 500) return;
|
|
_lastReloadRequestAt = now;
|
|
} catch (e) { console.debug?.(e); }
|
|
queuedReload().catch((err) => {
|
|
try {
|
|
console.warn("[Majoor] queuedReload failed", err);
|
|
} catch (e) { console.debug?.(e); }
|
|
});
|
|
};
|
|
try {
|
|
_reloadGridHandler = () => {
|
|
requestQueuedReload();
|
|
};
|
|
gridContainer.addEventListener("mjr:reload-grid", _reloadGridHandler, { signal: panelLifecycleAC?.signal });
|
|
const onDuplicateBadgeFocus = (e) => {
|
|
try {
|
|
const detail = e?.detail || {};
|
|
const filenameKey = String(detail?.filenameKey || "").trim().toLowerCase();
|
|
if (!filenameKey || !gridContainer) return;
|
|
const cards = Array.from(gridContainer.querySelectorAll(".mjr-asset-card"))
|
|
.filter((card) => String(card?.dataset?.mjrFilenameKey || "").trim().toLowerCase() === filenameKey);
|
|
if (!cards.length) return;
|
|
const ids = cards
|
|
.map((card) => String(card?.dataset?.mjrAssetId || "").trim())
|
|
.filter(Boolean);
|
|
const activeId = String(cards[0]?.dataset?.mjrAssetId || ids[0] || "").trim();
|
|
if (ids.length && typeof gridContainer?._mjrSetSelection === "function") {
|
|
gridContainer._mjrSetSelection(ids, activeId);
|
|
}
|
|
cards[0]?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
|
|
markUserInteraction();
|
|
notifyContextChanged();
|
|
const n = Number(detail?.count || ids.length || cards.length || 0);
|
|
try {
|
|
comfyToast(t("toast.nameCollisionInView", "Name collision in view: {n} item(s) selected", { n }), "info", 1800);
|
|
} catch (e) { console.debug?.(e); }
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
gridContainer.addEventListener("mjr:badge-duplicates-focus", onDuplicateBadgeFocus, { signal: panelLifecycleAC?.signal });
|
|
registerSummaryDispose(() => {
|
|
try {
|
|
if (_reloadGridHandler) gridContainer.removeEventListener("mjr:reload-grid", _reloadGridHandler);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
gridContainer.removeEventListener("mjr:badge-duplicates-focus", onDuplicateBadgeFocus);
|
|
} catch (e) { console.debug?.(e); }
|
|
_reloadGridHandler = null;
|
|
});
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
_globalReloadGridHandler = () => {
|
|
try {
|
|
if (panelLifecycleAC?.signal?.aborted) return;
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
if (globalThis?._mjrMaintenanceActive) return;
|
|
} catch (e) { console.debug?.(e); }
|
|
requestQueuedReload();
|
|
};
|
|
window.addEventListener("mjr:reload-grid", _globalReloadGridHandler, { signal: panelLifecycleAC?.signal });
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
const customRootsController = createCustomRootsController({
|
|
state,
|
|
customSelect,
|
|
customRemoveBtn,
|
|
comfyConfirm,
|
|
comfyPrompt,
|
|
comfyToast,
|
|
get,
|
|
post,
|
|
ENDPOINTS,
|
|
reloadGrid: gridController.reloadGrid,
|
|
onRootChanged: async () => {
|
|
try {
|
|
browserNav?.resetHistory?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
await applyWatcherForScope(state.scope);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
await refreshDuplicateAlerts();
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
});
|
|
const disposeCustomRootsBindings = customRootsController.bind({ customAddBtn, customRemoveBtn });
|
|
try {
|
|
window.addEventListener("mjr:custom-roots-changed", async (e) => {
|
|
const preferredId = String(e?.detail?.preferredId || "").trim() || null;
|
|
await customRootsController.refreshCustomRoots(preferredId);
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
scopeController = createScopeController({
|
|
state,
|
|
tabButtons,
|
|
customMenuBtn,
|
|
customPopover,
|
|
popovers,
|
|
refreshCustomRoots: customRootsController.refreshCustomRoots,
|
|
reloadGrid: gridController.reloadGrid,
|
|
onChanged: () => {
|
|
try { if (gridContainer) gridContainer.dataset.mjrScope = state.scope; } catch (e) { console.debug?.(e); }
|
|
notifyContextChanged();
|
|
},
|
|
onScopeChanged: async () => {
|
|
try {
|
|
await applyWatcherForScope(state.scope);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
await refreshDuplicateAlerts();
|
|
} catch (e) { console.debug?.(e); }
|
|
},
|
|
onBeforeReload: async () => {
|
|
// Prevent stale cards overlap during fast scope switches.
|
|
try {
|
|
prepareGridForScopeSwitch(gridContainer);
|
|
} catch (e) { console.debug?.(e); }
|
|
// Force an immediate counters refresh for this scope instead of waiting polling tick.
|
|
try {
|
|
await updateStatus(
|
|
statusDot,
|
|
statusText,
|
|
capabilitiesSection,
|
|
{ scope: state.scope, customRootId: state.customRootId },
|
|
null,
|
|
{ signal: panelLifecycleAC?.signal || null }
|
|
);
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
});
|
|
|
|
const exitSimilarViewIfActive = async ({ reload = false } = {}) => {
|
|
try {
|
|
if (String(state.viewScope || "").trim().toLowerCase() !== "similar") return;
|
|
await scopeController?.clearSimilarScope?.({ reload });
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
|
|
if (!header._mjrTabListenersBound) {
|
|
Object.values(tabButtons).forEach((btn) => {
|
|
btn.addEventListener("click", () => scopeController.setScope(btn.dataset.scope), { signal: panelLifecycleAC?.signal });
|
|
});
|
|
header._mjrTabListenersBound = true;
|
|
}
|
|
|
|
bindMessagePopoverController({
|
|
messageBtn,
|
|
messagePopover,
|
|
messageList,
|
|
markReadBtn,
|
|
popovers,
|
|
signal: panelLifecycleAC?.signal,
|
|
onBeforeToggle: () => {
|
|
popovers.close(customPopover);
|
|
popovers.close(filterPopover);
|
|
popovers.close(sortPopover);
|
|
popovers.close(collectionsPopover);
|
|
popovers.close(pinnedFoldersPopover);
|
|
},
|
|
});
|
|
|
|
popovers.setDismissWhitelist([
|
|
customPopover,
|
|
filterPopover,
|
|
sortPopover,
|
|
collectionsPopover,
|
|
pinnedFoldersPopover,
|
|
messagePopover,
|
|
customMenuBtn,
|
|
filterBtn,
|
|
sortBtn,
|
|
collectionsBtn,
|
|
pinnedFoldersBtn,
|
|
messageBtn
|
|
]);
|
|
|
|
customMenuBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
popovers.close(filterPopover);
|
|
popovers.close(sortPopover);
|
|
popovers.close(collectionsPopover);
|
|
popovers.close(messagePopover);
|
|
popovers.toggle(customPopover, customMenuBtn);
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
|
|
filterBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
popovers.close(customPopover);
|
|
popovers.close(sortPopover);
|
|
popovers.close(collectionsPopover);
|
|
popovers.close(messagePopover);
|
|
popovers.toggle(filterPopover, filterBtn);
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
|
|
similarBtn?.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
if (!isAiEnabled()) {
|
|
comfyToast(similarDisabledTitle, "info", 2200);
|
|
return;
|
|
}
|
|
try {
|
|
popovers.close(customPopover);
|
|
popovers.close(filterPopover);
|
|
popovers.close(sortPopover);
|
|
popovers.close(collectionsPopover);
|
|
popovers.close(messagePopover);
|
|
} catch (err) { console.debug?.(err); }
|
|
|
|
const selectedIdRaw = String(
|
|
state.activeAssetId
|
|
|| (Array.isArray(state.selectedAssetIds) ? state.selectedAssetIds[0] : "")
|
|
|| ""
|
|
).trim();
|
|
const selectedId = Number(selectedIdRaw);
|
|
if (!Number.isFinite(selectedId) || selectedId <= 0) {
|
|
comfyToast(t("search.selectAssetForSimilar", "Select an asset first to find similar images/videos."), "info", 2500);
|
|
return;
|
|
}
|
|
|
|
const prevTitle = similarBtn.title;
|
|
similarBtn.disabled = true;
|
|
similarBtn.title = t("search.findingSimilar", "Finding similar assets...");
|
|
try {
|
|
const res = await vectorFindSimilar(selectedId, {
|
|
topK: 100,
|
|
scope: state.scope || "output",
|
|
customRootId: state.customRootId || "",
|
|
});
|
|
if (!res?.ok) {
|
|
comfyToast(String(res?.error || t("search.findSimilarFailed", "Failed to find similar assets")), "error", 3000);
|
|
return;
|
|
}
|
|
const list = Array.isArray(res?.data) ? res.data : [];
|
|
state.similarResults = list;
|
|
state.similarSourceAssetId = String(selectedId);
|
|
state.similarTitle = t("search.similarResults", "Similar to asset #{id} ({n} results)", {
|
|
id: selectedId,
|
|
n: list.length,
|
|
});
|
|
await scopeController?.setScope?.("similar");
|
|
try {
|
|
state.lastGridCount = Number(list.length || 0) || 0;
|
|
state.lastGridTotal = Number(list.length || 0) || 0;
|
|
gridContainer.dispatchEvent?.(new CustomEvent("mjr:grid-stats", { detail: { count: list.length, total: list.length } }));
|
|
} catch (err) { console.debug?.(err); }
|
|
} catch (err) {
|
|
console.debug?.(err);
|
|
comfyToast(t("search.findSimilarFailed", "Failed to find similar assets"), "error", 3000);
|
|
} finally {
|
|
similarBtn.disabled = false;
|
|
similarBtn.title = prevTitle;
|
|
}
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
|
|
sortController = createSortController({
|
|
state,
|
|
sortBtn,
|
|
sortMenu,
|
|
sortPopover,
|
|
popovers,
|
|
reloadGrid: gridController.reloadGrid,
|
|
onChanged: () => {
|
|
notifyContextChanged();
|
|
}
|
|
});
|
|
sortController.bind({
|
|
onBeforeToggle: () => {
|
|
popovers.close(customPopover);
|
|
popovers.close(filterPopover);
|
|
popovers.close(collectionsPopover);
|
|
popovers.close(messagePopover);
|
|
}
|
|
});
|
|
|
|
const collectionsController = createCollectionsController({
|
|
state,
|
|
collectionsBtn,
|
|
collectionsMenu,
|
|
collectionsPopover,
|
|
popovers,
|
|
isAiEnabled,
|
|
reloadGrid: gridController.reloadGrid,
|
|
onChanged: () => {
|
|
notifyContextChanged();
|
|
}
|
|
});
|
|
collectionsController.bind({
|
|
onBeforeToggle: () => {
|
|
popovers.close(customPopover);
|
|
popovers.close(filterPopover);
|
|
popovers.close(sortPopover);
|
|
popovers.close(messagePopover);
|
|
}
|
|
});
|
|
|
|
const createPinnedMenuItem = (label, { right = null, danger = false } = {}) => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "mjr-menu-item";
|
|
if (danger) btn.style.color = "var(--error-text, #f44336)";
|
|
btn.style.cssText = `
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:space-between;
|
|
width:100%;
|
|
gap:10px;
|
|
padding:9px 10px;
|
|
border-radius:9px;
|
|
border:1px solid rgba(120,190,255,0.18);
|
|
background:linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
|
|
transition: border-color 120ms ease, background 120ms ease;
|
|
`;
|
|
const left = document.createElement("span");
|
|
left.textContent = String(label || "");
|
|
left.style.cssText = "font-weight:600; color:var(--fg-color, #e6edf7);";
|
|
const rightWrap = document.createElement("span");
|
|
rightWrap.style.cssText = "display:flex; align-items:center; gap:8px;";
|
|
if (right) {
|
|
const hint = document.createElement("span");
|
|
hint.textContent = String(right);
|
|
hint.style.cssText = "opacity:0.68; font-size:11px;";
|
|
rightWrap.appendChild(hint);
|
|
}
|
|
btn.appendChild(left);
|
|
btn.appendChild(rightWrap);
|
|
btn.addEventListener("mouseenter", () => {
|
|
btn.style.borderColor = "rgba(145,205,255,0.4)";
|
|
btn.style.background = "linear-gradient(135deg, rgba(80,140,255,0.18), rgba(32,100,200,0.14))";
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
btn.addEventListener("mouseleave", () => {
|
|
btn.style.borderColor = "rgba(120,190,255,0.18)";
|
|
btn.style.background = "linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01))";
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
return btn;
|
|
};
|
|
|
|
const renderPinnedFoldersMenu = async () => {
|
|
try {
|
|
pinnedFoldersMenu.replaceChildren();
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
let roots = [];
|
|
try {
|
|
const res = await get(ENDPOINTS.CUSTOM_ROOTS, panelLifecycleAC?.signal ? { signal: panelLifecycleAC.signal } : undefined);
|
|
roots = res?.ok && Array.isArray(res.data) ? res.data : [];
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
if (!roots.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "mjr-muted";
|
|
empty.style.cssText = "padding:10px 12px; opacity:0.75;";
|
|
empty.textContent = t("msg.noPinnedFolders", "No pinned folders");
|
|
pinnedFoldersMenu.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
for (const r of roots) {
|
|
const id = String(r?.id || "").trim();
|
|
if (!id) continue;
|
|
const label = String(r?.name || r?.path || id).trim();
|
|
const path = String(r?.path || "").trim();
|
|
|
|
const row = document.createElement("div");
|
|
row.style.cssText = "display:flex; align-items:stretch; width:100%;";
|
|
|
|
const openBtn = createPinnedMenuItem(label, { right: path || null });
|
|
openBtn.style.flex = "1";
|
|
openBtn.addEventListener("click", async () => {
|
|
state.scope = "custom";
|
|
try { if (gridContainer) gridContainer.dataset.mjrScope = "custom"; } catch (e) { console.debug?.(e); }
|
|
state.customRootId = id;
|
|
state.customRootLabel = label;
|
|
state.currentFolderRelativePath = "";
|
|
try {
|
|
if (customSelect) customSelect.value = id;
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
scopeController?.setActiveTabStyles?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
await applyWatcherForScope(state.scope);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
await refreshDuplicateAlerts();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
notifyContextChanged();
|
|
} catch (e) { console.debug?.(e); }
|
|
popovers.close(pinnedFoldersPopover);
|
|
await gridController.reloadGrid();
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
|
|
const unpinBtn = document.createElement("button");
|
|
unpinBtn.type = "button";
|
|
unpinBtn.className = "mjr-menu-item";
|
|
unpinBtn.title = t("ctx.unpinFolder", "Unpin folder");
|
|
unpinBtn.style.cssText =
|
|
"width:42px; justify-content:center; padding:0; border:1px solid rgba(255,95,95,0.35); border-radius:9px; margin-left:6px; background: linear-gradient(135deg, rgba(255,70,70,0.16), rgba(160,20,20,0.12));";
|
|
const trash = document.createElement("i");
|
|
trash.className = "pi pi-times";
|
|
trash.style.cssText = "opacity:0.88; color: rgba(255,130,130,0.96);";
|
|
unpinBtn.appendChild(trash);
|
|
unpinBtn.addEventListener("click", async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const ok = await comfyConfirm(t("dialog.unpinFolder", "Unpin folder \"{name}\"?", { name: label }));
|
|
if (!ok) return;
|
|
const res = await post(ENDPOINTS.CUSTOM_ROOTS_REMOVE, { id });
|
|
if (!res?.ok) {
|
|
comfyToast(res?.error || t("toast.unpinFolderFailed", "Failed to unpin folder"), "error");
|
|
return;
|
|
}
|
|
if (String(state.customRootId || "") === id) {
|
|
state.customRootId = "";
|
|
state.customRootLabel = "";
|
|
state.currentFolderRelativePath = "";
|
|
}
|
|
try {
|
|
await customRootsController.refreshCustomRoots();
|
|
} catch (e) { console.debug?.(e); }
|
|
await renderPinnedFoldersMenu();
|
|
await gridController.reloadGrid();
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
|
|
row.appendChild(openBtn);
|
|
row.appendChild(unpinBtn);
|
|
pinnedFoldersMenu.appendChild(row);
|
|
}
|
|
};
|
|
|
|
pinnedFoldersBtn?.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await renderPinnedFoldersMenu();
|
|
} catch (e) { console.debug?.(e); }
|
|
popovers.close(customPopover);
|
|
popovers.close(filterPopover);
|
|
popovers.close(sortPopover);
|
|
popovers.close(collectionsPopover);
|
|
popovers.close(messagePopover);
|
|
popovers.toggle(pinnedFoldersPopover, pinnedFoldersBtn);
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
|
|
// Calendar UI (separate module) for day indicators.
|
|
let agendaCalendar = null;
|
|
try {
|
|
try {
|
|
kindSelect.value = state.kindFilter || "";
|
|
wfCheckbox.checked = !!state.workflowOnly;
|
|
workflowTypeSelect.value = String(state.workflowType || "").trim().toUpperCase();
|
|
ratingSelect.value = String(Number(state.minRating || 0) || 0);
|
|
minSizeInput.value = Number(state.minSizeMB || 0) > 0 ? String(state.minSizeMB) : "";
|
|
maxSizeInput.value = Number(state.maxSizeMB || 0) > 0 ? String(state.maxSizeMB) : "";
|
|
minWidthInput.value = Number(state.minWidth || 0) > 0 ? String(state.minWidth) : "";
|
|
minHeightInput.value = Number(state.minHeight || 0) > 0 ? String(state.minHeight) : "";
|
|
maxWidthInput.value = Number(state.maxWidth || 0) > 0 ? String(state.maxWidth) : "";
|
|
maxHeightInput.value = Number(state.maxHeight || 0) > 0 ? String(state.maxHeight) : "";
|
|
const minW = Number(state.minWidth || 0) || 0;
|
|
const minH = Number(state.minHeight || 0) || 0;
|
|
if (minW >= 3840 && minH >= 2160) resolutionPresetSelect.value = "uhd";
|
|
else if (minW >= 2560 && minH >= 1440) resolutionPresetSelect.value = "qhd";
|
|
else if (minW >= 1920 && minH >= 1080) resolutionPresetSelect.value = "fhd";
|
|
else if (minW >= 1280 && minH >= 720) resolutionPresetSelect.value = "hd";
|
|
else resolutionPresetSelect.value = "";
|
|
dateRangeSelect.value = state.dateRangeFilter || "";
|
|
} catch (e) { console.debug?.(e); }
|
|
agendaCalendar = createAgendaCalendar({
|
|
container: agendaContainer,
|
|
hiddenInput: dateExactInput,
|
|
state,
|
|
onRequestReloadGrid: () => gridController.reloadGrid()
|
|
});
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
const disposeFilters = bindFilters({
|
|
state,
|
|
kindSelect,
|
|
wfCheckbox,
|
|
workflowTypeSelect,
|
|
ratingSelect,
|
|
minSizeInput,
|
|
maxSizeInput,
|
|
resolutionPresetSelect,
|
|
minWidthInput,
|
|
minHeightInput,
|
|
maxWidthInput,
|
|
maxHeightInput,
|
|
dateRangeSelect,
|
|
dateExactInput,
|
|
reloadGrid: gridController.reloadGrid,
|
|
lifecycleSignal: panelLifecycleAC?.signal || null,
|
|
onFiltersChanged: () => {
|
|
void exitSimilarViewIfActive({ reload: false });
|
|
try {
|
|
agendaCalendar?.refresh?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
notifyContextChanged();
|
|
}
|
|
});
|
|
|
|
// Context controller: pills + button highlighting.
|
|
try {
|
|
contextController = createContextController({
|
|
state,
|
|
gridContainer,
|
|
filterBtn,
|
|
sortBtn,
|
|
collectionsBtn,
|
|
searchInputEl,
|
|
kindSelect,
|
|
wfCheckbox,
|
|
workflowTypeSelect,
|
|
ratingSelect,
|
|
minSizeInput,
|
|
maxSizeInput,
|
|
minWidthInput,
|
|
minHeightInput,
|
|
maxWidthInput,
|
|
maxHeightInput,
|
|
resolutionPresetSelect,
|
|
dateRangeSelect,
|
|
dateExactInput,
|
|
scopeController,
|
|
sortController,
|
|
updateSummaryBar,
|
|
reloadGrid: () => gridController.reloadGrid(),
|
|
getExtraContext: () => ({ duplicatesAlert: _duplicatesAlert }),
|
|
extraActions: {
|
|
clearSimilarScope: async () => {
|
|
try {
|
|
await scopeController?.clearSimilarScope?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
},
|
|
clearTransientContext: async () => {
|
|
try {
|
|
state.viewScope = "";
|
|
state.similarResults = [];
|
|
state.similarTitle = "";
|
|
state.similarSourceAssetId = "";
|
|
} catch (e) { console.debug?.(e); }
|
|
},
|
|
resetBrowserHistory: () => {
|
|
try {
|
|
browserNav?.resetHistory?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
},
|
|
onDuplicateAlertClick: async (alert) => {
|
|
const current = alert || _duplicatesAlert || {};
|
|
const grp = current.firstGroup;
|
|
if (grp && Array.isArray(grp.assets) && grp.assets.length >= 2) {
|
|
const keep = grp.assets[0] || {};
|
|
const dupIds = grp.assets.slice(1).map((x) => Number(x?.id || 0)).filter((x) => x > 0);
|
|
if (dupIds.length) {
|
|
const mergeOk = await comfyConfirm(t("dialog.mergeDuplicateTags", "Exact duplicates detected ({count}). Merge tags into \"{target}\"?", { count: grp.assets.length, target: keep?.filename || keep?.id }));
|
|
if (mergeOk) {
|
|
const mergeRes = await mergeDuplicateTags(Number(keep?.id || 0), dupIds);
|
|
if (mergeRes?.ok) comfyToast(t("toast.tagsMerged", "Tags merged"), "success", 2200);
|
|
else comfyToast(t("toast.tagMergeFailed", "Tag merge failed: {error}", { error: mergeRes?.error || "error" }), "warning", 3500);
|
|
}
|
|
const delOk = await comfyConfirm(t("dialog.deleteExactDuplicates", "Delete {count} exact duplicate(s)?", { count: dupIds.length }));
|
|
if (delOk) {
|
|
const delRes = await deleteAssets(dupIds);
|
|
if (delRes?.ok) {
|
|
comfyToast(t("toast.duplicatesDeleted", "Duplicates deleted"), "success", 2200);
|
|
await gridController.reloadGrid();
|
|
} else {
|
|
comfyToast(t("toast.deleteFailed", "Delete failed: {error}", { error: delRes?.error || "error" }), "warning", 3500);
|
|
}
|
|
}
|
|
await refreshDuplicateAlerts();
|
|
return;
|
|
}
|
|
}
|
|
const startOk = await comfyConfirm(t("dialog.startDuplicateAnalysis", "Start duplicate analysis in background?"));
|
|
if (!startOk) return;
|
|
const runRes = await startDuplicatesAnalysis(500);
|
|
if (runRes?.ok) comfyToast(t("toast.dupAnalysisStarted", "Duplicate analysis started"), "info", 2200);
|
|
else comfyToast(t("toast.analysisNotStarted", "Analysis not started: {error}", { error: runRes?.error || "error" }), "warning", 3500);
|
|
await refreshDuplicateAlerts();
|
|
}
|
|
}
|
|
});
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
// Summary bar updates: selection changes, load changes, scope/collection changes.
|
|
try {
|
|
notifyContextChanged();
|
|
const onStats = () => notifyContextChanged();
|
|
const onSelectionChanged = (e) => {
|
|
markUserInteraction();
|
|
try {
|
|
const detail = e?.detail || {};
|
|
const ids = Array.isArray(detail.selectedIds) ? detail.selectedIds.map(String).filter(Boolean) : [];
|
|
state.selectedAssetIds = ids;
|
|
state.activeAssetId = String(detail.activeId || ids[0] || "");
|
|
} catch (e) { console.debug?.(e); }
|
|
notifyContextChanged();
|
|
};
|
|
gridContainer.addEventListener("mjr:grid-stats", onStats, { signal: panelLifecycleAC?.signal });
|
|
gridContainer.addEventListener("mjr:selection-changed", onSelectionChanged, { signal: panelLifecycleAC?.signal });
|
|
document.addEventListener?.("mjr-settings-changed", onStats, { signal: panelLifecycleAC?.signal });
|
|
|
|
// Observe DOM changes for card count / selection class changes.
|
|
let raf = null;
|
|
const schedule = () => {
|
|
if (raf) cancelAnimationFrame(raf);
|
|
raf = requestAnimationFrame(() => {
|
|
raf = null;
|
|
notifyContextChanged();
|
|
});
|
|
};
|
|
const mo = new MutationObserver(() => schedule());
|
|
mo.observe(gridContainer, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
|
|
gridContainer._mjrSummaryBarObserver = mo;
|
|
registerSummaryDispose(() => {
|
|
try {
|
|
gridContainer.removeEventListener("mjr:grid-stats", onStats);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
gridContainer.removeEventListener("mjr:selection-changed", onSelectionChanged);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
document.removeEventListener?.("mjr-settings-changed", onStats);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
mo.disconnect();
|
|
} catch (e) { console.debug?.(e); }
|
|
});
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
sidebarController = bindSidebarOpen({
|
|
gridContainer,
|
|
sidebar,
|
|
createRatingBadge,
|
|
createTagsBadge,
|
|
showAssetInSidebar,
|
|
closeSidebar,
|
|
state
|
|
});
|
|
|
|
const hotkeys = createPanelHotkeysController({
|
|
onTriggerScan: (ctx) => {
|
|
triggerScan(statusDot, statusText, capabilitiesSection, ctx || {});
|
|
},
|
|
onToggleDetails: () => {
|
|
sidebarController?.toggleDetails?.();
|
|
},
|
|
onFocusSearch: () => {
|
|
try {
|
|
searchInputEl?.focus?.();
|
|
// Move cursor to end for immediate typing.
|
|
const len = searchInputEl?.value?.length || 0;
|
|
searchInputEl?.setSelectionRange?.(len, len);
|
|
} catch (e) { console.debug?.(e); }
|
|
},
|
|
onClearSearch: () => {
|
|
try {
|
|
if (!searchInputEl) return;
|
|
searchInputEl.value = "";
|
|
searchInputEl.dispatchEvent?.(new Event("input", { bubbles: true }));
|
|
} catch (e) { console.debug?.(e); }
|
|
},
|
|
getScanContext: () => ({
|
|
scope: state.scope,
|
|
customRootId: state.customRootId
|
|
})
|
|
});
|
|
|
|
const ratingHotkeys = createRatingHotkeysController({
|
|
gridContainer,
|
|
createRatingBadge
|
|
});
|
|
|
|
let searchTimeout = null;
|
|
let lastSearchValue = "";
|
|
|
|
if (container._eventCleanup) {
|
|
try {
|
|
container._eventCleanup();
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
|
|
hotkeys.bind(content);
|
|
ratingHotkeys.bind();
|
|
setHotkeysScope("grid");
|
|
|
|
// Attach controllers to container for cleanup on re-render
|
|
try {
|
|
container._mjrHotkeys = hotkeys;
|
|
container._mjrRatingHotkeys = ratingHotkeys;
|
|
container._mjrSidebarController = sidebarController;
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
container._eventCleanup = () => {
|
|
try {
|
|
panelLifecycleAC?.abort?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
statusSection?._mjrStatusPollDispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
container._mjrVersionUpdateCleanup?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
container._mjrVersionUpdateCleanup = null;
|
|
try {
|
|
container._mjrPanelState?._mjrDispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
container._mjrPanelState = null;
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
if (_scrollTimer) clearTimeout(_scrollTimer);
|
|
_scrollTimer = null;
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
hotkeys.dispose();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
ratingHotkeys.dispose();
|
|
} catch (e) { console.debug?.(e); }
|
|
if (getHotkeysState().scope === "grid") {
|
|
setHotkeysScope(null);
|
|
}
|
|
try {
|
|
agendaCalendar?.dispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
disposeFilters?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
searchTimeout = null;
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
sidebar?.dispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
sidebarController?.dispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
if (_dupPollTimer) clearInterval(_dupPollTimer);
|
|
_dupPollTimer = null;
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
if (_autoLoadTimer) clearTimeout(_autoLoadTimer);
|
|
_autoLoadTimer = null;
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
gridContainer?._mjrSummaryBarDispose?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
gridContainer?._mjrSummaryBarObserver?.disconnect?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
gridContainer?._mjrGridContextMenuUnbind?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
disposeCustomRootsBindings?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
_unbindBrowserFolderNav?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
disposeGridScanListeners();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
if (gridContainer) disposeGrid(gridContainer);
|
|
} catch (e) { console.debug?.(e); }
|
|
if (_activeGridContainer === gridContainer) {
|
|
_activeGridContainer = null;
|
|
}
|
|
gridContainer = null;
|
|
try {
|
|
popovers.dispose();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
if (container._mjrPopoverManager === popovers) {
|
|
container._mjrPopoverManager = null;
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
|
|
let lastKnownScan = null;
|
|
let lastKnownIndexEnd = null;
|
|
let lastKnownTotalAssets = null;
|
|
let hasSeenFirstCounters = false;
|
|
let pendingReloadCount = 0;
|
|
let isReloading = false;
|
|
let _lastAutoReloadAt = 0;
|
|
|
|
const queuedReload = async () => {
|
|
if (!gridContainer) return;
|
|
pendingReloadCount++;
|
|
if (isReloading) return;
|
|
|
|
isReloading = true;
|
|
try {
|
|
while (pendingReloadCount > 0) {
|
|
pendingReloadCount = 0;
|
|
const anchor = captureAnchor(gridContainer);
|
|
await gridController.reloadGrid();
|
|
try {
|
|
await restoreAnchor(gridContainer, anchor);
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
} finally {
|
|
isReloading = false;
|
|
}
|
|
};
|
|
|
|
const handleCountersUpdate = async (counters) => {
|
|
if (!hasSeenFirstCounters) {
|
|
hasSeenFirstCounters = true;
|
|
lastKnownScan = counters.last_scan_end;
|
|
lastKnownIndexEnd = counters.last_index_end;
|
|
lastKnownTotalAssets = Number(counters.total_assets ?? null);
|
|
return;
|
|
}
|
|
|
|
const hasNewScan = counters.last_scan_end && counters.last_scan_end !== lastKnownScan;
|
|
// last_index_end is updated by index_paths (individual file indexing, e.g. after a workflow run).
|
|
// last_scan_end is only updated by full directory scans. Both must be checked to catch generation events.
|
|
const hasNewIndexEnd = counters.last_index_end && counters.last_index_end !== lastKnownIndexEnd;
|
|
const totalAssets = Number(counters.total_assets ?? null);
|
|
|
|
// Fallback for "new files appeared" without a full scan end update (e.g. incremental indexing).
|
|
// Only auto-refresh when browsing output with default filters to avoid disrupting searches.
|
|
const isDefaultBrowse =
|
|
state.scope === "output" &&
|
|
!state.collectionId &&
|
|
String(getQuery?.() ?? "*").trim() === "*" &&
|
|
!state.kindFilter &&
|
|
!state.workflowOnly &&
|
|
!(Number(state.minRating || 0) > 0) &&
|
|
!(Number(state.minSizeMB || 0) > 0) &&
|
|
!(Number(state.maxSizeMB || 0) > 0) &&
|
|
!(Number(state.minWidth || 0) > 0) &&
|
|
!(Number(state.minHeight || 0) > 0) &&
|
|
!(Number(state.maxWidth || 0) > 0) &&
|
|
!(Number(state.maxHeight || 0) > 0) &&
|
|
!String(state.workflowType || "").trim() &&
|
|
!state.dateRangeFilter &&
|
|
!state.dateExactFilter;
|
|
|
|
const totalDelta =
|
|
Number.isFinite(totalAssets) && Number.isFinite(lastKnownTotalAssets)
|
|
? totalAssets - lastKnownTotalAssets
|
|
: 0;
|
|
const hasNewTotal = totalDelta >= 3;
|
|
|
|
// Keep baseline current even if we skip auto-reload; avoids repeated trigger loops.
|
|
if (Number.isFinite(totalAssets)) {
|
|
lastKnownTotalAssets = totalAssets;
|
|
}
|
|
|
|
// Auto-reload should only happen in default output browsing mode.
|
|
// In Browser/Custom or filtered contexts it feels like random refresh storms.
|
|
if (!isDefaultBrowse) {
|
|
lastKnownScan = counters.last_scan_end;
|
|
lastKnownIndexEnd = counters.last_index_end;
|
|
return;
|
|
}
|
|
// hasNewIndexEnd: files were indexed (e.g. after a generation run).
|
|
// Primary handler: mjr-asset-added WS push → upsertAsset (instant, no flash).
|
|
// Fallback: reload when the upsert didn't run recently (panel was hidden, wrong
|
|
// scope, or WS event was missed). Skip the reload when upsert ran within the
|
|
// last 6 s to avoid a flash over assets that are already visible.
|
|
if (hasNewIndexEnd) lastKnownIndexEnd = counters.last_index_end;
|
|
const needsFallbackReload =
|
|
hasNewIndexEnd && !hasNewScan && !hasNewTotal &&
|
|
(Date.now() - Number(window.__mjrLastAssetUpsert || 0) > 6000);
|
|
if (!hasNewScan && !hasNewTotal && !needsFallbackReload) return;
|
|
|
|
// Global throttle against reload storms from frequent watcher/enrichment updates.
|
|
try {
|
|
const now = Date.now();
|
|
if (now - Number(_lastAutoReloadAt || 0) < 8000) return;
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
// Avoid disruptive auto-reload while user is actively interacting.
|
|
try {
|
|
const recentInteraction = (Date.now() - Number(_lastUserInteractionAt || 0)) < 1500;
|
|
if (recentInteraction) return;
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
lastKnownScan = counters.last_scan_end;
|
|
lastKnownIndexEnd = counters.last_index_end;
|
|
try {
|
|
await queuedReload();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
_lastAutoReloadAt = Date.now();
|
|
} catch {
|
|
_lastAutoReloadAt = 0;
|
|
}
|
|
};
|
|
|
|
setupStatusPolling(
|
|
statusDot,
|
|
statusText,
|
|
statusSection,
|
|
capabilitiesSection,
|
|
handleCountersUpdate,
|
|
() => ({ scope: state.scope, customRootId: state.customRootId })
|
|
);
|
|
|
|
scopeController.setActiveTabStyles();
|
|
browserNav?.renderBreadcrumb?.();
|
|
|
|
// Initial loading of custom roots (restores selection from state if applicable)
|
|
customRootsController.refreshCustomRoots().catch(() => {});
|
|
|
|
searchInputEl.addEventListener("input", (e) => {
|
|
const value = e?.target?.value ?? "";
|
|
if (value === lastSearchValue) return;
|
|
lastSearchValue = value;
|
|
void exitSimilarViewIfActive({ reload: false });
|
|
notifyContextChanged();
|
|
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
|
|
// Immediate search for empty / browse-all queries.
|
|
if (value.length === 0 || value === "*") {
|
|
startTimer('searchQuery');
|
|
gridController.reloadGrid();
|
|
return;
|
|
}
|
|
|
|
// Debounce for normal searches.
|
|
searchTimeout = setTimeout(() => {
|
|
searchTimeout = null;
|
|
startTimer('searchQuery');
|
|
gridController.reloadGrid();
|
|
}, 200);
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
searchInputEl.addEventListener("keypress", (e) => {
|
|
if (e.key === "Enter") {
|
|
void exitSimilarViewIfActive({ reload: false });
|
|
startTimer('searchQuery');
|
|
gridController.reloadGrid();
|
|
notifyContextChanged();
|
|
}
|
|
}, { signal: panelLifecycleAC?.signal });
|
|
|
|
const initialLoadPromise = gridController.reloadGrid();
|
|
try {
|
|
await refreshDuplicateAlerts();
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
_dupPollTimer = setInterval(() => {
|
|
try {
|
|
if (panelLifecycleAC?.signal?.aborted) return;
|
|
} catch (e) { console.debug?.(e); }
|
|
refreshDuplicateAlerts().catch(() => {});
|
|
}, 15000);
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
// Restore scroll, selection visuals, and sidebar AFTER grid loads.
|
|
const restoreUIState = async () => {
|
|
try {
|
|
await initialLoadPromise;
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
// Wait one frame for DOM to settle before restoring.
|
|
requestAnimationFrame(() => {
|
|
// 1. Restore scroll position
|
|
if (state.scrollTop > 0) {
|
|
try {
|
|
gridWrapper.scrollTop = state.scrollTop;
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
|
|
// 2. Restore selection visuals on cards
|
|
if (state.selectedAssetIds?.length || state.activeAssetId) {
|
|
try {
|
|
const selected = (state.selectedAssetIds || []).map(String);
|
|
const activeId = String(state.activeAssetId || selected[0] || "");
|
|
|
|
selected.forEach(id => {
|
|
const card = gridContainer.querySelector(`.mjr-asset-card[data-mjr-asset-id="${CSS?.escape ? CSS.escape(id) : id}"]`);
|
|
if (card) {
|
|
card.classList.add("is-selected");
|
|
card.setAttribute?.("aria-selected", "true");
|
|
}
|
|
});
|
|
|
|
if (activeId) {
|
|
const activeCard = gridContainer.querySelector(`.mjr-asset-card[data-mjr-asset-id="${CSS?.escape ? CSS.escape(activeId) : activeId}"]`);
|
|
if (activeCard) {
|
|
activeCard.classList.add("is-active");
|
|
activeCard.setAttribute?.("aria-selected", "true");
|
|
// Scroll selection into view if not visible after scroll restore
|
|
if (state.scrollTop === 0) {
|
|
activeCard.scrollIntoView?.({ block: "nearest", behavior: "instant" });
|
|
}
|
|
}
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
|
|
// 3. Restore sidebar if it was open
|
|
if (state.sidebarOpen && state.activeAssetId) {
|
|
try {
|
|
sidebarController?.toggleDetails?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
});
|
|
};
|
|
restoreUIState();
|
|
|
|
const checkAndAutoLoad = async () => {
|
|
try {
|
|
await initialLoadPromise;
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
const hasCards = gridContainer.querySelector(".mjr-card") != null;
|
|
if (hasCards) return;
|
|
if (state.scope !== "output") return;
|
|
if (getQuery() !== "*") return;
|
|
|
|
try {
|
|
const statusData = await get(`${ENDPOINTS.HEALTH_COUNTERS}?scope=output`, { signal: panelLifecycleAC?.signal });
|
|
if (statusData?.ok && (statusData.data?.total_assets || 0) > 0) {
|
|
await loadAssets(gridContainer);
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
_autoLoadTimer = setTimeout(checkAndAutoLoad, 2000);
|
|
|
|
// End performance timing and record metrics
|
|
const renderDuration = endTimer('panelRender', 'gridRender');
|
|
trackGridRender(renderDuration);
|
|
|
|
return { gridContainer };
|
|
}
|