global improvements and new features

This commit is contained in:
MajoorWaldi
2025-12-17 15:07:33 +01:00
parent bb2d1da9c0
commit 44a1655dc7
10 changed files with 831 additions and 305 deletions
+1 -2
View File
@@ -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
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+13 -9
View File
@@ -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
View File
@@ -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