global improvements and new features
This commit is contained in:
@@ -91,9 +91,8 @@ Hotkeys are ignored while typing in an input/textarea.
|
||||
|
||||
### Assets Manager (grid)
|
||||
|
||||
- `Tab`: focus/open the Assets Manager sidebar tab.
|
||||
- `0`-`5`: set rating for the current file (0 clears).
|
||||
- `Enter`: open viewer (1 selected) or A/B compare (2 selected; uses the first two).
|
||||
- `Space`: open viewer (1 selected) or A/B compare (2 selected; uses the first two).
|
||||
|
||||
### Viewer
|
||||
|
||||
|
||||
@@ -9,6 +9,43 @@ import {
|
||||
mjrSettings,
|
||||
} from "./ui_settings.js";
|
||||
|
||||
function formatCardHoverTitle(file) {
|
||||
const rawName = file?.name || file?.filename || "(unnamed)";
|
||||
const ext = String(file?.ext || getExt(rawName) || "").toUpperCase();
|
||||
const kind = file?.kind || detectKindFromExt(ext);
|
||||
const subfolder = file?.subfolder ? String(file.subfolder) : "(root)";
|
||||
const sizeReadable = file?.size_readable ? String(file.size_readable) : null;
|
||||
const sizeBytes = Number.isFinite(file?.size) ? `${file.size} B` : null;
|
||||
const size = sizeReadable || sizeBytes || null;
|
||||
const date = file?.date ? String(file.date) : null;
|
||||
|
||||
const rating = Math.max(0, Math.min(5, Number(file?.rating ?? (file?.meta && file.meta.rating) ?? 0) || 0));
|
||||
const tags =
|
||||
(Array.isArray(file?.tags) && file.tags) ||
|
||||
(Array.isArray(file?.meta && file.meta.tags) && file.meta.tags) ||
|
||||
[];
|
||||
|
||||
const wfState = resolveWorkflowState(file);
|
||||
|
||||
const lines = [];
|
||||
lines.push(rawName);
|
||||
lines.push(`Folder: ${subfolder}`);
|
||||
lines.push(`Type: ${kind}${ext ? ` (${ext})` : ""}`);
|
||||
if (date) lines.push(`Date: ${date}`);
|
||||
if (size) lines.push(`Size: ${size}`);
|
||||
lines.push(`Rating: ${rating}/5`);
|
||||
if (tags.length) {
|
||||
const maxTags = 20;
|
||||
const shown = tags.slice(0, maxTags).map((t) => String(t)).join(", ");
|
||||
const more = tags.length > maxTags ? ` (+${tags.length - maxTags} more)` : "";
|
||||
lines.push(`Tags: ${shown}${more}`);
|
||||
} else {
|
||||
lines.push("Tags: (none)");
|
||||
}
|
||||
lines.push(`Workflow: ${wfState}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function resolveWorkflowState(file) {
|
||||
const toState = (val) => {
|
||||
if (val === true || val === 1 || val === "true") return "yes";
|
||||
@@ -118,6 +155,16 @@ export function updateCardVisuals(card, file) {
|
||||
(Array.isArray(file.meta && file.meta.tags) && file.meta.tags) ||
|
||||
[];
|
||||
|
||||
if (mjrSettings.grid.hoverInfo) {
|
||||
try {
|
||||
card.title = formatCardHoverTitle(file);
|
||||
} catch (_) {
|
||||
card.title = "";
|
||||
}
|
||||
} else {
|
||||
card.title = "";
|
||||
}
|
||||
|
||||
const wfState = resolveWorkflowState(file);
|
||||
updateWorkflowDot(card, wfState);
|
||||
|
||||
|
||||
+99
-17
@@ -1,10 +1,40 @@
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
|
||||
export function createMetadataFetcher(state, gridView) {
|
||||
export function createMetadataFetcher(state, gridView, opts = {}) {
|
||||
let metadataAbortCtrl = null;
|
||||
let metadataFetchInFlight = null;
|
||||
let metadataDebounceTimer = null;
|
||||
let currentGridView = gridView || null;
|
||||
let onMetadataUpdated = typeof opts.onMetadataUpdated === "function" ? opts.onMetadataUpdated : null;
|
||||
|
||||
const setOnMetadataUpdated = (fn) => {
|
||||
onMetadataUpdated = typeof fn === "function" ? fn : null;
|
||||
};
|
||||
|
||||
const shouldHydrateForFilter = () => {
|
||||
const tagFilter = (state.tagFilter || "").trim();
|
||||
const minRating = Number(state.minRating || 0);
|
||||
return !!tagFilter || minRating > 0;
|
||||
};
|
||||
|
||||
const applyBatchResults = (data) => {
|
||||
const metaByKey = new Map();
|
||||
(data.metadatas || []).forEach((m) => metaByKey.set(`${m.subfolder || ""}/${m.filename || ""}`, m));
|
||||
|
||||
let updatedCount = 0;
|
||||
state.files.forEach((f) => {
|
||||
const key = `${f.subfolder || ""}/${f.filename || f.name || ""}`;
|
||||
const m = metaByKey.get(key);
|
||||
if (m) {
|
||||
f.rating = m.rating ?? 0;
|
||||
f.tags = m.tags || [];
|
||||
if (m.has_workflow !== undefined) f.hasWorkflow = !!m.has_workflow;
|
||||
f.__metaLoaded = true;
|
||||
updatedCount += 1;
|
||||
}
|
||||
});
|
||||
return updatedCount;
|
||||
};
|
||||
|
||||
async function fetchMetadataForVisible() {
|
||||
if (metadataFetchInFlight) return;
|
||||
@@ -37,26 +67,14 @@ export function createMetadataFetcher(state, gridView) {
|
||||
signal: thisCtrl.signal,
|
||||
});
|
||||
|
||||
let updatedAny = false;
|
||||
try {
|
||||
const res = await metadataFetchInFlight;
|
||||
if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion) return;
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion) return;
|
||||
|
||||
const metaByKey = new Map();
|
||||
(data.metadatas || []).forEach((m) => metaByKey.set(`${m.subfolder || ""}/${m.filename || ""}`, m));
|
||||
|
||||
state.files.forEach((f) => {
|
||||
const key = `${f.subfolder || ""}/${f.filename || f.name || ""}`;
|
||||
const m = metaByKey.get(key);
|
||||
if (m) {
|
||||
f.rating = m.rating ?? 0;
|
||||
f.tags = m.tags || [];
|
||||
if (m.has_workflow !== undefined) f.hasWorkflow = !!m.has_workflow;
|
||||
f.__metaLoaded = true;
|
||||
}
|
||||
});
|
||||
updatedAny = applyBatchResults(data) > 0;
|
||||
|
||||
if (currentGridView) currentGridView.renderGrid();
|
||||
} catch (err) {
|
||||
@@ -68,12 +86,76 @@ export function createMetadataFetcher(state, gridView) {
|
||||
} finally {
|
||||
metadataFetchInFlight = null;
|
||||
if (metadataAbortCtrl === thisCtrl) metadataAbortCtrl = null;
|
||||
if (fullList.slice(effectiveStart, effectiveEnd).some((f) => !f.__metaLoaded)) {
|
||||
if (updatedAny && onMetadataUpdated) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
onMetadataUpdated();
|
||||
} catch (_) {}
|
||||
}, 0);
|
||||
}
|
||||
if (reqVersion === state.renderVersion && fullList.slice(effectiveStart, effectiveEnd).some((f) => !f.__metaLoaded)) {
|
||||
fetchMetadataForVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMetadataForFilter() {
|
||||
if (metadataFetchInFlight) return;
|
||||
if (!shouldHydrateForFilter()) return;
|
||||
|
||||
const reqVersion = state.renderVersion;
|
||||
const allFiles = state.files || [];
|
||||
const targetFiles = allFiles.filter((f) => !f.__metaLoaded).slice(0, 30);
|
||||
if (!targetFiles.length) return;
|
||||
|
||||
const batch = targetFiles.map((f) => ({ filename: f.filename || f.name, subfolder: f.subfolder || "" }));
|
||||
|
||||
if (metadataAbortCtrl) {
|
||||
try {
|
||||
metadataAbortCtrl.abort();
|
||||
} catch (_) {}
|
||||
}
|
||||
metadataAbortCtrl = new AbortController();
|
||||
const thisCtrl = metadataAbortCtrl;
|
||||
|
||||
metadataFetchInFlight = api.fetchApi("/mjr/filemanager/metadata/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: batch }),
|
||||
signal: thisCtrl.signal,
|
||||
});
|
||||
|
||||
let updatedAny = false;
|
||||
try {
|
||||
const res = await metadataFetchInFlight;
|
||||
if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion) return;
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion) return;
|
||||
updatedAny = applyBatchResults(data) > 0;
|
||||
if (currentGridView) currentGridView.renderGrid();
|
||||
} catch (err) {
|
||||
if (err?.name === "AbortError") {
|
||||
// ignore aborted fetch
|
||||
} else {
|
||||
console.warn("[Majoor.AssetsManager] filter metadata load failed", err);
|
||||
}
|
||||
} finally {
|
||||
metadataFetchInFlight = null;
|
||||
if (metadataAbortCtrl === thisCtrl) metadataAbortCtrl = null;
|
||||
if (updatedAny && onMetadataUpdated) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
onMetadataUpdated();
|
||||
} catch (_) {}
|
||||
}, 0);
|
||||
}
|
||||
if (reqVersion === state.renderVersion && shouldHydrateForFilter() && allFiles.some((f) => !f.__metaLoaded)) {
|
||||
fetchMetadataForFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onRequestMetadata = (start, end) => {
|
||||
state.visibleStart = start;
|
||||
state.visibleEnd = end;
|
||||
@@ -94,5 +176,5 @@ export function createMetadataFetcher(state, gridView) {
|
||||
currentGridView = gv;
|
||||
};
|
||||
|
||||
return { fetchMetadataForVisible, onRequestMetadata, cleanup, setGridView };
|
||||
return { fetchMetadataForVisible, fetchMetadataForFilter, onRequestMetadata, cleanup, setGridView, setOnMetadataUpdated };
|
||||
}
|
||||
|
||||
+8
-2
@@ -1,7 +1,7 @@
|
||||
import { detectKindFromExt, getBaseName, getExt, mjrSettings } from "../ui_settings.js";
|
||||
import { normalizeMtimeValue } from "../am_filters.js";
|
||||
|
||||
export function createApplyFilterAndRender(state, fetchMetadataForVisible) {
|
||||
export function createApplyFilterAndRender(state, fetchMetadataForVisible, fetchMetadataForFilter) {
|
||||
let gridView = null;
|
||||
|
||||
const applyFilterAndRender = (options = {}) => {
|
||||
@@ -9,6 +9,7 @@ export function createApplyFilterAndRender(state, fetchMetadataForVisible) {
|
||||
const q = (state.search || "").trim().toLowerCase();
|
||||
const tagFilter = (state.tagFilter || "").trim().toLowerCase();
|
||||
const minRating = Number(state.minRating || 0);
|
||||
const needsMetaFilter = minRating > 0 || !!tagFilter;
|
||||
const hidePng = !!mjrSettings.siblings.hidePngSiblings;
|
||||
let nonImageStems = null;
|
||||
if (hidePng) {
|
||||
@@ -40,9 +41,11 @@ export function createApplyFilterAndRender(state, fetchMetadataForVisible) {
|
||||
}
|
||||
|
||||
const fileRating = Number(file.rating ?? 0);
|
||||
const hasMeta = !!file.__metaLoaded || file.rating !== undefined || Array.isArray(file.tags);
|
||||
if (needsMetaFilter && !hasMeta) return false;
|
||||
if (fileRating < minRating) return false;
|
||||
|
||||
const tagsArr = file.tags || [];
|
||||
const tagsArr = Array.isArray(file.tags) ? file.tags : [];
|
||||
if (tagFilter && !tagsArr.some((tag) => String(tag).toLowerCase().includes(tagFilter))) return false;
|
||||
|
||||
return true;
|
||||
@@ -55,6 +58,9 @@ export function createApplyFilterAndRender(state, fetchMetadataForVisible) {
|
||||
|
||||
if (!skipMetadataFetch) {
|
||||
fetchMetadataForVisible();
|
||||
if (needsMetaFilter && typeof fetchMetadataForFilter === "function") {
|
||||
fetchMetadataForFilter();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+429
-176
@@ -1,16 +1,30 @@
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
import { createEl, mjrAttachHoverFeedback, mjrCardBasePx, mjrDeepMerge, mjrSaveSettings, mjrSettings, mjrSettingsDefaults, mjrShowToast, setMjrSettings, detectKindFromExt } from "./ui_settings.js";
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
import {
|
||||
createEl,
|
||||
mjrAttachHoverFeedback,
|
||||
mjrCardBasePx,
|
||||
mjrDeepMerge,
|
||||
mjrPrefetchOutputs,
|
||||
mjrResetPrefetch,
|
||||
mjrSaveSettings,
|
||||
mjrSettings,
|
||||
mjrSettingsDefaults,
|
||||
mjrShowToast,
|
||||
setMjrSettings,
|
||||
detectKindFromExt,
|
||||
} from "./ui_settings.js";
|
||||
import { mjrGlobalState } from "./mjr_global.js";
|
||||
import { fileKey } from "./am_state.js";
|
||||
import { SMART_FILTERS } from "./am_filters.js";
|
||||
import { mjrCreateContextMenu } from "./am_context_menu.js";
|
||||
import {
|
||||
mjrOpenABViewer,
|
||||
mjrOpenViewerForFiles,
|
||||
mjrStepVideos,
|
||||
mjrUpdateNavVisibility,
|
||||
} from "./ui_viewer.js";
|
||||
import { SMART_FILTERS } from "./am_filters.js";
|
||||
import { mjrCreateContextMenu } from "./am_context_menu.js";
|
||||
import {
|
||||
mjrOpenABViewer,
|
||||
mjrOpenViewerForFiles,
|
||||
mjrStepVideos,
|
||||
mjrApplyOpenViewerSettings,
|
||||
mjrUpdateNavVisibility,
|
||||
} from "./ui_viewer.js";
|
||||
import { createMetadataSidebar } from "./ui_sidebar.js";
|
||||
import { createGridView } from "./am_gridview.js";
|
||||
import {
|
||||
@@ -20,22 +34,26 @@ import {
|
||||
ensureQueueListener,
|
||||
mjrStartAutoRefreshTimer,
|
||||
} from "./grid/events.js";
|
||||
import { createMetadataFetcher } from "./grid/fetch.js";
|
||||
import { initManagerState, populateCollectionsOptions } from "./grid/state.js";
|
||||
import { createApplyFilterAndRender } from "./grid/render.js";
|
||||
import { createRefreshController } from "./grid/load.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Majoor Assets Manager globals + hotkeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mjrMetaVisible = !!mjrSettings.metaPanel.showByDefault;
|
||||
let refreshAllInstances = () => Promise.resolve();
|
||||
|
||||
function mjrApplySettings(newSettings) {
|
||||
setMjrSettings(mjrDeepMerge(mjrSettingsDefaults, newSettings || {}));
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrMetaVisible = !!mjrSettings.metaPanel.showByDefault;
|
||||
import { createMetadataFetcher } from "./grid/fetch.js";
|
||||
import { initManagerState, populateCollectionsOptions } from "./grid/state.js";
|
||||
import { createApplyFilterAndRender } from "./grid/render.js";
|
||||
import { createRefreshController } from "./grid/load.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Majoor Assets Manager globals + hotkeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mjrMetaVisible = !!mjrSettings.metaPanel.showByDefault;
|
||||
let refreshAllInstances = () => Promise.resolve();
|
||||
|
||||
const mjrPanelMode = String(mjrSettings?.integration?.panel || mjrSettingsDefaults?.integration?.panel || "both").toLowerCase();
|
||||
const mjrUseBottomPanel = mjrPanelMode === "bottom" || mjrPanelMode === "both";
|
||||
const mjrUseSidebarPanel = mjrPanelMode === "sidebar" || mjrPanelMode === "both";
|
||||
|
||||
function mjrApplySettings(newSettings) {
|
||||
setMjrSettings(mjrDeepMerge(mjrSettingsDefaults, newSettings || {}));
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrMetaVisible = !!mjrSettings.metaPanel.showByDefault;
|
||||
mjrStartAutoRefreshTimer(refreshAllInstances);
|
||||
mjrUpdateNavVisibility();
|
||||
|
||||
@@ -161,20 +179,30 @@ function renderAssetsManager(root) {
|
||||
populateCollectionsOptions(state, collectionFilterSelect);
|
||||
|
||||
let openContextMenuEl = null;
|
||||
const {
|
||||
fetchMetadataForVisible,
|
||||
onRequestMetadata,
|
||||
cleanup: cleanupMetadataFetcher,
|
||||
setGridView: setGridViewForMetadata,
|
||||
} = createMetadataFetcher(state, null);
|
||||
const {
|
||||
applyFilterAndRender,
|
||||
setGridView: setGridViewForRender,
|
||||
} = createApplyFilterAndRender(state, fetchMetadataForVisible);
|
||||
const {
|
||||
refreshInstance,
|
||||
refreshAllInstances: refreshAllInstancesFn,
|
||||
loadFilesWrapper,
|
||||
const {
|
||||
fetchMetadataForVisible,
|
||||
fetchMetadataForFilter,
|
||||
onRequestMetadata,
|
||||
cleanup: cleanupMetadataFetcher,
|
||||
setGridView: setGridViewForMetadata,
|
||||
setOnMetadataUpdated,
|
||||
} = createMetadataFetcher(state, null);
|
||||
const {
|
||||
applyFilterAndRender,
|
||||
setGridView: setGridViewForRender,
|
||||
} = createApplyFilterAndRender(state, fetchMetadataForVisible, fetchMetadataForFilter);
|
||||
|
||||
setOnMetadataUpdated(() => {
|
||||
const tagFilter = (state.tagFilter || "").trim();
|
||||
const minRating = Number(state.minRating || 0);
|
||||
if (tagFilter || minRating > 0) {
|
||||
applyFilterAndRender();
|
||||
}
|
||||
});
|
||||
const {
|
||||
refreshInstance,
|
||||
refreshAllInstances: refreshAllInstancesFn,
|
||||
loadFilesWrapper,
|
||||
loadFiles,
|
||||
loadMoreFiles,
|
||||
lastSignatureRef,
|
||||
@@ -329,17 +357,21 @@ function renderAssetsManager(root) {
|
||||
};
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Majoor.AssetsManager",
|
||||
bottomPanelTabs: [{
|
||||
id: "majoorAssetsManagerBottom",
|
||||
title: "Assets Manager",
|
||||
type: "custom",
|
||||
render: (el) => renderAssetsManager(el),
|
||||
destroy: (el) => el?.__mjrCleanup?.(),
|
||||
}],
|
||||
async setup() {
|
||||
const SETTINGS_PREFIX = "MajoorAM";
|
||||
app.registerExtension({
|
||||
name: "Majoor.AssetsManager",
|
||||
bottomPanelTabs: mjrUseBottomPanel
|
||||
? [
|
||||
{
|
||||
id: "majoorAssetsManagerBottom",
|
||||
title: "Assets Manager",
|
||||
type: "custom",
|
||||
render: (el) => renderAssetsManager(el),
|
||||
destroy: (el) => el?.__mjrCleanup?.(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
async setup() {
|
||||
const SETTINGS_PREFIX = "MajoorAM";
|
||||
|
||||
const warnIfNoExiftool = async () => {
|
||||
const warnKey = "mjrExiftoolWarned";
|
||||
@@ -365,133 +397,354 @@ app.registerExtension({
|
||||
}
|
||||
};
|
||||
|
||||
// --- GRID SETTINGS ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Grid.CardSize`,
|
||||
name: "Majoor: Card Size",
|
||||
type: "combo",
|
||||
options: ["compact", "medium", "large"],
|
||||
defaultValue: mjrSettingsDefaults.grid.cardSize,
|
||||
onChange: (val) => {
|
||||
mjrSettings.grid.cardSize = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.());
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Grid.ShowTags`,
|
||||
name: "Majoor: Show Tags on Cards",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettingsDefaults.grid.showTags,
|
||||
onChange: (val) => {
|
||||
mjrSettings.grid.showTags = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.());
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Grid.ShowRating`,
|
||||
name: "Majoor: Show Ratings on Cards",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettingsDefaults.grid.showRating,
|
||||
onChange: (val) => {
|
||||
mjrSettings.grid.showRating = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.());
|
||||
},
|
||||
});
|
||||
|
||||
// --- AUTO REFRESH ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Refresh.Enabled`,
|
||||
name: "Majoor: Auto-Refresh Enabled",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettingsDefaults.autoRefresh.enabled,
|
||||
onChange: (val) => {
|
||||
mjrSettings.autoRefresh.enabled = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrStartAutoRefreshTimer(refreshAllInstances);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Refresh.Interval`,
|
||||
name: "Majoor: Refresh Interval (ms)",
|
||||
type: "number",
|
||||
defaultValue: mjrSettingsDefaults.autoRefresh.interval,
|
||||
attrs: { min: 1000, step: 500 },
|
||||
onChange: (val) => {
|
||||
mjrSettings.autoRefresh.interval = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrStartAutoRefreshTimer(refreshAllInstances);
|
||||
},
|
||||
});
|
||||
|
||||
// --- VIEWER SETTINGS ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Viewer.Autoplay`,
|
||||
name: "Majoor Viewer: Autoplay Videos",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettingsDefaults.viewer.autoplayVideos,
|
||||
onChange: (val) => {
|
||||
mjrSettings.viewer.autoplayVideos = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Viewer.Loop`,
|
||||
name: "Majoor Viewer: Loop Videos",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettingsDefaults.viewer.loopVideos,
|
||||
onChange: (val) => {
|
||||
mjrSettings.viewer.loopVideos = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Viewer.Checkerboard`,
|
||||
name: "Majoor Viewer: Checkerboard Background",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettingsDefaults.viewer.useCheckerboard,
|
||||
onChange: (val) => {
|
||||
mjrSettings.viewer.useCheckerboard = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
// --- GENERAL ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.General.HideSiblings`,
|
||||
name: "Majoor: Hide PNG Siblings (Video previews)",
|
||||
tooltip: "If a video has a matching .png, hide the .png from the grid",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettingsDefaults.siblings.hidePngSiblings,
|
||||
onChange: (val) => {
|
||||
mjrSettings.siblings.hidePngSiblings = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.());
|
||||
},
|
||||
});
|
||||
// --- INTEGRATION ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Integration.Panel`,
|
||||
name: "Majoor: Panel Integration (reload to apply)",
|
||||
tooltip: "Choose where the Assets Manager appears (reload ComfyUI page after changing).",
|
||||
type: "combo",
|
||||
options: ["both", "sidebar", "bottom"],
|
||||
defaultValue: mjrSettings.integration?.panel || mjrSettingsDefaults.integration.panel,
|
||||
onChange: (val) => {
|
||||
mjrSettings.integration = mjrSettings.integration || {};
|
||||
mjrSettings.integration.panel = String(val || "both");
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrShowToast("info", "Reload the ComfyUI page to apply the new panel placement.", "Assets Manager");
|
||||
},
|
||||
});
|
||||
|
||||
// --- GRID SETTINGS ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Grid.CardSize`,
|
||||
name: "Majoor: Card Size",
|
||||
type: "combo",
|
||||
options: ["compact", "medium", "large"],
|
||||
defaultValue: mjrSettings.grid.cardSize,
|
||||
onChange: (val) => {
|
||||
mjrSettings.grid.cardSize = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.());
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Grid.PageSize`,
|
||||
name: "Majoor: Page Size (files per request)",
|
||||
tooltip: "Bigger loads more at once; smaller reduces memory. Applies on next refresh.",
|
||||
type: "number",
|
||||
defaultValue: Number(mjrSettings.grid.pageSize) || mjrSettingsDefaults.grid.pageSize,
|
||||
attrs: { min: 50, max: 2000, step: 50 },
|
||||
onChange: (val) => {
|
||||
const n = Math.max(50, Math.min(2000, Math.floor(Number(val) || mjrSettingsDefaults.grid.pageSize)));
|
||||
mjrSettings.grid.pageSize = n;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrResetPrefetch();
|
||||
mjrPrefetchOutputs();
|
||||
refreshAllInstances({ silent: true, forceFiles: true });
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Grid.ShowTags`,
|
||||
name: "Majoor: Show Tags on Cards",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.grid.showTags,
|
||||
onChange: (val) => {
|
||||
mjrSettings.grid.showTags = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.());
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Grid.ShowRating`,
|
||||
name: "Majoor: Show Ratings on Cards",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.grid.showRating,
|
||||
onChange: (val) => {
|
||||
mjrSettings.grid.showRating = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.());
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Grid.HoverInfo`,
|
||||
name: "Majoor: Hover Info Tooltips",
|
||||
tooltip: "Show file info on hover (native tooltip).",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.grid.hoverInfo,
|
||||
onChange: (val) => {
|
||||
mjrSettings.grid.hoverInfo = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.());
|
||||
},
|
||||
});
|
||||
|
||||
// --- META PANEL ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.MetaPanel.ShowByDefault`,
|
||||
name: "Majoor: Show Metadata Panel",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.metaPanel.showByDefault,
|
||||
onChange: (val) => {
|
||||
mjrSettings.metaPanel.showByDefault = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrMetaVisible = !!val;
|
||||
mjrGlobalState.instances.forEach((i) => i.setMetadataPanelVisibility?.(mjrMetaVisible));
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.MetaPanel.RefreshInterval`,
|
||||
name: "Majoor: Metadata Refresh Interval (ms)",
|
||||
tooltip: "While a file is selected, re-fetch generation metadata at this interval (0 disables).",
|
||||
type: "number",
|
||||
defaultValue: Number(mjrSettings.metaPanel.refreshInterval) || mjrSettingsDefaults.metaPanel.refreshInterval,
|
||||
attrs: { min: 0, step: 250 },
|
||||
onChange: (val) => {
|
||||
const n = Math.max(0, Math.min(60000, Math.floor(Number(val) || 0)));
|
||||
mjrSettings.metaPanel.refreshInterval = n;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
// --- AUTO REFRESH ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Refresh.Enabled`,
|
||||
name: "Majoor: Auto-Refresh Enabled",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.autoRefresh.enabled,
|
||||
onChange: (val) => {
|
||||
mjrSettings.autoRefresh.enabled = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrStartAutoRefreshTimer(refreshAllInstances);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Refresh.Interval`,
|
||||
name: "Majoor: Refresh Interval (ms)",
|
||||
type: "number",
|
||||
defaultValue: Number(mjrSettings.autoRefresh.interval) || mjrSettingsDefaults.autoRefresh.interval,
|
||||
attrs: { min: 1000, step: 500 },
|
||||
onChange: (val) => {
|
||||
mjrSettings.autoRefresh.interval = Number(val) || mjrSettingsDefaults.autoRefresh.interval;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrStartAutoRefreshTimer(refreshAllInstances);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Refresh.FocusOnly`,
|
||||
name: "Majoor: Auto-Refresh Only When Focused",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.autoRefresh.focusOnly,
|
||||
onChange: (val) => {
|
||||
mjrSettings.autoRefresh.focusOnly = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrStartAutoRefreshTimer(refreshAllInstances);
|
||||
},
|
||||
});
|
||||
|
||||
// --- TOASTS ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Toasts.Enabled`,
|
||||
name: "Majoor: Toasts Enabled",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.toasts.enabled,
|
||||
onChange: (val) => {
|
||||
mjrSettings.toasts.enabled = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Toasts.Verbosity`,
|
||||
name: "Majoor: Toast Verbosity",
|
||||
type: "combo",
|
||||
options: ["all", "warnings", "errors", "none"],
|
||||
defaultValue: mjrSettings.toasts.verbosity || mjrSettingsDefaults.toasts.verbosity,
|
||||
onChange: (val) => {
|
||||
mjrSettings.toasts.verbosity = String(val || "all");
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Toasts.Duration`,
|
||||
name: "Majoor: Toast Duration (ms)",
|
||||
type: "number",
|
||||
defaultValue: Number(mjrSettings.toasts.duration) || mjrSettingsDefaults.toasts.duration,
|
||||
attrs: { min: 500, max: 20000, step: 250 },
|
||||
onChange: (val) => {
|
||||
const n = Math.max(500, Math.min(20000, Math.floor(Number(val) || mjrSettingsDefaults.toasts.duration)));
|
||||
mjrSettings.toasts.duration = n;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
// --- VIEWER SETTINGS ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Viewer.Autoplay`,
|
||||
name: "Majoor Viewer: Autoplay Videos",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.viewer.autoplayVideos,
|
||||
onChange: (val) => {
|
||||
mjrSettings.viewer.autoplayVideos = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrApplyOpenViewerSettings();
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Viewer.Loop`,
|
||||
name: "Majoor Viewer: Loop Videos",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.viewer.loopVideos,
|
||||
onChange: (val) => {
|
||||
mjrSettings.viewer.loopVideos = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrApplyOpenViewerSettings();
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Viewer.Mute`,
|
||||
name: "Majoor Viewer: Mute Videos",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.viewer.muteVideos,
|
||||
onChange: (val) => {
|
||||
mjrSettings.viewer.muteVideos = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrApplyOpenViewerSettings();
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Viewer.Navigation`,
|
||||
name: "Majoor Viewer: Navigation Enabled",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.viewer.navEnabled,
|
||||
onChange: (val) => {
|
||||
mjrSettings.viewer.navEnabled = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrApplyOpenViewerSettings();
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Viewer.RatingHUD`,
|
||||
name: "Majoor Viewer: Show Rating HUD",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.viewer.showRatingHUD,
|
||||
onChange: (val) => {
|
||||
mjrSettings.viewer.showRatingHUD = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrApplyOpenViewerSettings();
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Viewer.RatingHotkeys`,
|
||||
name: "Majoor Viewer: Rating Hotkeys (0-5)",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.viewer.ratingHotkeys,
|
||||
onChange: (val) => {
|
||||
mjrSettings.viewer.ratingHotkeys = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Viewer.Checkerboard`,
|
||||
name: "Majoor Viewer: Checkerboard Background",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.viewer.useCheckerboard,
|
||||
onChange: (val) => {
|
||||
mjrSettings.viewer.useCheckerboard = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
// --- HOTKEYS ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Hotkeys.Enabled`,
|
||||
name: "Majoor: Hotkeys Enabled",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.hotkeys.enabled,
|
||||
onChange: (val) => {
|
||||
mjrSettings.hotkeys.enabled = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Hotkeys.Rating`,
|
||||
name: "Majoor: Rating Hotkeys in Grid (0-5)",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.hotkeys.rating,
|
||||
onChange: (val) => {
|
||||
mjrSettings.hotkeys.rating = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Hotkeys.SpaceOpen`,
|
||||
name: "Majoor: Open Viewer Hotkey (Space)",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.hotkeys.enterOpen,
|
||||
onChange: (val) => {
|
||||
mjrSettings.hotkeys.enterOpen = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Hotkeys.FrameStep`,
|
||||
name: "Majoor: Viewer Frame-Step Hotkeys (ArrowUp/Down)",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.hotkeys.frameStep,
|
||||
onChange: (val) => {
|
||||
mjrSettings.hotkeys.frameStep = !!val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
},
|
||||
});
|
||||
|
||||
// --- GENERAL ---
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.General.HideSiblings`,
|
||||
name: "Majoor: Hide PNG Siblings (Video previews)",
|
||||
tooltip: "If a video has a matching .png, hide the .png from the grid",
|
||||
type: "boolean",
|
||||
defaultValue: mjrSettings.siblings.hidePngSiblings,
|
||||
onChange: (val) => {
|
||||
mjrSettings.siblings.hidePngSiblings = val;
|
||||
mjrSaveSettings(mjrSettings);
|
||||
mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.());
|
||||
},
|
||||
});
|
||||
|
||||
warnIfNoExiftool();
|
||||
|
||||
try {
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "majoorAssetsManagerSidebar",
|
||||
icon: "pi pi-folder-open",
|
||||
title: "Assets Manager",
|
||||
tooltip: "Majoor Assets Manager",
|
||||
type: "custom",
|
||||
render: (el) => renderAssetsManager(el),
|
||||
destroy: (el) => el?.__mjrCleanup?.(),
|
||||
});
|
||||
} catch (err) { console.error("[Majoor.AssetsManager] sidebar registration failed", err); }
|
||||
},
|
||||
});
|
||||
if (mjrUseSidebarPanel && app.extensionManager?.registerSidebarTab) {
|
||||
try {
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "majoorAssetsManagerSidebar",
|
||||
icon: "pi pi-folder-open",
|
||||
title: "Assets Manager",
|
||||
tooltip: "Majoor Assets Manager",
|
||||
type: "custom",
|
||||
render: (el) => renderAssetsManager(el),
|
||||
destroy: (el) => el?.__mjrCleanup?.(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Majoor.AssetsManager] sidebar registration failed", err);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!window.__MajoorAssetsManagerHotkeysInitialized) {
|
||||
window.__MajoorAssetsManagerHotkeysInitialized = true;
|
||||
|
||||
+24
-18
@@ -101,12 +101,13 @@ export const CONTEXT_MENU_STYLES = {
|
||||
export const mjrSettingsDefaults = {
|
||||
autoRefresh: { enabled: true, interval: 5000, focusOnly: true },
|
||||
metaPanel: { showByDefault: true, refreshInterval: 2000 },
|
||||
grid: { cardSize: "medium", showTags: true, showRating: true, pageSize: 500 },
|
||||
grid: { cardSize: "medium", showTags: true, showRating: true, pageSize: 500, hoverInfo: true },
|
||||
integration: { panel: "both" }, // "both" | "sidebar" | "bottom"
|
||||
viewer: {
|
||||
navEnabled: true,
|
||||
autoplayVideos: true,
|
||||
loopVideos: true,
|
||||
muteVideos: true,
|
||||
muteVideos: true,
|
||||
showRatingHUD: true,
|
||||
ratingHotkeys: true,
|
||||
useCheckerboard: false,
|
||||
@@ -189,19 +190,24 @@ export async function mjrPrefetchOutputs() {
|
||||
mjrPrefetchedFiles = null;
|
||||
});
|
||||
return mjrPrefetchPromise;
|
||||
}
|
||||
|
||||
export function mjrShouldShowToast(kind) {
|
||||
if (!mjrSettings.toasts.enabled) return false;
|
||||
const v = mjrSettings.toasts.verbosity || "all";
|
||||
if (v === "all") return true;
|
||||
}
|
||||
|
||||
export function mjrResetPrefetch() {
|
||||
mjrPrefetchedFiles = null;
|
||||
mjrPrefetchPromise = null;
|
||||
}
|
||||
|
||||
export function mjrShouldShowToast(kind) {
|
||||
if (!mjrSettings.toasts.enabled) return false;
|
||||
const v = mjrSettings.toasts.verbosity || "all";
|
||||
if (v === "all") return true;
|
||||
if (v === "errors") return kind === "error";
|
||||
if (v === "warnings") return kind === "warn" || kind === "error";
|
||||
return false;
|
||||
}
|
||||
|
||||
export function mjrShowToast(kind = "info", message = "", title = null, duration = null) {
|
||||
if (!mjrShouldShowToast(kind)) return;
|
||||
export function mjrShowToast(kind = "info", message = "", title = null, duration = null) {
|
||||
if (!mjrShouldShowToast(kind)) return;
|
||||
|
||||
const severityMap = {
|
||||
info: "info",
|
||||
@@ -215,14 +221,14 @@ export function mjrShowToast(kind = "info", message = "", title = null, duration
|
||||
return;
|
||||
}
|
||||
|
||||
app.toast.add({
|
||||
severity: severityMap[kind] || "info",
|
||||
summary: title || (kind.charAt(0).toUpperCase() + kind.slice(1)),
|
||||
detail: message,
|
||||
life: duration || 3000,
|
||||
closable: true,
|
||||
});
|
||||
}
|
||||
app.toast.add({
|
||||
severity: severityMap[kind] || "info",
|
||||
summary: title || (kind.charAt(0).toUpperCase() + kind.slice(1)),
|
||||
detail: message,
|
||||
life: duration || Number(mjrSettings.toasts.duration) || 3000,
|
||||
closable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function mjrAttachHoverFeedback(el, message, delay = 3000) {
|
||||
if (!el) return;
|
||||
|
||||
+50
-46
@@ -383,14 +383,14 @@ export function createMetadataSidebar(options) {
|
||||
saveTagsBtn.style.padding = "2px 8px";
|
||||
saveTagsBtn.style.borderRadius = "6px";
|
||||
|
||||
const persistTags = async () => {
|
||||
const newTags = tagsInput.value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
file.tags = newTags;
|
||||
await updateFileMetadata(file, "Tags updated");
|
||||
};
|
||||
const persistTags = async () => {
|
||||
const newTags = tagsInput.value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
file.tags = newTags;
|
||||
await updateFileMetadata(file, { tags: newTags }, "Tags updated");
|
||||
};
|
||||
|
||||
saveTagsBtn.addEventListener("click", () => persistTags());
|
||||
tagsInput.addEventListener("keydown", (e) => {
|
||||
@@ -461,36 +461,40 @@ export function createMetadataSidebar(options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFileMetadata(file, toastLabel = "Metadata updated") {
|
||||
const filename = file.filename || file.name;
|
||||
if (!filename) {
|
||||
console.error("Error updating metadata: missing filename");
|
||||
return;
|
||||
}
|
||||
const subfolder = file.subfolder || "";
|
||||
const rating = file.rating || 0;
|
||||
const tags = Array.isArray(file.tags) ? file.tags : [];
|
||||
|
||||
try {
|
||||
const res = await fetch("/mjr/filemanager/metadata/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename,
|
||||
subfolder,
|
||||
rating,
|
||||
tags,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
file.rating = data.rating ?? rating;
|
||||
file.tags = data.tags ?? tags;
|
||||
if (!(state.metaCache instanceof Map)) state.metaCache = new Map();
|
||||
const key = metadataKey(file);
|
||||
state.metaCache.delete(key);
|
||||
if (state.metaFetchAt && typeof state.metaFetchAt.delete === "function") {
|
||||
state.metaFetchAt.delete(key);
|
||||
async function updateFileMetadata(file, updates, toastLabel = "Metadata updated") {
|
||||
const filename = file.filename || file.name;
|
||||
if (!filename) {
|
||||
console.error("Error updating metadata: missing filename");
|
||||
return;
|
||||
}
|
||||
const subfolder = file.subfolder || "";
|
||||
const safeUpdates = updates && typeof updates === "object" ? updates : {};
|
||||
|
||||
try {
|
||||
const body = { filename, subfolder };
|
||||
if (Object.prototype.hasOwnProperty.call(safeUpdates, "rating")) {
|
||||
body.rating = Number(safeUpdates.rating) || 0;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(safeUpdates, "tags")) {
|
||||
body.tags = Array.isArray(safeUpdates.tags) ? safeUpdates.tags : [];
|
||||
}
|
||||
|
||||
const res = await fetch("/mjr/filemanager/metadata/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
if (data.rating !== undefined) file.rating = data.rating;
|
||||
if (data.tags !== undefined) file.tags = data.tags;
|
||||
if (!(state.metaCache instanceof Map)) state.metaCache = new Map();
|
||||
const key = metadataKey(file);
|
||||
state.metaCache.delete(key);
|
||||
if (state.metaFetchAt && typeof state.metaFetchAt.delete === "function") {
|
||||
state.metaFetchAt.delete(key);
|
||||
}
|
||||
applyFilterAndRender();
|
||||
if (state.currentFile === file) {
|
||||
@@ -508,14 +512,14 @@ export function createMetadataSidebar(options) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Majoor.AssetsManager] metadata update failed", err);
|
||||
mjrShowToast("error", "Network error while saving metadata", "Error");
|
||||
}
|
||||
}
|
||||
|
||||
function setRating(file, rating) {
|
||||
file.rating = rating;
|
||||
updateFileMetadata(file, "Rating updated");
|
||||
}
|
||||
mjrShowToast("error", "Network error while saving metadata", "Error");
|
||||
}
|
||||
}
|
||||
|
||||
function setRating(file, rating) {
|
||||
file.rating = rating;
|
||||
updateFileMetadata(file, { rating }, "Rating updated");
|
||||
}
|
||||
|
||||
function setMetadataPanelVisibility(visible) {
|
||||
metaPanel.style.display = visible ? "flex" : "none";
|
||||
|
||||
+64
-1
@@ -1331,6 +1331,69 @@ export function mjrUpdateViewerRatingDisplay(rating) {
|
||||
}
|
||||
}
|
||||
|
||||
export function mjrApplyOpenViewerSettings() {
|
||||
try {
|
||||
mjrUpdateNavVisibility();
|
||||
} catch (_) {}
|
||||
|
||||
const frame = mjrViewerState.frame;
|
||||
if (!frame) return;
|
||||
|
||||
// Update video element behavior without rerendering (keeps zoom/pan state)
|
||||
try {
|
||||
frame.querySelectorAll("video").forEach((el) => {
|
||||
el.autoplay = !!mjrSettings.viewer.autoplayVideos;
|
||||
el.loop = !!mjrSettings.viewer.loopVideos;
|
||||
el.muted = !!mjrSettings.viewer.muteVideos;
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
// Toggle rating HUD (single viewer only)
|
||||
try {
|
||||
if (mjrViewerIsAB) return;
|
||||
const pane = frame.firstChild;
|
||||
const mediaWrap = pane && pane.firstChild;
|
||||
if (!mediaWrap || !mediaWrap.appendChild) return;
|
||||
|
||||
if (!mjrSettings.viewer.showRatingHUD) {
|
||||
if (mjrViewerRatingEl && mjrViewerRatingEl.parentNode) {
|
||||
mjrViewerRatingEl.parentNode.removeChild(mjrViewerRatingEl);
|
||||
}
|
||||
mjrViewerRatingEl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mjrViewerRatingEl) {
|
||||
const existing = mediaWrap.querySelector?.("[data-mjr-viewer-rating='1']") || null;
|
||||
if (existing) {
|
||||
mjrViewerRatingEl = existing;
|
||||
} else {
|
||||
const badge = document.createElement("div");
|
||||
Object.assign(badge.style, {
|
||||
position: "absolute",
|
||||
top: "8px",
|
||||
left: "8px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "8px",
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
color: "#ffd45a",
|
||||
fontWeight: "700",
|
||||
letterSpacing: "2px",
|
||||
fontSize: "20px",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
badge.dataset.mjrViewerRating = "1";
|
||||
mediaWrap.appendChild(badge);
|
||||
mjrViewerRatingEl = badge;
|
||||
}
|
||||
}
|
||||
|
||||
const file = mjrCurrentList[mjrCurrentIndex];
|
||||
const currentRating = Math.max(0, Math.min(5, mjrGetRating(file)));
|
||||
mjrUpdateViewerRatingDisplay(currentRating);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function mjrSetViewerRating(rating) {
|
||||
if (mjrViewerIsAB) return;
|
||||
if (!mjrCurrentList.length) return;
|
||||
@@ -1344,7 +1407,7 @@ export function mjrUpdateViewerRatingDisplay(rating) {
|
||||
const firstInst = mjrGlobalState.instances.values().next().value;
|
||||
if (firstInst && typeof firstInst.updateFileMetadata === "function") {
|
||||
try {
|
||||
await firstInst.updateFileMetadata(file, "Rating updated");
|
||||
await firstInst.updateFileMetadata(file, { rating: r }, "Rating updated");
|
||||
} catch (err) {
|
||||
console.warn("[Majoor.AssetsManager] viewer rating persist failed", err);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ export function setupViewerInteractions({
|
||||
applyCheckerboardBg,
|
||||
mjrSettings,
|
||||
}) {
|
||||
const allowZoom = kind === "image";
|
||||
const allowZoom = kind === "image" || kind === "video";
|
||||
let scale = 1;
|
||||
const originX = 50;
|
||||
const originY = 50;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
let resetZoomPan = () => {};
|
||||
let minScale = 1;
|
||||
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
const calcFitScale = () => {
|
||||
@@ -27,8 +28,10 @@ export function setupViewerInteractions({
|
||||
return Math.max(0.01, Math.min(10, ratio || 1));
|
||||
};
|
||||
|
||||
if (allowZoom) el.style.transformOrigin = `${originX}% ${originY}%`;
|
||||
else {
|
||||
if (allowZoom) {
|
||||
el.style.transformOrigin = `${originX}% ${originY}%`;
|
||||
el.style.willChange = "transform";
|
||||
} else {
|
||||
zoomHud.style.display = "none";
|
||||
el.style.transform = "none";
|
||||
el.style.cursor = "default";
|
||||
@@ -36,18 +39,19 @@ export function setupViewerInteractions({
|
||||
|
||||
const applyTransform = () => {
|
||||
if (!allowZoom) return;
|
||||
if (scale <= 1) {
|
||||
if (scale <= minScale) {
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
}
|
||||
el.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
el.style.cursor = scale > 1 ? "grab" : "default";
|
||||
el.style.cursor = scale > minScale ? "grab" : "default";
|
||||
zoomHud.textContent = `${Math.round(scale * 100)}%`;
|
||||
};
|
||||
|
||||
if (allowZoom) {
|
||||
resetZoomPan = () => {
|
||||
scale = calcFitScale();
|
||||
minScale = calcFitScale();
|
||||
scale = minScale;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
applyTransform();
|
||||
@@ -97,7 +101,7 @@ export function setupViewerInteractions({
|
||||
const stopDrag = () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
el.style.cursor = scale > 1 ? "grab" : "default";
|
||||
el.style.cursor = scale > minScale ? "grab" : "default";
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", stopDrag);
|
||||
window.removeEventListener("pointercancel", stopDrag);
|
||||
@@ -105,7 +109,7 @@ export function setupViewerInteractions({
|
||||
|
||||
mediaWrap.addEventListener("pointerdown", (ev) => {
|
||||
if (ev.button !== 0) return;
|
||||
if (scale <= 1) return;
|
||||
if (scale <= minScale) return;
|
||||
ev.preventDefault();
|
||||
try {
|
||||
mediaWrap.setPointerCapture(ev.pointerId);
|
||||
@@ -145,7 +149,7 @@ export function setupViewerInteractions({
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
hint.textContent = "Scroll: Zoom • Drag: Pan • F: Fit/Reset";
|
||||
hint.textContent = "Scroll: Zoom • Drag (zoomed): Pan • F: Fit/Reset";
|
||||
mediaWrap.appendChild(hint);
|
||||
hint.style.transition = "opacity 0.3s ease";
|
||||
let hideTimer = setTimeout(() => {
|
||||
|
||||
+96
-34
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unicodedata
|
||||
@@ -138,6 +139,55 @@ def _parse_rating_value(raw) -> int:
|
||||
return int(round(min(numeric, 5)))
|
||||
|
||||
|
||||
def _coerce_rating_to_stars(raw) -> int:
|
||||
"""
|
||||
Normalize arbitrary rating inputs to 0..5 stars.
|
||||
Accepts:
|
||||
- 0..5 stars (int/float/str)
|
||||
- Windows percent scale (0..100)
|
||||
- star glyphs (e.g. ★★★☆☆)
|
||||
"""
|
||||
if raw in (None, ""):
|
||||
return 0
|
||||
try:
|
||||
num = float(str(raw).strip().replace(",", ".").split()[0])
|
||||
except Exception:
|
||||
return max(0, min(_parse_rating_value(raw), 5))
|
||||
|
||||
if num <= 0:
|
||||
return 0
|
||||
if 0 <= num <= 5:
|
||||
return max(0, min(int(round(num)), 5))
|
||||
if num <= 100:
|
||||
return windows_percent_to_stars(int(round(num)))
|
||||
return max(0, min(_parse_rating_value(raw), 5))
|
||||
|
||||
|
||||
def _normalize_tags(raw) -> List[str]:
|
||||
"""
|
||||
Normalize tags to a deduplicated, order-preserving list of non-empty strings.
|
||||
Accepts list or comma/semicolon-delimited strings.
|
||||
"""
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
candidates = raw
|
||||
elif isinstance(raw, str):
|
||||
candidates = [t.strip() for t in re.split(r"[;,]", raw)]
|
||||
else:
|
||||
return []
|
||||
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for t in candidates:
|
||||
s = str(t).strip()
|
||||
if not s or s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def rating_to_windows_percent(stars: int) -> int:
|
||||
"""
|
||||
Map 0..5 stars to Windows percent scale expected by SharedUserRating.
|
||||
@@ -373,7 +423,7 @@ def set_exif_metadata(file_path: str, rating: int, tags: list) -> bool:
|
||||
|
||||
try:
|
||||
write_timeout_s = _env_timeout("MJR_EXIFTOOL_WRITE_TIMEOUT", 3.0, min_s=0.5, max_s=120.0)
|
||||
r = max(0, min(int(rating), 5))
|
||||
r = _coerce_rating_to_stars(rating)
|
||||
win_r = rating_to_windows_percent(r)
|
||||
|
||||
is_video = file_path.lower().endswith((".mp4", ".mov", ".m4v", ".webm", ".mkv"))
|
||||
@@ -394,7 +444,7 @@ def set_exif_metadata(file_path: str, rating: int, tags: list) -> bool:
|
||||
cat = tags_to_windows_category(tags)
|
||||
cmd.append(f"-Microsoft:Category={cat}")
|
||||
|
||||
clean_tags = [str(t).strip() for t in tags if str(t).strip()]
|
||||
clean_tags = _normalize_tags(tags)
|
||||
# Always write Category for video, even if empty, to clear stale values
|
||||
if is_video and not clean_tags:
|
||||
cmd.append(f"-Microsoft:Category=")
|
||||
@@ -435,33 +485,43 @@ def set_windows_metadata(file_path: str, rating: int, tags: list) -> bool:
|
||||
if not win32com:
|
||||
return False
|
||||
|
||||
original_mtime = _get_mtime_safe(file_path)
|
||||
shell = win32com.Dispatch("Shell.Application")
|
||||
folder = shell.Namespace(os.path.dirname(file_path))
|
||||
file = folder.ParseName(os.path.basename(file_path))
|
||||
|
||||
rating_idx, tags_idx = _resolve_shell_indices(folder)
|
||||
|
||||
# Rating (scale 1-99); Windows 5-star uses 1-5, but accepting broader range
|
||||
# Rating (SharedUserRating percent 0..99); accept stars or percent and normalize
|
||||
if rating is not None:
|
||||
try:
|
||||
r_int = int(rating)
|
||||
except Exception:
|
||||
r_int = 0
|
||||
# Accept 0-5 stars or already-percent; normalize to Windows percent scale
|
||||
if 0 <= r_int <= 5:
|
||||
rating_val = rating_to_windows_percent(r_int)
|
||||
else:
|
||||
rating_val = max(0, min(99, r_int))
|
||||
stars = _coerce_rating_to_stars(rating)
|
||||
rating_val = rating_to_windows_percent(stars)
|
||||
folder.GetDetailsOf(file, rating_idx) # force index load
|
||||
folder.SetDetailsOf(file, rating_idx, str(rating_val))
|
||||
|
||||
if tags:
|
||||
tags_list = _normalize_tags(tags)
|
||||
if tags_list:
|
||||
folder.GetDetailsOf(file, tags_idx)
|
||||
folder.SetDetailsOf(file, tags_idx, "; ".join(tags))
|
||||
folder.SetDetailsOf(file, tags_idx, "; ".join(tags_list))
|
||||
elif tags == []:
|
||||
folder.GetDetailsOf(file, tags_idx)
|
||||
folder.SetDetailsOf(file, tags_idx, "")
|
||||
|
||||
# Restore mtime to avoid reordering the grid after rating/tag updates
|
||||
if original_mtime is not None:
|
||||
try:
|
||||
os.utime(file_path, (original_mtime, original_mtime))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
global _CACHE_EPOCH
|
||||
_CACHE_EPOCH += 1
|
||||
epoch = _CACHE_EPOCH
|
||||
_META_CACHE[file_path] = (
|
||||
original_mtime if original_mtime is not None else _get_mtime_safe(file_path),
|
||||
epoch,
|
||||
{"rating": _coerce_rating_to_stars(rating), "tags": tags_list},
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -524,8 +584,7 @@ def update_metadata_with_windows(file_path: str, updates: dict) -> dict:
|
||||
Returns final metadata.
|
||||
"""
|
||||
global _CACHE_EPOCH
|
||||
_CACHE_EPOCH += 1
|
||||
epoch = _CACHE_EPOCH
|
||||
start_epoch = _CACHE_EPOCH
|
||||
|
||||
# Load current values (prefer system metadata, fallback to sidecar)
|
||||
sys_meta = get_system_metadata(file_path) or {}
|
||||
@@ -539,11 +598,11 @@ def update_metadata_with_windows(file_path: str, updates: dict) -> dict:
|
||||
if not has_rating and not has_tags:
|
||||
return {"rating": current_rating, "tags": current_tags}
|
||||
|
||||
rating = updates.get("rating", current_rating if has_rating else current_rating)
|
||||
tags = updates.get("tags", current_tags if has_tags else current_tags)
|
||||
rating = _coerce_rating_to_stars(updates.get("rating", current_rating))
|
||||
tags = _normalize_tags(updates.get("tags", current_tags))
|
||||
|
||||
# Only write tags when provided; otherwise preserve existing tags in system metadata
|
||||
target_tags = tags if has_tags else current_tags
|
||||
target_tags = tags if has_tags else _normalize_tags(current_tags)
|
||||
|
||||
ok_win = False
|
||||
ok_exif = False
|
||||
@@ -551,12 +610,22 @@ def update_metadata_with_windows(file_path: str, updates: dict) -> dict:
|
||||
ok_win = set_windows_metadata(file_path, rating if has_rating else current_rating, target_tags if has_tags else current_tags)
|
||||
ok_exif = set_exif_metadata(file_path, rating if has_rating else current_rating, target_tags if has_tags else current_tags)
|
||||
|
||||
sidecar_saved = False
|
||||
if _sidecar_allowed_for(file_path):
|
||||
save_metadata(file_path, {"rating": rating if has_rating else current_rating, "tags": target_tags})
|
||||
sidecar_saved = True
|
||||
|
||||
effective_rating = (rating if has_rating else current_rating) if (ok_win or ok_exif) else current_rating
|
||||
effective_rating = (rating if has_rating else current_rating) if (ok_win or ok_exif or sidecar_saved) else current_rating
|
||||
effective_tags = target_tags
|
||||
_META_CACHE[file_path] = (_get_mtime_safe(file_path), epoch, {"rating": effective_rating, "tags": effective_tags})
|
||||
|
||||
# Ensure cache invalidates even when mtime is preserved (Windows/ExifTool writes).
|
||||
if (ok_win or ok_exif or sidecar_saved) and _CACHE_EPOCH == start_epoch:
|
||||
_CACHE_EPOCH += 1
|
||||
_META_CACHE[file_path] = (
|
||||
_get_mtime_safe(file_path),
|
||||
_CACHE_EPOCH,
|
||||
{"rating": _coerce_rating_to_stars(effective_rating), "tags": _normalize_tags(effective_tags)},
|
||||
)
|
||||
return {"rating": effective_rating, "tags": effective_tags}
|
||||
|
||||
|
||||
@@ -565,26 +634,19 @@ def apply_system_metadata(file_path: str, rating, tags: list) -> dict:
|
||||
Try exiftool first (if installed), otherwise Windows Shell.
|
||||
"""
|
||||
global _CACHE_EPOCH
|
||||
_CACHE_EPOCH += 1
|
||||
epoch = _CACHE_EPOCH
|
||||
try:
|
||||
rating_int = int(rating) if rating is not None else 0
|
||||
except Exception:
|
||||
rating_int = 0
|
||||
tags_list: List[str] = []
|
||||
if isinstance(tags, list):
|
||||
tags_list = [str(t) for t in tags if str(t).strip()]
|
||||
start_epoch = _CACHE_EPOCH
|
||||
rating_int = _coerce_rating_to_stars(rating)
|
||||
tags_list = _normalize_tags(tags)
|
||||
|
||||
# exiftool (meilleure compat Windows + autres OS)
|
||||
if set_exif_metadata(file_path, rating_int, tags_list):
|
||||
mtime = _get_mtime_safe(file_path)
|
||||
_META_CACHE[file_path] = (mtime, epoch, {"rating": rating_int, "tags": tags_list})
|
||||
return {"rating": rating_int, "tags": tags_list}
|
||||
|
||||
# fallback shell API
|
||||
meta = apply_windows_metadata(file_path, rating_int, tags_list)
|
||||
mtime = _get_mtime_safe(file_path)
|
||||
_META_CACHE[file_path] = (mtime, epoch, meta)
|
||||
if _CACHE_EPOCH == start_epoch:
|
||||
_CACHE_EPOCH += 1
|
||||
_META_CACHE[file_path] = (_get_mtime_safe(file_path), _CACHE_EPOCH, meta)
|
||||
return meta
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user