Files
malkuthro-ComfyUI-Koolook/web/koolook_sidebar.js
T
malkuthroandClaude Opus 4.7 4f282e7e79 feat(snapshot): durability + recovery UX — atomic writes, local time, in-UI fallback recovery (#105)
* fix(snapshot): atomic preset writes + local-timezone tooltips

Hardens the snapshot library against mid-write corruption and stops
the auto-saved indicator from rendering Central Time on every machine.

`POST /koolook/presets/file` now stages writes to `<file>.json.tmp`
and `os.replace`s the final name into place — same atomic-rename
pattern `_write_settings` already uses. Covers every preset write
path through the same route (named, periodic, pre-load, future
recovery snapshots). The `.tmp` suffix is invisible in Load and
Recovery dialogs because both list endpoints filter to `.json`.

`formatCentralTime` is now `formatLocalTime` with the hardcoded
`timeZone: "America/Chicago"` removed — the browser formats with the
user's system timezone, and `timeZoneName: "short"` renders the
matching abbreviation (PDT / EDT / CET / …).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): drop timezone abbreviation from local-time tooltip

Follow-up to b0f17b9. `timeZoneName: "short"` rendered the user's local
timezone abbreviation (`GMT+2`, `CDT`, `PDT`, …) in the auto-saved hover
tooltip, but the abbreviation read as clutter — the user already knows
their own timezone. Tooltip now shows just the wall-clock time, e.g.
`May 7, 2026, 11:43 PM`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): recovery section deep-link + save-time visibility

What you actually asked for last round, properly delivered this time.

The Load dialog's Recovery auto-saves section was a list of file names
with no path context, no save time, and no escape hatch to the file
system — so power users could see the autosaves existed but couldn't
inspect them in Finder, sort by mtime, or pick a specific recovery
point. Three additions close that gap:

1. **`📂 Open` button at the top of the Load dialog** opens the snapshot
   library folder in the OS file manager. Server route
   `POST /koolook/presets/reveal` shells out to `open` (macOS),
   `explorer.exe` (Windows), or `xdg-open` (Linux), with path-traversal
   grounded at the library base via `_resolve_target`.

2. **`📂 Open` button on each `<preset>_autosave` group header** deep-
   links into that specific subfolder, so the user lands directly on
   the autosave files for one preset.

3. **Per-row save time** — recovery rows now show
   `saved May 7, 2:34 PM` from the file's mtime instead of the
   `exported <date>` that hid the time-of-day. The bare filename
   replaces the redundant `Periodic auto-save · <subdir> · <iso>`
   displayName so the timestamp is visible at narrow modal widths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): offline-fallback banner gets Restore + Discard actions

The "Offline workflow data found" critical toast used to be a one-way
street — Copy details + Dismiss, but nothing ever cleared the
localStorage key, so the banner re-fired on every page load forever.

Two recovery actions close the loop:

- **Restore as snapshot** — wraps the offline workflows into a full
  snapshot envelope (live picks bundled in so a later Load wouldn't
  wipe them) and writes it as `recovered-<iso>.json` into the snapshot
  library via the now-atomic `writePreset`. Clears the localStorage
  key on success → banner self-extinguishes. The user lands on a
  durable, in-UI-visible artifact.

- **Discard offline copy** — `showConfirmModal` confirm-then-clear.
  Same end state for the banner; explicit user-acknowledged data loss.

Mechanics:

- `criticalToast` extended to accept `actions: [{label, onClick,
  primary?, busyLabel?}]`. Existing callers (`{ copyText }` only) are
  unchanged.
- Toast firing moved out of `loadWorkflowsStore()` and into
  `koolook_sidebar.js` (entry point) where the action handlers can
  reach `writePreset` / `loadUserPicks` / `showConfirmModal` without
  creating circular imports.
- `clearOfflineFallback()` exported from `workflows_store.js` for
  the localStorage `removeItem` step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(snapshot): unify Load dialog header and Recovery group layout

