From bebc6f618d5798a3e81fc3c366a2ff9b966abb81 Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Tue, 5 May 2026 08:59:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(sidebar):=20right-click=20menu=20pack=20?= =?UTF-8?q?=E2=80=94=20Duplicate,=20Tags,=20+=20New=20directory=20move=20(?= =?UTF-8?q?#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sidebar): right-click menu pack — Duplicate, Tags, + New directory move Three sibling sidebar slices bundled because they all extend the same workflow-row right-click menu in `web/sidebar/tree.js` and share a small schema/store surface in `workflows_store.js`. Refactor cleanup is held back for a separate PR (issue #47) so this diff stays a "no behavior change to existing flows" feature add. Changes ------- - Right-click "Duplicate…" → name modal with ` (copy)` default; deep-clones the source graph into a new entry in the same directory via `saveWorkflowEntry`. The duplicate inherits the source's tags so the user's categorization carries over. Same-name duplicates fall through to the existing archive-on-collision behavior. Closes #58. - Right-click "Tags…" → chip-row modal with add/remove. Each edit fires its own `persistMutation` so changes survive a mid-edit close. New `showTagsModal` in `modals.js` mirrors the existing `showInputModal` / `showConfirmModal` shape (escape-key teardown, overlay-click dismissal centralized in `makeModalShell`). - New Tags sidebar section between Workflows. Flat list of tag folders; each tag holds the workflows carrying it (sorted A→Z). Click loads the workflow from its real path. Archived workflows are filtered out of the section so the active view stays clean — their tags persist on the entry, so a restore brings them back. Search matches tag name OR workflow name. - Right-click "+ New directory…" / "+ New subdirectory under …" in the Move-to flow. Both create a fresh directory and move the workflow into it as one atomic mutation; if the move can't land, the new directory is rolled back so the cache never leaks an empty orphan. Closes #57. Schema additions (back-compatible) ---------------------------------- - Workflow entries gain optional `tags: string[]`. `normalizeDirNode` trims, drops empties, and dedupes case-sensitively, so old entries without a tags field load as `tags: []` and the rest of the code can assume the field always exists. Pre-v0.3 stores load fine. - New exports in `workflows_store.js`: `getWorkflowTags`, `addTag`, `removeTag`. `getWorkflowGraph` is now also exported so the duplicate flow can deep-clone without reaching into the cache directly. Why one PR for three issues --------------------------- All three slices touch `workflowRowContextMenu` in `tree.js` and the mutator API in `workflows_store.js`. A coherent UX-feature scope ("next batch of workflow menu enhancements") reviews more cheaply as one diff than three near-identical PRs. Issue #47 (refactor / tidiness pass) stays separate because mixing refactor and feature in the same diff defeats the point of the split — that work is queued for a follow-up PR. Testing ------- - `python tools/preflight_release.py --skip manager-meta -v` PASS (static / dispatch / workflows fixtures, all 3 stages). - `node --input-type=module --check` passes on each modified JS file. - Browser smoke test: pending — needs a ComfyUI session for the user to verify the three menu actions and the Tags section against a real workflow store. The CI gates are Python-only and don't catch JS regressions in the sidebar by themselves. Related ------- - Closes #57 (move-to: + New directory…) - Closes #58 (Duplicate) - Implements the right-click + section parts of #56 (Tags); the issue body's `addTag` / `removeTag` / `listAllTags()` ops landed under slightly different names (`addTag`, `removeTag`, `gatherTags`). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sidebar): address review findings on PR #59 Self-review pass against the validated agent findings. Five fixes total across the three issues' surface, holding refactor/file-size concerns for follow-up #47 widening. Fixes ----- - **HIGH** `normalizeDirNode` now rejects non-string `tags` elements outright instead of `String(raw).trim()` coercing them. `null` / `undefined` / `true` / `123` no longer silently surface as the literal tag strings `"null"` / `"undefined"` / etc. — `stats.dropped` increments instead so the existing console warning catches malformed migrations. Closes review HIGH-1. - **HIGH** Tags modal now distinguishes "no tags yet" from "workflow no longer exists." The `getCurrentTags` adapter no longer collapses `null` to `[]`, and `renderChips` renders an explicit gone-state with disabled add input + descriptive placeholder. Prevents users typing into a doomed modal when a concurrent action (another tab, drag-and-drop) deletes the workflow under them. Closes review HIGH-3. - **MEDIUM** Duplicate flow re-validates source existence at submit-time inside the `mutate` callback. Without it, a concurrent delete of the source between menu-open and submit would let `saveWorkflowEntry`'s `ensureDirectoryAtPath` silently re-create the parent directory and paste a clone of the captured graph — effectively undoing someone else's delete. Now returns `false` on missing source, surfacing "Could not duplicate — no longer exists." Closes review MEDIUM-6. - **MEDIUM** Duplicate flow now routes inherited tags through the public `addTag` mutator instead of writing `dir.workflows[newName].tags` directly. Keeps the tree.js UI layer outside the store's internal shape. The whole sequence still rides on one `persistMutation` snapshot — commit failure rolls back save + tag adds together. Closes review MEDIUM-5. - **LOW** `gatherTags` comment block clarifies that grouping is case-sensitive but search filter is case-insensitive substring. The comment previously said "Comparison is case-sensitive" which was technically true for grouping but misleading on its own. Skipped with rationale ---------------------- - **HIGH-2 modal cache desync on persist failure** — agent analysis was wrong on trace. `renderChips` reads from cache, X-click does not optimistically remove the chip from DOM, and `restore()` reverts cache to pre-mutation. DOM and cache stay consistent across every commit-failure path. Cross-tab updates aren't reflected, but that's a project-wide cache-coherency concern, not specific to this PR. - **HIGH-4 tree.js size** — real architectural concern but follow-up only. Track on #47 widening (or a sibling extraction issue). Adding extraction in this PR would mix refactor with feature. - **MEDIUM-7 tag API asymmetry** — agent claimed `addTag`'s parameter reassignment differed from neighbors, but verified the existing convention IS parameter reassignment (`addDirectory`, `renameDirectory`, `renameWorkflow`, `moveDirectory` all do `name = (name || "").trim();`). The defensive-copy difference between `getWorkflowTags` and `getWorkflowGraph` is intentional. - **MEDIUM-8 top-level "Archive" name** — pre-existing semantic shared by toolbar Create-directory and save-modal "+ New directory." Restricting only the right-click new-dir entry would be inconsistent. Filing as a separate cross-cutting issue is the cleaner path. - **LOW magic strings / modals.js size** — both queued for #47. Validation ---------- - `python tools/preflight_release.py --skip manager-meta -v` PASS. - `node --input-type=module --check` on all three modified JS files — clean. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 35 ++++++ web/sidebar/constants.js | 7 ++ web/sidebar/modals.js | 113 +++++++++++++++++ web/sidebar/tree.js | 222 ++++++++++++++++++++++++++++++++- web/sidebar/workflows_store.js | 63 +++++++++- 5 files changed, 437 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5402a8a..852c98a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,29 @@ The format is inspired by Keep a Changelog and SemVer. on #47. ### Added +- **Right-click "Duplicate…"** on any workflow row in the sidebar tree. + Opens a name modal pre-filled with ` (copy)`; saving deep-clones + the source graph into a new entry in the same directory. The duplicate + inherits the source's tags so the user's categorization carries over. + Same-name duplicates fall through to the existing archive-on-collision + behavior in `saveWorkflowEntry`. (Closes #58.) +- **Right-click "Tags…"** on any workflow row. Opens a chip-style modal + to view, add, and remove the workflow's tags one at a time. Each + edit fires its own `persistMutation` so changes survive a mid-edit + close. (Part of #56.) +- **Tags sidebar section.** A new section between Workflows lists every + tag in use across the active workflow tree. Each tag becomes a folder + whose entries are the tagged workflows (sorted A→Z); click loads the + workflow from its real directory. Archived workflows are filtered + out of the section so the active view stays clean — their tags are + still preserved on the entry, so a restore from the Archive folder + brings them back. Search matches tag name OR workflow name. (Part + of #56.) +- **Right-click "+ New directory…" / "+ New subdirectory under …"** + in the workflow row's Move-to flow. Both create a fresh directory and + move the workflow into it as one atomic mutation; if the move can't + land, the new directory is rolled back so the cache never leaks an + empty orphan. (Closes #57.) - **Recursive subdirectories under workflow directories.** Right-click any directory in the Workflows tree → "Create subdirectory…" to nest folders to arbitrary depth (e.g. `UP-scale / Type-A / Sharp`). Each nested @@ -95,6 +118,18 @@ The format is inspired by Keep a Changelog and SemVer. - Reserved-name check: subdirectory names cannot be `Archive` (case-insensitive) at any non-root level — collides with the synthetic Archive folder rendered for archived workflows. +- **Workflow entries gain optional `tags: string[]`.** `normalizeDirNode` + trims, drops empties, and dedupes case-sensitively, so old entries + without a tags field load as `tags: []` and the rest of the code can + assume the field always exists. New mutators in `workflows_store.js`: + `getWorkflowTags(path, wfName)`, `addTag(path, wfName, tag)`, + `removeTag(path, wfName, tag)`. `getWorkflowGraph(path, wfName)` is + now also exported so the duplicate flow can deep-clone without + reaching into the cache directly. +- **`showTagsModal` in `modals.js`** — chip-row UI with add/remove + callbacks, mirrors the existing `showInputModal` / `showConfirmModal` + surface so the modal shell, escape-key teardown, and overlay-click + dismissal stay centralized. ### Documentation - Closed out issue #28 (de-vendor upstream code under `upscaler_FIX/` diff --git a/web/sidebar/constants.js b/web/sidebar/constants.js index 71d711b..56bcfee 100644 --- a/web/sidebar/constants.js +++ b/web/sidebar/constants.js @@ -107,6 +107,13 @@ export function ensureStyle() { .koolook-row[draggable="true"] { cursor: grab; } .koolook-row[draggable="true"]:active { cursor: grabbing; } .koolook-drop-target { background: rgba(80,140,235,0.18) !important; outline: 2px solid rgba(80,140,235,0.6); outline-offset: -2px; } +.koolook-tags-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 4px; min-height: 22px; align-items: center; } +.koolook-tags-empty { opacity: 0.55; font-size: 12px; } +.koolook-tag-chip { display: inline-flex; align-items: center; gap: 4px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; padding: 2px 4px 2px 9px; font-size: 11px; line-height: 1.4; } +.koolook-tag-chip-x { cursor: pointer; opacity: 0.6; padding: 0 4px; font-size: 13px; line-height: 1; border-radius: 50%; } +.koolook-tag-chip-x:hover { opacity: 1; color: #ff7777; background: rgba(255,255,255,0.06); } +.koolook-tag-add-row { display: flex; gap: 6px; align-items: center; } +.koolook-tag-add-row .koolook-modal-input { flex: 1; } `; document.head.appendChild(s); } diff --git a/web/sidebar/modals.js b/web/sidebar/modals.js index b8b0180..8849f13 100644 --- a/web/sidebar/modals.js +++ b/web/sidebar/modals.js @@ -502,6 +502,119 @@ export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, on }, 0); } +// ============================================================================= +// Tags modal — adds/removes tags one at a time, each via its own +// persistMutation (passed in by the caller). The modal itself stays open until +// the user clicks Close, so several edits can be made in a row. Chip rendering +// reads from `getCurrentTags` after every successful mutation, so the live +// state matches what the caller persisted. +// +// `getCurrentTags()` returns `null` when the underlying workflow no longer +// exists (deleted/moved/renamed by a concurrent action). We surface this as +// an explicit "gone" state — empty placeholder + disabled add input — so the +// user doesn't keep typing tags into a modal that can't persist them. +// ============================================================================= +export function showTagsModal({ wfName, getCurrentTags, onAddTag, onRemoveTag }) { + const body = document.createElement("div"); + + const lbl = document.createElement("label"); + lbl.className = "koolook-modal-label"; + lbl.textContent = "Current tags"; + body.appendChild(lbl); + + const chipsContainer = document.createElement("div"); + chipsContainer.className = "koolook-tags-chips"; + body.appendChild(chipsContainer); + + let input; + let addBtn; + function renderChips() { + chipsContainer.innerHTML = ""; + const tags = getCurrentTags(); + if (tags === null) { + // Workflow disappeared between menu-open and now. Stay open so + // the user sees the explicit reason instead of a generic "no + // tags" placeholder, and disable the add path so a second click + // can't fire a doomed persistMutation. + const empty = document.createElement("span"); + empty.className = "koolook-tags-empty"; + empty.textContent = "Workflow no longer exists."; + chipsContainer.appendChild(empty); + if (input) { + input.disabled = true; + input.placeholder = "(workflow gone)"; + } + if (addBtn) addBtn.disabled = true; + return; + } + if (tags.length === 0) { + const empty = document.createElement("span"); + empty.className = "koolook-tags-empty"; + empty.textContent = "No tags yet."; + chipsContainer.appendChild(empty); + return; + } + for (const tag of tags) { + const chip = document.createElement("span"); + chip.className = "koolook-tag-chip"; + const name = document.createElement("span"); + name.textContent = tag; + chip.appendChild(name); + const x = document.createElement("span"); + x.className = "koolook-tag-chip-x"; + x.textContent = "×"; + x.title = `Remove "${tag}"`; + x.addEventListener("click", () => { + onRemoveTag(tag, () => renderChips()); + }); + chip.appendChild(x); + chipsContainer.appendChild(chip); + } + } + + const addLbl = document.createElement("label"); + addLbl.className = "koolook-modal-label"; + addLbl.textContent = "Add tag"; + body.appendChild(addLbl); + + const addRow = document.createElement("div"); + addRow.className = "koolook-tag-add-row"; + body.appendChild(addRow); + + input = document.createElement("input"); + input.className = "koolook-modal-input"; + input.placeholder = "tag name"; + addRow.appendChild(input); + + const submit = () => { + if (input.disabled) return; + const v = input.value.trim(); + if (!v) { input.focus(); return; } + onAddTag(v, () => { + input.value = ""; + renderChips(); + input.focus(); + }); + }; + input.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); + + addBtn = makeModalButton({ label: "Add", primary: true, onClick: submit }); + addRow.appendChild(addBtn); + + // Now that input + addBtn exist, run the initial render so the + // gone-state branch can disable them if the workflow is already missing. + renderChips(); + + let overlay; + const close = makeModalButton({ label: "Close", onClick: () => overlay.remove() }); + ({ overlay } = makeModalShell({ + title: `Tags for "${wfName}"`, + body, + actions: [close], + })); + setTimeout(() => { if (!input.disabled) input.focus(); }, 0); +} + // ============================================================================= // Context menu helper // ============================================================================= diff --git a/web/sidebar/tree.js b/web/sidebar/tree.js index 2f3a7b6..2f81a39 100644 --- a/web/sidebar/tree.js +++ b/web/sidebar/tree.js @@ -44,6 +44,10 @@ import { moveDirectory, clearArchive, pathsEqual, + getWorkflowGraph, + getWorkflowTags, + addTag, + removeTag, } from "./workflows_store.js"; import { serializeFullCanvas, @@ -58,6 +62,7 @@ import { showConfirmModal, showSaveWorkflowModal, showContextMenu, + showTagsModal, } from "./modals.js"; // ============================================================================= @@ -564,6 +569,55 @@ function countDescendantsOfRawDir(dir) { return { workflows, subdirs }; } +// ============================================================================= +// Data gathering — tags (DFS walk over the workflow tree). +// +// Each output entry has shape: +// { name, entries: [{ wfName, path }, ...] } +// `path` is the full directory string[] address of the workflow's home dir. +// Archived workflows are skipped here so the Tags section reflects the +// active set only — their tags are still persisted on the entry, so a +// restore (via the Archive folder's right-click) brings them back into +// the section. Tag grouping is case-sensitive: "AI" and "ai" produce +// separate tag folders (the dedupe in `normalizeDirNode` is also +// case-sensitive). The search filter is case-insensitive substring match +// against tag name OR workflow name — typing a tag narrows to that group, +// typing a workflow name keeps every tag the workflow carries. +// ============================================================================= +function gatherTags(query) { + const q = (query || "").trim().toLowerCase(); + const tagMap = new Map(); + + const walk = (parentPath) => { + for (const dirName of listDirectoryNames(parentPath)) { + const dirPath = [...parentPath, dirName]; + const dir = dirOf(dirPath); + if (!dir) continue; + for (const [wfName, wf] of Object.entries(dir.workflows || {})) { + if (wf.archived === true) continue; + const tags = Array.isArray(wf.tags) ? wf.tags : []; + if (tags.length === 0) continue; + for (const tag of tags) { + if (q && !tag.toLowerCase().includes(q) && !wfName.toLowerCase().includes(q)) continue; + if (!tagMap.has(tag)) tagMap.set(tag, []); + tagMap.get(tag).push({ wfName, path: dirPath }); + } + } + walk(dirPath); + } + }; + walk([]); + + const tags = [...tagMap.entries()] + .map(([name, entries]) => ({ + name, + entries: entries.sort((a, b) => compareNames(a.wfName, b.wfName)), + })) + .sort((a, b) => compareNames(a.name, b.name)); + const total = tags.reduce((acc, t) => acc + t.entries.length, 0); + return { tags, total }; +} + // ============================================================================= // DOM helpers // ============================================================================= @@ -765,6 +819,45 @@ function workflowRowContextMenu(event, dirPath, wfName, isArchived = false) { }), })); + // "+ New directory…" / "+ New subdirectory under …" both create a fresh + // directory and move the workflow into it as one atomic mutation. If + // the move couldn't land we manually undo the dir add so we don't leak + // an empty orphan; in practice that branch is unreachable (the new dir + // is empty so the move can't collide), but the explicit cleanup keeps a + // future regression from silently leaving stray directories behind. + function moveToNewDirAction(parentPath, parentLabel) { + showInputModal({ + title: parentLabel + ? `New subdirectory under "${parentLabel}"` + : "New top-level directory", + label: "Name", + placeholder: "e.g. drafts", + confirmLabel: "Create & Move", + onSubmit: (name) => persistMutation({ + mutate: () => { + if (!addDirectory(parentPath, name)) return false; + const target = [...parentPath, name]; + if (!moveWorkflow(dirPath, wfName, target)) { + deleteDirectory(parentPath, name); + return false; + } + return target; + }, + onSuccess: (target) => toast(`Moved "${wfName}" to ${target.join(" / ")}.`), + onNoOp: () => toast(`Could not create — name in use, empty, or "Archive" reserved.`), + }), + }); + } + + const newDirItem = { + label: "+ New directory…", + action: () => moveToNewDirAction([], null), + }; + const newSubdirItem = { + label: `+ New subdirectory under "${dirPath.join(" / ")}"…`, + action: () => moveToNewDirAction(dirPath, dirPath.join(" / ")), + }; + const archiveItem = isArchived ? { label: "Restore from archive", @@ -781,6 +874,84 @@ function workflowRowContextMenu(event, dirPath, wfName, isArchived = false) { }), }; + const duplicateItem = { + label: "Duplicate…", + action: () => { + const sourceGraph = getWorkflowGraph(dirPath, wfName); + if (!sourceGraph) { + toast("Could not duplicate — workflow not found."); + return; + } + // Capture tags at modal-open time so a same-name duplicate (which + // archives the original via `saveWorkflowEntry`) still inherits + // the source's categorization onto the new active entry. + const sourceTags = getWorkflowTags(dirPath, wfName) || []; + showInputModal({ + title: "Duplicate workflow", + label: "New name", + defaultValue: `${wfName} (copy)`, + confirmLabel: "Duplicate", + onSubmit: (newName) => persistMutation({ + mutate: () => { + // Re-validate at submit-time. A concurrent action + // (another tab, drag-and-drop) could have deleted + // the source between menu-open and submit. Without + // this guard, `saveWorkflowEntry` would call + // `ensureDirectoryAtPath` and silently re-create + // the parent directory with a clone of the + // captured graph — effectively undoing someone + // else's delete with no feedback. + if (getWorkflowGraph(dirPath, wfName) === null) return false; + const cloned = JSON.parse(JSON.stringify(sourceGraph)); + const result = saveWorkflowEntry(dirPath, newName, cloned); + if (!result) return false; + // Route tag inheritance through the public `addTag` + // mutator instead of writing `wf.tags` directly, so + // the Duplicate path stays inside the documented + // store API. The whole sequence rides on the same + // persistMutation snapshot — commit failure rolls + // back the save AND the tag adds together. + for (const t of sourceTags) addTag(dirPath, newName, t); + return result; + }, + onSuccess: (result) => { + if (result && result.archivedAs) { + toast(`Duplicated to "${newName}" — previous "${newName}" archived as "${result.archivedAs}".`); + } else { + toast(`Duplicated to "${newName}".`); + } + }, + onNoOp: () => toast(`Could not duplicate — "${wfName}" no longer exists.`), + }), + }); + }, + }; + + const tagsItem = { + label: "Tags…", + action: () => { + showTagsModal({ + wfName, + // Pass the raw `null` through so the modal can render an + // explicit gone-state when the workflow is deleted/moved/ + // renamed by a concurrent action. Collapsing to `[]` would + // silently morph "workflow gone" into "no tags yet" and + // accept doomed Add inputs. + getCurrentTags: () => getWorkflowTags(dirPath, wfName), + onAddTag: (tag, onDone) => persistMutation({ + mutate: () => addTag(dirPath, wfName, tag), + onSuccess: () => { onDone(); toast(`Tagged "${wfName}" with "${tag}".`); }, + onNoOp: () => toast(`Could not add — empty or already tagged.`), + }), + onRemoveTag: (tag, onDone) => persistMutation({ + mutate: () => removeTag(dirPath, wfName, tag), + onSuccess: () => { onDone(); toast(`Removed tag "${tag}" from "${wfName}".`); }, + onNoOp: () => toast(`Tag "${tag}" was not present.`), + }), + }); + }, + }; + showContextMenu(event, [ { label: "Load", @@ -802,8 +973,19 @@ function workflowRowContextMenu(event, dirPath, wfName, isArchived = false) { }); }, }, + duplicateItem, + tagsItem, archiveItem, - ...(moveItems.length > 0 ? [null, ...moveItems] : []), + // Move section — separator first; existing-path entries (when any), + // then a separator, then the always-available new-dir / new-subdir + // entries. Skipping the inner separator when there are no existing + // paths keeps the menu visually clean for a fresh store with one + // directory. + null, + ...moveItems, + ...(moveItems.length > 0 ? [null] : []), + newDirItem, + newSubdirItem, null, { label: "Delete", @@ -995,6 +1177,44 @@ addSection({ }, }); +// Tags section — flat list of tag folders, each containing the workflows +// carrying that tag. Workflows that share a tag appear under the tag folder +// regardless of which directory they live in. Click loads the workflow from +// its real path; the right-click menu operates on the workflow at its +// canonical location, so renames/moves originating here update the same +// underlying entry the Workflows section shows. +addSection({ + id: "tags", + label: "Tags", + iconKind: "folder", + // No emptyMessage — until any workflow carries a tag, the section stays + // silent rather than nag the user about an empty feature. + gather(q) { + return gatherTags(q); + }, + build(ctx) { + for (const tag of ctx.data.tags) { + ctx.folder({ + name: tag.name, + count: tag.entries.length, + path: tag.name, + build: (tagCtx) => { + for (const entry of tag.entries) { + tagCtx.leaf({ + row: makeWorkflowLeafRow({ + name: entry.wfName, + dirName: entry.path.join(" / "), + onClick: () => loadWorkflowOntoCanvas(entry.path, entry.wfName), + onContextMenu: (e) => workflowRowContextMenu(e, entry.path, entry.wfName, false), + }), + }); + } + }, + }); + } + }, +}); + // Render one gathered directory entry (and its descendants) under // `parentCtx`. Layout per directory: subdirectories first (so their own // trees nest under the parent), then the synthetic Archive folder if any diff --git a/web/sidebar/workflows_store.js b/web/sidebar/workflows_store.js index f0196ba..7482de1 100644 --- a/web/sidebar/workflows_store.js +++ b/web/sidebar/workflows_store.js @@ -61,12 +61,34 @@ function normalizeDirNode(node, stats) { for (const [wfName, wf] of Object.entries(wfs)) { // Drop entries that can't be loaded (missing/non-object graph). // Coerce `archived` to a strict boolean so a stray "false" string - // can't accidentally flag an entry as archived. + // can't accidentally flag an entry as archived. Coerce `tags` to a + // clean string[] (trim, drop empties, dedupe) so old entries without + // a tags field load with `tags: []` and the rest of the code can + // assume the field always exists. if (!wf || typeof wf !== "object" || !wf.graph || typeof wf.graph !== "object") { stats.dropped += 1; continue; } - cleanedWfs[wfName] = { ...wf, archived: wf.archived === true }; + const tags = []; + if (Array.isArray(wf.tags)) { + const seen = new Set(); + for (const raw of wf.tags) { + // Reject non-strings outright instead of coercing — `String(null)` + // becomes the literal string `"null"`, which would silently + // surface as a garbage tag in the Tags section. The schema + // contract is `string[]`; anything else is corruption from a + // hand-edited /userdata file or a bug elsewhere. + if (typeof raw !== "string") { + stats.dropped += 1; + continue; + } + const t = raw.trim(); + if (!t || seen.has(t)) continue; + seen.add(t); + tags.push(t); + } + } + cleanedWfs[wfName] = { ...wf, archived: wf.archived === true, tags }; } // Recurse into subdirectories. Pre-v0.3 nodes don't have a `directories` // field — give them an empty one so the rest of the code can assume it @@ -505,6 +527,43 @@ export function getWorkflowGraph(path, wfName) { return dir.workflows[wfName].graph || null; } +// ============================================================================= +// Per-workflow tag operations. Tags are insertion-ordered string[] on each +// workflow entry. Comparison is case-sensitive: "AI" and "ai" are distinct. +// `normalizeDirNode` guarantees `tags` is always an array on cached entries, +// but the mutators still defensively coerce so a freshly-saved workflow that +// hasn't gone through normalize still gets sane behavior. +// ============================================================================= +export function getWorkflowTags(path, wfName) { + const dir = dirOf(path); + if (!dir || !dir.workflows[wfName]) return null; + const tags = dir.workflows[wfName].tags; + return Array.isArray(tags) ? [...tags] : []; +} + +export function addTag(path, wfName, tag) { + tag = (tag || "").trim(); + if (!tag) return false; + const dir = dirOf(path); + if (!dir || !dir.workflows[wfName]) return false; + const wf = dir.workflows[wfName]; + if (!Array.isArray(wf.tags)) wf.tags = []; + if (wf.tags.includes(tag)) return false; + wf.tags.push(tag); + return true; +} + +export function removeTag(path, wfName, tag) { + const dir = dirOf(path); + if (!dir || !dir.workflows[wfName]) return false; + const wf = dir.workflows[wfName]; + if (!Array.isArray(wf.tags)) return false; + const idx = wf.tags.indexOf(tag); + if (idx < 0) return false; + wf.tags.splice(idx, 1); + return true; +} + // Delete every archived workflow under the directory at `path`. Returns // `{ count }` (number of entries removed) on success, `false` if the // directory is missing or has no archived entries. Active (non-archived)