Files
ausboss-ComfyUI-AusBoss/js/shared/workflow_note.mjs
T
ausboss 40571ce86b Workflow Note: point loaders at a model found in a subfolder
The note said "installed" for a model kept in a subfolder while the
workflow's loader still asked for the bare file name, which ComfyUI could
not find and marked red. Such a row now says where the file is ("in
LTXV 2.3/") and has a Use it button that points those loaders at the found
copy. The check stays on the ComfyUI server's own model lists.
2026-09-29 21:33:25 -05:00

348 lines
11 KiB
JavaScript

// Pure logic for Workflow Note: the card's state shape, a Markdown-lite
// parser that yields data (never HTML), URL safety, and the model-presence
// match. No DOM and no ComfyUI imports, so it runs under node:test.
//
// The card is stored as JSON in one hidden STRING widget. Everything here
// treats that JSON as untrusted: a downloaded workflow is someone else's
// text, so every URL is filtered to http(s) and every string is coerced.
export const NOTE_VERSION = 1;
export const LAYOUTS = ["card", "banner"];
// The one non-model folder a row may name: sample inputs live in
// ComfyUI/input, and a shared workflow often ships one.
export const INPUT_FOLDER = "input";
// Folders every install has, offered first in the editor's folder picker.
export const COMMON_FOLDERS = [
"diffusion_models",
"text_encoders",
"vae",
"loras",
"checkpoints",
"clip",
"clip_vision",
"controlnet",
"upscale_models",
"embeddings",
INPUT_FOLDER,
];
const ROW_LIMIT = 60;
const TEXT_LIMIT = 20000;
function str(value, limit = 400) {
if (value === undefined || value === null) return "";
const text = typeof value === "string" ? value : String(value);
return text.slice(0, limit);
}
// http(s) only. A javascript: or data: URL in a downloaded workflow must
// never become a clickable button.
export function safeUrl(value) {
const text = str(value, 2000).trim();
if (!/^https?:\/\/\S+$/i.test(text)) return "";
return text;
}
// "huggingface.co" from a full URL - the small print under a button.
export function hostLabel(url) {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return "";
}
}
export function baseName(path) {
const text = str(path, 1000).trim();
const cut = Math.max(text.lastIndexOf("/"), text.lastIndexOf("\\"));
return cut >= 0 ? text.slice(cut + 1) : text;
}
export function emptyNote() {
return {
v: NOTE_VERSION,
title: "",
subtitle: "",
author: "",
body: "",
models: [],
packs: [],
links: [],
layout: "card",
accent: "",
};
}
function normalizeModel(row) {
if (!row || typeof row !== "object") return null;
const name = str(row.name).trim();
if (!name) return null;
return {
name,
dir: str(row.dir, 120).trim().replace(/^[\\/]+|[\\/]+$/g, ""),
url: safeUrl(row.url),
size: str(row.size, 40).trim(),
note: str(row.note, 300).trim(),
};
}
function normalizePack(row) {
if (!row || typeof row !== "object") return null;
const name = str(row.name).trim();
if (!name) return null;
return { name, url: safeUrl(row.url), node: str(row.node, 200).trim() };
}
function normalizeLink(row) {
if (!row || typeof row !== "object") return null;
const url = safeUrl(row.url);
const label = str(row.label, 80).trim();
if (!url && !label) return null;
return { label: label || hostLabel(url) || url, url };
}
function rows(list, normalize) {
if (!Array.isArray(list)) return [];
const out = [];
for (const row of list.slice(0, ROW_LIMIT)) {
const clean = normalize(row);
if (clean) out.push(clean);
}
return out;
}
// Accepts the widget string, a parsed object, or nothing; always returns a
// complete card. Unknown keys are dropped and bad values fall back to empty.
export function normalizeNote(raw) {
let source = raw;
if (typeof raw === "string") {
try {
source = raw.trim() ? JSON.parse(raw) : {};
} catch {
source = {};
}
}
if (!source || typeof source !== "object" || Array.isArray(source)) source = {};
const note = emptyNote();
note.title = str(source.title, 200).trim();
note.subtitle = str(source.subtitle, 400).trim();
note.author = str(source.author, 120).trim();
note.body = str(source.body, TEXT_LIMIT);
note.models = rows(source.models, normalizeModel);
note.packs = rows(source.packs, normalizePack);
note.links = rows(source.links, normalizeLink);
note.layout = LAYOUTS.includes(source.layout) ? source.layout : "card";
const accent = str(source.accent, 20).trim();
note.accent = /^#[0-9a-f]{6}$/i.test(accent) ? accent.toLowerCase() : "";
return note;
}
export function serializeNote(note) {
return JSON.stringify(normalizeNote(note));
}
// True when the card has nothing to show but its placeholder.
export function noteIsEmpty(note) {
return !(
note.title || note.subtitle || note.author || note.body.trim()
|| note.models.length || note.packs.length || note.links.length
);
}
// "ComfyUI/models/loras" - the folder hint under each group of rows.
export function folderPath(dir) {
if (!dir) return "ComfyUI/models";
if (dir === INPUT_FOLDER) return "ComfyUI/input";
return `ComfyUI/models/${dir}`;
}
// Rows grouped by folder in first-seen order, so the card reads like the
// install tree: one heading per folder, its files beneath.
export function groupModels(models) {
const groups = [];
const byDir = new Map();
for (const row of models) {
let group = byDir.get(row.dir);
if (!group) {
group = { dir: row.dir, path: folderPath(row.dir), rows: [] };
byDir.set(row.dir, group);
groups.push(group);
}
group.rows.push(row);
}
return groups;
}
// Match a row's file against the server's list for its folder. A file that
// moved into a subfolder still counts - the loaders resolve it there - so
// the basename decides, case-insensitively; an exact relative path wins.
export function matchInstalled(name, files) {
if (!Array.isArray(files) || !files.length) return { found: false, path: null };
const wanted = str(name, 1000).trim();
const wantedBase = baseName(wanted).toLowerCase();
if (!wantedBase) return { found: false, path: null };
const normalized = files.map((file) => str(file, 1000).replace(/\\/g, "/"));
const exact = normalized.find((file) => file.toLowerCase() === wanted.replace(/\\/g, "/").toLowerCase());
if (exact) return { found: true, path: exact };
const loose = normalized.find((file) => baseName(file).toLowerCase() === wantedBase);
return loose ? { found: true, path: loose } : { found: false, path: null };
}
// Node packs present in a graph, from each node type's python_module
// ("custom_nodes.ComfyUI-AusBoss"). Core modules ("nodes",
// "comfy_extras.*") are skipped: nobody needs to install those. The first
// node type seen per pack becomes its presence probe.
export function packsFromGraph(nodeTypes, moduleOf) {
const packs = [];
const seen = new Set();
for (const type of nodeTypes) {
const module = str(moduleOf(type), 300);
const match = /^custom_nodes\.([^.]+)/.exec(module);
if (!match) continue;
const name = match[1];
if (seen.has(name)) continue;
seen.add(name);
packs.push({ name, url: "", node: type });
}
return packs;
}
// ---------------------------------------------------------------------------
// Markdown-lite. Headings, paragraphs, bullet and numbered lists, rules,
// fenced code, and inline bold / italic / code / links. Output is data; the
// renderer builds DOM nodes from it, so no user text ever meets innerHTML.
// ---------------------------------------------------------------------------
const INLINE_PATTERN = /(\*\*[^*\n]+?\*\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)\s]+?\)|(?<![\w*])\*[^*\n]+?\*(?![\w*]))/;
export function parseInline(text) {
const spans = [];
let rest = str(text, TEXT_LIMIT);
while (rest.length) {
const match = INLINE_PATTERN.exec(rest);
if (!match) {
spans.push({ type: "text", text: rest });
break;
}
if (match.index > 0) spans.push({ type: "text", text: rest.slice(0, match.index) });
const token = match[0];
if (token.startsWith("**")) spans.push({ type: "strong", text: token.slice(2, -2) });
else if (token.startsWith("`")) spans.push({ type: "code", text: token.slice(1, -1) });
else if (token.startsWith("[")) {
const close = token.indexOf("](");
const label = token.slice(1, close);
const url = safeUrl(token.slice(close + 2, -1));
spans.push(url ? { type: "link", text: label, url } : { type: "text", text: label });
} else spans.push({ type: "em", text: token.slice(1, -1) });
rest = rest.slice(match.index + token.length);
}
return spans;
}
export function parseMarkdown(text) {
const lines = str(text, TEXT_LIMIT).replace(/\r\n?/g, "\n").split("\n");
const blocks = [];
let paragraph = [];
let list = null;
let code = null;
const flushParagraph = () => {
if (!paragraph.length) return;
const spans = [];
paragraph.forEach((line, index) => {
if (index) spans.push({ type: "br" });
spans.push(...parseInline(line));
});
blocks.push({ type: "paragraph", spans });
paragraph = [];
};
const flushList = () => {
if (list) blocks.push(list);
list = null;
};
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (code) {
if (/^```/.test(line)) {
blocks.push(code);
code = null;
} else code.text += (code.text ? "\n" : "") + rawLine;
continue;
}
if (/^```/.test(line)) {
flushParagraph();
flushList();
code = { type: "code", text: "" };
continue;
}
if (!line.trim()) {
flushParagraph();
flushList();
continue;
}
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
if (heading) {
flushParagraph();
flushList();
blocks.push({ type: "heading", level: heading[1].length, spans: parseInline(heading[2]) });
continue;
}
if (/^(-{3,}|\*{3,})$/.test(line.trim())) {
flushParagraph();
flushList();
blocks.push({ type: "hr" });
continue;
}
const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
if (bullet || numbered) {
flushParagraph();
const ordered = Boolean(numbered);
if (!list || list.ordered !== ordered) {
flushList();
list = { type: "list", ordered, items: [] };
}
list.items.push(parseInline((bullet || numbered)[1]));
continue;
}
if (list) {
// An indented continuation line belongs to the last item.
if (/^\s{2,}/.test(rawLine)) {
list.items[list.items.length - 1].push({ type: "br" }, ...parseInline(line.trim()));
continue;
}
flushList();
}
paragraph.push(line);
}
if (code) blocks.push(code);
flushParagraph();
flushList();
return blocks;
}
// "LTXV 2.3" for "LTXV 2.3/ltx.safetensors": the subfolder a found file
// sits in, or "" at the folder's top.
export function subfolderOf(path) {
const text = str(path, 1000).replace(/\\/g, "/").trim();
const cut = text.lastIndexOf("/");
return cut > 0 ? text.slice(0, cut) : "";
}
// Loaders in the graph that ask for `name` but cannot load it: a list
// widget holding the bare file name (or another copy's path) that is not
// one of its choices, while `foundPath` - the copy the note found - is.
// That is the loader ComfyUI marks red when the file sits in a subfolder.
// `widgets` is [{ node, widget, value, options }]; returns the matches.
export function loadersNeeding(name, foundPath, widgets) {
const wanted = baseName(name).toLowerCase();
const found = str(foundPath, 1000).replace(/\\/g, "/");
if (!wanted || !found || !Array.isArray(widgets)) return [];
return widgets.filter((entry) => {
const options = Array.isArray(entry?.options) ? entry.options.map((item) => str(item, 1000).replace(/\\/g, "/")) : [];
const current = str(entry?.value, 1000).replace(/\\/g, "/");
return baseName(current).toLowerCase() === wanted && current !== found && !options.includes(current) && options.includes(found);
});
}