Three visual-consistency fixes so the library section and each
`<preset>_autosave` group header read like the same UI control instead
of two slightly-different ones.

1. **Saved-time on every row.** `loadPreview` now threads `mtime` from
   the listing endpoint into the preview object, and `formatPreviewMeta`
   prefers it over the snapshot's self-reported `exportedAt`. Regular
   snapshot rows now show `saved May 7, 11:53 PM` — same format the
   recovery rows already used. Falls back to `exported <date>` when
   mtime isn't available.

2. **Recovery group headers get the full path.** Subdir name on top,
   `<library>/<subdir>` underneath — the same CSS pair
   (`folder-name` + `folder-path`) the library section uses. Reuses
   `renderLibraryLocation` with a new `title` option so the autosave
   header skips the "Library folder:" prefix while inheriting all
   styling.

3. **Library Open button matches the group Open button.** Was
   `koolook-modal-button` (larger), now `koolook-snapshot-row-btn`
   (smaller, same as group headers). Same flex options on both rows
   (`flex-start` alignment, `space-between` justification, `gap: 8px`).

`formatAutosaveMeta` collapsed back into `formatPreviewMeta` since the
formats converged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(snapshot): single disclosure arrow + identical row layouts

Two visual fixes after the previous round still left the Recovery
section feeling different from the Snapshots section.

- Drop the literal `▸` / `▾` characters from the recovery summary —
  the native `<details>` element already renders a disclosure
  triangle, so the previous text duplicated it. User saw two arrows
  side-by-side.
- Wrap each group's rows in `.koolook-snapshot-list` so they get the
  same bordered-box treatment around them that the library snapshot
  rows have. Strip the outer border + visuals from
  `.koolook-recovery-list` (now just a scroll container) and
  `.koolook-recovery-group-header` (now just padding) so the inner
  classes drive appearance — same `folder-name` + `folder-path` pair
  + `koolook-snapshot-row-btn` Open button + `koolook-snapshot-list`
  rows wrapper as the library section. Both surfaces should now read
  as the same UI control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "chore(snapshot): single disclosure arrow + identical row layouts"

This reverts commit 82874c3b3ada977c236c03e57479a29126ee68e7.

* Reapply "chore(snapshot): single disclosure arrow + identical row layouts"

This reverts commit 76e52bdf7cce5c6e84e883f8714921e2f506a9b3.

* fix(sidebar): remove backticks from CSS comments inside template literal

The comments I added in 82874c3 contained literal backticks (around
identifiers like `koolook-snapshot-list`) — fine in CSS, FATAL inside
a JS template literal. The CSS string in `ensureStyle()` is a
backtick-delimited template, so an inner backtick prematurely closed
the template, the rest of constants.js parsed as broken JS, the module
failed to load, and every sidebar file that imports from constants.js
collapsed with it. Net effect: entire Kforge Labs sidebar disappeared
in the standalone app.

Why pre-commit `node --check` missed it: that command defaults to
script mode for `.js` files and tolerates this edge case. The browser
loads our files as ES modules (stricter), and would have flagged the
backtick. Verified the fix with `node --check` on a `.mjs` copy of
every sidebar file before committing.

Comments rewritten without backticks. Added an in-CSS-comment warning
calling out the constraint so future changes don't regress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): tempfile-based atomic write + onCancel for showConfirmModal

Addresses two review findings on PR #105.

P1: The previous atomic-write used `<file>.json.tmp` — a fixed,
predictable name. An attacker (or accidental user) with write access
to a shared library could plant that name as a symlink pointing
outside the library, and `Path.write_bytes` would FOLLOW the symlink
past the path-traversal guard. Replaced with `tempfile.mkstemp`,
which (1) opens with `O_CREAT | O_EXCL` semantics so any existing
dirent (including a symlink) at the target name aborts the open, and
(2) generates a randomized name so an attacker can't pre-plant
anything there. The temp file still lives inside the validated
target_dir and `os.replace` still does the atomic swap onto the final
name.

