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.
348 lines
11 KiB
JavaScript
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);
|
|
});
|
|
}
|