Merge main into feat/bulk-rating and rebuild dist
This commit is contained in:
@@ -6,6 +6,7 @@ All notable changes to this project are documented in this file.
|
||||
|
||||
### New Features
|
||||
- **Bulk star rating across a selection**: new batch route `POST /mjr/am/assets/rating` (`{ids, rating}`, max 500 ids, 10 req/min, per-id partial results) and the 0-5 hotkeys plus the grid "Set rating" submenu now rate multi-card selections in one batched, chunked request instead of one request per card (progress toast on large selections, `N rated, M failed` summary on partial failures, single-card behaviour unchanged).
|
||||
- **Empty trash**: an "Empty trash" button in the Status panel permanently removes the files Majoor sent to the freedesktop trash (Linux/BSD). It only selects entries whose original location was inside a Majoor folder, shows their count and size before asking for confirmation, and shares the `allow_delete` / `MAJOOR_ALLOW_DELETE` gate with asset deletion. Directories in the trash are never removed (reported as skipped); trash directories owned by another user or group/world-writable are ignored. New routes `GET /mjr/am/trash` and `POST /mjr/am/trash/empty`.
|
||||
- **Majoor Save Image formats**: `format` now offers `png 16-bit`, `exr` and `avif` (ComfyUI Save Image (Advanced) encoders) with `input_color_space` and AVIF `crf` options. The node also returns its `images` like the core Save Image node.
|
||||
- **Majoor Save Video formats**: MP4, MKV and WebM containers with H.264 or AV1 (`mp4 (av1)`, `mkv (h264)`, `mkv (av1)`, `webm (av1)`). 10-bit and HDR (HLG / PQ) videos keep their bit depth and colour space. The node returns the encoded `images`.
|
||||
- **EXR / TIFF / SVG / BMP / AVI / M4V indexing**: these formats are now indexed. EXR and TIFF are served to the viewer as a JPEG preview (EXR tone-mapped from scene-linear to sRGB); add `?original=1` to `/mjr/am/viewer/asset/{id}` for the source file. SVG is served sandboxed.
|
||||
|
||||
+170
-169
@@ -1,14 +1,15 @@
|
||||
import { An as e, Cn as t, Dn as n, Gt as r, Mn as i, Nn as a, Sn as o, Tn as s, Xn as c, an as l, fn as u, hn as d, ln as f, mn as p, s as m, t as h, u as g, yn as _ } from "./config-D98p5fVn.js";
|
||||
import { a as v, i as y, o as b, s as x } from "./graphTraversal-DTdQWkn5.js";
|
||||
import { c as S, l as C, m as w, p as ee, s as T, t as E, u as D, v as O } from "./generationSectionState-BPjCPC13.js";
|
||||
import { En as e, Mn as t, Sn as n, Wt as r, Yn as i, cn as a, dn as o, in as s, jn as c, kn as l, l as u, mn as d, o as f, pn as p, vn as m, wn as h, xn as g } from "./client-BVwNDRIx.js";
|
||||
import { a as _, i as v, o as y, s as b } from "./graphTraversal-DTdQWkn5.js";
|
||||
import { t as x } from "./config-BFDzOF1N.js";
|
||||
import { c as S, l as C, m as w, p as ee, s as T, t as E, u as D, v as O } from "./generationSectionState-DwIh8vyW.js";
|
||||
import { r as k } from "./events-1iwo5blr.js";
|
||||
import { C as A, D as j, E as M, T as N, _ as P, a as F, b as I, d as L, f as R, g as z, h as B, m as te, n as V, o as ne, p as H, r as re, s as ie, t as U, v as ae, w as oe, y as se } from "./tooltipShortcuts-jtVskF3l.js";
|
||||
import { I as ce } from "./mjr-primevue-C07IsEdU.js";
|
||||
import { C as le, S as ue, n as de, t as fe, w as pe } from "./genInfo-BlwZQHxb.js";
|
||||
import { C as A, D as j, E as M, T as N, _ as P, a as F, b as I, d as L, f as R, g as z, h as B, m as te, n as V, o as ne, p as H, r as re, s as ie, t as U, v as ae, w as oe, y as se } from "./tooltipShortcuts-CdiE8JHR.js";
|
||||
import { I as ce } from "./mjr-primevue-CE8VjDUg.js";
|
||||
import { C as le, S as ue, n as de, t as fe, w as pe } from "./genInfo-BQayEE-N.js";
|
||||
// ui/features/viewer/videoSync.ts
|
||||
var me = () => {
|
||||
try {
|
||||
return !!h?.DEBUG_VIEWER;
|
||||
return !!x?.DEBUG_VIEWER;
|
||||
} catch {
|
||||
return !1;
|
||||
}
|
||||
@@ -592,7 +593,7 @@ function G(e) {
|
||||
return a === ".gif" ? "gif" : Me.has(a) ? "video" : Ne.has(a) ? "audio" : ue.has(a) ? "model3d" : "image";
|
||||
}
|
||||
function Fe(e) {
|
||||
return e ? e.url ? String(e.url) : e.filename && c(e) || "" : "";
|
||||
return e ? e.url ? String(e.url) : e.filename && i(e) || "" : "";
|
||||
}
|
||||
function K(e = "No media - select assets in the grid") {
|
||||
let t = document.createElement("div");
|
||||
@@ -743,7 +744,7 @@ function J(e, { fill: t = !1, controls: n = !0, initialMuted: r = !1, initialPla
|
||||
canvasClassName: `mjr-mfv-media mjr-model3d-render-canvas${t ? " mjr-mfv-media--fill" : ""}`,
|
||||
hintText: "Rotate: left drag Pan: right drag Zoom: wheel or middle drag",
|
||||
disableViewerTransform: !0,
|
||||
pauseDuringExecution: !!h.FLOATING_VIEWER_PAUSE_DURING_EXECUTION
|
||||
pauseDuringExecution: !!x.FLOATING_VIEWER_PAUSE_DURING_EXECUTION
|
||||
});
|
||||
let l = document.createElement("img");
|
||||
return l.className = s, l.src = a, l.alt = String(e?.filename || ""), l.draggable = !1, (o === "gif" || c) && Ae(l, e, { kind: o === "gif" ? "gif" : "animated-image" }) || l;
|
||||
@@ -856,80 +857,80 @@ var nt = class {
|
||||
}), this._onToggle?.(this._expanded);
|
||||
}
|
||||
_render() {
|
||||
let t = this._node, n = document.createElement("section");
|
||||
n.className = "mjr-ws-node", n.dataset.nodeId = String(t.id ?? ""), this._isSubgraph && (n.classList.add("mjr-ws-node--subgraph"), n.dataset.subgraph = "true", n.dataset.childCount = String(this._childCount)), this._depth > 0 && (n.dataset.depth = String(this._depth), n.classList.add("mjr-ws-node--nested"));
|
||||
let r = document.createElement("div");
|
||||
if (r.className = "mjr-ws-node-header", this._collapsible) {
|
||||
this._header = r;
|
||||
let e = this._node, t = document.createElement("section");
|
||||
t.className = "mjr-ws-node", t.dataset.nodeId = String(e.id ?? ""), this._isSubgraph && (t.classList.add("mjr-ws-node--subgraph"), t.dataset.subgraph = "true", t.dataset.childCount = String(this._childCount)), this._depth > 0 && (t.dataset.depth = String(this._depth), t.classList.add("mjr-ws-node--nested"));
|
||||
let n = document.createElement("div");
|
||||
if (n.className = "mjr-ws-node-header", this._collapsible) {
|
||||
this._header = n;
|
||||
let e = document.createElement("button");
|
||||
e.type = "button", e.className = "mjr-icon-btn mjr-ws-node-toggle", e.title = this._expanded ? "Collapse node" : "Expand node", e.addEventListener("click", (e) => {
|
||||
e.stopPropagation(), this.setExpanded(!this._expanded);
|
||||
}), r.appendChild(e), this._toggleBtn = e, r.addEventListener("click", (e) => {
|
||||
}), n.appendChild(e), this._toggleBtn = e, n.addEventListener("click", (e) => {
|
||||
e.target?.closest?.("button") || this.setExpanded(!this._expanded);
|
||||
}), r.title = this._expanded ? "Collapse node" : "Expand node";
|
||||
}), n.title = this._expanded ? "Collapse node" : "Expand node";
|
||||
}
|
||||
let i = document.createElement("div");
|
||||
i.className = "mjr-ws-node-title-wrap";
|
||||
let a = document.createElement("span");
|
||||
a.className = "mjr-ws-node-type";
|
||||
let o = et(t, { isSubgraph: this._isSubgraph });
|
||||
a.textContent = o, i.appendChild(a);
|
||||
let s = tt(t, o, { isSubgraph: this._isSubgraph });
|
||||
if (s) {
|
||||
let r = document.createElement("div");
|
||||
r.className = "mjr-ws-node-title-wrap";
|
||||
let i = document.createElement("span");
|
||||
i.className = "mjr-ws-node-type";
|
||||
let a = et(e, { isSubgraph: this._isSubgraph });
|
||||
i.textContent = a, r.appendChild(i);
|
||||
let o = tt(e, a, { isSubgraph: this._isSubgraph });
|
||||
if (o) {
|
||||
let e = document.createElement("span");
|
||||
e.className = "mjr-ws-node-title", e.textContent = s, i.appendChild(e);
|
||||
e.className = "mjr-ws-node-title", e.textContent = o, r.appendChild(e);
|
||||
}
|
||||
if (r.appendChild(i), this._isSubgraph) {
|
||||
if (n.appendChild(r), this._isSubgraph) {
|
||||
let e = document.createElement("span");
|
||||
e.className = "mjr-ws-node-kind", e.title = `${this._childCount} inner node${this._childCount === 1 ? "" : "s"}`;
|
||||
let t = document.createElement("i");
|
||||
t.className = "pi pi-sitemap", t.setAttribute("aria-hidden", "true"), e.appendChild(t);
|
||||
let n = document.createElement("span");
|
||||
n.textContent = "Subgraph", e.appendChild(n);
|
||||
let r = document.createElement("span");
|
||||
r.textContent = "Subgraph", e.appendChild(r);
|
||||
let i = document.createElement("span");
|
||||
i.className = "mjr-ws-node-kind-count", i.textContent = String(this._childCount), e.appendChild(i), r.appendChild(e), this._subgraphHeaderTitle = `${o} | Subgraph | ${this._childCount} inner node${this._childCount === 1 ? "" : "s"}`, r.title = this._subgraphHeaderTitle;
|
||||
i.className = "mjr-ws-node-kind-count", i.textContent = String(this._childCount), e.appendChild(i), n.appendChild(e), this._subgraphHeaderTitle = `${a} | Subgraph | ${this._childCount} inner node${this._childCount === 1 ? "" : "s"}`, n.title = this._subgraphHeaderTitle;
|
||||
}
|
||||
let c = document.createElement("button");
|
||||
c.type = "button", c.className = "mjr-icon-btn mjr-ws-locate", c.title = "Locate on canvas", c.innerHTML = "<i class=\"pi pi-map-marker\" aria-hidden=\"true\"></i>", c.addEventListener("click", (e) => {
|
||||
let s = document.createElement("button");
|
||||
s.type = "button", s.className = "mjr-icon-btn mjr-ws-locate", s.title = "Locate on canvas", s.innerHTML = "<i class=\"pi pi-map-marker\" aria-hidden=\"true\"></i>", s.addEventListener("click", (e) => {
|
||||
e.stopPropagation(), this._locateNode();
|
||||
}), r.appendChild(c), n.appendChild(r);
|
||||
let l = document.createElement("div");
|
||||
if (l.className = "mjr-ws-node-body", Ke(t)) {
|
||||
let r = document.createElement("textarea");
|
||||
r.className = "mjr-ws-input mjr-ws-textarea mjr-ws-note-textarea", r.value = qe(t), r.rows = 4;
|
||||
let i = () => {
|
||||
r.style.height = "auto", r.style.height = r.scrollHeight + "px";
|
||||
}), n.appendChild(s), t.appendChild(n);
|
||||
let c = document.createElement("div");
|
||||
if (c.className = "mjr-ws-node-body", Ke(e)) {
|
||||
let n = document.createElement("textarea");
|
||||
n.className = "mjr-ws-input mjr-ws-textarea mjr-ws-note-textarea", n.value = qe(e), n.rows = 4;
|
||||
let r = () => {
|
||||
n.style.height = "auto", n.style.height = n.scrollHeight + "px";
|
||||
};
|
||||
return r.addEventListener("input", () => {
|
||||
Je(t, r.value);
|
||||
let n = t?.widgets?.[0], a = n?.inputEl ?? n?.element ?? n?.el ?? null;
|
||||
a != null && "value" in a && a.value !== r.value && (a.value = r.value), i();
|
||||
return n.addEventListener("input", () => {
|
||||
Je(e, n.value);
|
||||
let t = e?.widgets?.[0], i = t?.inputEl ?? t?.element ?? t?.el ?? null;
|
||||
i != null && "value" in i && i.value !== n.value && (i.value = n.value), r();
|
||||
try {
|
||||
e(t);
|
||||
l(e);
|
||||
} catch {}
|
||||
}), r._mjrAutoFit = i, this._noteTextarea = r, this._autoFits.push(i), l.appendChild(r), this._body = l, n.appendChild(l), this._el = n, this._applyExpandedState(), requestAnimationFrame(i), n;
|
||||
}), n._mjrAutoFit = r, this._noteTextarea = n, this._autoFits.push(r), c.appendChild(n), this._body = c, t.appendChild(c), this._el = t, this._applyExpandedState(), requestAnimationFrame(r), t;
|
||||
}
|
||||
let u = t.widgets ?? [], d = !1;
|
||||
for (let e of u) {
|
||||
let n = String(e.type || "").toLowerCase();
|
||||
if (We.has(n) || e.hidden || e.options?.hidden) continue;
|
||||
let u = e.widgets ?? [], d = !1;
|
||||
for (let t of u) {
|
||||
let n = String(t.type || "").toLowerCase();
|
||||
if (We.has(n) || t.hidden || t.options?.hidden) continue;
|
||||
d = !0;
|
||||
let r = n === "text" || n === "string" || n === "customtext", i = document.createElement("div");
|
||||
i.className = r ? "mjr-ws-widget-row mjr-ws-widget-row--block" : "mjr-ws-widget-row";
|
||||
let a = document.createElement("label");
|
||||
a.className = "mjr-ws-widget-label", a.textContent = e.name || "";
|
||||
a.className = "mjr-ws-widget-label", a.textContent = t.name || "";
|
||||
let o = document.createElement("div");
|
||||
o.className = "mjr-ws-widget-input";
|
||||
let s = F(e, () => {}, t);
|
||||
o.appendChild(s), this._inputMap.set(e.name, s);
|
||||
let c = s._mjrAutoFit ?? s.querySelector?.("textarea")?._mjrAutoFit;
|
||||
c && this._autoFits.push(c), i.appendChild(a), i.appendChild(o), l.appendChild(i);
|
||||
let s = F(t, () => {}, e);
|
||||
o.appendChild(s), this._inputMap.set(t.name, s);
|
||||
let l = s._mjrAutoFit ?? s.querySelector?.("textarea")?._mjrAutoFit;
|
||||
l && this._autoFits.push(l), i.appendChild(a), i.appendChild(o), c.appendChild(i);
|
||||
}
|
||||
if (!d) {
|
||||
let e = document.createElement("div");
|
||||
e.className = "mjr-ws-node-empty", e.textContent = "No editable parameters", l.appendChild(e);
|
||||
e.className = "mjr-ws-node-empty", e.textContent = "No editable parameters", c.appendChild(e);
|
||||
}
|
||||
return this._body = l, n.appendChild(l), this._el = n, this._applyExpandedState(), n;
|
||||
return this._body = c, t.appendChild(c), this._el = t, this._applyExpandedState(), t;
|
||||
}
|
||||
_applyExpandedState() {
|
||||
if (this._el && this._body) {
|
||||
@@ -1085,18 +1086,18 @@ var it = class {
|
||||
};
|
||||
function at() {
|
||||
try {
|
||||
return _();
|
||||
return m();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function ot(e) {
|
||||
return b(e);
|
||||
return y(e);
|
||||
}
|
||||
function st(e, t = /* @__PURE__ */ new Set()) {
|
||||
if (!e || t.has(e)) return [];
|
||||
t.add(e);
|
||||
let n = y(e), r = [];
|
||||
let n = v(e), r = [];
|
||||
for (let e of n) {
|
||||
if (!e) continue;
|
||||
let n = ot(e).flatMap((e) => st(e, t));
|
||||
@@ -1142,7 +1143,7 @@ function dt(e) {
|
||||
}
|
||||
function ft() {
|
||||
try {
|
||||
return t();
|
||||
return n();
|
||||
} catch (e) {
|
||||
console.debug?.("[MFV] _getSelectedNodeIds", e);
|
||||
}
|
||||
@@ -1168,62 +1169,62 @@ function ht(e) {
|
||||
}
|
||||
}
|
||||
function gt(e) {
|
||||
return s(e);
|
||||
return h(e);
|
||||
}
|
||||
function _t(e) {
|
||||
let t = _();
|
||||
let t = m();
|
||||
if (!e || !t || typeof t.add != "function") return !1;
|
||||
let n = String(e?.type || e?.class_type || e?.comfyClass || "").trim(), r = null;
|
||||
try {
|
||||
r = u(n);
|
||||
r = o(n);
|
||||
} catch (e) {
|
||||
console.debug?.("[MFV Graph Map] createNode failed", e);
|
||||
}
|
||||
if (!r) return !1;
|
||||
try {
|
||||
let t = typeof structuredClone == "function" ? structuredClone(e) : JSON.parse(JSON.stringify(e));
|
||||
return St(t, e), delete t.id, Array.isArray(t.pos) && (t.pos = [Number(t.pos[0] || 0) + 32, Number(t.pos[1] || 0) + 32]), typeof r.configure == "function" ? r.configure(t) : Object.assign(r, t), f(r);
|
||||
return St(t, e), delete t.id, Array.isArray(t.pos) && (t.pos = [Number(t.pos[0] || 0) + 32, Number(t.pos[1] || 0) + 32]), typeof r.configure == "function" ? r.configure(t) : Object.assign(r, t), a(r);
|
||||
} catch (e) {
|
||||
return console.debug?.("[MFV Graph Map] import node failed", e), !1;
|
||||
}
|
||||
}
|
||||
function vt(e) {
|
||||
let t = yt();
|
||||
if (!e || !t) return {
|
||||
let n = yt();
|
||||
if (!e || !n) return {
|
||||
ok: !1,
|
||||
count: 0,
|
||||
reason: "no-target"
|
||||
};
|
||||
let n = bt(e), r = Array.isArray(t.widgets) ? t.widgets : [];
|
||||
if (!n.length || !r.length) return {
|
||||
let r = bt(e), i = Array.isArray(n.widgets) ? n.widgets : [];
|
||||
if (!r.length || !i.length) return {
|
||||
ok: !1,
|
||||
count: 0,
|
||||
reason: "no-widgets"
|
||||
};
|
||||
let i = /* @__PURE__ */ new Map();
|
||||
r.forEach((e, t) => {
|
||||
for (let n of wt(e)) i.has(n) || i.set(n, {
|
||||
let a = /* @__PURE__ */ new Map();
|
||||
i.forEach((e, t) => {
|
||||
for (let n of wt(e)) a.has(n) || a.set(n, {
|
||||
widget: e,
|
||||
index: t
|
||||
});
|
||||
});
|
||||
let o = Et(B(e)), s = Et(t?.type || t?.comfyClass || t?.class_type), c = !!(o && s && o === s), l = /* @__PURE__ */ new Set(), u = 0;
|
||||
for (let e of n) {
|
||||
let n = Tt(e.label), a = n ? i.get(n) : null, o = e.index;
|
||||
if ((!a || l.has(a.index)) && c && typeof o == "number" && Number.isInteger(o)) {
|
||||
let e = r[o];
|
||||
e && (a = {
|
||||
let o = Et(B(e)), s = Et(n?.type || n?.comfyClass || n?.class_type), c = !!(o && s && o === s), l = /* @__PURE__ */ new Set(), u = 0;
|
||||
for (let e of r) {
|
||||
let t = Tt(e.label), r = t ? a.get(t) : null, o = e.index;
|
||||
if ((!r || l.has(r.index)) && c && typeof o == "number" && Number.isInteger(o)) {
|
||||
let e = i[o];
|
||||
e && (r = {
|
||||
widget: e,
|
||||
index: o
|
||||
});
|
||||
}
|
||||
a && !l.has(a.index) && ne(a.widget, X(e.value), t) && (l.add(a.index), u += 1);
|
||||
r && !l.has(r.index) && ne(r.widget, X(e.value), n) && (l.add(r.index), u += 1);
|
||||
}
|
||||
return a(), {
|
||||
return t(), {
|
||||
ok: u > 0,
|
||||
count: u,
|
||||
reason: u > 0 ? "ok" : "no-match",
|
||||
targetNode: t
|
||||
targetNode: n
|
||||
};
|
||||
}
|
||||
function yt() {
|
||||
@@ -1735,14 +1736,14 @@ var Ht = 16, Ut = 250, Wt = class {
|
||||
let t = document.createElement("div");
|
||||
t.className = "mjr-ws-sidebar-header";
|
||||
let n = document.createElement("span");
|
||||
n.className = "mjr-ws-sidebar-title", n.textContent = l("workflowSidebar.nodes", "Nodes"), t.appendChild(n);
|
||||
n.className = "mjr-ws-sidebar-title", n.textContent = s("workflowSidebar.nodes", "Nodes"), t.appendChild(n);
|
||||
let r = document.createElement("button");
|
||||
r.type = "button", r.className = "mjr-icon-btn", r.title = l("workflowSidebar.close", "Close sidebar"), r.innerHTML = "<i class=\"pi pi-times\" aria-hidden=\"true\"></i>", r.addEventListener("click", () => {
|
||||
r.type = "button", r.className = "mjr-icon-btn", r.title = s("workflowSidebar.close", "Close sidebar"), r.innerHTML = "<i class=\"pi pi-times\" aria-hidden=\"true\"></i>", r.addEventListener("click", () => {
|
||||
let e = this._activeMode === "graph";
|
||||
this.hide(), e && this._onCloseGraphMap?.(), this._onClose?.();
|
||||
}), t.appendChild(r), e.appendChild(t);
|
||||
let i = document.createElement("div");
|
||||
i.className = "mjr-ws-tab-bar", this._nodesModeBtn = this._makeModeButton(l("workflowSidebar.nodes", "Nodes"), "pi pi-sliders-h", "nodes"), this._graphModeBtn = this._makeModeButton(l("workflowSidebar.graphMap", "Graph Map"), "pi pi-sitemap", "graph"), i.appendChild(this._nodesModeBtn), i.appendChild(this._graphModeBtn), e.appendChild(i);
|
||||
i.className = "mjr-ws-tab-bar", this._nodesModeBtn = this._makeModeButton(s("workflowSidebar.nodes", "Nodes"), "pi pi-sliders-h", "nodes"), this._graphModeBtn = this._makeModeButton(s("workflowSidebar.graphMap", "Graph Map"), "pi pi-sitemap", "graph"), i.appendChild(this._nodesModeBtn), i.appendChild(this._graphModeBtn), e.appendChild(i);
|
||||
let a = document.createElement("div");
|
||||
return a.className = "mjr-ws-sidebar-resizer", a.setAttribute("role", "separator"), a.setAttribute("aria-orientation", "vertical"), a.setAttribute("aria-hidden", "true"), e.appendChild(a), this._bindResize(a), this._body = document.createElement("div"), this._body.className = "mjr-ws-sidebar-body", e.appendChild(this._body), this._renderActiveMode(), e;
|
||||
}
|
||||
@@ -1834,90 +1835,90 @@ var Q = Object.freeze({
|
||||
"latent2rgb",
|
||||
"taesd",
|
||||
"none"
|
||||
]), Jt = "progress-update", Yt = (e, t = "") => l(e, t, void 0);
|
||||
]), Jt = "progress-update", Yt = (e, t = "") => s(e, t, void 0);
|
||||
function Xt() {
|
||||
let e = document.createElement("div");
|
||||
e.className = "mjr-mfv-run-controls";
|
||||
let t = document.createElement("button");
|
||||
t.type = "button", t.className = "mjr-icon-btn mjr-mfv-run-btn";
|
||||
let t = document.createElement("div");
|
||||
t.className = "mjr-mfv-run-controls";
|
||||
let n = document.createElement("button");
|
||||
n.type = "button", n.className = "mjr-icon-btn mjr-mfv-run-btn";
|
||||
let r = Yt("tooltip.queuePrompt", "Queue Prompt (Run)");
|
||||
t.title = r, t.setAttribute("aria-label", r);
|
||||
n.title = r, n.setAttribute("aria-label", r);
|
||||
let i = document.createElement("i");
|
||||
i.className = "pi pi-play", i.setAttribute("aria-hidden", "true"), t.appendChild(i);
|
||||
i.className = "pi pi-play", i.setAttribute("aria-hidden", "true"), n.appendChild(i);
|
||||
let a = document.createElement("button");
|
||||
a.type = "button", a.className = "mjr-icon-btn mjr-mfv-stop-btn";
|
||||
let s = document.createElement("i");
|
||||
s.className = "pi pi-stop", s.setAttribute("aria-hidden", "true"), a.appendChild(s), e.appendChild(t), e.appendChild(a);
|
||||
let c = Q.IDLE, l = !1, u = !1, d = null;
|
||||
function f() {
|
||||
d != null && (clearTimeout(d), d = null);
|
||||
let o = document.createElement("i");
|
||||
o.className = "pi pi-stop", o.setAttribute("aria-hidden", "true"), a.appendChild(o), t.appendChild(n), t.appendChild(a);
|
||||
let s = Q.IDLE, c = !1, l = !1, u = null;
|
||||
function d() {
|
||||
u != null && (clearTimeout(u), u = null);
|
||||
}
|
||||
function p(e, { canStop: n = !1 } = {}) {
|
||||
c = e, t.classList.toggle("running", c === Q.RUNNING), t.classList.toggle("stopping", c === Q.STOPPING), t.classList.toggle("error", c === Q.ERROR), t.disabled = c === Q.RUNNING || c === Q.STOPPING, a.disabled = !n || c === Q.STOPPING, a.classList.toggle("active", n && c !== Q.STOPPING), a.classList.toggle("stopping", c === Q.STOPPING), c === Q.RUNNING || c === Q.STOPPING ? i.className = "pi pi-spin pi-spinner" : i.className = "pi pi-play";
|
||||
function f(e, { canStop: t = !1 } = {}) {
|
||||
s = e, n.classList.toggle("running", s === Q.RUNNING), n.classList.toggle("stopping", s === Q.STOPPING), n.classList.toggle("error", s === Q.ERROR), n.disabled = s === Q.RUNNING || s === Q.STOPPING, a.disabled = !t || s === Q.STOPPING, a.classList.toggle("active", t && s !== Q.STOPPING), a.classList.toggle("stopping", s === Q.STOPPING), s === Q.RUNNING || s === Q.STOPPING ? i.className = "pi pi-spin pi-spinner" : i.className = "pi pi-play";
|
||||
}
|
||||
function m() {
|
||||
function p() {
|
||||
let e = Yt("tooltip.queueStop", "Stop Generation");
|
||||
a.title = e, a.setAttribute("aria-label", e);
|
||||
}
|
||||
function h(e = j.getSnapshot(), { authoritative: t = !1 } = {}) {
|
||||
function m(e = j.getSnapshot(), { authoritative: t = !1 } = {}) {
|
||||
let n = Math.max(0, Number(e?.queue) || 0), r = e?.prompt || null, i = !!r?.currentlyExecuting, a = !!(r && (r.currentlyExecuting || r.errorDetails)), o = n > 0 || a, s = !!r?.errorDetails;
|
||||
t && n === 0 && !r && (l = !1, u = !1);
|
||||
let c = l || u || i || n > 0;
|
||||
if ((i || o || n > 0) && (l = !1), s) u = !1, f(), p(Q.ERROR, { canStop: !1 });
|
||||
else if (u) {
|
||||
if (!c) {
|
||||
u = !1, h(e);
|
||||
t && n === 0 && !r && (c = !1, l = !1);
|
||||
let u = c || l || i || n > 0;
|
||||
if ((i || o || n > 0) && (c = !1), s) l = !1, d(), f(Q.ERROR, { canStop: !1 });
|
||||
else if (l) {
|
||||
if (!u) {
|
||||
l = !1, m(e);
|
||||
return;
|
||||
}
|
||||
p(Q.STOPPING, { canStop: !1 });
|
||||
} else l || i || o || n > 0 ? (f(), p(Q.RUNNING, { canStop: !0 })) : (f(), p(Q.IDLE, { canStop: !1 }));
|
||||
f(Q.STOPPING, { canStop: !1 });
|
||||
} else c || i || o || n > 0 ? (d(), f(Q.RUNNING, { canStop: !0 })) : (d(), f(Q.IDLE, { canStop: !1 }));
|
||||
}
|
||||
function g() {
|
||||
l = !1, u = !1, f(), p(Q.ERROR, { canStop: !1 }), d = setTimeout(() => {
|
||||
d = null, h();
|
||||
function h() {
|
||||
c = !1, l = !1, d(), f(Q.ERROR, { canStop: !1 }), u = setTimeout(() => {
|
||||
u = null, m();
|
||||
}, 1500);
|
||||
}
|
||||
async function _() {
|
||||
return { tracked: await n(o()) };
|
||||
return { tracked: await e(g()) };
|
||||
}
|
||||
async function v() {
|
||||
if (c !== Q.RUNNING && c !== Q.STOPPING) {
|
||||
l = !0, u = !1, h();
|
||||
if (s !== Q.RUNNING && s !== Q.STOPPING) {
|
||||
c = !0, l = !1, m();
|
||||
try {
|
||||
(await nn())?.tracked || (l = !1), h();
|
||||
(await nn())?.tracked || (c = !1), m();
|
||||
} catch (e) {
|
||||
console.error?.("[MFV Run]", e), g();
|
||||
console.error?.("[MFV Run]", e), h();
|
||||
}
|
||||
}
|
||||
}
|
||||
async function y() {
|
||||
if (c === Q.RUNNING) {
|
||||
u = !0, h();
|
||||
if (s === Q.RUNNING) {
|
||||
l = !0, m();
|
||||
try {
|
||||
(await _())?.tracked || (u = !1, l = !1), h();
|
||||
(await _())?.tracked || (l = !1, c = !1), m();
|
||||
} catch (e) {
|
||||
console.error?.("[MFV Stop]", e), u = !1, h();
|
||||
console.error?.("[MFV Stop]", e), l = !1, m();
|
||||
}
|
||||
}
|
||||
}
|
||||
m(), a.disabled = !0, t.addEventListener("click", v), a.addEventListener("click", y);
|
||||
p(), a.disabled = !0, n.addEventListener("click", v), a.addEventListener("click", y);
|
||||
let b = (e) => {
|
||||
h(e?.detail || j.getSnapshot(), { authoritative: !0 });
|
||||
m(e?.detail || j.getSnapshot(), { authoritative: !0 });
|
||||
};
|
||||
return j.addEventListener(Jt, b), M({ timeoutMs: 4e3 }).catch((e) => {
|
||||
console.debug?.(e);
|
||||
}), h(), {
|
||||
el: e,
|
||||
}), m(), {
|
||||
el: t,
|
||||
dispose() {
|
||||
f(), t.removeEventListener("click", v), a.removeEventListener("click", y), j.removeEventListener(Jt, b);
|
||||
d(), n.removeEventListener("click", v), a.removeEventListener("click", y), j.removeEventListener(Jt, b);
|
||||
}
|
||||
};
|
||||
}
|
||||
function Zt(e = h.MFV_PREVIEW_METHOD) {
|
||||
function Zt(e = x.MFV_PREVIEW_METHOD) {
|
||||
let t = String(e || "").trim().toLowerCase();
|
||||
return qt.has(t) ? t : h.MFV_PREVIEW_METHOD || "auto";
|
||||
return qt.has(t) ? t : x.MFV_PREVIEW_METHOD || "auto";
|
||||
}
|
||||
function Qt(e, t = h.MFV_PREVIEW_METHOD) {
|
||||
function Qt(e, t = x.MFV_PREVIEW_METHOD) {
|
||||
let n = Zt(t), r = {
|
||||
...e?.extra_data || {},
|
||||
extra_pnginfo: { ...e?.extra_data?.extra_pnginfo || {} }
|
||||
@@ -1927,7 +1928,7 @@ function Qt(e, t = h.MFV_PREVIEW_METHOD) {
|
||||
extra_data: r
|
||||
};
|
||||
}
|
||||
function $t(e, { previewMethod: t = h.MFV_PREVIEW_METHOD, clientId: n = null } = {}) {
|
||||
function $t(e, { previewMethod: t = x.MFV_PREVIEW_METHOD, clientId: n = null } = {}) {
|
||||
let r = Qt(e, t), i = {
|
||||
prompt: r?.output,
|
||||
extra_data: r?.extra_data || {}
|
||||
@@ -1945,15 +1946,15 @@ function en(e) {
|
||||
}
|
||||
function tn(e) {
|
||||
let t = !1;
|
||||
return x(e, ({ node: e }) => {
|
||||
return b(e, ({ node: e }) => {
|
||||
t ||= en(e);
|
||||
}), t;
|
||||
}
|
||||
async function nn() {
|
||||
let e = o();
|
||||
let e = g();
|
||||
if (!e) throw Error("ComfyUI app not available");
|
||||
let t = tn(v(e));
|
||||
return { tracked: await i({
|
||||
let t = tn(_(e));
|
||||
return { tracked: await c({
|
||||
app: e,
|
||||
forceNativeQueue: t,
|
||||
resolvePromptData(e) {
|
||||
@@ -2083,7 +2084,7 @@ function dn(e) {
|
||||
let i = document.createElement("span");
|
||||
i.textContent = "Majoor Floating Viewer", n.append(r, i);
|
||||
let a = document.createElement("button");
|
||||
e._closeBtn = a, a.type = "button", a.className = "mjr-icon-btn mjr-mfv-close-btn", V(a, l("tooltip.closeViewer", "Close viewer"), "Esc");
|
||||
e._closeBtn = a, a.type = "button", a.className = "mjr-icon-btn mjr-mfv-close-btn", V(a, s("tooltip.closeViewer", "Close viewer"), "Esc");
|
||||
let o = document.createElement("i");
|
||||
return o.className = "pi pi-times", o.setAttribute("aria-hidden", "true"), a.appendChild(o), t.appendChild(n), t.appendChild(a), t;
|
||||
}
|
||||
@@ -2101,31 +2102,31 @@ function fn(e) {
|
||||
};
|
||||
}, n = document.createElement("div");
|
||||
n.className = "mjr-mfv-toolbar";
|
||||
let r = on("<i class=\"pi pi-image\" aria-hidden=\"true\"></i>", l("viewer.mode", "Viewer mode"), [
|
||||
let r = on("<i class=\"pi pi-image\" aria-hidden=\"true\"></i>", s("viewer.mode", "Viewer mode"), [
|
||||
{
|
||||
value: W.SIMPLE,
|
||||
html: `<i class="pi pi-image" aria-hidden="true"></i><span>${l("viewer.mode.simple", "Simple")}</span>`
|
||||
html: `<i class="pi pi-image" aria-hidden="true"></i><span>${s("viewer.mode.simple", "Simple")}</span>`
|
||||
},
|
||||
{
|
||||
value: W.AB,
|
||||
html: `<i class="pi pi-clone" aria-hidden="true"></i><span>${l("viewer.mode.abCompare", "A/B Compare")}</span>`
|
||||
html: `<i class="pi pi-clone" aria-hidden="true"></i><span>${s("viewer.mode.abCompare", "A/B Compare")}</span>`
|
||||
},
|
||||
{
|
||||
value: W.SIDE,
|
||||
html: `<i class="pi pi-table" aria-hidden="true"></i><span>${l("viewer.mode.sideBySide", "Side-by-side")}</span>`
|
||||
html: `<i class="pi pi-table" aria-hidden="true"></i><span>${s("viewer.mode.sideBySide", "Side-by-side")}</span>`
|
||||
},
|
||||
{
|
||||
value: W.GRID,
|
||||
html: `<i class="pi pi-th-large" aria-hidden="true"></i><span>${l("viewer.mode.grid", "Grid")}</span>`
|
||||
html: `<i class="pi pi-th-large" aria-hidden="true"></i><span>${s("viewer.mode.grid", "Grid")}</span>`
|
||||
},
|
||||
{
|
||||
value: W.GRAPH,
|
||||
html: `<i class="pi pi-sitemap" aria-hidden="true"></i><span>${l("workflowSidebar.graphMap", "Graph Map")}</span>`
|
||||
html: `<i class="pi pi-sitemap" aria-hidden="true"></i><span>${s("workflowSidebar.graphMap", "Graph Map")}</span>`
|
||||
}
|
||||
], e);
|
||||
e._modeDrop = r, e._modeBtn = r.trigger, e._modeSelect = r.select, e._updateModeBtnUI(), n.appendChild(r.wrap);
|
||||
let i = document.createElement("button");
|
||||
i.type = "button", i.className = "mjr-icon-btn mjr-mfv-pin-trigger", i.title = l("viewer.pinSlots", "Pin slots A/B/C/D"), i.setAttribute("aria-haspopup", "dialog"), i.setAttribute("aria-expanded", "false"), i.innerHTML = "<i class=\"pi pi-map-marker\" aria-hidden=\"true\"></i>", n.appendChild(i);
|
||||
i.type = "button", i.className = "mjr-icon-btn mjr-mfv-pin-trigger", i.title = s("viewer.pinSlots", "Pin slots A/B/C/D"), i.setAttribute("aria-haspopup", "dialog"), i.setAttribute("aria-expanded", "false"), i.innerHTML = "<i class=\"pi pi-map-marker\" aria-hidden=\"true\"></i>", n.appendChild(i);
|
||||
let a = document.createElement("div");
|
||||
a.className = "mjr-mfv-pin-popover", e.element.appendChild(a);
|
||||
let o = document.createElement("div");
|
||||
@@ -2144,19 +2145,19 @@ function fn(e) {
|
||||
i.className = "pi pi-map-marker mjr-menu-item-check", i.style.opacity = "0", n.appendChild(r), n.appendChild(i), o.appendChild(n), e._pinBtns[t] = n;
|
||||
}
|
||||
e._updatePinUI(), e._pinBtn = i, e._pinPopover = a;
|
||||
let s = () => {
|
||||
let c = () => {
|
||||
let t = i.getBoundingClientRect(), n = e.element.getBoundingClientRect();
|
||||
a.style.left = `${t.left - n.left}px`, a.style.top = `${t.bottom - n.top + 4}px`, a.classList.add("is-open"), i.setAttribute("aria-expanded", "true");
|
||||
};
|
||||
e._closePinPopover = () => {
|
||||
a.classList.remove("is-open"), i.setAttribute("aria-expanded", "false");
|
||||
}, i.addEventListener("click", (t) => {
|
||||
t.stopPropagation(), a.classList.contains("is-open") ? e._closePinPopover() : (e._closeAllToolbarPopovers?.(), s());
|
||||
t.stopPropagation(), a.classList.contains("is-open") ? e._closePinPopover() : (e._closeAllToolbarPopovers?.(), c());
|
||||
});
|
||||
let c = document.createElement("button");
|
||||
c.type = "button", c.className = "mjr-icon-btn mjr-mfv-guides-trigger", c.title = "Guides", c.setAttribute("aria-haspopup", "listbox"), c.setAttribute("aria-expanded", "false");
|
||||
let l = document.createElement("button");
|
||||
l.type = "button", l.className = "mjr-icon-btn mjr-mfv-guides-trigger", l.title = "Guides", l.setAttribute("aria-haspopup", "listbox"), l.setAttribute("aria-expanded", "false");
|
||||
let u = document.createElement("img");
|
||||
u.src = new URL("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKi0lEQVR4nO2d+28dxRXH58dSQIW2gIoqpP7YX4qqUtLS2A4xNIQCMQTHTlOgCZDEia/vtR3b+O3r2PEjNnbiujFuo6oFU6DQl1rRpqjlEWiiUlQU+Hu+1Vjfg45Wd2d87fXYjPZIR7u5dnbn7mfPzHnt2phccskll1xyySWXXHLJJZfIBcBXAbQAWADwCwBlALXbYFzfBHAQwAkARwDsBnC9iVn4Zf8L4BMA1wB8DOB/AD4CsALg9i0Y01cA9AG4COCXAJYBvADgAoBZADUmRgHQA+BTwrgC4C8A/gjg7wA+pP4NwK0Bx3QTL/pFWus8gLPUc7Riu603MQmA79AiPiEIe/f9HMAi1d6Z7wG4CuB8wHG1E8YSgNMARgAMAxiiTgCYAzAD4GsmFuEUYGH8qwKMn1EvAvg3gA8A3BFgTLfxRrCWMVYBxiCAAQCTBNJsYhEAl2khKykw7NRwHsAlAO/bxTXAmO4jkAUHjAF+bqewXhOLcPH+mBcgDcZ5rikWXluAMT3GBXzOAaOf/7ZWMmZiEeVNLTtgnAPwewDv2rk9wJj2cyqddcAQPQNg3MQiyrVddsCYB/AGgHcAlAIBucDpKA1GH3XcLvomFiGMj3hHpsGYB/A6gLcBFANNWdZapx0werm1HtioiUUI40MCSYMxB+B3AP4JoBBgTA0cy5QDhuioXWdMLEIY/6G/nwZjDsBrAN4KBGQfxzDhgPEct3bRHzSxCGFcpcubBuN5AK8C+AeAkwHG9AjPe8YBQ9ROaf0mFiGMKwSSBmMWwCuMRU4EGNNDPO+4A0YPtT+2OOQKo/BFB4xZAL9lbqslEJAZRulpMLq5Xf3cxCKE8T49rDQYMwBeBvAmgGMBxvQgXd5RBwxR+1m3iUUI4zKBpMGYAfASgL8GArKXEXjZAaOLavc7zXYTAN+m63pZpUMk6BPXVhZwmaYExrt0M9NgnAXwIrPBfwbwJwB/YPT+Ol3iVzmtrRDebwD8GsCvUuoZiyqFLueVc01yQR/xwDhlYSS2yf2kdtibitBv2SwYPSqFfm0dMN7hxUmDMc0LHBLGOKP0LGGI6mPcvRmVvk9VCn2FaesXqEtUuRCSr1pQet4DY5oqn01RJ6kTvIhyIce5IJ+mlpWOqAzuYIVIXFIisna4YHTyju9g/aSU0KJNhlILAFqVdqjj3ZkVjFtV2bVSccmVm3It4EkYUwrARAUAYwkAoykAXIlClzdVDYyiAlFQIE5ST1Bb+Hvd/N0vZAGkRZVdcxioCsZxaifPsXEr4QW/Ru8nh4GqYRxTVrInCyDLXMBt8SiHgaphHOPv2nM9nAWQMr0pG0HnMFA1jKM8hj1vXRZAapVba13LHAaqgnFMnfPrGwZCKCsE8h59/q2EMfw5gnGc57DnbMoEBoHczia2qwz4LiWCtzdUAPcag7hXEoHciyq+SIMxn4hlxBJ1cCeg5VhyHIE5quAJtAEFSeB0rRFGkZ/Lz9tT4hH5P7LfoW4CC+aGzIAQyi28OB9UiMDfZqXvLdYzLnHNeZPemaRDZjyWccERgS9VGYGLdUlwuJpCl0RhIgJ3WUZbSgSuVaxOJyQFui2EXZcpjASYO9iULAuVDCr5xbSZSzrkrGeaWiQMexEPAfgxz9UM4ACARjYn2Hr4oyzD7mOx6UdUm8V9wLqYAO5n/1U9gHsB7KLW8LhiIa5pSgPZxePcmzie/mw3j/8tAF8y21HU9DbtWTMWaRlNAcZ0j7IQ15pREMswsYhaa6Y9C/gCp6nGAGP6gcrMuhbwVpkFTCyisrZTHm/qHNeM/YGAyDTr8qaiBCIe2JTHtZ3nAr4/4JTV4XFtT8YIROoZk544Y47p/McCT1muOCNKIFJcmvAEfc/TtW0ICKTdE/SdEJfWxCIqQJzwpENm6WntCzCmnQkgaRF4i8QZJhZR0fq4Jx1ylp7WIwGBdHjSIS0xWojUwMc9ualpeloPBbYQV27quETfJhZR6ZAxT6Jwigt7SCAlT6IwSiCSmzrtydpOcGF/MLCFuLK2kj7vMbGIShSe9qTQzzBRuDcQkC5aiCuFflSytiYWUVnbUU89Y5wL+wMBxlSjgKTBeJbb6CxEUuhlT3FpjMHjnsAWkgZDdLUL3sQiqp5R9lT6Rjlt3R/YQtJgPKOmrKiASHFpxFN2LXPaui8wkDQYzygLiepxBKn0jXhq4CNc+OsDAelmYjENxtPcrpZ9TSyiyq7DnoaEIU5buwOMqVZZSBqMI9yPzkKkBj7k6Q4ZJLRdgYCIhaTBOML91c4VE4uohoQhT6vOAKHUbQGQSjAOxwpEukMGPX1Tffx5beA1JA3GYe6vPh5tYhHVqjPgaWLrpdYEspAeFqfSYPyU29VxmVhE9U31ezoKn6PuDDCmOmUhaTCeihWINLH1edo7e7h/T0ALKTpgPMX97QuEb2KrVw1qD/OZb2lS28smtR+qRjXpKOzz9Np2sRPENsTdTd2R2Bf9XgXVn3+fjQyyTepBNWWlwXhSWwjXnZ3c1hBqndqK2ua5uwDcvNkvjGxnXmq97Z29m/yAZdca2jtl26OApMF4UllIX0L7Ey68xFeDSofYdZl5X++XE2/vXFhn4/NaHrDsrNAErXU9jc+6cVo/qFlkpjcNxhNU/VCnaPLfSe1UcZU9541ZApH32i7xQn+eHgl41hNnuGD8hCp9xtJrLP3GTdQDqvf4cep+/j95m9DhLN/4LJZh80w5DKwJhjSFN/N62VniG1kAOcg1w3YV5jBQFYxHqW2cujdeeOMXXmZHSA4DVcNo4JjKmXT180ssqZdF5jBQFYwGjrWcSYssfeoLKlubw0BVMBp4PazHuSMLINfTvV1Q7yfMYWBNMPZxnKO8Vl/cMBBCqVFB32Tiub3ki1z0q/G61wGjrcKLXDSUlgpQjlaA8nQCShKMwHliDTCqBdTI47UThtXvZgJDQalPPOa8ngcsfTDaM4zA5cbQL7PsraCFNcCoFIEPKa9T3NqRxFuJJDDenHKC/ZMNHGgvA8Qsn3Yt8OchYfTxvK5p6uA6YAzzO1hLuc1sN1FQfGtGO38vRD1kN4G0eqakz4CYWETd9b4FvEQL2BkQSMGzPjSLZZhYRE1Z7R5vqp1AarYASNpi3STTlIlF1JRV8ri2pcBA+jkGl+ckQIZjnbJccUYpMJABjsHlxh6QxdtECKToCfpKBBKi66SeFtLqiSmiBtLmicBLdGFrA09Zrgi8UVxbEykQVzqkFNhCBhSQtHRIo8QZJkIgBU9uqkgLqQkMxJWbEiBlEyGQVk+isBRwyqpnfNHqSRQ+LhG4iUVUSqTVk7UtEUiIwHAXgRQ8WdtGSYeYWIQXu0sBSUuhdzAHdWeAMd1FIJ2eFPohAjllYhE2z3XxgrteGNlDC7kpwJhuVgW3Q456hqTQN/0dXsGE72uUDG4x5VWqpwijIeC4pCOkl/vJSp8Ul8rbMmu7EWEr6Gd/HEUFibJudPMCXBdwTDfw/JJGL/IGaVFl19EQTsaWiF0bGItUqmc0Zd5yubYx3cgqYqXiUn/mlb7tJvZPNhDMHs7RdZm98XkDYpvY+DbTZq4nOzKrgeeSSy655JJLLrnkkksuuZjtK/8HkUQ57PG6Io0AAAAASUVORK5CYII=", "" + import.meta.url).href, u.className = "mjr-mfv-guides-icon", u.alt = "", u.setAttribute("aria-hidden", "true"), c.appendChild(u), n.appendChild(c);
|
||||
u.src = new URL("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKi0lEQVR4nO2d+28dxRXH58dSQIW2gIoqpP7YX4qqUtLS2A4xNIQCMQTHTlOgCZDEia/vtR3b+O3r2PEjNnbiujFuo6oFU6DQl1rRpqjlEWiiUlQU+Hu+1Vjfg45Wd2d87fXYjPZIR7u5dnbn7mfPzHnt2phccskll1xyySWXXHLJJZfIBcBXAbQAWADwCwBlALXbYFzfBHAQwAkARwDsBnC9iVn4Zf8L4BMA1wB8DOB/AD4CsALg9i0Y01cA9AG4COCXAJYBvADgAoBZADUmRgHQA+BTwrgC4C8A/gjg7wA+pP4NwK0Bx3QTL/pFWus8gLPUc7Riu603MQmA79AiPiEIe/f9HMAi1d6Z7wG4CuB8wHG1E8YSgNMARgAMAxiiTgCYAzAD4GsmFuEUYGH8qwKMn1EvAvg3gA8A3BFgTLfxRrCWMVYBxiCAAQCTBNJsYhEAl2khKykw7NRwHsAlAO/bxTXAmO4jkAUHjAF+bqewXhOLcPH+mBcgDcZ5rikWXluAMT3GBXzOAaOf/7ZWMmZiEeVNLTtgnAPwewDv2rk9wJj2cyqddcAQPQNg3MQiyrVddsCYB/AGgHcAlAIBucDpKA1GH3XcLvomFiGMj3hHpsGYB/A6gLcBFANNWdZapx0werm1HtioiUUI40MCSYMxB+B3AP4JoBBgTA0cy5QDhuioXWdMLEIY/6G/nwZjDsBrAN4KBGQfxzDhgPEct3bRHzSxCGFcpcubBuN5AK8C+AeAkwHG9AjPe8YBQ9ROaf0mFiGMKwSSBmMWwCuMRU4EGNNDPO+4A0YPtT+2OOQKo/BFB4xZAL9lbqslEJAZRulpMLq5Xf3cxCKE8T49rDQYMwBeBvAmgGMBxvQgXd5RBwxR+1m3iUUI4zKBpMGYAfASgL8GArKXEXjZAaOLavc7zXYTAN+m63pZpUMk6BPXVhZwmaYExrt0M9NgnAXwIrPBfwbwJwB/YPT+Ol3iVzmtrRDebwD8GsCvUuoZiyqFLueVc01yQR/xwDhlYSS2yf2kdtibitBv2SwYPSqFfm0dMN7hxUmDMc0LHBLGOKP0LGGI6mPcvRmVvk9VCn2FaesXqEtUuRCSr1pQet4DY5oqn01RJ6kTvIhyIce5IJ+mlpWOqAzuYIVIXFIisna4YHTyju9g/aSU0KJNhlILAFqVdqjj3ZkVjFtV2bVSccmVm3It4EkYUwrARAUAYwkAoykAXIlClzdVDYyiAlFQIE5ST1Bb+Hvd/N0vZAGkRZVdcxioCsZxaifPsXEr4QW/Ru8nh4GqYRxTVrInCyDLXMBt8SiHgaphHOPv2nM9nAWQMr0pG0HnMFA1jKM8hj1vXRZAapVba13LHAaqgnFMnfPrGwZCKCsE8h59/q2EMfw5gnGc57DnbMoEBoHczia2qwz4LiWCtzdUAPcag7hXEoHciyq+SIMxn4hlxBJ1cCeg5VhyHIE5quAJtAEFSeB0rRFGkZ/Lz9tT4hH5P7LfoW4CC+aGzIAQyi28OB9UiMDfZqXvLdYzLnHNeZPemaRDZjyWccERgS9VGYGLdUlwuJpCl0RhIgJ3WUZbSgSuVaxOJyQFui2EXZcpjASYO9iULAuVDCr5xbSZSzrkrGeaWiQMexEPAfgxz9UM4ACARjYn2Hr4oyzD7mOx6UdUm8V9wLqYAO5n/1U9gHsB7KLW8LhiIa5pSgPZxePcmzie/mw3j/8tAF8y21HU9DbtWTMWaRlNAcZ0j7IQ15pREMswsYhaa6Y9C/gCp6nGAGP6gcrMuhbwVpkFTCyisrZTHm/qHNeM/YGAyDTr8qaiBCIe2JTHtZ3nAr4/4JTV4XFtT8YIROoZk544Y47p/McCT1muOCNKIFJcmvAEfc/TtW0ICKTdE/SdEJfWxCIqQJzwpENm6WntCzCmnQkgaRF4i8QZJhZR0fq4Jx1ylp7WIwGBdHjSIS0xWojUwMc9ualpeloPBbYQV27quETfJhZR6ZAxT6Jwigt7SCAlT6IwSiCSmzrtydpOcGF/MLCFuLK2kj7vMbGIShSe9qTQzzBRuDcQkC5aiCuFflSytiYWUVnbUU89Y5wL+wMBxlSjgKTBeJbb6CxEUuhlT3FpjMHjnsAWkgZDdLUL3sQiqp5R9lT6Rjlt3R/YQtJgPKOmrKiASHFpxFN2LXPaui8wkDQYzygLiepxBKn0jXhq4CNc+OsDAelmYjENxtPcrpZ9TSyiyq7DnoaEIU5buwOMqVZZSBqMI9yPzkKkBj7k6Q4ZJLRdgYCIhaTBOML91c4VE4uohoQhT6vOAKHUbQGQSjAOxwpEukMGPX1Tffx5beA1JA3GYe6vPh5tYhHVqjPgaWLrpdYEspAeFqfSYPyU29VxmVhE9U31ezoKn6PuDDCmOmUhaTCeihWINLH1edo7e7h/T0ALKTpgPMX97QuEb2KrVw1qD/OZb2lS28smtR+qRjXpKOzz9Np2sRPENsTdTd2R2Bf9XgXVn3+fjQyyTepBNWWlwXhSWwjXnZ3c1hBqndqK2ua5uwDcvNkvjGxnXmq97Z29m/yAZdca2jtl26OApMF4UllIX0L7Ey68xFeDSofYdZl5X++XE2/vXFhn4/NaHrDsrNAErXU9jc+6cVo/qFlkpjcNxhNU/VCnaPLfSe1UcZU9541ZApH32i7xQn+eHgl41hNnuGD8hCp9xtJrLP3GTdQDqvf4cep+/j95m9DhLN/4LJZh80w5DKwJhjSFN/N62VniG1kAOcg1w3YV5jBQFYxHqW2cujdeeOMXXmZHSA4DVcNo4JjKmXT180ssqZdF5jBQFYwGjrWcSYssfeoLKlubw0BVMBp4PazHuSMLINfTvV1Q7yfMYWBNMPZxnKO8Vl/cMBBCqVFB32Tiub3ki1z0q/G61wGjrcKLXDSUlgpQjlaA8nQCShKMwHliDTCqBdTI47UThtXvZgJDQalPPOa8ngcsfTDaM4zA5cbQL7PsraCFNcCoFIEPKa9T3NqRxFuJJDDenHKC/ZMNHGgvA8Qsn3Yt8OchYfTxvK5p6uA6YAzzO1hLuc1sN1FQfGtGO38vRD1kN4G0eqakz4CYWETd9b4FvEQL2BkQSMGzPjSLZZhYRE1Z7R5vqp1AarYASNpi3STTlIlF1JRV8ri2pcBA+jkGl+ckQIZjnbJccUYpMJABjsHlxh6QxdtECKToCfpKBBKi66SeFtLqiSmiBtLmicBLdGFrA09Zrgi8UVxbEykQVzqkFNhCBhSQtHRIo8QZJkIgBU9uqkgLqQkMxJWbEiBlEyGQVk+isBRwyqpnfNHqSRQ+LhG4iUVUSqTVk7UtEUiIwHAXgRQ8WdtGSYeYWIQXu0sBSUuhdzAHdWeAMd1FIJ2eFPohAjllYhE2z3XxgrteGNlDC7kpwJhuVgW3Q456hqTQN/0dXsGE72uUDG4x5VWqpwijIeC4pCOkl/vJSp8Ul8rbMmu7EWEr6Gd/HEUFibJudPMCXBdwTDfw/JJGL/IGaVFl19EQTsaWiF0bGItUqmc0Zd5yubYx3cgqYqXiUn/mlb7tJvZPNhDMHs7RdZm98XkDYpvY+DbTZq4nOzKrgeeSSy655JJLLrnkkksuuZjtK/8HkUQ57PG6Io0AAAAASUVORK5CYII=", "" + import.meta.url).href, u.className = "mjr-mfv-guides-icon", u.alt = "", u.setAttribute("aria-hidden", "true"), l.appendChild(u), n.appendChild(l);
|
||||
let d = document.createElement("div");
|
||||
d.className = "mjr-mfv-guides-popover", e.element.appendChild(d);
|
||||
let f = document.createElement("div");
|
||||
@@ -2191,14 +2192,14 @@ function fn(e) {
|
||||
let i = document.createElement("i");
|
||||
i.className = "pi pi-check mjr-menu-item-check", i.style.opacity = e.value === h ? "1" : "0", n.appendChild(r), n.appendChild(i), f.appendChild(n), g.push(n), e.value === h && n.classList.add("is-active");
|
||||
}
|
||||
p.value = h, c.classList.toggle("is-on", h !== "0"), e._guidesSelect = p, e._guideBtn = c, e._guidePopover = d;
|
||||
p.value = h, l.classList.toggle("is-on", h !== "0"), e._guidesSelect = p, e._guideBtn = l, e._guidePopover = d;
|
||||
let _ = () => {
|
||||
let t = c.getBoundingClientRect(), n = e.element.getBoundingClientRect();
|
||||
d.style.left = `${t.left - n.left}px`, d.style.top = `${t.bottom - n.top + 4}px`, d.classList.add("is-open"), c.setAttribute("aria-expanded", "true");
|
||||
let t = l.getBoundingClientRect(), n = e.element.getBoundingClientRect();
|
||||
d.style.left = `${t.left - n.left}px`, d.style.top = `${t.bottom - n.top + 4}px`, d.classList.add("is-open"), l.setAttribute("aria-expanded", "true");
|
||||
};
|
||||
e._closeGuidePopover = () => {
|
||||
d.classList.remove("is-open"), c.setAttribute("aria-expanded", "false");
|
||||
}, c.addEventListener("click", (t) => {
|
||||
d.classList.remove("is-open"), l.setAttribute("aria-expanded", "false");
|
||||
}, l.addEventListener("click", (t) => {
|
||||
t.stopPropagation(), d.classList.contains("is-open") ? e._closeGuidePopover() : (e._closeAllToolbarPopovers?.(), _());
|
||||
}), f.addEventListener("click", (t) => {
|
||||
let n = t.target.closest(".mjr-menu-item");
|
||||
@@ -2207,7 +2208,7 @@ function fn(e) {
|
||||
p.value = r ?? "", p.dispatchEvent(new Event("change", { bubbles: !0 })), g.forEach((e) => {
|
||||
let t = e.dataset.value === r;
|
||||
e.classList.toggle("is-active", t), e.querySelector(".mjr-menu-item-check").style.opacity = t ? "1" : "0";
|
||||
}), c.classList.toggle("is-on", r !== "0"), e._closeGuidePopover();
|
||||
}), l.classList.toggle("is-on", r !== "0"), e._closeGuidePopover();
|
||||
});
|
||||
let v = String(e._channel || "rgb"), y = document.createElement("button");
|
||||
y.type = "button", y.className = "mjr-icon-btn mjr-mfv-ch-trigger", y.title = "Channel", y.setAttribute("aria-haspopup", "listbox"), y.setAttribute("aria-expanded", "false");
|
||||
@@ -2376,24 +2377,24 @@ function fn(e) {
|
||||
}), e._closeFormatPopover();
|
||||
});
|
||||
let L = document.createElement("div");
|
||||
L.className = "mjr-mfv-toolbar-sep", L.setAttribute("aria-hidden", "true"), n.appendChild(L), e._liveBtn = document.createElement("button"), e._liveBtn.type = "button", e._liveBtn.className = "mjr-icon-btn", e._liveBtn.innerHTML = "<i class=\"pi pi-circle\" aria-hidden=\"true\"></i>", e._liveBtn.setAttribute("aria-pressed", "false"), V(e._liveBtn, l("tooltip.liveStreamOff", "Live Stream: OFF - click to follow final generation outputs"), "L"), n.appendChild(e._liveBtn), e._previewBtn = document.createElement("button"), e._previewBtn.type = "button", e._previewBtn.className = "mjr-icon-btn", e._previewBtn.innerHTML = "<i class=\"pi pi-eye\" aria-hidden=\"true\"></i>", e._previewBtn.setAttribute("aria-pressed", "false"), V(e._previewBtn, l("tooltip.previewStreamOff", "KSampler Preview: OFF - click to stream sampler denoising frames"), "K"), n.appendChild(e._previewBtn), e._nodeStreamBtn = document.createElement("button"), e._nodeStreamBtn.type = "button", e._nodeStreamBtn.className = "mjr-icon-btn", e._nodeStreamBtn.innerHTML = "<i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>", e._nodeStreamBtn.setAttribute("aria-pressed", "false"), V(e._nodeStreamBtn, l("tooltip.nodeStreamOff", "Node Stream: OFF - click to follow selected node previews, including ImageOps live canvases"), "N"), n.appendChild(e._nodeStreamBtn), e._genBtn = document.createElement("button"), e._genBtn.type = "button", e._genBtn.className = "mjr-icon-btn", e._genBtn.setAttribute("aria-pressed", String(!!e._genSidebarEnabled));
|
||||
L.className = "mjr-mfv-toolbar-sep", L.setAttribute("aria-hidden", "true"), n.appendChild(L), e._liveBtn = document.createElement("button"), e._liveBtn.type = "button", e._liveBtn.className = "mjr-icon-btn", e._liveBtn.innerHTML = "<i class=\"pi pi-circle\" aria-hidden=\"true\"></i>", e._liveBtn.setAttribute("aria-pressed", "false"), V(e._liveBtn, s("tooltip.liveStreamOff", "Live Stream: OFF - click to follow final generation outputs"), "L"), n.appendChild(e._liveBtn), e._previewBtn = document.createElement("button"), e._previewBtn.type = "button", e._previewBtn.className = "mjr-icon-btn", e._previewBtn.innerHTML = "<i class=\"pi pi-eye\" aria-hidden=\"true\"></i>", e._previewBtn.setAttribute("aria-pressed", "false"), V(e._previewBtn, s("tooltip.previewStreamOff", "KSampler Preview: OFF - click to stream sampler denoising frames"), "K"), n.appendChild(e._previewBtn), e._nodeStreamBtn = document.createElement("button"), e._nodeStreamBtn.type = "button", e._nodeStreamBtn.className = "mjr-icon-btn", e._nodeStreamBtn.innerHTML = "<i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>", e._nodeStreamBtn.setAttribute("aria-pressed", "false"), V(e._nodeStreamBtn, s("tooltip.nodeStreamOff", "Node Stream: OFF - click to follow selected node previews, including ImageOps live canvases"), "N"), n.appendChild(e._nodeStreamBtn), e._genBtn = document.createElement("button"), e._genBtn.type = "button", e._genBtn.className = "mjr-icon-btn", e._genBtn.setAttribute("aria-pressed", String(!!e._genSidebarEnabled));
|
||||
let R = document.createElement("i");
|
||||
R.className = "pi pi-info-circle", R.setAttribute("aria-hidden", "true"), e._genBtn.appendChild(R), n.appendChild(e._genBtn), e._updateGenBtnUI(), e._popoutBtn = document.createElement("button"), e._popoutBtn.type = "button", e._popoutBtn.className = "mjr-icon-btn";
|
||||
let z = l("tooltip.popOutViewer", "Pop out viewer to separate window");
|
||||
let z = s("tooltip.popOutViewer", "Pop out viewer to separate window");
|
||||
e._popoutBtn.title = z, e._popoutBtn.setAttribute("aria-label", z), e._popoutBtn.setAttribute("aria-pressed", "false");
|
||||
let B = document.createElement("i");
|
||||
B.className = "pi pi-external-link", B.setAttribute("aria-hidden", "true"), e._popoutBtn.appendChild(B), n.appendChild(e._popoutBtn), e._captureBtn = document.createElement("button"), e._captureBtn.type = "button", e._captureBtn.className = "mjr-icon-btn";
|
||||
let te = l("tooltip.captureView", "Save view as image");
|
||||
let te = s("tooltip.captureView", "Save view as image");
|
||||
e._captureBtn.title = te, e._captureBtn.setAttribute("aria-label", te);
|
||||
let ne = document.createElement("i");
|
||||
ne.className = "pi pi-download", ne.setAttribute("aria-hidden", "true"), e._captureBtn.appendChild(ne), n.appendChild(e._captureBtn);
|
||||
let H = document.createElement("div");
|
||||
H.className = "mjr-mfv-toolbar-sep", H.style.marginLeft = "auto", H.setAttribute("aria-hidden", "true"), n.appendChild(H), e._settingsBtn = document.createElement("button"), e._settingsBtn.type = "button", e._settingsBtn.className = "mjr-icon-btn mjr-mfv-settings-btn";
|
||||
let re = l("tooltip.nodeParams", "Node Parameters");
|
||||
let re = s("tooltip.nodeParams", "Node Parameters");
|
||||
e._settingsBtn.title = re, e._settingsBtn.setAttribute("aria-label", re), e._settingsBtn.setAttribute("aria-pressed", "false");
|
||||
let ie = document.createElement("i");
|
||||
ie.className = "pi pi-sliders-h", ie.setAttribute("aria-hidden", "true"), e._settingsBtn.appendChild(ie), n.appendChild(e._settingsBtn), e._runHandle = Xt(), n.appendChild(e._runHandle.el), e._majoorSettingsBtn = document.createElement("button"), e._majoorSettingsBtn.type = "button", e._majoorSettingsBtn.className = "mjr-icon-btn mjr-mfv-majoor-settings-btn";
|
||||
let U = l("tooltip.openMajoorSettings", "Open Majoor Assets Manager settings");
|
||||
let U = s("tooltip.openMajoorSettings", "Open Majoor Assets Manager settings");
|
||||
e._majoorSettingsBtn.title = U, e._majoorSettingsBtn.setAttribute("aria-label", U);
|
||||
let ae = document.createElement("i");
|
||||
return ae.className = "pi pi-cog", ae.setAttribute("aria-hidden", "true"), e._majoorSettingsBtn.appendChild(ae), n.appendChild(e._majoorSettingsBtn), e._handleDocClick = (t) => {
|
||||
@@ -2455,7 +2456,7 @@ function mn(e, t) {
|
||||
}
|
||||
function hn(e) {
|
||||
if (!e._contentWrapper) return;
|
||||
let t = h.MFV_SIDEBAR_POSITION || "right";
|
||||
let t = x.MFV_SIDEBAR_POSITION || "right";
|
||||
e._contentWrapper.setAttribute("data-sidebar-pos", t), e._sidebar?.el && e._contentEl && (t === "left" ? e._contentWrapper.insertBefore(e._sidebar.el, e._contentEl) : e._contentWrapper.appendChild(e._sidebar.el));
|
||||
}
|
||||
function gn(e) {
|
||||
@@ -2645,7 +2646,7 @@ function kn(e, t) {
|
||||
if (!e._liveBtn) return;
|
||||
let n = !!t;
|
||||
e._liveBtn.classList.toggle("mjr-live-active", n);
|
||||
let r = n ? l("tooltip.liveStreamOn", "Live Stream: ON - follows final generation outputs after execution") : l("tooltip.liveStreamOff", "Live Stream: OFF - click to follow final generation outputs"), i = U(r, "L");
|
||||
let r = n ? s("tooltip.liveStreamOn", "Live Stream: ON - follows final generation outputs after execution") : s("tooltip.liveStreamOff", "Live Stream: OFF - click to follow final generation outputs"), i = U(r, "L");
|
||||
e._liveBtn.setAttribute("aria-pressed", String(n)), e._liveBtn.setAttribute("aria-label", i);
|
||||
let a = document.createElement("i");
|
||||
a.className = n ? "pi pi-circle-fill" : "pi pi-circle", a.setAttribute("aria-hidden", "true"), e._liveBtn.replaceChildren(a), e._liveBtn.title = i;
|
||||
@@ -2653,7 +2654,7 @@ function kn(e, t) {
|
||||
function An(e, t) {
|
||||
if (e._previewActive = !!t, !e._previewBtn) return;
|
||||
e._previewBtn.classList.toggle("mjr-preview-active", e._previewActive);
|
||||
let n = e._previewActive ? l("tooltip.previewStreamOn", "KSampler Preview: ON - streams sampler denoising frames during execution") : l("tooltip.previewStreamOff", "KSampler Preview: OFF - click to stream sampler denoising frames"), r = U(n, "K");
|
||||
let n = e._previewActive ? s("tooltip.previewStreamOn", "KSampler Preview: ON - streams sampler denoising frames during execution") : s("tooltip.previewStreamOff", "KSampler Preview: OFF - click to stream sampler denoising frames"), r = U(n, "K");
|
||||
e._previewBtn.setAttribute("aria-pressed", String(e._previewActive)), e._previewBtn.setAttribute("aria-label", r);
|
||||
let i = document.createElement("i");
|
||||
i.className = e._previewActive ? "pi pi-eye" : "pi pi-eye-slash", i.setAttribute("aria-hidden", "true"), e._previewBtn.replaceChildren(i), e._previewBtn.title = r;
|
||||
@@ -2730,7 +2731,7 @@ function Pn(e) {
|
||||
function Fn(e, t) {
|
||||
if (e._nodeStreamActive = !!t, e._nodeStreamActive || e.setNodeStreamSelection?.(null), !e._nodeStreamBtn) return;
|
||||
e._nodeStreamBtn.classList.toggle("mjr-nodestream-active", e._nodeStreamActive);
|
||||
let n = e._nodeStreamActive ? l("tooltip.nodeStreamOn", "Node Stream: ON - follows the selected node preview when frontend media exists") : l("tooltip.nodeStreamOff", "Node Stream: OFF - click to follow selected node previews, including ImageOps live canvases"), r = U(n, "N");
|
||||
let n = e._nodeStreamActive ? s("tooltip.nodeStreamOn", "Node Stream: ON - follows the selected node preview when frontend media exists") : s("tooltip.nodeStreamOff", "Node Stream: OFF - click to follow selected node previews, including ImageOps live canvases"), r = U(n, "N");
|
||||
e._nodeStreamBtn.setAttribute("aria-pressed", String(e._nodeStreamActive)), e._nodeStreamBtn.setAttribute("aria-label", r);
|
||||
let i = document.createElement("i");
|
||||
i.className = "pi pi-sitemap", i.setAttribute("aria-hidden", "true"), e._nodeStreamBtn.replaceChildren(i), e._nodeStreamBtn.title = r;
|
||||
@@ -2841,7 +2842,7 @@ function Wn(e, t) {
|
||||
function Gn(e, t) {
|
||||
e._desktopPopoutUnsupported = !0, $("electron-in-app-fallback", { message: t?.message || String(t || "unknown error") }, "warn"), e._setDesktopExpanded(!0);
|
||||
try {
|
||||
r(l("toast.popoutElectronInAppFallback", "Desktop PiP is unavailable here. Viewer expanded inside the app instead."), "warning", 4500);
|
||||
r(s("toast.popoutElectronInAppFallback", "Desktop PiP is unavailable here. Viewer expanded inside the app instead."), "warning", 4500);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
@@ -3055,7 +3056,7 @@ function er(e) {
|
||||
if (!e._popoutBtn) return;
|
||||
let t = e._isPopped || e._desktopExpanded;
|
||||
e.element && e.element.classList.toggle("mjr-mfv--popped", t), e._popoutBtn.classList.toggle("mjr-popin-active", t);
|
||||
let n = e._popoutBtn.querySelector("i") || document.createElement("i"), r = t ? l("tooltip.popInViewer", "Return to floating panel") : l("tooltip.popOutViewer", "Pop out viewer to separate window");
|
||||
let n = e._popoutBtn.querySelector("i") || document.createElement("i"), r = t ? s("tooltip.popInViewer", "Return to floating panel") : s("tooltip.popOutViewer", "Pop out viewer to separate window");
|
||||
n.className = t ? "pi pi-sign-in" : "pi pi-external-link", e._popoutBtn.title = r, e._popoutBtn.setAttribute("aria-label", r), e._popoutBtn.setAttribute("aria-pressed", String(t)), e._popoutBtn.contains(n) || e._popoutBtn.replaceChildren(n);
|
||||
}
|
||||
// ui/features/viewer/floatingViewerPanel.ts
|
||||
@@ -3315,7 +3316,7 @@ function lr(e, t, n, r, i, a, o) {
|
||||
}
|
||||
async function ur(e) {
|
||||
if (!e._contentEl) return;
|
||||
e._captureBtn && (e._captureBtn.disabled = !0, e._captureBtn.setAttribute("aria-label", l("tooltip.capturingView", "Capturing...")));
|
||||
e._captureBtn && (e._captureBtn.disabled = !0, e._captureBtn.setAttribute("aria-label", s("tooltip.capturingView", "Capturing...")));
|
||||
let t = e._contentEl.clientWidth || 480, n = e._contentEl.clientHeight || 360, r = n;
|
||||
if (e._mode === W.SIMPLE && e._mediaA && e._genInfoSelections.size) {
|
||||
let i = document.createElement("canvas");
|
||||
@@ -3393,7 +3394,7 @@ async function ur(e) {
|
||||
} catch (e) {
|
||||
console.warn("[MFV] download failed:", e);
|
||||
} finally {
|
||||
e._captureBtn && (e._captureBtn.disabled = !1, e._captureBtn.setAttribute("aria-label", l("tooltip.captureView", "Save view as image")));
|
||||
e._captureBtn && (e._captureBtn.disabled = !1, e._captureBtn.setAttribute("aria-label", s("tooltip.captureView", "Save view as image")));
|
||||
}
|
||||
}
|
||||
// ui/features/viewer/floatingViewerLoader.ts
|
||||
@@ -3408,8 +3409,8 @@ function pr(e, t, { autoMode: n = !1 } = {}) {
|
||||
(async () => {
|
||||
try {
|
||||
let n = await fe(e._mediaA, {
|
||||
getAssetMetadata: m,
|
||||
getFileMetadataScoped: g
|
||||
getAssetMetadata: f,
|
||||
getFileMetadataScoped: u
|
||||
});
|
||||
if (e._refreshGen !== t) return;
|
||||
n && typeof n == "object" && (e._mediaA = n, e._refresh());
|
||||
@@ -3426,8 +3427,8 @@ function mr(e, t, n) {
|
||||
if (!e) return e;
|
||||
try {
|
||||
return await fe(e, {
|
||||
getAssetMetadata: m,
|
||||
getFileMetadataScoped: g
|
||||
getAssetMetadata: f,
|
||||
getFileMetadataScoped: u
|
||||
}) || e;
|
||||
} catch {
|
||||
return e;
|
||||
@@ -3450,8 +3451,8 @@ function hr(e, t, n, r, i) {
|
||||
if (!e) return e;
|
||||
try {
|
||||
return await fe(e, {
|
||||
getAssetMetadata: m,
|
||||
getFileMetadataScoped: g
|
||||
getAssetMetadata: f,
|
||||
getFileMetadataScoped: u
|
||||
}) || e;
|
||||
} catch {
|
||||
return e;
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
import { Hn as e, t } from "./config-D98p5fVn.js";
|
||||
import { t as n } from "./floatingViewerManager-DbOx9dhE.js";
|
||||
import { Vn as e } from "./client-BVwNDRIx.js";
|
||||
import { t } from "./config-BFDzOF1N.js";
|
||||
import { t as n } from "./floatingViewerManager-Dn53ZRsL.js";
|
||||
import { r } from "./events-1iwo5blr.js";
|
||||
// ui/features/viewer/LiveStreamTracker.ts
|
||||
var i = !1, a = null, o = null, s = null, c = null, l = null, u = null, d = null, f = null, p = 0, m = 0, h = !1, g = 400, _ = "kj_preview_override", v = /* @__PURE__ */ new Set([
|
||||
+408
-408
File diff suppressed because it is too large
Load Diff
+22
-22
@@ -1,9 +1,9 @@
|
||||
import { Gt as e, T as t, Xn as n, an as r, ar as i, f as a, s as o, u as s } from "./config-D98p5fVn.js";
|
||||
import { a as c, i as l, r as u } from "./floatingViewerManager-DbOx9dhE.js";
|
||||
import { Wt as e, Yn as t, d as n, in as r, ir as i, l as a, o, w as s } from "./client-BVwNDRIx.js";
|
||||
import { a as c, i as l, r as u } from "./floatingViewerManager-Dn53ZRsL.js";
|
||||
import { i as d, r as f, t as p } from "./events-1iwo5blr.js";
|
||||
import { a as ee, i as m, n as h, r as g, t as _ } from "./TagsEditor-C-qX3Ns8.js";
|
||||
import { $ as te, A as v, B as ne, C as y, D as b, E as x, H as S, J as re, K as C, M as w, O as T, P as E, Q as D, S as O, T as k, U as A, V as ie, W as j, X as M, Y as N, b as ae, gt as P, ht as F, j as I, k as L, lt as oe, mt as R, ot as se, pt as z, q as B, rt as ce, st as V, w as le, z as H } from "./mjr-primevue-C07IsEdU.js";
|
||||
import { C as U, _ as W, a as ue, b as de, c as G, d as fe, f as pe, g as me, h as he, i as ge, l as _e, m as ve, n as ye, o as K, p as be, r as xe, s as q, t as Se, u as Ce, v as we, x as Te, y as Ee } from "./genInfo-BlwZQHxb.js";
|
||||
import { a as ee, i as m, n as h, r as g, t as _ } from "./TagsEditor-BxqU2WEs.js";
|
||||
import { $ as te, A as v, B as ne, C as y, D as b, E as x, H as S, J as re, K as C, M as w, O as T, P as E, Q as D, S as O, T as k, U as A, V as ie, W as j, X as M, Y as N, b as ae, gt as P, ht as F, j as I, k as L, lt as oe, mt as R, ot as se, pt as z, q as B, rt as ce, st as V, w as le, z as H } from "./mjr-primevue-CE8VjDUg.js";
|
||||
import { C as U, _ as W, a as ue, b as de, c as G, d as fe, f as pe, g as me, h as he, i as ge, l as _e, m as ve, n as ye, o as K, p as be, r as xe, s as q, t as Se, u as Ce, v as we, x as Te, y as Ee } from "./genInfo-BQayEE-N.js";
|
||||
// ui/vue/components/viewer/FloatingViewerHost.vue
|
||||
var De = /* @__PURE__ */ E({
|
||||
__name: "FloatingViewerHost",
|
||||
@@ -1376,7 +1376,7 @@ var Re = {
|
||||
}, _n = /* @__PURE__ */ E({
|
||||
__name: "StudioViewer",
|
||||
setup(c) {
|
||||
let l = V(null), u = V(null), f = V(null), te = V(null), v = V(!0), S = V("generation"), E = V(!1), A = V(1), N = oe(null), ae = oe(null), F = null, B = k(() => Q.assets[Q.index] ?? null), H = k(() => N.value ?? B.value), U = k(() => K(B.value)), W = k(() => B.value ? n(B.value) : ""), G = k(() => Q.assets.length), fe = k(() => xe(H.value)), pe = k(() => ue(H.value, ae.value)), me = k(() => Number(ae.value?.frame_count) || null), he = k(() => Number(H.value?.rating) || 0), _e = k(() => Array.isArray(H.value?.tags) ? H.value.tags : []), be = k(() => {
|
||||
let l = V(null), u = V(null), f = V(null), te = V(null), v = V(!0), S = V("generation"), E = V(!1), A = V(1), N = oe(null), ae = oe(null), F = null, B = k(() => Q.assets[Q.index] ?? null), H = k(() => N.value ?? B.value), U = k(() => K(B.value)), W = k(() => B.value ? t(B.value) : ""), G = k(() => Q.assets.length), fe = k(() => xe(H.value)), pe = k(() => ue(H.value, ae.value)), me = k(() => Number(ae.value?.frame_count) || null), he = k(() => Number(H.value?.rating) || 0), _e = k(() => Array.isArray(H.value?.tags) ? H.value.tags : []), be = k(() => {
|
||||
let { start: e, end: t } = ge(G.value, Q.index);
|
||||
return Q.assets.slice(e, t).map((t, n) => ({
|
||||
asset: t,
|
||||
@@ -1386,7 +1386,7 @@ var Re = {
|
||||
if (!J.value) return null;
|
||||
let e = we.value, t = Q.assets, n = Q.index;
|
||||
return e != null && e !== n && t[e] && K(t[e]) === U.value ? e : de(t, n, 1, (e) => K(e) === U.value);
|
||||
}), X = k(() => Y.value != null), Z = k(() => Y.value == null ? null : Q.assets[Y.value]), ke = k(() => q.compare !== "off" && !!Z.value), Ae = k(() => ke.value && Z.value ? n(Z.value) : null), je = k(() => ke.value && q.compare === "subtract" && U.value === "image" ? Ae.value : null), Ne = k(() => J.value && (Ee(q) || je.value) ? `url(#${Ce})` : ""), Pe = k(() => J.value && (q.zebra || q.scopes !== "off"));
|
||||
}), X = k(() => Y.value != null), Z = k(() => Y.value == null ? null : Q.assets[Y.value]), ke = k(() => q.compare !== "off" && !!Z.value), Ae = k(() => ke.value && Z.value ? t(Z.value) : null), je = k(() => ke.value && q.compare === "subtract" && U.value === "image" ? Ae.value : null), Ne = k(() => J.value && (Ee(q) || je.value) ? `url(#${Ce})` : ""), Pe = k(() => J.value && (q.zebra || q.scopes !== "off"));
|
||||
function Re() {
|
||||
return U.value === "image" ? u.value?.getSource?.() ?? null : U.value === "video" ? f.value?.getSource?.() ?? null : null;
|
||||
}
|
||||
@@ -1464,17 +1464,17 @@ var Re = {
|
||||
try {
|
||||
let n = await Se(e, {
|
||||
getAssetMetadata: o,
|
||||
getFileMetadataScoped: s,
|
||||
getFileMetadataScoped: a,
|
||||
signal: t?.signal
|
||||
});
|
||||
F === t && B.value === e && (N.value = n && typeof n == "object" ? n : e);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
let n = K(e);
|
||||
if ((n === "video" || n === "audio") && e?.id != null) try {
|
||||
let n = await a(e.id, t ? { signal: t.signal } : {});
|
||||
F === t && n?.ok && (ae.value = n.data || null);
|
||||
let r = K(e);
|
||||
if ((r === "video" || r === "audio") && e?.id != null) try {
|
||||
let r = await n(e.id, t ? { signal: t.signal } : {});
|
||||
F === t && r?.ok && (ae.value = r.data || null);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
@@ -1496,17 +1496,17 @@ var Re = {
|
||||
let e = document.createElement("a");
|
||||
e.href = W.value, e.download = String(B.value?.filename || ""), e.click();
|
||||
}
|
||||
async function tt(n) {
|
||||
let i = B.value;
|
||||
if (!i) return;
|
||||
let a = Math.max(0, Math.min(5, n)), o = await t(i, a);
|
||||
o?.ok ? (i.rating = a, N.value = N.value ? {
|
||||
async function tt(t) {
|
||||
let n = B.value;
|
||||
if (!n) return;
|
||||
let i = Math.max(0, Math.min(5, t)), a = await s(n, i);
|
||||
a?.ok ? (n.rating = i, N.value = N.value ? {
|
||||
...N.value,
|
||||
rating: a
|
||||
} : i, window.dispatchEvent(new CustomEvent(p, { detail: {
|
||||
assetId: i.id == null ? "" : String(i.id),
|
||||
rating: a
|
||||
} }))) : e(o?.error || r("toast.ratingUpdateFailed", "Rating update failed"), "error");
|
||||
rating: i
|
||||
} : n, window.dispatchEvent(new CustomEvent(p, { detail: {
|
||||
assetId: n.id == null ? "" : String(n.id),
|
||||
rating: i
|
||||
} }))) : e(a?.error || r("toast.ratingUpdateFailed", "Rating update failed"), "error");
|
||||
}
|
||||
function rt(e) {
|
||||
return !!e?.closest?.("input, textarea, select, [contenteditable='true']");
|
||||
+829
-754
File diff suppressed because it is too large
Load Diff
Vendored
+84
@@ -0,0 +1,84 @@
|
||||
// ui/app/config.ts
|
||||
var e = Object.freeze({
|
||||
DEBUG_SAFE_CALL: !1,
|
||||
DEBUG_SAFE_LISTENERS: !1,
|
||||
DEBUG_VIEWER: !1,
|
||||
GRID_MIN_SIZE: 150,
|
||||
FEED_GRID_MIN_SIZE: 120,
|
||||
GRID_GAP: 10,
|
||||
GRID_SHOW_BADGES_EXTENSION: !0,
|
||||
GRID_SHOW_BADGES_RATING: !0,
|
||||
GRID_SHOW_BADGES_TAGS: !0,
|
||||
GRID_SHOW_DETAILS: !0,
|
||||
GRID_SHOW_DETAILS_FILENAME: !0,
|
||||
GRID_SHOW_DETAILS_DATE: !0,
|
||||
GRID_SHOW_DETAILS_DIMENSIONS: !0,
|
||||
GRID_SHOW_DETAILS_GENTIME: !0,
|
||||
GRID_SHOW_HOVER_INFO: !0,
|
||||
GRID_SHOW_WORKFLOW_DOT: !0,
|
||||
WORKFLOW_GRID_GROUP_BY: "model",
|
||||
GRID_VIDEO_AUTOPLAY_MODE: "hover",
|
||||
FEED_SHOW_INFO: !0,
|
||||
FEED_SHOW_FILENAME: !1,
|
||||
FEED_SHOW_DIMENSIONS: !0,
|
||||
FEED_SHOW_DATE: !0,
|
||||
FEED_SHOW_GENTIME: !0,
|
||||
FEED_SHOW_WORKFLOW_DOT: !1,
|
||||
FEED_SHOW_BADGES_EXTENSION: !0,
|
||||
FEED_SHOW_BADGES_RATING: !0,
|
||||
FEED_SHOW_BADGES_TAGS: !0,
|
||||
UI_CARD_HOVER_COLOR: "#3D3D3D",
|
||||
UI_CARD_SELECTION_COLOR: "#4A90E2",
|
||||
UI_RATING_COLOR: "#FF9500",
|
||||
UI_TAG_COLOR: "#4A90E2",
|
||||
BADGE_STAR_COLOR: "#FFD45A",
|
||||
BADGE_IMAGE_COLOR: "#2196F3",
|
||||
BADGE_VIDEO_COLOR: "#9C27B0",
|
||||
BADGE_AUDIO_COLOR: "#FF9800",
|
||||
BADGE_MODEL3D_COLOR: "#4CAF50",
|
||||
BADGE_DUPLICATE_ALERT_COLOR: "#FF1744",
|
||||
DEFAULT_PAGE_SIZE: 80,
|
||||
MAX_PAGE_SIZE: 2e3,
|
||||
PREFETCH_NEXT_PAGE: !0,
|
||||
PREFETCH_NEXT_PAGE_DELAY_MS: 700,
|
||||
SEARCH_DEFAULT_LIMIT: 500,
|
||||
INFINITE_SCROLL_ENABLED: !0,
|
||||
INFINITE_SCROLL_ROOT_MARGIN: "800px",
|
||||
INFINITE_SCROLL_THRESHOLD: .01,
|
||||
BOTTOM_GAP_PX: 80,
|
||||
STATUS_POLL_INTERVAL: 5e3,
|
||||
AUTO_SCAN_ON_STARTUP: !1,
|
||||
EXECUTION_GROUPING_ENABLED: !0,
|
||||
EXECUTION_IDLE_GRACE_MS: 6e3,
|
||||
DEFER_GRID_FETCH_DURING_EXECUTION: !1,
|
||||
VIEWER_PAUSE_DURING_EXECUTION: !1,
|
||||
FLOATING_VIEWER_PAUSE_DURING_EXECUTION: !1,
|
||||
MFV_SIDEBAR_POSITION: "right",
|
||||
MFV_LIVE_DEFAULT: !0,
|
||||
MFV_PREVIEW_DEFAULT: !0,
|
||||
MFV_LIVE_AUTO_OPEN: !1,
|
||||
MFV_PREVIEW_AUTO_OPEN: !1,
|
||||
MFV_NODE_STREAM_AUTO_OPEN: !1,
|
||||
MFV_PREVIEW_METHOD: "auto",
|
||||
MFV_KJ_PREVIEW_OVERRIDE_ENABLED: !0,
|
||||
MFV_TOPBAR_BUTTON: !0,
|
||||
RT_HYDRATE_CONCURRENCY: 2,
|
||||
RT_HYDRATE_QUEUE_MAX: 100,
|
||||
RT_HYDRATE_SEEN_MAX: 2e4,
|
||||
RT_HYDRATE_PRUNE_BUDGET: 250,
|
||||
RT_HYDRATE_SEEN_TTL_MS: 6e5,
|
||||
WORKFLOW_MINIMAP_ENABLED: !0,
|
||||
WATCHER_DEBOUNCE_MS: 3e3,
|
||||
WATCHER_DEDUPE_TTL_MS: 3e3,
|
||||
WATCHER_MAX_PENDING: 500,
|
||||
WATCHER_MIN_SIZE: 100,
|
||||
WATCHER_MAX_SIZE: 4294967296,
|
||||
DB_TIMEOUT_MS: 5e3,
|
||||
DB_MAX_CONNECTIONS: 10,
|
||||
DB_QUERY_TIMEOUT_MS: 1e3,
|
||||
SEARCH_REQUEST_LIMIT: 500,
|
||||
DELETE_CONFIRMATION: !0,
|
||||
DEBUG_VERBOSE_ERRORS: !1,
|
||||
SIDEBAR_ASSET_BADGE_ENABLED: !0
|
||||
}), t = { ...e };
|
||||
export { e as n, t };
|
||||
Vendored
+10
-9
@@ -1,5 +1,6 @@
|
||||
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
|
||||
import { Gt as t, c as n, jn as r, t as i } from "./config-D98p5fVn.js";
|
||||
import { An as t, Wt as n, s as r } from "./client-BVwNDRIx.js";
|
||||
import { t as i } from "./config-BFDzOF1N.js";
|
||||
import { r as a } from "./events-1iwo5blr.js";
|
||||
// ui/utils/logging.ts
|
||||
function o(e, ...t) {
|
||||
@@ -7,15 +8,15 @@ function o(e, ...t) {
|
||||
i.DEBUG_VERBOSE_ERRORS && console.debug(e, ...t);
|
||||
} catch {}
|
||||
}
|
||||
function s(e, n = "Majoor", { showToast: r = !1, toastType: a = "error" } = {}) {
|
||||
function s(e, t = "Majoor", { showToast: r = !1, toastType: a = "error" } = {}) {
|
||||
let o = e?.message || String(e || "Unknown error");
|
||||
try {
|
||||
i.DEBUG_VERBOSE_ERRORS ? console.error(`[Majoor][${n}]`, o, e) : console.debug(`[Majoor][${n}]`, o);
|
||||
i.DEBUG_VERBOSE_ERRORS ? console.error(`[Majoor][${t}]`, o, e) : console.debug(`[Majoor][${t}]`, o);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
if (r && i.DEBUG_VERBOSE_ERRORS) try {
|
||||
t(`${n}: ${o}`, a, 4e3);
|
||||
n(`${t}: ${o}`, a, 4e3);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
@@ -201,7 +202,7 @@ var he = /* @__PURE__ */ e({
|
||||
teardownFloatingViewerManager: () => Ue
|
||||
}), S = null, ge = null;
|
||||
async function _e() {
|
||||
return S || (ge ||= import("./FloatingViewer-C_TFPglf.js").then((e) => (S = e.FloatingViewer, S)), ge);
|
||||
return S || (ge ||= import("./FloatingViewer-DQK63pBw.js").then((e) => (S = e.FloatingViewer, S)), ge);
|
||||
}
|
||||
var C = null, ve = null;
|
||||
async function ye() {
|
||||
@@ -334,7 +335,7 @@ function Ce(e) {
|
||||
async function H(e, t = []) {
|
||||
if (!e.length || !T) return;
|
||||
I();
|
||||
let r = ++P, i = typeof AbortController < "u" ? new AbortController() : null;
|
||||
let n = ++P, i = typeof AbortController < "u" ? new AbortController() : null;
|
||||
N = i;
|
||||
try {
|
||||
let a = T.getPinnedSlots(), o = a.size > 0, s = T._mode, c = s === w.GRID, l = s === w.AB || s === w.SIDE, u = c ? 4 : 2, d = e.slice(0, u);
|
||||
@@ -345,11 +346,11 @@ async function H(e, t = []) {
|
||||
let e = Ce(d[0]);
|
||||
e && (d = [d[0], e]);
|
||||
}
|
||||
let f = L(), p = f?._mjrGetAssets?.() || f?._mjrGetSelectedAssets?.() || [], m = new Map([...p, ...t].filter((e) => e && e.kind !== "folder").map((e) => [String(e.id), e])), h = d.filter((e) => Number.isSafeInteger(Number(e)) && Number(e) > 0), g = h.length ? await n(h, i ? { signal: i.signal } : {}) : {
|
||||
let f = L(), p = f?._mjrGetAssets?.() || f?._mjrGetSelectedAssets?.() || [], m = new Map([...p, ...t].filter((e) => e && e.kind !== "folder").map((e) => [String(e.id), e])), h = d.filter((e) => Number.isSafeInteger(Number(e)) && Number(e) > 0), g = h.length ? await r(h, i ? { signal: i.signal } : {}) : {
|
||||
ok: !0,
|
||||
data: []
|
||||
};
|
||||
if (i?.signal.aborted || P !== r || !T) return;
|
||||
if (i?.signal.aborted || P !== n || !T) return;
|
||||
let _ = new Map((g?.ok && Array.isArray(g.data) ? g.data : []).map((e) => [String(e.id), e])), v = d.map((e) => _.get(String(e)) || m.get(String(e))).filter(Boolean);
|
||||
if (!v.length) return;
|
||||
if (c) {
|
||||
@@ -434,7 +435,7 @@ function Oe() {
|
||||
}
|
||||
function Y() {
|
||||
if (!G) try {
|
||||
K = r(De, { includePointerFallback: !0 }), G = typeof K == "function";
|
||||
K = t(De, { includePointerFallback: !0 }), G = typeof K == "function";
|
||||
} catch (e) {
|
||||
console.debug?.("[MFV] _bindNodeSelectionListener error", e);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { an as e, sr as t, t as n } from "./config-D98p5fVn.js";
|
||||
import { _ as r, f as i, p as a } from "./generationSectionState-BPjCPC13.js";
|
||||
import { in as e, or as t } from "./client-BVwNDRIx.js";
|
||||
import { t as n } from "./config-BFDzOF1N.js";
|
||||
import { _ as r, f as i, p as a } from "./generationSectionState-DwIh8vyW.js";
|
||||
import { r as o } from "./events-1iwo5blr.js";
|
||||
import { $ as s, B as c, C as l, D as u, E as d, H as ee, J as te, M as f, O as p, P as m, Q as h, S as g, T as _, V as v, W as y, X as ne, b, gt as x, ht as S, j as C, k as w, mt as T, pt as E, q as re, st as D } from "./mjr-primevue-C07IsEdU.js";
|
||||
import { $ as s, B as c, C as l, D as u, E as d, H as ee, J as te, M as f, O as p, P as m, Q as h, S as g, T as _, V as v, W as y, X as ne, b, gt as x, ht as S, j as C, k as w, mt as T, pt as E, q as re, st as D } from "./mjr-primevue-CE8VjDUg.js";
|
||||
// ui/features/viewer/model3dCore.ts
|
||||
function ie(e, t) {
|
||||
if (e && typeof t == "function") try {
|
||||
Vendored
+1487
-1486
File diff suppressed because it is too large
Load Diff
+42
-42
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { B as e, L as t, R as n, T as r, X as i, at as a, dt as o, et as s, it as c, nt as l, ot as u, rt as d, st as f, tt as p, ut as m } from "./mjr-primevue-C07IsEdU.js";
|
||||
// node_modules/pinia/dist/pinia.js
|
||||
import { B as e, L as t, R as n, T as r, X as i, at as a, dt as o, et as s, it as c, nt as l, ot as u, rt as d, st as f, tt as p, ut as m } from "./mjr-primevue-CE8VjDUg.js";
|
||||
// ../../../../../../../../ComfyUI-Installs/ComfyUI (from Portable)/ComfyUI/custom_nodes/ComfyUI-Majoor-AssetsManager/node_modules/pinia/dist/pinia.js
|
||||
var h = typeof window < "u", g, _ = (e) => g = e, v = () => t() && n(y) || g, y = Symbol();
|
||||
function b(e) {
|
||||
return e && typeof e == "object" && Object.prototype.toString.call(e) === "[object Object]" && typeof e.toJSON != "function";
|
||||
+26
-26
@@ -1,8 +1,8 @@
|
||||
import { An as e, Hn as t, Sn as n, _n as r, an as i, dn as a, pn as o, xn as s } from "./config-D98p5fVn.js";
|
||||
import { Vn as e, bn as t, fn as n, gn as r, in as i, kn as a, un as o, xn as s } from "./client-BVwNDRIx.js";
|
||||
import { a as c, n as l } from "./graphTraversal-DTdQWkn5.js";
|
||||
import { C as u, S as d, b as f, x as p, y as m } from "./generationSectionState-BPjCPC13.js";
|
||||
import { _ as ee, a as te, c as ne, d as re, f as ie, g as ae, h as oe, i as se, l as ce, m as le, n as ue, o as de, p as fe, r as pe, s as me, t as he, u as ge } from "./mjr-primevue-C07IsEdU.js";
|
||||
import { t as _e } from "./mjr-vue-vendor-CEDOnn2t.js";
|
||||
import { C as u, S as d, b as f, x as p, y as m } from "./generationSectionState-DwIh8vyW.js";
|
||||
import { _ as ee, a as te, c as ne, d as re, f as ie, g as ae, h as oe, i as se, l as ce, m as le, n as ue, o as de, p as fe, r as pe, s as me, t as he, u as ge } from "./mjr-primevue-CE8VjDUg.js";
|
||||
import { t as _e } from "./mjr-vue-vendor-pJNm95sb.js";
|
||||
// ui/features/viewer/floatingViewerProgress.ts
|
||||
var h = "progress-update", g = "__MJR_MFV_PROGRESS_SERVICE__";
|
||||
function ve() {
|
||||
@@ -14,7 +14,7 @@ function ye(e, t) {
|
||||
return n.detail = t, n;
|
||||
}
|
||||
var be = class {
|
||||
constructor(e, t = () => n()) {
|
||||
constructor(e, t = () => s()) {
|
||||
this.id = String(e || ""), this.promptApi = null, this.executedNodeIds = [], this.totalNodes = 0, this.currentlyExecuting = null, this.errorDetails = null, this._getApp = typeof t == "function" ? t : () => null;
|
||||
}
|
||||
setPrompt(e) {
|
||||
@@ -63,8 +63,8 @@ var be = class {
|
||||
this.errorDetails = e || null;
|
||||
}
|
||||
}, xe = class extends EventTarget {
|
||||
constructor({ getApi: e = (e) => s(e), getApp: r = () => n(), waitForApi: i = (e) => t(e) } = {}) {
|
||||
super(), this._getApi = e, this._getApp = r, this._waitForApi = i, this.promptsMap = /* @__PURE__ */ new Map(), this.currentExecution = null, this.lastQueueRemaining = 0, this._api = null, this._listenerEntries = [], this._initPromise = null, this._hydrations = /* @__PURE__ */ new Map();
|
||||
constructor({ getApi: n = (e) => t(e), getApp: r = () => s(), waitForApi: i = (t) => e(t) } = {}) {
|
||||
super(), this._getApi = n, this._getApp = r, this._waitForApi = i, this.promptsMap = /* @__PURE__ */ new Map(), this.currentExecution = null, this.lastQueueRemaining = 0, this._api = null, this._listenerEntries = [], this._initPromise = null, this._hydrations = /* @__PURE__ */ new Map();
|
||||
}
|
||||
getSnapshot() {
|
||||
return {
|
||||
@@ -247,10 +247,10 @@ function b(e, t) {
|
||||
}
|
||||
}
|
||||
function we(e, t) {
|
||||
let r = String(t || "").trim();
|
||||
if (!r) return !1;
|
||||
let n = String(t || "").trim();
|
||||
if (!n) return !1;
|
||||
try {
|
||||
return a(r, n());
|
||||
return o(n, s());
|
||||
} catch (e) {
|
||||
return console.debug?.(e), !1;
|
||||
}
|
||||
@@ -321,7 +321,7 @@ var x = /* @__PURE__ */ new Map(), S = null;
|
||||
async function C(e) {
|
||||
let t = Array.from(new Set(w(e).map((e) => D(e)).filter(Boolean)));
|
||||
if (t.length && t.filter((e) => !x.has(e)).length) try {
|
||||
S ||= o("/object_info").then((e) => e?.ok ? e.json() : null).then((e) => {
|
||||
S ||= n("/object_info").then((e) => e?.ok ? e.json() : null).then((e) => {
|
||||
if (e && typeof e == "object") for (let [t, n] of Object.entries(e)) x.set(String(t), n);
|
||||
return e;
|
||||
}).catch(() => null), await S;
|
||||
@@ -871,18 +871,18 @@ function dt(e, t, n = null) {
|
||||
default: return _t(e);
|
||||
}
|
||||
}
|
||||
function G(t, n, i = null) {
|
||||
if (!t) return !1;
|
||||
let a = String(t.type || "").toLowerCase();
|
||||
if (a === "number" || a === "int" || a === "float") {
|
||||
let e = Number(n);
|
||||
if (Number.isNaN(e)) return !1;
|
||||
let r = t.options ?? {}, i = r.min ?? -Infinity, o = r.max ?? Infinity, s = Math.min(o, Math.max(i, e));
|
||||
(a === "int" || r.precision === 0 || r.round === 1) && (s = Math.round(s)), t.value = s;
|
||||
} else t.value = a === "toggle" || a === "boolean" ? !!n : n;
|
||||
function G(e, t, n = null) {
|
||||
if (!e) return !1;
|
||||
let i = String(e.type || "").toLowerCase();
|
||||
if (i === "number" || i === "int" || i === "float") {
|
||||
let n = Number(t);
|
||||
if (Number.isNaN(n)) return !1;
|
||||
let r = e.options ?? {}, a = r.min ?? -Infinity, o = r.max ?? Infinity, s = Math.min(o, Math.max(a, n));
|
||||
(i === "int" || r.precision === 0 || r.round === 1) && (s = Math.round(s)), e.value = s;
|
||||
} else e.value = i === "toggle" || i === "boolean" ? !!t : t;
|
||||
try {
|
||||
let n = r(), o = i ?? t?.parent ?? null, s = t.value;
|
||||
t.callback?.(t.value, n, o, null, t), (a === "number" || a === "int" || a === "float") && (t.value = s), ft(t), e(o);
|
||||
let t = r(), o = n ?? e?.parent ?? null, s = e.value;
|
||||
e.callback?.(e.value, t, o, null, e), (i === "number" || i === "int" || i === "float") && (e.value = s), ft(e), a(o);
|
||||
} catch (e) {
|
||||
console.debug?.("[MFV] writeWidgetValue", e);
|
||||
}
|
||||
@@ -1325,11 +1325,11 @@ function Z() {
|
||||
}
|
||||
}
|
||||
}
|
||||
function Ot(e = n()) {
|
||||
function Ot(e = s()) {
|
||||
if (typeof document > "u") return !1;
|
||||
Y?.root?.isConnected || (Y = Dt());
|
||||
let t = u(e), r = () => Et(Y.body, t, String(Y.search.value || "").trim().toLowerCase());
|
||||
Y.search.oninput = r, Y.search.value = "", r(), Y.root.hidden = !1;
|
||||
let t = u(e), n = () => Et(Y.body, t, String(Y.search.value || "").trim().toLowerCase());
|
||||
Y.search.oninput = n, Y.search.value = "", n(), Y.root.hidden = !1;
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(J, { detail: { open: !0 } }));
|
||||
} catch (e) {
|
||||
@@ -1338,7 +1338,7 @@ function Ot(e = n()) {
|
||||
return setTimeout(() => Y?.search?.focus?.(), 0), !0;
|
||||
}
|
||||
// ui/app/openMajoorSettings.ts
|
||||
function Q(e = n()) {
|
||||
function Q(e = s()) {
|
||||
return Ot(e);
|
||||
}
|
||||
try {
|
||||
Vendored
+4494
-4389
File diff suppressed because it is too large
Load Diff
@@ -1226,6 +1226,51 @@ POST /mjr/am/db/force-delete
|
||||
|
||||
---
|
||||
|
||||
### Trash Status
|
||||
```http
|
||||
GET /mjr/am/trash
|
||||
```
|
||||
|
||||
**Purpose**: Report how many freedesktop trash entries came from Majoor's folders (output, input, custom roots). Linux/BSD only; the response never contains file paths. Rate-limited to 20 requests per 60 seconds.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"supported": true,
|
||||
"count": 3,
|
||||
"bytes": 1048576
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Empty Trash
|
||||
```http
|
||||
POST /mjr/am/trash/empty
|
||||
```
|
||||
|
||||
**Purpose**: Permanently remove the freedesktop trash entries whose original location was inside a Majoor folder. Entries from outside Majoor's folders, orphans, and symlink-reachable payloads are never touched; a trash directory is only used when it belongs to the current user and is not group/world-writable. Directories inside the trash are never removed (reported in `skipped_dirs`). Requires the same permission as asset deletion (`allow_delete` / `MAJOOR_ALLOW_DELETE=1`); rate-limited to 3 requests per 60 seconds.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"removed": 3,
|
||||
"bytes": 1048576,
|
||||
"failed": [],
|
||||
"skipped_dirs": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`failed` lists entries that could not be removed (`name` + `error`); the remaining entries are still removed. `skipped_dirs` counts directory payloads, which are left in place.
|
||||
|
||||
---
|
||||
|
||||
## Utilities
|
||||
|
||||
### Date Histogram
|
||||
|
||||
@@ -88,6 +88,25 @@ Open the Assets Manager panel and expand the **Index Status** section. Maintenan
|
||||
| **Memory purge** | Unloads Majoor AI models, asks ComfyUI to unload loaded models, and clears torch cache when the queue is idle. |
|
||||
| **Reset index** | Clears cached data inside the existing database and triggers a background rescan. Requires the database to be readable. |
|
||||
| **Delete DB** | Force-deletes the database files from disk (bypassing all DB-dependent checks) and rebuilds from scratch. Works even when the database is corrupted. |
|
||||
| **Empty trash** | Permanently removes the files Majoor sent to the freedesktop trash. Linux/BSD only; requires the delete permission (`allow_delete` / `MAJOOR_ALLOW_DELETE=1`). |
|
||||
|
||||
## Empty Trash
|
||||
|
||||
Majoor deletes files through the system trash (`send2trash`). On Linux and the BSDs those files land in the freedesktop trash (`~/.local/share/Trash` or `<mount>/.Trash-<uid>`), and on a headless server nothing ever clears it — so deleting assets never actually frees disk space.
|
||||
|
||||
The **Empty trash** button in the Status panel fixes that:
|
||||
|
||||
1. It counts only trash entries whose original location (recorded in each `.trashinfo` file) was inside one of Majoor's folders (output, input, or a custom root).
|
||||
2. It asks for confirmation showing the count and total size.
|
||||
3. On confirm it permanently deletes those entries — payload **and** metadata — via `POST /mjr/am/trash/empty`.
|
||||
|
||||
Notes:
|
||||
|
||||
- Selection is by original location, so files you trashed from those same folders with **other tools are included** and will be permanently deleted too.
|
||||
- Entries from outside Majoor's folders, orphans without trash metadata, and anything reachable only through a symlink are never touched.
|
||||
- **Directories in the trash are never removed.** Majoor only ever trashes files; a trashed directory (and its contents) is left in place and reported as skipped. Clear those by hand if needed.
|
||||
- **Ownership requirement:** a trash directory is only used when it belongs to the user running ComfyUI and is not group- or world-writable. Trash state planted by another user on a shared mount is ignored.
|
||||
- The button is hidden on non-freedesktop platforms (Windows/macOS) and disabled when deletion is disabled, because emptying shares the `allow_delete` gate with asset deletion.
|
||||
|
||||
## Reset Index
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ Safe Mode adds an explicit opt-in layer for operations that modify files or user
|
||||
- Allows rating/tags writes while Safe Mode is enabled.
|
||||
- `MAJOOR_ALLOW_DELETE=1`
|
||||
- Enables asset deletion (disabled by default).
|
||||
- Also gates emptying the freedesktop trash (`POST /mjr/am/trash/empty`), which permanently removes trashed files.
|
||||
- `MAJOOR_ALLOW_RENAME=1`
|
||||
- Enables asset rename (disabled by default).
|
||||
- `MAJOOR_ALLOW_OPEN_IN_FOLDER=1`
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Freedesktop trash discovery and emptying for Majoor-managed roots.
|
||||
|
||||
Majoor deletes through ``send2trash``; on freedesktop platforms that moves
|
||||
files into the freedesktop trash (``<mount>/.Trash-<uid>/``, ``<mount>/.Trash/<uid>/``
|
||||
or ``$XDG_DATA_HOME/Trash``). Headless servers never empty it, so curation
|
||||
never frees disk.
|
||||
|
||||
This module finds, among the trash directories relevant to Majoor's roots,
|
||||
only the entries whose original path (recorded in the ``.trashinfo`` ``Path=``
|
||||
field) lies inside a Majoor root, and permanently removes those on request.
|
||||
Everything else — foreign entries, malformed metadata, orphans and anything
|
||||
reachable only through a symlink — is never touched.
|
||||
|
||||
Hardening rules:
|
||||
|
||||
- A trash directory (and its ``files/`` and ``info/`` subdirectories) is used
|
||||
only when it is a real directory owned by the effective uid and not
|
||||
group/world-writable, so trashed state planted by another user on a shared
|
||||
mount is ignored.
|
||||
- Removal never operates on absolute paths. The trash directory and its
|
||||
``files/``/``info/`` subdirectories are opened with
|
||||
``O_RDONLY|O_DIRECTORY|O_NOFOLLOW`` relative to their parent fd, ownership
|
||||
and mode are re-checked via ``fstat``, and unlink targets are addressed by
|
||||
bare name via ``dir_fd``. Directory payloads are never removed (Majoor only
|
||||
trashes files); they are reported in ``skipped_dirs``.
|
||||
|
||||
All filesystem state is injected via parameters (``uid``, ``home``,
|
||||
``xdg_data_home``, and ``platform`` for :func:`is_supported`) so tests can
|
||||
point the service at synthetic trees under ``tmp_path``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from ...shared import Result, get_logger
|
||||
from ...shared import sanitize_error_message as _safe_error_message
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_SUPPORTED_PLATFORM_PREFIXES = ("linux", "freebsd", "openbsd", "netbsd")
|
||||
_TRASH_INFO_HEADER = "[Trash Info]"
|
||||
_INFO_SUFFIX = ".trashinfo"
|
||||
_MAX_INFO_BYTES = 64 * 1024
|
||||
_FORBIDDEN_WRITE_BITS = 0o022
|
||||
_OPEN_DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
|
||||
|
||||
def is_supported(platform: str | None = None) -> bool:
|
||||
"""Return True only on freedesktop platforms (Linux and the BSDs)."""
|
||||
plat = sys.platform if platform is None else str(platform)
|
||||
return any(plat.startswith(prefix) for prefix in _SUPPORTED_PLATFORM_PREFIXES)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrashDir:
|
||||
"""A validated trash directory with the topdir used for relative paths."""
|
||||
|
||||
path: Path
|
||||
topdir: Path
|
||||
info_dir: Path
|
||||
files_dir: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrashEntry:
|
||||
"""One selected trash entry: payload/info names inside its trash dir."""
|
||||
|
||||
trash_dir: Path
|
||||
payload_name: str
|
||||
info_name: str
|
||||
size: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrashScan:
|
||||
"""Aggregated scan result: selected entry count, total bytes and entries."""
|
||||
|
||||
count: int
|
||||
bytes: int
|
||||
entries: tuple[TrashEntry, ...]
|
||||
|
||||
|
||||
def _normalize_roots(roots: Iterable[Path | str] | None) -> list[Path]:
|
||||
"""Resolve and deduplicate the Majoor roots; unresolvable roots are dropped."""
|
||||
out: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for root in roots or []:
|
||||
try:
|
||||
resolved = Path(str(root)).resolve(strict=False)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
logger.debug("trash: skipping unresolvable root %r", root)
|
||||
continue
|
||||
key = str(resolved)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(resolved)
|
||||
return out
|
||||
|
||||
|
||||
def _lexical_roots(roots: Iterable[Path | str] | None) -> list[Path]:
|
||||
"""Absolute, lexically-normalised roots (symlinks NOT resolved)."""
|
||||
out: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for root in roots or []:
|
||||
normalized = Path(os.path.normpath(os.path.abspath(str(root))))
|
||||
key = str(normalized)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(normalized)
|
||||
return out
|
||||
|
||||
|
||||
def _mount_point(path: Path) -> Path:
|
||||
"""Walk up to the mount point (topdir) owning *path*."""
|
||||
current = Path(path)
|
||||
while not os.path.ismount(current):
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
return current
|
||||
current = parent
|
||||
return current
|
||||
|
||||
|
||||
def _lstat_safe(path: Path) -> os.stat_result | None:
|
||||
try:
|
||||
return os.lstat(path)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _owned_dir_mode_ok(st: os.stat_result) -> bool:
|
||||
return stat.S_ISDIR(st.st_mode) and (st.st_mode & _FORBIDDEN_WRITE_BITS) == 0
|
||||
|
||||
|
||||
def _is_owned_dir(path: Path, uid: int) -> bool:
|
||||
"""Real directory (never a symlink), owned by *uid*, not group/world-writable."""
|
||||
st = _lstat_safe(path)
|
||||
return st is not None and st.st_uid == uid and _owned_dir_mode_ok(st)
|
||||
|
||||
|
||||
def _is_owned_dir_or_absent(path: Path, uid: int) -> bool:
|
||||
"""Absent is fine; otherwise a real dir owned by *uid* with a safe mode."""
|
||||
st = _lstat_safe(path)
|
||||
return st is None or (st.st_uid == uid and _owned_dir_mode_ok(st))
|
||||
|
||||
|
||||
def _fd_owned_safe(fd: int, uid: int) -> bool:
|
||||
"""Re-check ownership/mode on an already-open directory fd."""
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
except OSError:
|
||||
return False
|
||||
return st.st_uid == uid and _owned_dir_mode_ok(st)
|
||||
|
||||
|
||||
def trash_dirs(
|
||||
roots: Sequence[Path | str],
|
||||
*,
|
||||
uid: int | None = None,
|
||||
home: Path | None = None,
|
||||
xdg_data_home: Path | str | None = None,
|
||||
) -> list[TrashDir]:
|
||||
"""Enumerate the freedesktop trash dirs relevant to *roots*.
|
||||
|
||||
Scans the home trash (``$XDG_DATA_HOME/Trash``) plus, for the mount of
|
||||
each root, ``<topdir>/.Trash-<uid>`` and the sticky-bit
|
||||
``<topdir>/.Trash/<uid>``. Every candidate must be a real directory owned
|
||||
by *uid* with no group/world write bits (likewise its ``files/`` and
|
||||
``info/``), and results are deduplicated by real path.
|
||||
"""
|
||||
if uid is None:
|
||||
uid = os.getuid()
|
||||
if home is None:
|
||||
home = Path.home()
|
||||
if xdg_data_home is None:
|
||||
env_xdg = os.environ.get("XDG_DATA_HOME")
|
||||
xdg_data_home = Path(env_xdg) if env_xdg else Path(home) / ".local" / "share"
|
||||
|
||||
candidates: list[tuple[Path, Path]] = [(Path(xdg_data_home) / "Trash", Path(xdg_data_home))]
|
||||
|
||||
seen_topdirs: set[str] = set()
|
||||
for root in _normalize_roots(roots):
|
||||
topdir = _mount_point(root)
|
||||
key = os.path.realpath(topdir)
|
||||
if key in seen_topdirs:
|
||||
continue
|
||||
seen_topdirs.add(key)
|
||||
candidates.append((topdir / f".Trash-{uid}", topdir))
|
||||
sticky_parent = topdir / ".Trash"
|
||||
st = _lstat_safe(sticky_parent)
|
||||
if st is not None and stat.S_ISDIR(st.st_mode) and (st.st_mode & stat.S_ISVTX):
|
||||
candidates.append((sticky_parent / str(uid), topdir))
|
||||
|
||||
out: list[TrashDir] = []
|
||||
seen: set[str] = set()
|
||||
for path, topdir in candidates:
|
||||
key = os.path.realpath(path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if not _is_owned_dir(path, uid):
|
||||
continue
|
||||
if not (_is_owned_dir_or_absent(path / "files", uid) and _is_owned_dir_or_absent(path / "info", uid)):
|
||||
continue
|
||||
out.append(
|
||||
TrashDir(path=path, topdir=topdir, info_dir=path / "info", files_dir=path / "files")
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_trashinfo(info_path: Path) -> str | None:
|
||||
"""Return the raw ``Path=`` value, or None when the file is malformed.
|
||||
|
||||
Reads at most 64 KiB; larger or undecodable files are treated as
|
||||
malformed rather than parsing unbounded attacker-supplied content.
|
||||
The open uses ``O_NOFOLLOW`` so a symlink swapped in after the lstat
|
||||
fails and is treated as malformed.
|
||||
"""
|
||||
try:
|
||||
info_fd = os.open(info_path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
with os.fdopen(info_fd, "rb") as handle:
|
||||
raw = handle.read(_MAX_INFO_BYTES + 1)
|
||||
except OSError:
|
||||
return None
|
||||
if len(raw) > _MAX_INFO_BYTES:
|
||||
return None
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
if _TRASH_INFO_HEADER not in text:
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
if line.startswith("Path="):
|
||||
value = line[len("Path=") :].strip()
|
||||
return value or None
|
||||
return None
|
||||
|
||||
|
||||
def _is_within(candidate: Path, roots: list[Path]) -> bool:
|
||||
"""Component-wise containment: *candidate* equals or sits under a root."""
|
||||
for root in roots:
|
||||
if candidate == root or root in candidate.parents:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _payload_size(payload: Path) -> int:
|
||||
"""lstat-based size of a payload; directories and missing files count 0."""
|
||||
try:
|
||||
st = os.lstat(payload)
|
||||
except OSError:
|
||||
return 0
|
||||
if stat.S_ISDIR(st.st_mode):
|
||||
return 0
|
||||
return st.st_size
|
||||
|
||||
|
||||
def _entry_from_info(
|
||||
item: os.DirEntry,
|
||||
trash_dir: TrashDir,
|
||||
resolved_roots: list[Path],
|
||||
lexical_roots: list[Path],
|
||||
) -> TrashEntry | None:
|
||||
"""Select a trash entry from one scandir item of the info directory."""
|
||||
name = item.name
|
||||
if not name.endswith(_INFO_SUFFIX):
|
||||
return None
|
||||
try:
|
||||
st = os.lstat(item.path)
|
||||
except OSError:
|
||||
return None
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
return None
|
||||
info_path = Path(item.path)
|
||||
raw = _parse_trashinfo(info_path)
|
||||
if not raw:
|
||||
return None
|
||||
original = unquote(raw)
|
||||
if "\x00" in original:
|
||||
return None
|
||||
if os.path.isabs(original):
|
||||
candidate = Path(os.path.normpath(original))
|
||||
else:
|
||||
candidate = Path(os.path.normpath(str(trash_dir.topdir / original)))
|
||||
# Match either the lexical form or the realpath form of the original
|
||||
# location against either root form, so entries recorded through a
|
||||
# symlinked root prefix are still recognised. Selection only decides
|
||||
# which trash payload to remove — it never touches the original path.
|
||||
forms = [candidate]
|
||||
real = Path(os.path.realpath(candidate))
|
||||
if real != candidate:
|
||||
forms.append(real)
|
||||
selected = False
|
||||
for form in forms:
|
||||
if _is_within(form, resolved_roots) or _is_within(form, lexical_roots):
|
||||
selected = True
|
||||
break
|
||||
if not selected:
|
||||
return None
|
||||
# The payload name is attacker-controlled: a crafted info basename
|
||||
# (".trashinfo", "..trashinfo", ...) must never resolve the payload to
|
||||
# the files dir, the trash dir, or anything outside files/.
|
||||
stem = name[: -len(_INFO_SUFFIX)]
|
||||
payload = trash_dir.files_dir / stem
|
||||
if (
|
||||
stem in ("", ".", "..")
|
||||
or os.sep in stem
|
||||
or (os.altsep and os.altsep in stem)
|
||||
or "\x00" in stem
|
||||
or payload.parent != trash_dir.files_dir
|
||||
):
|
||||
return None
|
||||
return TrashEntry(
|
||||
trash_dir=trash_dir.path,
|
||||
payload_name=stem,
|
||||
info_name=name,
|
||||
size=_payload_size(payload),
|
||||
)
|
||||
|
||||
|
||||
def scan(
|
||||
roots: Sequence[Path | str],
|
||||
*,
|
||||
uid: int | None = None,
|
||||
home: Path | None = None,
|
||||
xdg_data_home: Path | str | None = None,
|
||||
) -> TrashScan:
|
||||
"""Count (and collect) trash entries whose original path is inside *roots*."""
|
||||
resolved = _normalize_roots(roots)
|
||||
lexical = _lexical_roots(roots)
|
||||
entries: list[TrashEntry] = []
|
||||
for trash_dir in trash_dirs(resolved, uid=uid, home=home, xdg_data_home=xdg_data_home):
|
||||
try:
|
||||
listing = list(os.scandir(trash_dir.info_dir))
|
||||
except OSError as exc:
|
||||
logger.debug("trash scan: cannot list %s: %s", trash_dir.info_dir, exc)
|
||||
continue
|
||||
for item in listing:
|
||||
try:
|
||||
entry = _entry_from_info(item, trash_dir, resolved, lexical)
|
||||
except (OSError, ValueError):
|
||||
# A single hostile info entry must never fail the whole scan.
|
||||
continue
|
||||
if entry is not None:
|
||||
entries.append(entry)
|
||||
return TrashScan(count=len(entries), bytes=sum(e.size for e in entries), entries=tuple(entries))
|
||||
|
||||
|
||||
def _remove_entry(entry: TrashEntry, *, uid: int) -> tuple[str, str | None]:
|
||||
"""Remove one payload plus its trashinfo using fd-relative operations only.
|
||||
|
||||
Returns ``(outcome, error)`` where outcome is ``"removed"``,
|
||||
``"skipped_dir"`` (directory payload — never removed) or ``"failed"``.
|
||||
"""
|
||||
try:
|
||||
trash_fd = os.open(entry.trash_dir, _OPEN_DIR_FLAGS)
|
||||
except OSError as exc:
|
||||
return "failed", _safe_error_message(exc, "Failed to open trash directory")
|
||||
try:
|
||||
try:
|
||||
files_fd = os.open("files", _OPEN_DIR_FLAGS, dir_fd=trash_fd)
|
||||
except OSError as exc:
|
||||
return "failed", _safe_error_message(exc, "Failed to open trash files directory")
|
||||
try:
|
||||
try:
|
||||
info_fd = os.open("info", _OPEN_DIR_FLAGS, dir_fd=trash_fd)
|
||||
except OSError as exc:
|
||||
return "failed", _safe_error_message(exc, "Failed to open trash info directory")
|
||||
try:
|
||||
if not (
|
||||
_fd_owned_safe(trash_fd, uid)
|
||||
and _fd_owned_safe(files_fd, uid)
|
||||
and _fd_owned_safe(info_fd, uid)
|
||||
):
|
||||
return "failed", "Trash directory failed ownership or mode re-check"
|
||||
try:
|
||||
st = os.lstat(entry.payload_name, dir_fd=files_fd)
|
||||
except FileNotFoundError:
|
||||
st = None
|
||||
except OSError as exc:
|
||||
return "failed", _safe_error_message(exc, "Failed to stat trashed entry")
|
||||
if st is not None:
|
||||
if stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode):
|
||||
try:
|
||||
os.unlink(entry.payload_name, dir_fd=files_fd)
|
||||
except FileNotFoundError:
|
||||
pass # vanished concurrently — treat as removed
|
||||
except OSError as exc:
|
||||
return "failed", _safe_error_message(exc, "Failed to remove trashed entry")
|
||||
else:
|
||||
# Majoor only ever trashes files; directory payloads
|
||||
# (and any other non-file type) are skipped, not removed.
|
||||
return "skipped_dir", None
|
||||
try:
|
||||
os.unlink(entry.info_name, dir_fd=info_fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
return "failed", _safe_error_message(exc, "Failed to remove trashinfo")
|
||||
return "removed", None
|
||||
finally:
|
||||
os.close(info_fd)
|
||||
finally:
|
||||
os.close(files_fd)
|
||||
finally:
|
||||
os.close(trash_fd)
|
||||
|
||||
|
||||
def empty(
|
||||
roots: Sequence[Path | str],
|
||||
*,
|
||||
uid: int | None = None,
|
||||
home: Path | None = None,
|
||||
xdg_data_home: Path | str | None = None,
|
||||
) -> Result[dict[str, Any]]:
|
||||
"""Permanently remove every selected trash entry for *roots*.
|
||||
|
||||
Failures on individual entries are reported and skipped; the rest of the
|
||||
batch still proceeds. Directory payloads are never removed and are
|
||||
counted in ``skipped_dirs``.
|
||||
"""
|
||||
if uid is None:
|
||||
uid = os.getuid()
|
||||
scan_result = scan(roots, uid=uid, home=home, xdg_data_home=xdg_data_home)
|
||||
removed = 0
|
||||
freed = 0
|
||||
skipped_dirs = 0
|
||||
failed: list[dict[str, str]] = []
|
||||
for entry in scan_result.entries:
|
||||
outcome, error = _remove_entry(entry, uid=uid)
|
||||
if outcome == "skipped_dir":
|
||||
skipped_dirs += 1
|
||||
continue
|
||||
if error is not None:
|
||||
failed.append({"name": entry.payload_name, "error": error})
|
||||
continue
|
||||
removed += 1
|
||||
freed += entry.size
|
||||
return Result.Ok(
|
||||
{"removed": removed, "bytes": freed, "failed": failed, "skipped_dirs": skipped_dirs}
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TrashDir",
|
||||
"TrashEntry",
|
||||
"TrashScan",
|
||||
"empty",
|
||||
"is_supported",
|
||||
"scan",
|
||||
"trash_dirs",
|
||||
]
|
||||
@@ -142,7 +142,7 @@ def _operation_static_gates() -> tuple[tuple[tuple[str, ...], str, str, str], ..
|
||||
"Reset index is disabled by default. Enable 'allow_reset_index' in settings or set MAJOOR_ALLOW_RESET_INDEX=1.",
|
||||
),
|
||||
(
|
||||
("delete", "asset_delete", "assets_delete"),
|
||||
("delete", "asset_delete", "assets_delete", "trash_empty"),
|
||||
"allow_delete",
|
||||
"MAJOOR_ALLOW_DELETE",
|
||||
"Delete is disabled by default. Set MAJOOR_ALLOW_DELETE=1 to enable asset deletion.",
|
||||
|
||||
@@ -14,13 +14,16 @@ from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from mjr_am_backend.config import INDEX_DB_PATH, get_runtime_output_root, is_vector_search_enabled
|
||||
from mjr_am_backend.custom_roots import resolve_custom_root
|
||||
from mjr_am_backend.custom_roots import list_custom_roots, resolve_custom_root
|
||||
from mjr_am_backend.features.assets import trash_service
|
||||
from mjr_am_backend.features.index.vector_runtime import maybe_unload_vector_runtime_after_use
|
||||
from mjr_am_backend.runtime_activity import is_generation_busy
|
||||
from mjr_am_backend.shared import FileKind, Result, get_logger
|
||||
|
||||
from ..core import (
|
||||
_check_rate_limit,
|
||||
_csrf_error,
|
||||
_get_allowed_directories,
|
||||
_json_response,
|
||||
_read_json,
|
||||
_require_operation_enabled,
|
||||
@@ -263,6 +266,29 @@ def _emit_restore_status(step: str, level: str = "info", message: str | None = N
|
||||
return archive_runtime.emit_restore_status(step, level, message, **extra)
|
||||
|
||||
|
||||
def _trash_roots() -> list[Path]:
|
||||
"""Resolve the Majoor roots (output, input, custom) for trash scanning."""
|
||||
roots: list[Path] = []
|
||||
try:
|
||||
roots.extend(_get_allowed_directories())
|
||||
except Exception:
|
||||
logger.debug("trash: failed to enumerate allowed directories", exc_info=True)
|
||||
try:
|
||||
custom = list_custom_roots()
|
||||
if custom.ok:
|
||||
for item in custom.data or []:
|
||||
raw = item.get("path") if isinstance(item, dict) else None
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
roots.append(Path(str(raw)).resolve(strict=False))
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
continue
|
||||
except Exception:
|
||||
logger.debug("trash: failed to enumerate custom roots", exc_info=True)
|
||||
return roots
|
||||
|
||||
|
||||
async def _remove_with_retry(path: Path, attempts: int = 6) -> None:
|
||||
return await archive_runtime.remove_with_retry(path, attempts)
|
||||
|
||||
@@ -1445,3 +1471,83 @@ def register_db_maintenance_routes(routes: web.RouteTableDef) -> None:
|
||||
scans=len(scans_triggered if "scans_triggered" in locals() else []),
|
||||
)
|
||||
return _json_response(result)
|
||||
|
||||
@routes.get("/mjr/am/trash")
|
||||
async def trash_status(request: web.Request):
|
||||
"""Report how many trashed files came from Majoor's roots (no paths)."""
|
||||
auth = _require_write_access(request)
|
||||
if not auth.ok:
|
||||
return _json_response(auth)
|
||||
allowed, retry_after = _check_rate_limit(
|
||||
request, "trash_status", max_requests=20, window_seconds=60
|
||||
)
|
||||
if not allowed:
|
||||
return _json_response(
|
||||
Result.Err(
|
||||
"RATE_LIMITED",
|
||||
f"Too many trash status requests. Retry in {int(retry_after or 60)} seconds.",
|
||||
)
|
||||
)
|
||||
if not trash_service.is_supported():
|
||||
return _json_response(Result.Ok({"supported": False, "count": 0, "bytes": 0}))
|
||||
try:
|
||||
scan = await asyncio.to_thread(lambda: trash_service.scan(_trash_roots()))
|
||||
except Exception as exc:
|
||||
return _json_response(
|
||||
Result.Err("TRASH_ERROR", safe_error_message(exc, "Failed to scan trash"))
|
||||
)
|
||||
return _json_response(
|
||||
Result.Ok({"supported": True, "count": scan.count, "bytes": scan.bytes})
|
||||
)
|
||||
|
||||
@routes.post("/mjr/am/trash/empty")
|
||||
async def trash_empty(request: web.Request):
|
||||
"""Permanently remove trash entries that came from Majoor's roots."""
|
||||
csrf = _csrf_error(request)
|
||||
if csrf:
|
||||
return _json_response(Result.Err("CSRF", csrf))
|
||||
auth = _require_write_access(request)
|
||||
if not auth.ok:
|
||||
return _json_response(auth)
|
||||
if not trash_service.is_supported():
|
||||
return _json_response(
|
||||
Result.Err(
|
||||
"UNSUPPORTED",
|
||||
"Trash operations are only supported on Linux and BSD platforms.",
|
||||
)
|
||||
)
|
||||
allowed, retry_after = _check_rate_limit(
|
||||
request, "trash_empty", max_requests=3, window_seconds=60
|
||||
)
|
||||
if not allowed:
|
||||
return _json_response(
|
||||
Result.Err(
|
||||
"RATE_LIMITED",
|
||||
f"Too many empty-trash requests. Retry in {int(retry_after or 60)} seconds.",
|
||||
)
|
||||
)
|
||||
|
||||
svc, error_result = await _require_services()
|
||||
if error_result:
|
||||
return _json_response(error_result)
|
||||
prefs = await _resolve_security_prefs(svc if isinstance(svc, dict) else None)
|
||||
op = _require_operation_enabled("trash_empty", prefs=prefs)
|
||||
if not op.ok:
|
||||
return _json_response(op)
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(lambda: trash_service.empty(_trash_roots()))
|
||||
except Exception as exc:
|
||||
result = Result.Err("TRASH_ERROR", safe_error_message(exc, "Failed to empty trash"))
|
||||
await _audit_db_maintenance_write(
|
||||
svc if isinstance(svc, dict) else {},
|
||||
request,
|
||||
"trash_empty",
|
||||
"trash:empty",
|
||||
result,
|
||||
removed=int((result.data or {}).get("removed") or 0) if result.ok else 0,
|
||||
bytes=int((result.data or {}).get("bytes") or 0) if result.ok else 0,
|
||||
failed=len((result.data or {}).get("failed") or []) if result.ok else 0,
|
||||
skipped_dirs=int((result.data or {}).get("skipped_dirs") or 0) if result.ok else 0,
|
||||
)
|
||||
return _json_response(result)
|
||||
|
||||
Generated
+51
-71
@@ -9,20 +9,20 @@
|
||||
"@tanstack/vue-virtual": "^3.13.39",
|
||||
"pinia": "^4.0.3",
|
||||
"primevue": "4.5.5",
|
||||
"three": "0.186.1",
|
||||
"three": "0.187.0",
|
||||
"vue": "^3.5.44"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@comfyorg/comfyui-frontend-types": "^1.60.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@types/node": "^26.6.4",
|
||||
"@vitejs/plugin-vue": "^6.0.9",
|
||||
"@vitest/coverage-v8": "^5.0.5",
|
||||
"@types/node": "^26.6.6",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vitest/coverage-v8": "^5.1.0",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"eslint": "^10.12.0",
|
||||
"happy-dom": "^21.0.4",
|
||||
"knip": "^6.39.0",
|
||||
"knip": "^6.44.1",
|
||||
"oxlint": "^1.90.0",
|
||||
"prettier": "^3.9.10",
|
||||
"typescript": "^7.0.2",
|
||||
@@ -1809,9 +1809,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
|
||||
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
|
||||
"version": "26.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
|
||||
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1842,7 +1842,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1859,7 +1858,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1876,7 +1874,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1893,7 +1890,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1910,7 +1906,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1927,7 +1922,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1944,7 +1938,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1961,7 +1954,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1978,7 +1970,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1995,7 +1986,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2012,7 +2002,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2029,7 +2018,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2046,7 +2034,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2063,7 +2050,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2080,7 +2066,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2097,7 +2082,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2114,7 +2098,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2131,7 +2114,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2148,7 +2130,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2165,7 +2146,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2176,9 +2156,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz",
|
||||
"integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==",
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz",
|
||||
"integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2189,19 +2169,19 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"vue": "^3.2.25"
|
||||
"vue": "^3.2.25 || ^3.6.0-rc.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.5.tgz",
|
||||
"integrity": "sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.1.0.tgz",
|
||||
"integrity": "sha512-OY9aShBQG5KOIw2zCo9fDoCHI9RHliNHJ//nM7m6jGg41Hyke9JOGWmeMlK74xYErsxMoK8mIB3gAeEyFVA7LA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/istanbul-lib-coverage": "^1.0.2",
|
||||
"@vitest/istanbul-lib-report": "^1.0.2",
|
||||
"@vitest/istanbul-lib-coverage": "^2.1.0",
|
||||
"@vitest/istanbul-lib-report": "^2.1.0",
|
||||
"ast-v8-to-istanbul": "^1.0.7",
|
||||
"magicast": "^0.5.5",
|
||||
"obug": "^3.0.0",
|
||||
@@ -2212,8 +2192,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "5.0.5",
|
||||
"vitest": "5.0.5"
|
||||
"@vitest/browser": "5.1.0",
|
||||
"vitest": "5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -2222,9 +2202,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/istanbul-lib-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-2.1.0.tgz",
|
||||
"integrity": "sha512-WOjuS+xcjSIy7ruYBQ+9BUcV7XZQ0FyVcbWbWxW9TRx/kLGx5SQaeFRIFDJxjNplb6YsT7SpKo2zuXXUo91OIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2232,27 +2212,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/istanbul-lib-report": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz",
|
||||
"integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-2.1.0.tgz",
|
||||
"integrity": "sha512-+ZFta25nWtGCOBG9/IET8w1RO5Oawsd6H8Uf6AJ9EA/StFuKVtKAEJEJ6uRt84OH+T4YkI0ASmIHAhRA+0KVxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/istanbul-lib-coverage": "1.0.2"
|
||||
"@vitest/istanbul-lib-coverage": "2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
|
||||
"integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz",
|
||||
"integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.5",
|
||||
"@vitest/spy": "5.1.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^1.4.3"
|
||||
},
|
||||
@@ -2273,9 +2253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
|
||||
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz",
|
||||
"integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2283,9 +2263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
|
||||
"integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz",
|
||||
"integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -3243,9 +3223,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.43.0.tgz",
|
||||
"integrity": "sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==",
|
||||
"version": "6.44.1",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.1.tgz",
|
||||
"integrity": "sha512-Hbd8nvkIsNgFfXgC7Jsb5LHv7jj6VwA3tmfHd53XPX2z9Vc9RnAZqV0BIiP4OrtYqKwCoLseFHQZerb1bNlbSw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4218,9 +4198,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.186.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
|
||||
"integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
|
||||
"version": "0.187.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.187.0.tgz",
|
||||
"integrity": "sha512-Qiu7R+Cmh8CstZsY1uJOSWnVlfo7ZtNLV7mF8Jltk3LB3AtDu9cRZlcnRKkAk8Y4z6KKw35z7Y7Urd7jWY8etA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
@@ -4432,14 +4412,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
|
||||
"integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz",
|
||||
"integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.5",
|
||||
"@vitest/mocker": "5.1.0",
|
||||
"chai": "^6.3.0",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
@@ -4465,12 +4445,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.5",
|
||||
"@vitest/browser-preview": "5.0.5",
|
||||
"@vitest/browser-playwright": "5.1.0",
|
||||
"@vitest/browser-preview": "5.1.0",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/ui": "5.0.5",
|
||||
"@vitest/coverage-istanbul": "5.1.0",
|
||||
"@vitest/coverage-v8": "5.1.0",
|
||||
"@vitest/ui": "5.1.0",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
+5
-5
@@ -40,13 +40,13 @@
|
||||
"@comfyorg/comfyui-frontend-types": "^1.60.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@types/node": "^26.6.4",
|
||||
"@vitejs/plugin-vue": "^6.0.9",
|
||||
"@vitest/coverage-v8": "^5.0.5",
|
||||
"@types/node": "^26.6.6",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vitest/coverage-v8": "^5.1.0",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"eslint": "^10.12.0",
|
||||
"happy-dom": "^21.0.4",
|
||||
"knip": "^6.39.0",
|
||||
"knip": "^6.44.1",
|
||||
"oxlint": "^1.90.0",
|
||||
"prettier": "^3.9.10",
|
||||
"typescript": "^7.0.2",
|
||||
@@ -57,7 +57,7 @@
|
||||
"@tanstack/vue-virtual": "^3.13.39",
|
||||
"pinia": "^4.0.3",
|
||||
"primevue": "4.5.5",
|
||||
"three": "0.186.1",
|
||||
"three": "0.187.0",
|
||||
"vue": "^3.5.44"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -8,9 +9,12 @@ import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.streams import StreamReader
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
from mjr_am_backend.features.assets import trash_service as _real_trash_service
|
||||
from mjr_am_backend.routes.handlers import db_maintenance as m
|
||||
from mjr_am_backend.shared import Result
|
||||
|
||||
ts_types = types.SimpleNamespace(TrashScan=_real_trash_service.TrashScan)
|
||||
|
||||
|
||||
def _app():
|
||||
app = web.Application()
|
||||
@@ -476,3 +480,434 @@ async def test_db_backup_restore_rejects_oversized_json_when_content_length_is_s
|
||||
payload = json.loads(resp.text)
|
||||
assert payload.get("ok") is False
|
||||
assert payload.get("code") == "INVALID_INPUT"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trash status / empty routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeTrashService:
|
||||
"""Standalone fake for the trash_service module used by the routes."""
|
||||
|
||||
def __init__(self, *, supported=True, scan=None, empty=None):
|
||||
self.supported = supported
|
||||
self.scan_result = scan if scan is not None else ts_types.TrashScan(0, 0, ())
|
||||
self.empty_result = empty
|
||||
self.empty_calls: list[list] = []
|
||||
|
||||
def is_supported(self, platform=None):
|
||||
return self.supported
|
||||
|
||||
def scan(self, roots, **_kwargs):
|
||||
return self.scan_result
|
||||
|
||||
def empty(self, roots, **_kwargs):
|
||||
self.empty_calls.append(list(roots))
|
||||
if isinstance(self.empty_result, Exception):
|
||||
raise self.empty_result
|
||||
return self.empty_result if self.empty_result is not None else Result.Ok(
|
||||
{"removed": 0, "bytes": 0, "failed": [], "skipped_dirs": 0}
|
||||
)
|
||||
|
||||
|
||||
def _install_trash(monkeypatch, fake: _FakeTrashService):
|
||||
monkeypatch.setattr(m, "trash_service", fake, raising=False)
|
||||
|
||||
|
||||
def _reset_trash_rate_limit():
|
||||
from mjr_am_backend.routes.core.security_rate_limit import _reset_rate_limit_state_for_tests
|
||||
|
||||
_reset_rate_limit_state_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trash_rate_limit_clean():
|
||||
_reset_trash_rate_limit()
|
||||
yield
|
||||
_reset_trash_rate_limit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_status_success(monkeypatch, tmp_path: Path):
|
||||
scan = ts_types.TrashScan(3, 128, ())
|
||||
_install_trash(monkeypatch, _FakeTrashService(scan=scan))
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
|
||||
|
||||
app = _app()
|
||||
req = make_mocked_request("GET", "/mjr/am/trash", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
assert payload.get("ok") is True
|
||||
assert payload.get("data") == {"supported": True, "count": 3, "bytes": 128}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_status_unsupported_platform(monkeypatch):
|
||||
_install_trash(monkeypatch, _FakeTrashService(supported=False))
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
|
||||
app = _app()
|
||||
req = make_mocked_request("GET", "/mjr/am/trash", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
assert payload.get("ok") is True
|
||||
assert payload.get("data") == {"supported": False, "count": 0, "bytes": 0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_status_requires_write_access(monkeypatch):
|
||||
_install_trash(monkeypatch, _FakeTrashService())
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Err("FORBIDDEN", "no"))
|
||||
|
||||
app = _app()
|
||||
req = make_mocked_request("GET", "/mjr/am/trash", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
assert payload.get("ok") is False
|
||||
assert payload.get("code") == "FORBIDDEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_status_scan_error_reported(monkeypatch):
|
||||
def _boom(_roots, **_kwargs):
|
||||
raise PermissionError("scan failed")
|
||||
|
||||
fake = _FakeTrashService()
|
||||
fake.scan = _boom
|
||||
_install_trash(monkeypatch, fake)
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
monkeypatch.setattr(m, "_trash_roots", lambda: [], raising=False)
|
||||
|
||||
app = _app()
|
||||
req = make_mocked_request("GET", "/mjr/am/trash", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
assert payload.get("ok") is False
|
||||
assert payload.get("code") == "TRASH_ERROR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_empty_success(monkeypatch, tmp_path: Path):
|
||||
audit_calls: list[dict] = []
|
||||
|
||||
async def _audit(_services, *, request, operation, target, result, details=None):
|
||||
audit_calls.append(
|
||||
{
|
||||
"operation": operation,
|
||||
"target": target,
|
||||
"details": dict(details or {}),
|
||||
"ok": bool(result.ok),
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(m, "audit_log_write", _audit)
|
||||
fake = _FakeTrashService(
|
||||
empty=Result.Ok({"removed": 2, "bytes": 99, "failed": [], "skipped_dirs": 1})
|
||||
)
|
||||
_install_trash(monkeypatch, fake)
|
||||
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
|
||||
async def _require_services():
|
||||
return {"settings": object()}, None
|
||||
|
||||
monkeypatch.setattr(m, "_require_services", _require_services)
|
||||
|
||||
async def _prefs(_svc):
|
||||
return {"safe_mode": False, "allow_delete": True}
|
||||
|
||||
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
|
||||
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
|
||||
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
|
||||
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
|
||||
_reset_trash_rate_limit()
|
||||
try:
|
||||
app = _app()
|
||||
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
finally:
|
||||
_reset_trash_rate_limit()
|
||||
assert payload.get("ok") is True
|
||||
assert payload.get("data") == {"removed": 2, "bytes": 99, "failed": [], "skipped_dirs": 1}
|
||||
assert fake.empty_calls == [[tmp_path]]
|
||||
assert len(audit_calls) == 1
|
||||
assert audit_calls[0]["operation"] == "trash_empty"
|
||||
assert audit_calls[0]["details"].get("removed") == 2
|
||||
assert audit_calls[0]["details"].get("bytes") == 99
|
||||
assert audit_calls[0]["details"].get("skipped_dirs") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_empty_reports_failed_entries(monkeypatch, tmp_path: Path):
|
||||
failed = [{"name": "a.png", "error": "permission denied"}]
|
||||
fake = _FakeTrashService(
|
||||
empty=Result.Ok({"removed": 1, "bytes": 5, "failed": failed, "skipped_dirs": 0})
|
||||
)
|
||||
_install_trash(monkeypatch, fake)
|
||||
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
|
||||
async def _require_services():
|
||||
return {"settings": object()}, None
|
||||
|
||||
monkeypatch.setattr(m, "_require_services", _require_services)
|
||||
|
||||
async def _prefs(_svc):
|
||||
return {"safe_mode": False, "allow_delete": True}
|
||||
|
||||
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
|
||||
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
|
||||
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
|
||||
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
|
||||
_reset_trash_rate_limit()
|
||||
try:
|
||||
app = _app()
|
||||
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
finally:
|
||||
_reset_trash_rate_limit()
|
||||
assert payload.get("ok") is True
|
||||
assert payload.get("data", {}).get("failed") == failed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_empty_unsupported_platform(monkeypatch):
|
||||
fake = _FakeTrashService(supported=False)
|
||||
_install_trash(monkeypatch, fake)
|
||||
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
_reset_trash_rate_limit()
|
||||
try:
|
||||
app = _app()
|
||||
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
finally:
|
||||
_reset_trash_rate_limit()
|
||||
assert payload.get("ok") is False
|
||||
assert payload.get("code") == "UNSUPPORTED"
|
||||
assert fake.empty_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_empty_blocked_when_delete_disabled(monkeypatch):
|
||||
"""Acceptance: MAJOOR_ALLOW_DELETE unset → POST refused, nothing removed."""
|
||||
fake = _FakeTrashService()
|
||||
_install_trash(monkeypatch, fake)
|
||||
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
|
||||
async def _require_services():
|
||||
return {"settings": object()}, None
|
||||
|
||||
monkeypatch.setattr(m, "_require_services", _require_services)
|
||||
|
||||
async def _prefs(_svc):
|
||||
return {"safe_mode": False, "allow_delete": True}
|
||||
|
||||
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
|
||||
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
|
||||
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
|
||||
monkeypatch.setattr(
|
||||
m,
|
||||
"_require_operation_enabled",
|
||||
lambda *_args, **_kwargs: Result.Err("FORBIDDEN", "Delete is disabled by default."),
|
||||
)
|
||||
_reset_trash_rate_limit()
|
||||
try:
|
||||
app = _app()
|
||||
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
finally:
|
||||
_reset_trash_rate_limit()
|
||||
assert payload.get("ok") is False
|
||||
assert payload.get("code") == "FORBIDDEN"
|
||||
assert fake.empty_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_empty_csrf_required(monkeypatch):
|
||||
_install_trash(monkeypatch, _FakeTrashService())
|
||||
monkeypatch.setattr(m, "_csrf_error", lambda _request: "missing csrf token")
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
_reset_trash_rate_limit()
|
||||
try:
|
||||
app = _app()
|
||||
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
finally:
|
||||
_reset_trash_rate_limit()
|
||||
assert payload.get("ok") is False
|
||||
assert payload.get("code") == "CSRF"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_empty_rate_limited(monkeypatch):
|
||||
fake = _FakeTrashService()
|
||||
_install_trash(monkeypatch, fake)
|
||||
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
|
||||
async def _require_services():
|
||||
return {"settings": object()}, None
|
||||
|
||||
monkeypatch.setattr(m, "_require_services", _require_services)
|
||||
|
||||
async def _prefs(_svc):
|
||||
return {"safe_mode": False, "allow_delete": True}
|
||||
|
||||
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
|
||||
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
|
||||
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
|
||||
monkeypatch.setattr(m, "_trash_roots", lambda: [], raising=False)
|
||||
_reset_trash_rate_limit()
|
||||
try:
|
||||
app = _app()
|
||||
codes = []
|
||||
for _ in range(4):
|
||||
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
codes.append(json.loads(resp.text).get("code"))
|
||||
finally:
|
||||
_reset_trash_rate_limit()
|
||||
assert codes[:3] == ["OK", "OK", "OK"]
|
||||
assert codes[3] == "RATE_LIMITED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_empty_real_gate_refuses_when_delete_disabled(monkeypatch, tmp_path: Path):
|
||||
"""Wire the REAL operation gate: trash_empty refused while delete is off."""
|
||||
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
|
||||
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
|
||||
fake = _FakeTrashService()
|
||||
_install_trash(monkeypatch, fake)
|
||||
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
|
||||
async def _require_services():
|
||||
return {"settings": object()}, None
|
||||
|
||||
monkeypatch.setattr(m, "_require_services", _require_services)
|
||||
|
||||
async def _prefs(_svc):
|
||||
return {"safe_mode": False, "allow_delete": False}
|
||||
|
||||
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
|
||||
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
|
||||
_reset_trash_rate_limit()
|
||||
try:
|
||||
app = _app()
|
||||
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
finally:
|
||||
_reset_trash_rate_limit()
|
||||
assert payload.get("ok") is False
|
||||
assert payload.get("code") == "FORBIDDEN"
|
||||
assert fake.empty_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_empty_real_gate_allows_when_delete_enabled(monkeypatch, tmp_path: Path):
|
||||
"""Wire the REAL operation gate: allow_delete permits trash_empty in safe mode."""
|
||||
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
|
||||
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
|
||||
fake = _FakeTrashService(
|
||||
empty=Result.Ok({"removed": 1, "bytes": 2, "failed": [], "skipped_dirs": 0})
|
||||
)
|
||||
_install_trash(monkeypatch, fake)
|
||||
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
|
||||
async def _require_services():
|
||||
return {"settings": object()}, None
|
||||
|
||||
monkeypatch.setattr(m, "_require_services", _require_services)
|
||||
|
||||
async def _prefs(_svc):
|
||||
return {"safe_mode": True, "allow_delete": True}
|
||||
|
||||
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
|
||||
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
|
||||
_reset_trash_rate_limit()
|
||||
try:
|
||||
app = _app()
|
||||
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
finally:
|
||||
_reset_trash_rate_limit()
|
||||
assert payload.get("ok") is True
|
||||
assert payload.get("data") == {"removed": 1, "bytes": 2, "failed": [], "skipped_dirs": 0}
|
||||
assert fake.empty_calls == [[tmp_path]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_empty_real_gate_allows_via_env(monkeypatch, tmp_path: Path):
|
||||
"""MAJOOR_ALLOW_DELETE=1 unlocks trash_empty like deletion."""
|
||||
monkeypatch.setenv("MAJOOR_ALLOW_DELETE", "1")
|
||||
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
|
||||
fake = _FakeTrashService()
|
||||
_install_trash(monkeypatch, fake)
|
||||
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
|
||||
async def _require_services():
|
||||
return {"settings": object()}, None
|
||||
|
||||
monkeypatch.setattr(m, "_require_services", _require_services)
|
||||
|
||||
async def _prefs(_svc):
|
||||
return {"safe_mode": True}
|
||||
|
||||
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
|
||||
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
|
||||
_reset_trash_rate_limit()
|
||||
try:
|
||||
app = _app()
|
||||
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
payload = json.loads(resp.text)
|
||||
finally:
|
||||
_reset_trash_rate_limit()
|
||||
assert payload.get("ok") is True
|
||||
assert fake.empty_calls == [[tmp_path]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trash_status_rate_limited(monkeypatch):
|
||||
"""GET /mjr/am/trash is rate-limited to 20 requests per 60 seconds."""
|
||||
_install_trash(monkeypatch, _FakeTrashService())
|
||||
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
|
||||
_reset_trash_rate_limit()
|
||||
try:
|
||||
app = _app()
|
||||
codes = []
|
||||
for _ in range(21):
|
||||
req = make_mocked_request("GET", "/mjr/am/trash", app=app)
|
||||
match = await app.router.resolve(req)
|
||||
resp = await match.handler(req)
|
||||
codes.append(json.loads(resp.text).get("code"))
|
||||
finally:
|
||||
_reset_trash_rate_limit()
|
||||
assert codes[:20] == ["OK"] * 20
|
||||
assert codes[20] == "RATE_LIMITED"
|
||||
|
||||
@@ -0,0 +1,994 @@
|
||||
# mypy: disable-error-code="index,union-attr"
|
||||
"""Unit tests for the freedesktop trash service (synthetic trees only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mjr_am_backend.features.assets import trash_service as ts
|
||||
|
||||
UID = os.getuid()
|
||||
FOREIGN_UID = UID + 1
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_trash_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Keep the service away from the real home/XDG trash of the test host."""
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "no-xdg"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_mount(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Treat tmp_path itself as a mount point; everything else uses the real check."""
|
||||
real_ismount = os.path.ismount
|
||||
|
||||
def _fake_ismount(path):
|
||||
try:
|
||||
if Path(str(path)) == tmp_path:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return real_ismount(path)
|
||||
|
||||
monkeypatch.setattr(os.path, "ismount", _fake_ismount)
|
||||
|
||||
|
||||
def _make_admin_trash(topdir: Path, uid: int = UID) -> Path:
|
||||
td = topdir / f".Trash-{uid}"
|
||||
(td / "info").mkdir(parents=True)
|
||||
(td / "files").mkdir(parents=True)
|
||||
return td
|
||||
|
||||
|
||||
def _write_trashinfo(td: Path, name: str, path_value: str) -> Path:
|
||||
info = td / "info" / f"{name}.trashinfo"
|
||||
info.write_text(
|
||||
f"[Trash Info]\nPath={path_value}\nDeletionDate=2026-01-01T00:00:00\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
class TestIsSupported:
|
||||
def test_freedesktop_platforms_supported(self):
|
||||
assert ts.is_supported("linux") is True
|
||||
assert ts.is_supported("linux2") is True
|
||||
assert ts.is_supported("freebsd14") is True
|
||||
assert ts.is_supported("openbsd7") is True
|
||||
assert ts.is_supported("netbsd10") is True
|
||||
|
||||
def test_windows_and_macos_unsupported(self):
|
||||
assert ts.is_supported("win32") is False
|
||||
assert ts.is_supported("darwin") is False
|
||||
|
||||
def test_default_uses_sys_platform(self):
|
||||
import sys
|
||||
|
||||
assert ts.is_supported() is ts.is_supported(sys.platform)
|
||||
|
||||
|
||||
class TestTrashDirs:
|
||||
def test_home_trash_included_with_xdg_topdir(self, tmp_path: Path):
|
||||
xdg = tmp_path / "xdg"
|
||||
(xdg / "Trash" / "info").mkdir(parents=True)
|
||||
(xdg / "Trash" / "files").mkdir(parents=True)
|
||||
dirs = ts.trash_dirs([], uid=UID, xdg_data_home=xdg)
|
||||
assert len(dirs) == 1
|
||||
assert dirs[0].path == xdg / "Trash"
|
||||
assert dirs[0].topdir == xdg
|
||||
|
||||
def test_home_trash_default_derived_from_home(self, tmp_path: Path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
|
||||
dirs = ts.trash_dirs([], uid=UID, home=home)
|
||||
assert dirs == []
|
||||
(home / ".local" / "share" / "Trash" / "info").mkdir(parents=True)
|
||||
dirs = ts.trash_dirs([], uid=UID, home=home)
|
||||
assert [d.path for d in dirs] == [home / ".local" / "share" / "Trash"]
|
||||
assert dirs[0].topdir == home / ".local" / "share"
|
||||
|
||||
def test_home_trash_env_var_used(self, tmp_path: Path, monkeypatch):
|
||||
xdg = tmp_path / "envxdg"
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(xdg))
|
||||
home = tmp_path / "home"
|
||||
(xdg / "Trash" / "info").mkdir(parents=True)
|
||||
dirs = ts.trash_dirs([], uid=UID, home=home)
|
||||
assert [d.path for d in dirs] == [xdg / "Trash"]
|
||||
|
||||
def test_home_trash_symlink_skipped(self, tmp_path: Path):
|
||||
xdg = tmp_path / "xdg"
|
||||
xdg.mkdir()
|
||||
target = tmp_path / "real-trash"
|
||||
target.mkdir()
|
||||
os.symlink(target, xdg / "Trash")
|
||||
dirs = ts.trash_dirs([], uid=UID, xdg_data_home=xdg)
|
||||
assert dirs == []
|
||||
|
||||
def test_home_trash_foreign_owner_skipped(self, tmp_path: Path):
|
||||
xdg = tmp_path / "xdg"
|
||||
(xdg / "Trash" / "info").mkdir(parents=True)
|
||||
dirs = ts.trash_dirs([], uid=FOREIGN_UID, xdg_data_home=xdg)
|
||||
assert dirs == []
|
||||
|
||||
def test_home_trash_group_writable_skipped(self, tmp_path: Path):
|
||||
xdg = tmp_path / "xdg"
|
||||
trash = xdg / "Trash"
|
||||
(trash / "info").mkdir(parents=True)
|
||||
trash.chmod(0o770)
|
||||
try:
|
||||
dirs = ts.trash_dirs([], uid=UID, xdg_data_home=xdg)
|
||||
finally:
|
||||
trash.chmod(0o755)
|
||||
assert dirs == []
|
||||
|
||||
def test_admin_trash_uid_dir_for_root_mount(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
assert [d.path for d in dirs] == [td]
|
||||
assert dirs[0].topdir == tmp_path
|
||||
|
||||
def test_admin_trash_foreign_owner_skipped(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
assert td.stat().st_uid == UID
|
||||
dirs = ts.trash_dirs([root], uid=FOREIGN_UID)
|
||||
assert dirs == []
|
||||
|
||||
def test_admin_trash_world_writable_skipped(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
td.chmod(0o777)
|
||||
try:
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
finally:
|
||||
td.chmod(0o755)
|
||||
assert dirs == []
|
||||
|
||||
def test_group_writable_files_subdir_skips_dir(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files").chmod(0o777)
|
||||
try:
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
finally:
|
||||
(td / "files").chmod(0o755)
|
||||
assert dirs == []
|
||||
|
||||
def test_foreign_owned_info_subdir_skips_dir(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
assert (td / "info").stat().st_uid == UID
|
||||
dirs = ts.trash_dirs([root], uid=FOREIGN_UID)
|
||||
assert dirs == []
|
||||
|
||||
def test_missing_files_subdir_tolerated(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = tmp_path / f".Trash-{UID}"
|
||||
(td / "info").mkdir(parents=True)
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
assert [d.path for d in dirs] == [td]
|
||||
|
||||
def test_sticky_trash_dir_included(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
sticky = tmp_path / ".Trash"
|
||||
sticky.mkdir()
|
||||
sticky.chmod(sticky.stat().st_mode | stat.S_ISVTX)
|
||||
(sticky / str(UID) / "info").mkdir(parents=True)
|
||||
(sticky / str(UID) / "files").mkdir(parents=True)
|
||||
_make_admin_trash(tmp_path)
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
assert {d.path for d in dirs} == {tmp_path / f".Trash-{UID}", sticky / str(UID)}
|
||||
sticky_entry = next(d for d in dirs if d.path == sticky / str(UID))
|
||||
assert sticky_entry.topdir == tmp_path
|
||||
|
||||
def test_sticky_dir_foreign_uid_child_skipped(self, tmp_path: Path):
|
||||
"""Sticky .Trash is fine for anyone, but .Trash/<uid> must be ours."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
sticky = tmp_path / ".Trash"
|
||||
sticky.mkdir()
|
||||
sticky.chmod(sticky.stat().st_mode | stat.S_ISVTX)
|
||||
child = sticky / str(FOREIGN_UID)
|
||||
(child / "info").mkdir(parents=True)
|
||||
(child / "files").mkdir(parents=True)
|
||||
dirs = ts.trash_dirs([root], uid=FOREIGN_UID)
|
||||
assert dirs == []
|
||||
|
||||
def test_non_sticky_trash_dir_skipped(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
plain = tmp_path / ".Trash"
|
||||
(plain / str(UID) / "info").mkdir(parents=True)
|
||||
_make_admin_trash(tmp_path)
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
assert {d.path for d in dirs} == {tmp_path / f".Trash-{UID}"}
|
||||
|
||||
def test_symlinked_dot_trash_skipped(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
other = tmp_path / "evil"
|
||||
other.mkdir()
|
||||
(other / str(UID) / "info").mkdir(parents=True)
|
||||
os.symlink(other, tmp_path / ".Trash", target_is_directory=True)
|
||||
_make_admin_trash(tmp_path)
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
assert {d.path for d in dirs} == {tmp_path / f".Trash-{UID}"}
|
||||
|
||||
def test_symlinked_trash_dir_skipped(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
target = tmp_path / "realtrash"
|
||||
target.mkdir()
|
||||
os.symlink(target, tmp_path / f".Trash-{UID}", target_is_directory=True)
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
assert dirs == []
|
||||
|
||||
def test_symlinked_files_or_info_skips_dir(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = tmp_path / f".Trash-{UID}"
|
||||
td.mkdir()
|
||||
(td / "files").mkdir()
|
||||
evil = tmp_path / "evilinfo"
|
||||
evil.mkdir()
|
||||
os.symlink(evil, td / "info")
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
assert dirs == []
|
||||
|
||||
def test_dedupe_same_mount_scanned_once(self, tmp_path: Path):
|
||||
root_a = tmp_path / "out"
|
||||
root_a.mkdir()
|
||||
root_b = tmp_path / "custom"
|
||||
root_b.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
dirs = ts.trash_dirs([root_a, root_b], uid=UID)
|
||||
assert [d.path for d in dirs] == [td]
|
||||
|
||||
def test_root_alias_deduped(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
alias = tmp_path / "link-out"
|
||||
os.symlink(root, alias, target_is_directory=True)
|
||||
td = _make_admin_trash(tmp_path)
|
||||
dirs = ts.trash_dirs([root, alias], uid=UID)
|
||||
assert [d.path for d in dirs] == [td]
|
||||
|
||||
def test_invalid_root_skipped(self, tmp_path: Path):
|
||||
dirs = ts.trash_dirs(["/bad\x00root"], uid=UID)
|
||||
assert dirs == []
|
||||
|
||||
def test_missing_trash_dirs_not_reported(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
assert dirs == []
|
||||
|
||||
def test_uid_defaults_to_current_user(self, tmp_path: Path):
|
||||
xdg = tmp_path / "xdg"
|
||||
(xdg / "Trash" / "info").mkdir(parents=True)
|
||||
dirs = ts.trash_dirs([], xdg_data_home=xdg)
|
||||
assert [d.path for d in dirs] == [xdg / "Trash"]
|
||||
|
||||
def test_mount_point_fallback_when_no_mount_found(self, tmp_path: Path, monkeypatch):
|
||||
root = tmp_path / "deep" / "out"
|
||||
root.mkdir(parents=True)
|
||||
monkeypatch.setattr(os.path, "ismount", lambda _p: False)
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
assert dirs == []
|
||||
|
||||
def test_candidate_dedupe_symlinked_alias_trash_dir(self, tmp_path: Path, monkeypatch):
|
||||
m1 = tmp_path / "m1"
|
||||
m2 = tmp_path / "m2"
|
||||
(m1 / "out").mkdir(parents=True)
|
||||
(m2 / "out").mkdir(parents=True)
|
||||
real_trash = m1 / f".Trash-{UID}"
|
||||
(real_trash / "info").mkdir(parents=True)
|
||||
(real_trash / "files").mkdir(parents=True)
|
||||
os.symlink(real_trash, m2 / f".Trash-{UID}", target_is_directory=True)
|
||||
real_ismount = os.path.ismount
|
||||
|
||||
def _fake_ismount(path):
|
||||
if Path(str(path)) in (m1, m2):
|
||||
return True
|
||||
return real_ismount(path)
|
||||
|
||||
monkeypatch.setattr(os.path, "ismount", _fake_ismount)
|
||||
dirs = ts.trash_dirs([m1 / "out", m2 / "out"], uid=UID)
|
||||
assert [d.path for d in dirs] == [real_trash]
|
||||
|
||||
|
||||
class TestScan:
|
||||
def test_relative_path_inside_root(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"12345")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 1
|
||||
assert scan.bytes == 5
|
||||
entry = scan.entries[0]
|
||||
assert entry.trash_dir == td
|
||||
assert entry.payload_name == "a.png"
|
||||
assert entry.info_name == "a.png.trashinfo"
|
||||
|
||||
def test_absolute_encoded_path_home_trash(self, tmp_path: Path):
|
||||
root = tmp_path / "roots" / "out"
|
||||
root.mkdir(parents=True)
|
||||
xdg = tmp_path / "xdg"
|
||||
td = xdg / "Trash"
|
||||
(td / "info").mkdir(parents=True)
|
||||
(td / "files").mkdir(parents=True)
|
||||
(td / "files" / "my cat 2.png").write_bytes(b"x" * 7)
|
||||
_write_trashinfo(td, "my cat 2.png", f"{root}/my%20cat%202.png")
|
||||
scan = ts.scan([root], uid=UID, xdg_data_home=xdg)
|
||||
assert scan.count == 1
|
||||
assert scan.bytes == 7
|
||||
|
||||
def test_outside_root_ignored(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "x.png").write_bytes(b"zz")
|
||||
_write_trashinfo(td, "x.png", "/etc/passwd")
|
||||
_write_trashinfo(td, "y.png", "other/app/x.png")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
def test_traversal_escape_ignored(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "t.png").write_bytes(b"z")
|
||||
_write_trashinfo(td, "t.png", "out/../../etc/x")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
def test_string_prefix_not_containment(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
sibling = tmp_path / "outwardly"
|
||||
sibling.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "p.png").write_bytes(b"z")
|
||||
_write_trashinfo(td, "p.png", str(sibling / "p.png"))
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
def test_nul_in_path_ignored_valid_entry_still_selected(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"abc")
|
||||
(td / "files" / "n.png").write_bytes(b"n")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
_write_trashinfo(td, "n.png", "out/a%00.png")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 1
|
||||
assert scan.entries[0].payload_name == "a.png"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
"no header\nPath=/out/a.png\n",
|
||||
"[Trash Info]\nDeletionDate=2026-01-01T00:00:00\n",
|
||||
"[Trash Info]\nPath=\n",
|
||||
"\x00\x01\x02binary",
|
||||
],
|
||||
ids=["no-header", "no-path", "empty-path", "binary"],
|
||||
)
|
||||
def test_malformed_info_ignored(self, tmp_path: Path, content: str):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "m.png").write_bytes(b"z")
|
||||
(td / "info" / "m.png.trashinfo").write_bytes(content.encode("utf-8", errors="ignore"))
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
def test_info_symlink_ignored(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
real_info = tmp_path / "elsewhere.trashinfo"
|
||||
real_info.write_text(f"[Trash Info]\nPath={root}/a.png\n")
|
||||
os.symlink(real_info, td / "info" / "a.png.trashinfo")
|
||||
(td / "files" / "a.png").write_bytes(b"z")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
def test_junk_file_in_info_dir_ignored(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "info" / "notes.txt").write_text("not a trashinfo")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
def test_info_lstat_error_ignored(self, tmp_path: Path, monkeypatch):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
info = td / "info" / "a.png.trashinfo"
|
||||
info.write_text("[Trash Info]\nPath=out/a.png\n")
|
||||
real_lstat = os.lstat
|
||||
|
||||
def _guarded_lstat(path, *args, **kwargs):
|
||||
if str(path) == str(info):
|
||||
raise PermissionError("denied")
|
||||
return real_lstat(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(os, "lstat", _guarded_lstat)
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
def test_hostile_info_entry_never_fails_scan(self, tmp_path: Path):
|
||||
"""A DirEntry whose metadata raises is skipped, not fatal."""
|
||||
|
||||
class _Hostile:
|
||||
@property
|
||||
def name(self):
|
||||
raise ValueError("hostile name")
|
||||
|
||||
path = "irrelevant"
|
||||
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"abc")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
|
||||
real_scandir = os.scandir
|
||||
|
||||
def _scandir_with_hostile(path, *args, **kwargs):
|
||||
listing = list(real_scandir(path, *args, **kwargs))
|
||||
if Path(str(path)) == td / "info":
|
||||
listing.append(_Hostile())
|
||||
return listing
|
||||
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr(os, "scandir", _scandir_with_hostile)
|
||||
try:
|
||||
scan = ts.scan([root], uid=UID)
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
assert scan.count == 1
|
||||
|
||||
def test_directory_payload_size_zero(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
payload = td / "files" / "d"
|
||||
(payload / "sub").mkdir(parents=True)
|
||||
(payload / "one.png").write_bytes(b"a" * 10)
|
||||
(payload / "sub" / "two.png").write_bytes(b"b" * 20)
|
||||
_write_trashinfo(td, "d", "out/d")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 1
|
||||
assert scan.bytes == 0
|
||||
|
||||
def test_symlink_payload_size_from_lstat(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
target = tmp_path / "keep.png"
|
||||
target.write_bytes(b"x" * 100)
|
||||
os.symlink(target, td / "files" / "link.png")
|
||||
_write_trashinfo(td, "link.png", "out/link.png")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 1
|
||||
# Size must come from the link itself (lstat), never from the target.
|
||||
assert scan.bytes == os.lstat(td / "files" / "link.png").st_size
|
||||
assert scan.bytes != 100
|
||||
|
||||
def test_missing_payload_size_zero(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
_write_trashinfo(td, "gone.png", "out/gone.png")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 1
|
||||
assert scan.bytes == 0
|
||||
|
||||
def test_dedupe_two_roots_same_mount_counted_once(self, tmp_path: Path):
|
||||
out_root = tmp_path / "out"
|
||||
custom_root = tmp_path / "custom"
|
||||
out_root.mkdir()
|
||||
custom_root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"123")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
scan = ts.scan([out_root, custom_root], uid=UID)
|
||||
assert scan.count == 1
|
||||
|
||||
def test_same_root_twice_deduped(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"123")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
scan = ts.scan([root, root], uid=UID)
|
||||
assert scan.count == 1
|
||||
|
||||
def test_missing_info_dir_tolerated(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = tmp_path / f".Trash-{UID}"
|
||||
(td / "files").mkdir(parents=True)
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
def test_info_scandir_oserror_tolerated(self, tmp_path: Path, monkeypatch):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
real_scandir = os.scandir
|
||||
|
||||
def _raise_on_info(path, *args, **kwargs):
|
||||
if Path(str(path)) == td / "info":
|
||||
raise PermissionError("denied")
|
||||
return real_scandir(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(os, "scandir", _raise_on_info)
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
def test_read_error_info_ignored(self, tmp_path: Path):
|
||||
"""A directory as the info target: reading fails, treated as malformed."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "info" / "weird.trashinfo").mkdir()
|
||||
assert ts._parse_trashinfo(td / "info" / "weird.trashinfo") is None
|
||||
|
||||
def test_info_swapped_for_symlink_malformed(self, tmp_path: Path):
|
||||
"""A symlink swapped in after the lstat fails O_NOFOLLOW → malformed."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
target = tmp_path / "planted.trashinfo"
|
||||
target.write_text("[Trash Info]\nPath=out/a.png\n")
|
||||
os.symlink(target, td / "info" / "a.png.trashinfo")
|
||||
assert ts._parse_trashinfo(td / "info" / "a.png.trashinfo") is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"crafted",
|
||||
[".trashinfo", "..trashinfo", "...trashinfo"],
|
||||
ids=["empty-stem", "dot", "dot-dot"],
|
||||
)
|
||||
def test_crafted_info_names_rejected(self, tmp_path: Path, crafted: str):
|
||||
"""Info basenames whose stem is empty/./.. must never target files/ or the trash dir."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "info" / crafted).write_text("[Trash Info]\nPath=out/a.png\n")
|
||||
(td / "files" / "foreign.png").write_bytes(b"foreign")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (td / "files" / "foreign.png").read_bytes() == b"foreign"
|
||||
assert (td / "info" / crafted).exists()
|
||||
assert (td / "info").is_dir()
|
||||
assert (td / "files").is_dir()
|
||||
|
||||
def test_symlinked_root_prefix_path_selected(self, tmp_path: Path):
|
||||
"""Path= recorded through a symlinked root prefix still matches the root."""
|
||||
real_root = tmp_path / "data" / "out"
|
||||
real_root.mkdir(parents=True)
|
||||
alias = tmp_path / "link"
|
||||
os.symlink(tmp_path / "data", alias, target_is_directory=True)
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"abc")
|
||||
_write_trashinfo(td, "a.png", str(alias / "out" / "a.png"))
|
||||
scan = ts.scan([alias / "out"], uid=UID)
|
||||
assert scan.count == 1
|
||||
result = ts.empty([alias / "out"], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 1
|
||||
assert not (td / "files" / "a.png").exists()
|
||||
|
||||
def test_path_realpath_outside_roots_ignored(self, tmp_path: Path):
|
||||
"""A Path whose lexical and realpath forms both sit outside roots is ignored."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
decoy = tmp_path / "decoy"
|
||||
os.symlink(outside, decoy, target_is_directory=True)
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "x.png").write_bytes(b"x")
|
||||
_write_trashinfo(td, "x.png", str(decoy / "x.png"))
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
def test_oversized_trashinfo_ignored(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "big.png").write_bytes(b"z")
|
||||
padding = "x" * (70 * 1024)
|
||||
(td / "info" / "big.png.trashinfo").write_text(
|
||||
f"[Trash Info]\nPath=out/big.png\nJunk={padding}\n", encoding="utf-8"
|
||||
)
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 0
|
||||
|
||||
|
||||
class TestEntryFromInfoSubGuards:
|
||||
"""Defence-in-depth guards real scandir output can never reach."""
|
||||
|
||||
def _trash_dir(self, tmp_path: Path) -> ts.TrashDir:
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "ok.png").write_bytes(b"z")
|
||||
_write_trashinfo(td, "ok.png", "out/ok.png")
|
||||
dirs = ts.trash_dirs([root], uid=UID)
|
||||
assert len(dirs) == 1
|
||||
return dirs[0]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"sub/a.png.trashinfo",
|
||||
"a\x00b.png.trashinfo",
|
||||
"a\\b.png.trashinfo",
|
||||
],
|
||||
ids=["sep", "nul", "altsep"],
|
||||
)
|
||||
def test_separator_or_nul_stem_rejected(self, tmp_path: Path, name: str):
|
||||
trash_dir = self._trash_dir(tmp_path)
|
||||
fake = type("FakeDirEntry", (), {"name": name, "path": str(trash_dir.files_dir / "x")})()
|
||||
root = tmp_path / "out"
|
||||
entry = ts._entry_from_info(fake, trash_dir, [root], [root])
|
||||
assert entry is None
|
||||
|
||||
def test_valid_fake_entry_selected(self, tmp_path: Path):
|
||||
trash_dir = self._trash_dir(tmp_path)
|
||||
(trash_dir.files_dir / "fake.png").write_bytes(b"1234")
|
||||
info_file = trash_dir.info_dir / "fake.png.trashinfo"
|
||||
info_file.write_text("[Trash Info]\nPath=out/fake.png\n")
|
||||
fake = type("FakeDirEntry", (), {"name": "fake.png.trashinfo", "path": str(info_file)})()
|
||||
root = tmp_path / "out"
|
||||
entry = ts._entry_from_info(fake, trash_dir, [root], [root])
|
||||
assert entry is not None
|
||||
assert entry.payload_name == "fake.png"
|
||||
assert entry.size == 4
|
||||
|
||||
|
||||
class TestEmpty:
|
||||
def test_removes_only_majoor_entries(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
for i in range(3):
|
||||
(td / "files" / f"m{i}.png").write_bytes(f"content{i}".encode())
|
||||
_write_trashinfo(td, f"m{i}.png", f"out/m{i}.png")
|
||||
(td / "files" / "foreign.png").write_bytes(b"foreign-bytes")
|
||||
foreign_info = _write_trashinfo(td, "foreign.png", "/elsewhere/foreign.png")
|
||||
(td / "files" / "orphan.png").write_bytes(b"orphan-bytes")
|
||||
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 3
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok
|
||||
assert result.data["removed"] == 3
|
||||
assert result.data["bytes"] == len("content0") + len("content1") + len("content2")
|
||||
assert result.data["failed"] == []
|
||||
assert result.data["skipped_dirs"] == 0
|
||||
for i in range(3):
|
||||
assert not (td / "files" / f"m{i}.png").exists()
|
||||
assert not (td / "info" / f"m{i}.png.trashinfo").exists()
|
||||
assert (td / "files" / "foreign.png").read_bytes() == b"foreign-bytes"
|
||||
assert foreign_info.exists()
|
||||
assert (td / "files" / "orphan.png").read_bytes() == b"orphan-bytes"
|
||||
|
||||
def test_symlink_payload_unlinked_target_intact(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
target = tmp_path / "outside" / "keep.png"
|
||||
target.parent.mkdir()
|
||||
target.write_bytes(b"precious")
|
||||
os.symlink(target, td / "files" / "a.png")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 1
|
||||
assert not (td / "files" / "a.png").is_symlink()
|
||||
assert target.read_bytes() == b"precious"
|
||||
|
||||
def test_directory_payload_skipped_never_removed(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
payload = td / "files" / "d"
|
||||
(payload / "inner.png").mkdir(parents=True)
|
||||
(payload / "inner.png" / "x.png").write_bytes(b"xxx")
|
||||
_write_trashinfo(td, "d", "out/d")
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok
|
||||
assert result.data["removed"] == 0
|
||||
assert result.data["skipped_dirs"] == 1
|
||||
assert (payload / "inner.png" / "x.png").read_bytes() == b"xxx"
|
||||
assert (td / "info" / "d.trashinfo").exists()
|
||||
|
||||
def test_fifo_payload_skipped(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
os.mkfifo(td / "files" / "pipe")
|
||||
_write_trashinfo(td, "pipe", "out/pipe")
|
||||
try:
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok
|
||||
assert result.data["removed"] == 0
|
||||
assert result.data["skipped_dirs"] == 1
|
||||
assert (td / "files" / "pipe").exists()
|
||||
finally:
|
||||
(td / "files" / "pipe").unlink()
|
||||
|
||||
def test_vanished_payload_still_removes_info(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
info = _write_trashinfo(td, "a.png", "out/a.png")
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok
|
||||
assert result.data["removed"] == 1
|
||||
assert result.data == {"removed": 1, "bytes": 0, "failed": [], "skipped_dirs": 0}
|
||||
assert not info.exists()
|
||||
|
||||
def test_payload_unlink_file_not_found_tolerated(self, tmp_path: Path, monkeypatch):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
payload = td / "files" / "a.png"
|
||||
payload.write_bytes(b"x")
|
||||
info = _write_trashinfo(td, "a.png", "out/a.png")
|
||||
|
||||
real_unlink = os.unlink
|
||||
|
||||
def _vanishing_unlink(path, *args, **kwargs):
|
||||
if str(path) == "a.png":
|
||||
raise FileNotFoundError("vanished")
|
||||
return real_unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(os, "unlink", _vanishing_unlink)
|
||||
try:
|
||||
result = ts.empty([root], uid=UID)
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
assert result.ok and result.data["removed"] == 1
|
||||
assert not info.exists()
|
||||
|
||||
def test_info_unlink_file_not_found_tolerated(self, tmp_path: Path, monkeypatch):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
payload = td / "files" / "a.png"
|
||||
payload.write_bytes(b"x")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
|
||||
real_unlink = os.unlink
|
||||
|
||||
def _vanishing_unlink(path, *args, **kwargs):
|
||||
if str(path) == "a.png.trashinfo":
|
||||
raise FileNotFoundError("vanished")
|
||||
return real_unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(os, "unlink", _vanishing_unlink)
|
||||
try:
|
||||
result = ts.empty([root], uid=UID)
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
assert result.ok and result.data["removed"] == 1
|
||||
assert not payload.exists()
|
||||
|
||||
def test_payload_unlink_permission_error_recorded(self, tmp_path: Path, monkeypatch):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
blocked = td / "files" / "blocked.png"
|
||||
blocked.write_bytes(b"secret")
|
||||
_write_trashinfo(td, "blocked.png", "out/blocked.png")
|
||||
(td / "files" / "fine.png").write_bytes(b"f")
|
||||
_write_trashinfo(td, "fine.png", "out/fine.png")
|
||||
|
||||
real_unlink = os.unlink
|
||||
|
||||
def _guarded_unlink(path, *args, **kwargs):
|
||||
if str(path) == "blocked.png":
|
||||
raise PermissionError("permission denied")
|
||||
return real_unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(os, "unlink", _guarded_unlink)
|
||||
try:
|
||||
result = ts.empty([root], uid=UID)
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
assert result.ok
|
||||
assert result.data["removed"] == 1
|
||||
assert len(result.data["failed"]) == 1
|
||||
failure = result.data["failed"][0]
|
||||
assert failure["name"] == "blocked.png"
|
||||
assert "permission denied" in failure["error"].lower()
|
||||
assert blocked.exists()
|
||||
assert (td / "info" / "blocked.png.trashinfo").exists()
|
||||
assert not (td / "info" / "fine.png.trashinfo").exists()
|
||||
|
||||
def test_info_unlink_error_recorded(self, tmp_path: Path, monkeypatch):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"x")
|
||||
info = _write_trashinfo(td, "a.png", "out/a.png")
|
||||
|
||||
real_unlink = os.unlink
|
||||
|
||||
def _guarded_unlink(path, *args, **kwargs):
|
||||
if str(path) == "a.png.trashinfo":
|
||||
raise PermissionError("info denied")
|
||||
return real_unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(os, "unlink", _guarded_unlink)
|
||||
try:
|
||||
result = ts.empty([root], uid=UID)
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
assert result.ok
|
||||
assert result.data["removed"] == 0
|
||||
assert result.data["failed"][0]["name"] == "a.png"
|
||||
assert info.exists()
|
||||
|
||||
def test_payload_lstat_error_recorded(self, tmp_path: Path, monkeypatch):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
payload = td / "files" / "a.png"
|
||||
payload.write_bytes(b"x")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
|
||||
real_lstat = os.lstat
|
||||
|
||||
def _guarded_lstat(path, *args, **kwargs):
|
||||
if str(path) == "a.png" and kwargs.get("dir_fd") is not None:
|
||||
raise PermissionError("stat denied")
|
||||
return real_lstat(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(os, "lstat", _guarded_lstat)
|
||||
try:
|
||||
result = ts.empty([root], uid=UID)
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
assert result.ok
|
||||
assert result.data["removed"] == 0
|
||||
assert result.data["failed"][0]["name"] == "a.png"
|
||||
assert (td / "info" / "a.png.trashinfo").exists()
|
||||
|
||||
def test_missing_trash_dir_open_failed(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
entry = ts.TrashEntry(
|
||||
trash_dir=tmp_path / "no-such-trash",
|
||||
payload_name="a.png",
|
||||
info_name="a.png.trashinfo",
|
||||
size=1,
|
||||
)
|
||||
outcome, error = ts._remove_entry(entry, uid=UID)
|
||||
assert outcome == "failed"
|
||||
assert error
|
||||
|
||||
def test_files_swapped_for_symlink_after_scan(self, tmp_path: Path):
|
||||
"""TOCTOU: files/ replaced by a symlink after scan → failed, outside untouched."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"abc")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 1
|
||||
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "loot.png").write_bytes(b"loot")
|
||||
(td / "files" / "a.png").unlink() # empty files/ so rmdir works
|
||||
(td / "files").rmdir()
|
||||
os.symlink(outside, td / "files", target_is_directory=True)
|
||||
|
||||
outcome, error = ts._remove_entry(scan.entries[0], uid=UID)
|
||||
assert outcome == "failed"
|
||||
assert error
|
||||
assert (outside / "loot.png").read_bytes() == b"loot"
|
||||
|
||||
def test_info_swapped_for_symlink_after_scan(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"abc")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 1
|
||||
|
||||
outside = tmp_path / "outside-info"
|
||||
outside.mkdir()
|
||||
(td / "info" / "a.png.trashinfo").unlink()
|
||||
(td / "info").rmdir()
|
||||
os.symlink(outside, td / "info", target_is_directory=True)
|
||||
|
||||
outcome, error = ts._remove_entry(scan.entries[0], uid=UID)
|
||||
assert outcome == "failed"
|
||||
assert error
|
||||
assert (td / "files" / "a.png").exists()
|
||||
|
||||
def test_ownership_recheck_fails_after_mode_change(self, tmp_path: Path):
|
||||
"""files/ chmod'd group-writable after scan → refused, payload kept."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"abc")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 1
|
||||
|
||||
(td / "files").chmod(0o770)
|
||||
try:
|
||||
outcome, error = ts._remove_entry(scan.entries[0], uid=UID)
|
||||
finally:
|
||||
(td / "files").chmod(0o755)
|
||||
assert outcome == "failed"
|
||||
assert "ownership" in error.lower()
|
||||
assert (td / "files" / "a.png").exists()
|
||||
|
||||
def test_fstat_error_fails_recheck(self, tmp_path: Path, monkeypatch):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"abc")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 1
|
||||
|
||||
# Scope the failure to the service's own re-check: pytest's capture
|
||||
# machinery also calls os.fstat before monkeypatch teardown.
|
||||
import sys as _sys
|
||||
|
||||
real_fstat = os.fstat
|
||||
|
||||
def _raise_for_owned_check(fd, *args, **kwargs):
|
||||
if _sys._getframe(1).f_code.co_name == "_fd_owned_safe":
|
||||
raise PermissionError("fstat denied")
|
||||
return real_fstat(fd, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(os, "fstat", _raise_for_owned_check)
|
||||
outcome, error = ts._remove_entry(scan.entries[0], uid=UID)
|
||||
assert outcome == "failed"
|
||||
assert "ownership" in error.lower()
|
||||
|
||||
def test_empty_trash_no_entries(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
_make_admin_trash(tmp_path)
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok
|
||||
assert result.data == {"removed": 0, "bytes": 0, "failed": [], "skipped_dirs": 0}
|
||||
|
||||
def test_empty_uid_defaults_to_current_user(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_admin_trash(tmp_path, uid=os.getuid())
|
||||
(td / "files" / "a.png").write_bytes(b"ab")
|
||||
_write_trashinfo(td, "a.png", "out/a.png")
|
||||
result = ts.empty([root])
|
||||
assert result.ok and result.data["removed"] == 1
|
||||
@@ -349,3 +349,26 @@ def monkeypatch():
|
||||
_mp = pytest.MonkeyPatch()
|
||||
yield _mp
|
||||
_mp.undo()
|
||||
|
||||
|
||||
def test_require_operation_enabled_trash_empty_shares_delete_gate(monkeypatch) -> None:
|
||||
"""trash_empty must ride the delete gate, not fall through to unknown-op."""
|
||||
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
|
||||
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
|
||||
|
||||
refused = sec._require_operation_enabled(
|
||||
"trash_empty", prefs={"safe_mode": False, "allow_delete": False}
|
||||
)
|
||||
assert not refused.ok
|
||||
assert refused.code == "FORBIDDEN"
|
||||
|
||||
# Recognised operation: allow_delete prefs permit it even in safe mode.
|
||||
allowed = sec._require_operation_enabled(
|
||||
"trash_empty", prefs={"safe_mode": True, "allow_delete": "true"}
|
||||
)
|
||||
assert allowed.ok
|
||||
|
||||
# Env fallback mirrors MAJOOR_ALLOW_DELETE like asset deletion.
|
||||
monkeypatch.setenv("MAJOOR_ALLOW_DELETE", "1")
|
||||
via_env = sec._require_operation_enabled("trash_empty", prefs={"safe_mode": True})
|
||||
assert via_env.ok
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
# mypy: disable-error-code="index"
|
||||
"""Security tests for emptying Majoor-owned freedesktop trash entries.
|
||||
|
||||
Every test builds a synthetic trash tree under pytest's tmp_path. The service
|
||||
must never remove an entry whose original path is outside Majoor roots, an
|
||||
orphan payload, or anything reached through a symlink.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mjr_am_backend.features.assets import trash_service as ts
|
||||
|
||||
UID = os.getuid()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_trash_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "no-xdg"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_mount(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
real_ismount = os.path.ismount
|
||||
|
||||
def _fake_ismount(path):
|
||||
try:
|
||||
if Path(str(path)) == tmp_path:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return real_ismount(path)
|
||||
|
||||
monkeypatch.setattr(os.path, "ismount", _fake_ismount)
|
||||
|
||||
|
||||
def _make_trash(topdir: Path) -> Path:
|
||||
td = topdir / f".Trash-{UID}"
|
||||
(td / "info").mkdir(parents=True)
|
||||
(td / "files").mkdir(parents=True)
|
||||
return td
|
||||
|
||||
|
||||
def _info(td: Path, name: str, path_value: str) -> Path:
|
||||
p = td / "info" / f"{name}.trashinfo"
|
||||
p.write_text(f"[Trash Info]\nPath={path_value}\n", encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
class TestTrashEmptySecurity:
|
||||
def test_only_majoor_entries_removed_foreign_byte_identical(self, tmp_path: Path):
|
||||
"""Acceptance: 3 Majoor entries removed; 2 foreign entries untouched."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
for i in range(3):
|
||||
(td / "files" / f"m{i}.png").write_bytes(f"majoor{i}".encode())
|
||||
_info(td, f"m{i}.png", f"out/m{i}.png")
|
||||
(td / "files" / "outside.png").write_bytes(b"outside-bytes")
|
||||
outside_info = _info(td, "outside.png", "/home/attacker/outside.png")
|
||||
(td / "files" / "orphan.png").write_bytes(b"orphan-bytes")
|
||||
|
||||
result = ts.empty([root], uid=UID)
|
||||
|
||||
assert result.ok
|
||||
assert result.data["removed"] == 3
|
||||
assert (td / "files" / "outside.png").read_bytes() == b"outside-bytes"
|
||||
assert outside_info.exists()
|
||||
assert (td / "files" / "orphan.png").read_bytes() == b"orphan-bytes"
|
||||
for i in range(3):
|
||||
assert not (td / "files" / f"m{i}.png").exists()
|
||||
assert not (td / "info" / f"m{i}.png.trashinfo").exists()
|
||||
|
||||
def test_etc_passwd_path_can_be_faked_but_never_matched(self, tmp_path: Path):
|
||||
"""An attacker-controlled trashinfo claiming /etc/passwd stays ignored."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
(td / "files" / "passwd").write_bytes(b"root:x:0:0")
|
||||
info = _info(td, "passwd", "/etc/passwd")
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (td / "files" / "passwd").exists()
|
||||
assert info.exists()
|
||||
|
||||
def test_traversal_payload_name_is_not_a_path(self, tmp_path: Path):
|
||||
"""Traversal in Path= normalises outside the root and is ignored."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_bytes(b"secret")
|
||||
td = _make_trash(tmp_path)
|
||||
(td / "files" / "t.png").write_bytes(b"z")
|
||||
info = _info(td, "t.png", "out/../../secret.txt")
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert secret.read_bytes() == b"secret"
|
||||
assert info.exists()
|
||||
|
||||
def test_symlink_payload_never_follows_target(self, tmp_path: Path):
|
||||
"""Payload symlink to a file outside roots: link removed, target intact."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
victim = tmp_path / "victim" / "private.png"
|
||||
victim.parent.mkdir()
|
||||
victim.write_bytes(b"private-data")
|
||||
os.symlink(victim, td / "files" / "a.png")
|
||||
_info(td, "a.png", "out/a.png")
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 1
|
||||
assert victim.read_bytes() == b"private-data"
|
||||
assert not (td / "files" / "a.png").exists()
|
||||
|
||||
def test_directory_payloads_are_skipped_not_removed(self, tmp_path: Path):
|
||||
"""Directory payloads (even with symlinks inside) are never removed."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
victim_dir = tmp_path / "victimdir"
|
||||
victim_dir.mkdir()
|
||||
(victim_dir / "keep.png").write_bytes(b"keep-me")
|
||||
payload = td / "files" / "d"
|
||||
payload.mkdir()
|
||||
os.symlink(victim_dir, payload / "escape")
|
||||
_info(td, "d", "out/d")
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok
|
||||
assert result.data["removed"] == 0
|
||||
assert result.data["skipped_dirs"] == 1
|
||||
assert (victim_dir / "keep.png").read_bytes() == b"keep-me"
|
||||
assert (payload / "escape").exists()
|
||||
|
||||
def test_foreign_owned_trash_dir_never_used(self, tmp_path: Path):
|
||||
"""A .Trash-<uid> planted by another user is ignored entirely."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
(td / "files" / "planted.png").write_bytes(b"planted")
|
||||
_info(td, "planted.png", "out/planted.png")
|
||||
assert td.stat().st_uid == UID
|
||||
# Scan as a different (foreign) uid: the dir belongs to UID, not to them.
|
||||
result = ts.empty([root], uid=UID + 1)
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (td / "files" / "planted.png").exists()
|
||||
assert (td / "info" / "planted.png.trashinfo").exists()
|
||||
|
||||
def test_group_writable_trash_dir_never_used(self, tmp_path: Path):
|
||||
"""Group/world-writable trash dirs are refused (planted-state defence)."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"a")
|
||||
_info(td, "a.png", "out/a.png")
|
||||
td.chmod(0o777)
|
||||
try:
|
||||
scan = ts.scan([root], uid=UID)
|
||||
result = ts.empty([root], uid=UID)
|
||||
finally:
|
||||
td.chmod(0o755)
|
||||
assert scan.count == 0
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (td / "files" / "a.png").exists()
|
||||
|
||||
def test_nul_in_path_with_valid_entry_alongside(self, tmp_path: Path):
|
||||
"""A NUL-bearing Path= entry is ignored without failing the scan."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"abc")
|
||||
(td / "files" / "n.png").write_bytes(b"n")
|
||||
_info(td, "a.png", "out/a.png")
|
||||
_info(td, "n.png", "out/a%00.png")
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok
|
||||
assert result.data["removed"] == 1
|
||||
assert not (td / "files" / "a.png").exists()
|
||||
assert (td / "files" / "n.png").exists()
|
||||
assert (td / "info" / "n.png.trashinfo").exists()
|
||||
|
||||
def test_files_dir_swapped_for_symlink_after_scan(self, tmp_path: Path):
|
||||
"""TOCTOU: files/ swapped for a symlink between scan and removal."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"abc")
|
||||
_info(td, "a.png", "out/a.png")
|
||||
scan = ts.scan([root], uid=UID)
|
||||
assert scan.count == 1
|
||||
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "loot.png").write_bytes(b"loot")
|
||||
(td / "files" / "a.png").unlink()
|
||||
(td / "files").rmdir()
|
||||
os.symlink(outside, td / "files", target_is_directory=True)
|
||||
|
||||
# empty() re-scans (and would reject the swapped dir), so target the
|
||||
# removal step directly to exercise the scan→removal race window.
|
||||
outcome, error = ts._remove_entry(scan.entries[0], uid=UID)
|
||||
assert outcome == "failed"
|
||||
assert error
|
||||
assert (outside / "loot.png").read_bytes() == b"loot"
|
||||
assert (td / "files").is_symlink()
|
||||
|
||||
def test_symlinked_trash_dir_never_entered(self, tmp_path: Path):
|
||||
"""A .Trash-<uid> symlink pointing at a sensitive dir is skipped."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
sensitive = tmp_path / "sensitive"
|
||||
(sensitive / "info").mkdir(parents=True)
|
||||
(sensitive / "files").mkdir(parents=True)
|
||||
(sensitive / "files" / "a.png").write_bytes(b"a")
|
||||
_info(sensitive, "a.png", "out/a.png")
|
||||
os.symlink(sensitive, tmp_path / f".Trash-{UID}", target_is_directory=True)
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (sensitive / "files" / "a.png").exists()
|
||||
|
||||
def test_non_sticky_dot_trash_skipped(self, tmp_path: Path):
|
||||
"""Without the sticky bit, <topdir>/.Trash/<uid> must not be trusted."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
shared = tmp_path / ".Trash"
|
||||
(shared / str(UID) / "info").mkdir(parents=True)
|
||||
(shared / str(UID) / "files").mkdir(parents=True)
|
||||
(shared / str(UID) / "files" / "a.png").write_bytes(b"a")
|
||||
_info(shared / str(UID), "a.png", "out/a.png")
|
||||
assert not (shared.stat().st_mode & stat.S_ISVTX)
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (shared / str(UID) / "files" / "a.png").exists()
|
||||
|
||||
def test_symlinked_dot_trash_skipped(self, tmp_path: Path):
|
||||
""".Trash as a symlink is not a valid sticky admin trash parent."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
evil = tmp_path / "evil"
|
||||
(evil / str(UID) / "info").mkdir(parents=True)
|
||||
(evil / str(UID) / "files").mkdir(parents=True)
|
||||
(evil / str(UID) / "files" / "a.png").write_bytes(b"a")
|
||||
_info(evil / str(UID), "a.png", "out/a.png")
|
||||
os.symlink(evil, tmp_path / ".Trash", target_is_directory=True)
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (evil / str(UID) / "files" / "a.png").exists()
|
||||
|
||||
def test_malformed_and_binary_trashinfo_ignored(self, tmp_path: Path):
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
(td / "files" / "a.png").write_bytes(b"a")
|
||||
(td / "info" / "a.png.trashinfo").write_bytes(b"\x00\x01\x02\xff garbage")
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (td / "files" / "a.png").exists()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"crafted",
|
||||
[".trashinfo", "..trashinfo", "...trashinfo"],
|
||||
ids=["empty-stem", "dot", "dot-dot"],
|
||||
)
|
||||
def test_crafted_info_names_never_widen_removal(self, tmp_path: Path, crafted: str):
|
||||
"""Crafted info basenames must not remove files/, the trash dir, or foreign entries."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
(td / "info" / crafted).write_text("[Trash Info]\nPath=out/a.png\n")
|
||||
(td / "files" / "foreign.png").write_bytes(b"foreign-payload")
|
||||
foreign_info = _info(td, "foreign.png", "/elsewhere/foreign.png")
|
||||
|
||||
result = ts.empty([root], uid=UID)
|
||||
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (td / "files" / "foreign.png").read_bytes() == b"foreign-payload"
|
||||
assert foreign_info.exists()
|
||||
assert (td / "info" / crafted).exists()
|
||||
assert (td / "files").is_dir() and (td / "info").is_dir()
|
||||
|
||||
def test_trashinfo_larger_than_64kib_ignored(self, tmp_path: Path):
|
||||
"""An oversized trashinfo (potential parse bomb) is treated as malformed."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
(td / "files" / "big.png").write_bytes(b"b")
|
||||
(td / "info" / "big.png.trashinfo").write_text(
|
||||
f"[Trash Info]\nPath=out/big.png\nJunk={'y' * (70 * 1024)}\n", encoding="utf-8"
|
||||
)
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (td / "files" / "big.png").exists()
|
||||
|
||||
def test_percent_encoded_absolute_path_selected(self, tmp_path: Path):
|
||||
"""Encoded Path= referencing the root is selected (happy path, not a hole)."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
td = _make_trash(tmp_path)
|
||||
(td / "files" / "my cat.png").write_bytes(b"abc")
|
||||
_info(td, "my cat.png", f"{root}/my%20cat.png")
|
||||
result = ts.empty([root], uid=UID)
|
||||
assert result.ok and result.data["removed"] == 1
|
||||
|
||||
def test_home_trash_entries_outside_roots_untouched(self, tmp_path: Path):
|
||||
"""Home trash scanned for reporting but foreign entries stay put."""
|
||||
root = tmp_path / "out"
|
||||
root.mkdir()
|
||||
xdg = tmp_path / "xdg"
|
||||
td = xdg / "Trash"
|
||||
(td / "info").mkdir(parents=True)
|
||||
(td / "files").mkdir(parents=True)
|
||||
(td / "files" / "notes.txt").write_bytes(b"not-majoor")
|
||||
_info(td, "notes.txt", "/home/user/Documents/notes.txt")
|
||||
scan = ts.scan([root], uid=UID, xdg_data_home=xdg)
|
||||
assert scan.count == 0
|
||||
result = ts.empty([root], uid=UID, xdg_data_home=xdg)
|
||||
assert result.ok and result.data["removed"] == 0
|
||||
assert (td / "files" / "notes.txt").read_bytes() == b"not-majoor"
|
||||
@@ -345,6 +345,14 @@ export async function restoreDbBackup({ name = "", useLatest = false }: Record<s
|
||||
return post(ENDPOINTS.DB_BACKUP_RESTORE, body);
|
||||
}
|
||||
|
||||
export async function getTrashStatus() {
|
||||
return get(ENDPOINTS.TRASH);
|
||||
}
|
||||
|
||||
export async function emptyTrash() {
|
||||
return post(ENDPOINTS.TRASH_EMPTY, {});
|
||||
}
|
||||
|
||||
export async function startDuplicatesAnalysis(limit = 250) {
|
||||
return post("/mjr/am/duplicates/analyze", {
|
||||
limit: Math.max(10, Math.min(5000, Number(limit) || 250)),
|
||||
|
||||
@@ -146,6 +146,10 @@ export const ENDPOINTS = {
|
||||
DB_BACKUP_SAVE: "/mjr/am/db/backup-save",
|
||||
DB_BACKUP_RESTORE: "/mjr/am/db/backup-restore",
|
||||
|
||||
// Trash (freedesktop trash entries from Majoor roots)
|
||||
TRASH: "/mjr/am/trash",
|
||||
TRASH_EMPTY: "/mjr/am/trash/empty",
|
||||
|
||||
// Vector / Semantic search
|
||||
VECTOR_SEARCH: "/mjr/am/vector/search",
|
||||
VECTOR_SIMILAR: "/mjr/am/vector/similar",
|
||||
|
||||
@@ -367,6 +367,8 @@ const DICTIONARY: Record<string, Record<string, string>> = {
|
||||
"btn.close": "Close",
|
||||
"btn.dbSave": "Save DB",
|
||||
"btn.dbRestore": "Restore DB",
|
||||
"btn.emptyTrash": "Empty trash",
|
||||
"btn.emptyTrashRunning": "Emptying...",
|
||||
"btn.back": "Back",
|
||||
"btn.up": "Up",
|
||||
"btn.saving": "Saving...",
|
||||
@@ -558,6 +560,10 @@ const DICTIONARY: Record<string, Record<string, string>> = {
|
||||
"status.memoryPurgeStarted": "Memory purge started...",
|
||||
"status.toolStatusChecking": "Tool status: checking...",
|
||||
"status.resetIndexHint": "Reset index cache (requires allowResetIndex in settings).",
|
||||
"status.emptyTrashHint":
|
||||
"Permanently remove files Majoor sent to the freedesktop trash (requires allowDelete in settings).",
|
||||
"status.emptyTrashDisabled":
|
||||
"Empty trash is disabled. Enable 'allow_delete' in settings or set MAJOOR_ALLOW_DELETE=1.",
|
||||
"status.scanningHint": "This may take a while",
|
||||
"status.toolAvailable": "{tool} available",
|
||||
"status.toolUnavailable": "{tool} unavailable",
|
||||
@@ -1049,6 +1055,9 @@ const DICTIONARY: Record<string, Record<string, string>> = {
|
||||
"dialog.newCollection.placeholder": "Collection name",
|
||||
"dialog.resetIndex.title": "Reset index?",
|
||||
"dialog.resetIndex.msg": "This will delete the database and rescan all files. Continue?",
|
||||
"dialog.emptyTrash.title": "Empty trash?",
|
||||
"dialog.emptyTrash.confirm":
|
||||
"Permanently delete {count} trashed files ({size}) that came from Majoor's folders? This cannot be undone.",
|
||||
"dialog.securityWarning":
|
||||
"This looks like a system or very broad directory.\n\nAdding it can expose sensitive files via the viewer/custom roots feature.\n\nContinue?",
|
||||
"dialog.securityWarningTitle": "Majoor: Security Warning",
|
||||
@@ -1180,6 +1189,13 @@ const DICTIONARY: Record<string, Record<string, string>> = {
|
||||
"toast.dbRestoreReplacing": "Replacing database files",
|
||||
"toast.dbRestoreRescan": "Restarting scan",
|
||||
"toast.dbRestoreSuccess": "Database backup restored",
|
||||
"toast.trashIsEmpty": "Trash is empty",
|
||||
"toast.trashRemoved": "{count} files removed ({size})",
|
||||
"toast.trashFailures": "{count} entries could not be removed",
|
||||
"toast.trashSkippedDirs": "{count} directories in the trash were not removed",
|
||||
"toast.trashStatusFailed": "Failed to check trash",
|
||||
"toast.trashEmptyFailed": "Failed to empty trash",
|
||||
"toast.trashUnsupported": "Trash is not supported on this platform",
|
||||
"toast.nameCollisionInView": "Name collision in current view",
|
||||
"toast.fileRenamedSuccess": "File renamed successfully!",
|
||||
"toast.fileRenameFailed": "Failed to rename file.",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { APP_CONFIG } from "../../app/config.js";
|
||||
import { comfyConfirm } from "../../app/dialogs.js";
|
||||
import { comfyToast, recordToastHistory } from "../../app/toast.js";
|
||||
import { t } from "../../app/i18n.js";
|
||||
import { resolveTrashClient, runEmptyTrashAction } from "./emptyTrashAction.js";
|
||||
import {
|
||||
getRuntimeEnrichmentState,
|
||||
setRuntimeEnrichmentState,
|
||||
@@ -1214,6 +1215,90 @@ export function createStatusIndicator(options: Record<string, any> = {}): any {
|
||||
}
|
||||
};
|
||||
|
||||
const emptyTrashBtn = document.createElement("button");
|
||||
emptyTrashBtn.type = "button";
|
||||
emptyTrashBtn.textContent = t("btn.emptyTrash", "Empty trash");
|
||||
emptyTrashBtn.title = t(
|
||||
"status.emptyTrashHint",
|
||||
"Permanently remove files Majoor sent to the freedesktop trash (requires allowDelete in settings).",
|
||||
);
|
||||
emptyTrashBtn.style.cssText = `
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,167,38,0.45);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: border 0.2s, background 0.2s;
|
||||
`;
|
||||
emptyTrashBtn.onmouseenter = () => {
|
||||
emptyTrashBtn.style.borderColor = "rgba(255,167,38,0.85)";
|
||||
emptyTrashBtn.style.background = "rgba(255,167,38,0.12)";
|
||||
};
|
||||
emptyTrashBtn.onmouseleave = () => {
|
||||
emptyTrashBtn.style.borderColor = "rgba(255,167,38,0.45)";
|
||||
emptyTrashBtn.style.background = "transparent";
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const client = await resolveTrashClient();
|
||||
if (typeof client.getTrashStatus !== "function") {
|
||||
emptyTrashBtn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const res = await client.getTrashStatus();
|
||||
if (res?.ok && res?.data?.supported === false) {
|
||||
emptyTrashBtn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (typeof client.getSecuritySettings === "function") {
|
||||
const sec = await client.getSecuritySettings();
|
||||
if (sec?.ok && sec?.data?.prefs && sec.data.prefs.allow_delete === false) {
|
||||
emptyTrashBtn.disabled = true;
|
||||
emptyTrashBtn.title = t(
|
||||
"status.emptyTrashDisabled",
|
||||
"Empty trash is disabled. Enable 'allow_delete' in settings or set MAJOOR_ALLOW_DELETE=1.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
})();
|
||||
emptyTrashBtn.onclick = async (event) => {
|
||||
event.stopPropagation();
|
||||
setMaintenanceActive(true);
|
||||
const originalText = emptyTrashBtn.textContent;
|
||||
emptyTrashBtn.disabled = true;
|
||||
resetBtn.disabled = true;
|
||||
memoryPurgeBtn.disabled = true;
|
||||
emptyTrashBtn.textContent = t("btn.emptyTrashRunning", "Emptying...");
|
||||
try {
|
||||
const client = await resolveTrashClient();
|
||||
await runEmptyTrashAction({
|
||||
getStatus: client.getTrashStatus,
|
||||
empty: client.emptyTrash,
|
||||
confirm: (message: string, title: string) =>
|
||||
Promise.resolve(comfyConfirm(message, title)) as Promise<boolean>,
|
||||
toast: (message, level, duration, options) =>
|
||||
comfyToast(message, level, duration, options),
|
||||
formatBytes,
|
||||
translate: (key, fallback, params) => t(key, fallback, params),
|
||||
});
|
||||
} finally {
|
||||
setMaintenanceActive(false);
|
||||
emptyTrashBtn.disabled = false;
|
||||
resetBtn.disabled = false;
|
||||
memoryPurgeBtn.disabled = false;
|
||||
emptyTrashBtn.textContent = originalText;
|
||||
}
|
||||
};
|
||||
|
||||
actionsRow.appendChild(backfillBtn);
|
||||
actionsRow.appendChild(memoryPurgeBtn);
|
||||
actionsRow.appendChild(resetBtn);
|
||||
actionsRow.appendChild(emptyTrashBtn);
|
||||
actionsRow.appendChild(backfillBtn);
|
||||
actionsRow.appendChild(memoryPurgeBtn);
|
||||
actionsRow.appendChild(resetBtn);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Empty-trash action flow for the Status panel.
|
||||
*
|
||||
* Kept free of DOM and module imports so the whole confirm → POST → toast
|
||||
* flow is unit-testable in a plain Node environment: every dependency is
|
||||
* injected. The Status panel wires real implementations; tests wire fakes.
|
||||
*/
|
||||
|
||||
export interface EmptyTrashDeps {
|
||||
getStatus: () => Promise<any>;
|
||||
empty: () => Promise<any>;
|
||||
confirm: (message: string, title: string) => Promise<boolean> | boolean;
|
||||
toast: (message: string, level: string, duration?: number, options?: any) => void;
|
||||
formatBytes: (bytes: number) => string;
|
||||
translate: (key: string, fallback: string, params?: Record<string, any>) => string;
|
||||
}
|
||||
|
||||
export type EmptyTrashOutcome = "empty" | "removed" | "cancelled" | "unsupported" | "error";
|
||||
|
||||
/**
|
||||
* Resolve the trash client functions lazily so a partially mocked API module
|
||||
* (e.g. in other suites' tests) never throws or logs during panel init.
|
||||
* Missing functions are reported as undefined and treated as "unsupported".
|
||||
*/
|
||||
let _trashClientCache: Record<string, any> | null = null;
|
||||
|
||||
export async function resolveTrashClient(): Promise<Record<string, any>> {
|
||||
if (_trashClientCache) return _trashClientCache;
|
||||
const resolved: Record<string, any> = {};
|
||||
try {
|
||||
const mod: any = await import("../../api/client.js");
|
||||
const available: string[] = Object.keys(mod);
|
||||
for (const key of ["getTrashStatus", "emptyTrash", "getSecuritySettings"]) {
|
||||
if (available.includes(key)) {
|
||||
try {
|
||||
const value = mod[key];
|
||||
resolved[key] = typeof value === "function" ? value : undefined;
|
||||
} catch {
|
||||
resolved[key] = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Module unavailable — leave everything undefined (treated as unsupported).
|
||||
}
|
||||
_trashClientCache = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function _translateDefault(
|
||||
deps: EmptyTrashDeps,
|
||||
key: string,
|
||||
fallback: string,
|
||||
params?: Record<string, any>,
|
||||
) {
|
||||
try {
|
||||
return deps.translate(key, fallback, params);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runEmptyTrashAction(deps: EmptyTrashDeps): Promise<EmptyTrashOutcome> {
|
||||
const t = (key: string, fallback: string, params?: Record<string, any>) =>
|
||||
_translateDefault(deps, key, fallback, params);
|
||||
|
||||
let status: any = null;
|
||||
try {
|
||||
status = await deps.getStatus();
|
||||
} catch (error: any) {
|
||||
deps.toast(
|
||||
String(
|
||||
error?.message || error || t("toast.trashStatusFailed", "Failed to check trash"),
|
||||
),
|
||||
"error",
|
||||
4500,
|
||||
);
|
||||
return "error";
|
||||
}
|
||||
if (!status?.ok) {
|
||||
deps.toast(
|
||||
String(status?.error || t("toast.trashStatusFailed", "Failed to check trash")),
|
||||
"error",
|
||||
4500,
|
||||
);
|
||||
return "error";
|
||||
}
|
||||
|
||||
const data = status?.data || {};
|
||||
if (data?.supported === false) {
|
||||
deps.toast(
|
||||
t("toast.trashUnsupported", "Trash is not supported on this platform"),
|
||||
"info",
|
||||
3000,
|
||||
);
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
const count = Number(data?.count) || 0;
|
||||
if (!count) {
|
||||
deps.toast(t("toast.trashIsEmpty", "Trash is empty"), "info", 2200);
|
||||
return "empty";
|
||||
}
|
||||
|
||||
const size = Number(data?.bytes) || 0;
|
||||
const message = t(
|
||||
"dialog.emptyTrash.confirm",
|
||||
"Permanently delete {count} trashed files ({size}) that came from Majoor's folders? This cannot be undone.",
|
||||
{ count, size: deps.formatBytes(size) },
|
||||
);
|
||||
let confirmed = false;
|
||||
try {
|
||||
confirmed = !!(await deps.confirm(message, t("dialog.emptyTrash.title", "Empty trash?")));
|
||||
} catch {
|
||||
confirmed = false;
|
||||
}
|
||||
if (!confirmed) return "cancelled";
|
||||
|
||||
let result: any = null;
|
||||
try {
|
||||
result = await deps.empty();
|
||||
} catch (error: any) {
|
||||
deps.toast(
|
||||
String(error?.message || error || t("toast.trashEmptyFailed", "Failed to empty trash")),
|
||||
"error",
|
||||
4500,
|
||||
);
|
||||
return "error";
|
||||
}
|
||||
if (!result?.ok) {
|
||||
deps.toast(
|
||||
String(result?.error || t("toast.trashEmptyFailed", "Failed to empty trash")),
|
||||
"error",
|
||||
4500,
|
||||
);
|
||||
return "error";
|
||||
}
|
||||
|
||||
const removed = Number(result?.data?.removed) || 0;
|
||||
const failed = Array.isArray(result?.data?.failed) ? result.data.failed : [];
|
||||
const skippedDirs = Number(result?.data?.skipped_dirs) || 0;
|
||||
deps.toast(
|
||||
t("toast.trashRemoved", "{count} files removed ({size})", {
|
||||
count: removed,
|
||||
size: deps.formatBytes(Number(result?.data?.bytes) || 0),
|
||||
}),
|
||||
"success",
|
||||
3200,
|
||||
);
|
||||
if (failed.length) {
|
||||
deps.toast(
|
||||
t("toast.trashFailures", "{count} entries could not be removed", {
|
||||
count: failed.length,
|
||||
}),
|
||||
"warning",
|
||||
4500,
|
||||
);
|
||||
}
|
||||
if (skippedDirs > 0) {
|
||||
deps.toast(
|
||||
t("toast.trashSkippedDirs", "{count} directories in the trash were not removed", {
|
||||
count: skippedDirs,
|
||||
}),
|
||||
"warning",
|
||||
4500,
|
||||
);
|
||||
}
|
||||
return "removed";
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runEmptyTrashAction } from "../features/status/emptyTrashAction.js";
|
||||
|
||||
function makeDeps(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
getStatus: vi.fn(async () => ({
|
||||
ok: true,
|
||||
data: { supported: true, count: 3, bytes: 4096 },
|
||||
})),
|
||||
empty: vi.fn(async () => ({
|
||||
ok: true,
|
||||
data: { removed: 3, bytes: 4096, failed: [] },
|
||||
})),
|
||||
confirm: vi.fn(async (_message: string, _title: string) => true),
|
||||
toast: vi.fn(),
|
||||
formatBytes: vi.fn((n: number) => `${n} B`),
|
||||
translate: vi.fn((_key: string, fallback: string, params?: Record<string, any>) => {
|
||||
if (!params) return fallback;
|
||||
let out = fallback;
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
out = out.split(`{${k}}`).join(String(v));
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("runEmptyTrashAction", () => {
|
||||
it("shows an info toast and never posts when the trash is empty", async () => {
|
||||
const deps = makeDeps({
|
||||
getStatus: vi.fn(async () => ({
|
||||
ok: true,
|
||||
data: { supported: true, count: 0, bytes: 0 },
|
||||
})),
|
||||
});
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("empty");
|
||||
expect(deps.toast).toHaveBeenCalledTimes(1);
|
||||
expect(deps.toast.mock.calls[0][0]).toBe("Trash is empty");
|
||||
expect(deps.toast.mock.calls[0][1]).toBe("info");
|
||||
expect(deps.confirm).not.toHaveBeenCalled();
|
||||
expect(deps.empty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for confirmation with count and size, then posts and toasts the result", async () => {
|
||||
const deps = makeDeps();
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("removed");
|
||||
expect(deps.confirm).toHaveBeenCalledTimes(1);
|
||||
const [message, title] = deps.confirm.mock.calls[0];
|
||||
expect(message).toContain("3");
|
||||
expect(message).toContain("4096 B");
|
||||
expect(message).toContain("cannot be undone");
|
||||
expect(title).toBe("Empty trash?");
|
||||
expect(deps.empty).toHaveBeenCalledTimes(1);
|
||||
expect(deps.toast.mock.calls[0]).toEqual(["3 files removed (4096 B)", "success", 3200]);
|
||||
});
|
||||
|
||||
it("does not post when the user cancels the confirmation", async () => {
|
||||
const deps = makeDeps({ confirm: vi.fn(async () => false) });
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("cancelled");
|
||||
expect(deps.empty).not.toHaveBeenCalled();
|
||||
expect(deps.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a warning toast when some entries fail to remove", async () => {
|
||||
const deps = makeDeps({
|
||||
empty: vi.fn(async () => ({
|
||||
ok: true,
|
||||
data: {
|
||||
removed: 2,
|
||||
bytes: 2048,
|
||||
failed: [{ name: "a.png", error: "permission denied" }],
|
||||
},
|
||||
})),
|
||||
});
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("removed");
|
||||
expect(deps.toast).toHaveBeenCalledTimes(2);
|
||||
expect(deps.toast.mock.calls[1][0]).toContain("1");
|
||||
expect(deps.toast.mock.calls[1][1]).toBe("warning");
|
||||
});
|
||||
|
||||
it("warns when directories in the trash are skipped", async () => {
|
||||
const deps = makeDeps({
|
||||
empty: vi.fn(async () => ({
|
||||
ok: true,
|
||||
data: { removed: 2, bytes: 2048, failed: [], skipped_dirs: 3 },
|
||||
})),
|
||||
});
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("removed");
|
||||
expect(deps.toast).toHaveBeenCalledTimes(2);
|
||||
expect(deps.toast.mock.calls[1][0]).toContain("3");
|
||||
expect(deps.toast.mock.calls[1][1]).toBe("warning");
|
||||
});
|
||||
|
||||
it("reports unsupported platforms without posting", async () => {
|
||||
const deps = makeDeps({
|
||||
getStatus: vi.fn(async () => ({
|
||||
ok: true,
|
||||
data: { supported: false, count: 9, bytes: 9 },
|
||||
})),
|
||||
});
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("unsupported");
|
||||
expect(deps.confirm).not.toHaveBeenCalled();
|
||||
expect(deps.empty).not.toHaveBeenCalled();
|
||||
expect(deps.toast.mock.calls[0][1]).toBe("info");
|
||||
});
|
||||
|
||||
it("surfaces GET failures as an error toast", async () => {
|
||||
const deps = makeDeps({
|
||||
getStatus: vi.fn(async () => ({ ok: false, error: "nope" })),
|
||||
});
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("error");
|
||||
expect(deps.toast.mock.calls[0]).toEqual(["nope", "error", 4500]);
|
||||
expect(deps.empty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces thrown GET errors as an error toast", async () => {
|
||||
const deps = makeDeps({
|
||||
getStatus: vi.fn(async () => {
|
||||
throw new Error("network down");
|
||||
}),
|
||||
});
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("error");
|
||||
expect(deps.toast.mock.calls[0][0]).toBe("network down");
|
||||
});
|
||||
|
||||
it("surfaces POST failures as an error toast", async () => {
|
||||
const deps = makeDeps({
|
||||
empty: vi.fn(async () => ({ ok: false, error: "FORBIDDEN by policy" })),
|
||||
});
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("error");
|
||||
expect(deps.toast.mock.calls[0]).toEqual(["FORBIDDEN by policy", "error", 4500]);
|
||||
});
|
||||
|
||||
it("surfaces thrown POST errors as an error toast", async () => {
|
||||
const deps = makeDeps({
|
||||
empty: vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
});
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("error");
|
||||
expect(deps.toast.mock.calls[0][0]).toBe("boom");
|
||||
});
|
||||
|
||||
it("treats a throwing confirm dialog as a cancellation", async () => {
|
||||
const deps = makeDeps({
|
||||
confirm: vi.fn(async () => {
|
||||
throw new Error("dialog closed");
|
||||
}),
|
||||
});
|
||||
await expect(runEmptyTrashAction(deps)).resolves.toBe("cancelled");
|
||||
expect(deps.empty).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user