P2: `showConfirmModal` had no cancel callback. The recovery toast's
"Discard offline copy" handler wrapped it in a `new Promise(resolve =>
…)` that only resolved on confirm, so cancelling left the toast button
stuck in `"Discarding…"`. Added `onCancel` parameter; the recovery
toast now resolves `{ dismiss: false }` on cancel, re-enabling the
button. Also closes the second half of #84 — `dropPlaceholdersForPacks`
in canvas_io.js had the same Promise-leak shape and the missing
cancel callback was the root cause.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:14:57 +02:00

207 lines
10 KiB
JavaScript

// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
// SPDX-License-Identifier: GPL-3.0-only
// =============================================================================
// Koolook curated-nodes sidebar — entry point.
//
// This file boots the sidebar; module bodies live in `web/sidebar/`. ComfyUI
// discovers the extension via `WEB_DIRECTORY = "./web"` (set in __init__.py)
// and calls `app.registerExtension(...).setup()` once during frontend startup.
// =============================================================================
import { app } from "../../../scripts/app.js";
import {
TAB_ID,
TAB_TITLE,
TAB_TOOLTIP,
TAB_ICON,
WORKFLOWS_FALLBACK_KEY,
criticalToast,
toast,
} from "./sidebar/constants.js";
import {
seedStarterPresetIfNeeded,
startPeriodicAutosave,
markStateSaved,
getCurrentPresetName,
SNAPSHOT_KIND,
SNAPSHOT_VERSION,
writePreset,
} from "./sidebar/snapshot.js";
import {
loadWorkflowsStore,
seedWorkflowDefaultsIfNeeded,
clearOfflineFallback,
} from "./sidebar/workflows_store.js";
import { loadUserPicks } from "./sidebar/picks_store.js";
import { showConfirmModal } from "./sidebar/modals.js";
import { renderPanel } from "./sidebar/tree.js";
import { patchCanvasMenu } from "./sidebar/canvas_menu.js";
function wireOfflineFallbackRecovery(initialBlob) {
criticalToast(
"Offline workflow data found in browser localStorage from a " +
"previous /userdata outage. If workflows you saved earlier are " +
"missing now, click Restore to save them as a snapshot — or " +
"Copy details for the raw JSON. Discard removes the offline " +
"copy permanently.",
{
copyText: initialBlob,
actions: [
{
label: "Restore as snapshot",
primary: true,
busyLabel: "Restoring…",
onClick: async () => {
// Re-read at click time — the user may have already
// discarded the blob from another tab; `null` means
// there's nothing left to restore and we should
// just dismiss.
const blob = localStorage.getItem(WORKFLOWS_FALLBACK_KEY);
if (!blob) {
toast("Offline copy was already cleared.");
return { dismiss: true };
}
let fallbackStore;
try {
fallbackStore = JSON.parse(blob);
} catch (e) {
console.error("[Koolook] offline fallback parse failed:", e);
toast("Offline copy is unparseable — copy the raw JSON instead.");
return { dismiss: false };
}
const isoNow = new Date().toISOString();
const tsForFile = isoNow.replace(/[:.]/g, "-");
const fileName = `recovered-${tsForFile}`;
// Wrap as a full snapshot envelope so the recovered
// file is a first-class snapshot the user can
// inspect / Load via the regular Snapshot UI. Picks
// come from the live store (NOT the offline blob —
// the offline blob only holds workflows) so that
// loading the recovered snapshot later wouldn't
// wipe whatever picks the user has accumulated
// since the outage.
const envelope = {
kind: SNAPSHOT_KIND,
version: SNAPSHOT_VERSION,
name: `Offline recovery · ${isoNow}`,
exportedAt: isoNow,
picks: loadUserPicks(),
workflows: fallbackStore,
};
try {
await writePreset(fileName, envelope);
} catch (e) {
console.error("[Koolook] offline restore write failed:", e);
toast(`Could not save recovery snapshot: ${e.message}`);
return { dismiss: false };
}
clearOfflineFallback();
toast(
`Saved offline copy as snapshot "${fileName}". ` +
`Open Snapshot → Load to inspect or restore.`,
5500
);
return { dismiss: true };
},
},
{
label: "Discard offline copy",
busyLabel: "Discarding…",
onClick: () => new Promise((resolve) => {
showConfirmModal({
title: "Discard offline copy?",
message:
"This permanently removes the localStorage " +
"fallback. Make sure you've already recovered " +
"anything you needed — once cleared, this " +
"data cannot be retrieved.",
confirmLabel: "Discard",
danger: true,
onConfirm: () => {
clearOfflineFallback();
toast("Offline copy discarded.");
resolve({ dismiss: true });
},
// Cancel resolves with `{ dismiss: false }` so the
// toast button re-enables and the user can Dismiss
// or Restore from the still-open recovery banner
// instead of being stuck in a "Discarding…" state.
onCancel: () => resolve({ dismiss: false }),
});
}),
},
],
}
);
}
app.registerExtension({
name: "koolook.curated_sidebar",
async setup() {
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) {
console.warn("[Koolook] extensionManager.registerSidebarTab not available; sidebar not registered.");
return;
}
// Seeds the bundled starter preset into the user's snapshot library
// on first run (replaces the legacy `curated_defaults.json` localStorage
// pick-seed). Async-fires server I/O — kept off the critical path so
// sidebar tab registration isn't blocked behind it on a slow server.
seedStarterPresetIfNeeded();
const loadResult = await loadWorkflowsStore();
// Skip workflow seeding when /userdata file is corrupt — we don't want
// to overwrite a recoverable-but-unparseable file with stock defaults.
if (!loadResult.corrupt) {
await seedWorkflowDefaultsIfNeeded();
}
// Offline-fallback recovery toast — fires when /userdata loaded
// successfully but a stale localStorage blob from a past outage
// still exists. Two recovery actions:
// • Restore as snapshot — wraps the offline workflows into a
// full snapshot envelope (with current picks so a later Load
// wouldn't wipe them) and writes it as `recovered-<iso>.json`
// into the snapshot library. Clears the fallback key on
// success so the banner self-extinguishes.
// • Discard offline copy — confirm-then-clear. Same end state
// for the banner, but the user explicitly accepts data loss.
// Wired here (not in `loadWorkflowsStore`) because the action
// handlers need `writePreset`, `loadUserPicks`, and
// `showConfirmModal` — pulling those into workflows_store would
// create a circular import via snapshot.js / modals.js.
if (loadResult.fallbackBlob) {
wireOfflineFallbackRecovery(loadResult.fallbackBlob);
}
// Baseline the saved-state fingerprint at session start IFF the
// tracker says a preset is currently loaded AND we don't already
// have a baseline persisted from a previous session. The "saved
// fingerprint persists across reloads" path is more accurate, but
// first-ever-session needs this seeding to avoid showing "unsaved"
// immediately for users who closed the tab on a clean state.
// Without this, the indicator would flicker "unsaved → saved" the
// first time the user clicks Save (since markStateSaved baselines
// there). With it, "saved" is shown from the start. Trade-off: if
// the user closed mid-edit (state on /userdata diverges from the
// tracked preset's content), we'll show "saved" briefly until they
// mutate again — acceptable for an indicator.
if (getCurrentPresetName() && !localStorage.getItem("koolook.snapshot.savedFingerprint.v1")) {
markStateSaved();
}
app.extensionManager.registerSidebarTab({
id: TAB_ID,
title: TAB_TITLE,
tooltip: TAB_TOOLTIP,
icon: TAB_ICON,
type: "custom",
render: (el) => renderPanel(el),
});
patchCanvasMenu();
// Periodic defensive auto-save — fires every 5 minutes if state has
// changed since the last successful auto-save. Keeps last 5 in the
// snapshot library as `_autosave_periodic_<iso>.json`. Started AFTER
// sidebar registration so it doesn't compete with first-render work,
// and uses an internal grace period before the first tick so the
// load/seed flows above settle first.
startPeriodicAutosave();
},
});