feat(sidebar): right-click menu pack — Duplicate, Tags, + New directory move (#59)

* 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 `<name> (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 <path>…"
  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) <noreply@anthropic.com>

* 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 — <name> 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-05 08:59:13 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 8c1e6e3d78
commit bebc6f618d
5 changed files with 437 additions and 3 deletions
+35
View File
@@ -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 `<name> (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 <path>…"**
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/`
+7
View File
@@ -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);
}
+113
View File
@@ -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
// =============================================================================
+221 -1
View File
@@ -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
+61 -2
View File
@@ -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)