A LoRA from the note's list that sits in a subfolder only showed as installed, while the model, text encoder and VAE lines offered Use it. The LoRA line now shows the folder and offers Use it as well, which writes the found path into the LoRA Loader row. LoRA Loader follows a row change made by another node in Nodes 2.0 too. Use it also wrote the found path with forward slashes. A Windows server lists files in subfolders with backslashes, and a loader only takes a file exactly as listed, so the loader kept an unknown value. Use it now writes the folder's own spelling.
412 lines
15 KiB
JavaScript
412 lines
15 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(),
|
|
};
|
|
}
|
|
|
|
// The editor's model table: one column for every field normalizeModel
|
|
// keeps, in the order they show. The form builds its cells from this list
|
|
// and reads them back with rowFromCells, so saving the form can't drop a
|
|
// field the card shows, such as the note under the file name.
|
|
export const MODEL_COLUMNS = [
|
|
{ key: "name", label: "File", placeholder: "krea2_turbo_fp8_scaled.safetensors" },
|
|
{ key: "dir", label: "Folder" },
|
|
{ key: "size", label: "Size", placeholder: "13.1 GB" },
|
|
{ key: "url", label: "Download URL", placeholder: "https://huggingface.co/.../resolve/main/file.safetensors" },
|
|
{ key: "note", label: "Note", placeholder: "tip, credit, trigger word" },
|
|
];
|
|
|
|
// A table row from its cell values, given in the columns' order.
|
|
export function rowFromCells(columns, values) {
|
|
return Object.fromEntries(columns.map(({ key }, index) => [key, values?.[index] ?? ""]));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// How tall the node must be for the card to show all of its content, from
|
|
// three numbers the page measures: the card's natural height at its current
|
|
// width, the node's height, and the part of the node height the card never
|
|
// gets (the gap above the first widget plus the widget frame). `fit` is that
|
|
// node height. `grow` says whether the node has to get there now: Chrome
|
|
// rounds both heights before it draws a scrollbar, so a card less than half
|
|
// a pixel short already shows everything and keeps its size.
|
|
export function noteFit({ natural, nodeHeight, chrome }) {
|
|
const values = [natural, nodeHeight, chrome].map(Number);
|
|
if (!values.every(Number.isFinite) || values[0] <= 0) return { fit: null, grow: false };
|
|
const [content, node, frame] = values;
|
|
return { fit: content + frame, grow: Math.round(content) > Math.round(node - frame) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
// LoRA Loader rows that ask for `name` but hold another spelling than the
|
|
// copy the note found: a bare file name, or a path from someone else's
|
|
// install, that is no file here. The loader finds such a file by its name
|
|
// when it runs; Use it writes the real path into the row. `loaders` is
|
|
// [{ node, widget, rows }] with rows as the loader keeps them ({ name });
|
|
// `files` is the loras folder as the server lists it. Returns
|
|
// [{ node, widget, index }], one per row.
|
|
export function loraRowsNeeding(name, foundPath, loaders, files) {
|
|
const wanted = baseName(name).toLowerCase();
|
|
const found = str(foundPath, 1000).replace(/\\/g, "/");
|
|
const have = new Set((Array.isArray(files) ? files : []).map((item) => str(item, 1000).replace(/\\/g, "/")));
|
|
if (!wanted || !found || !have.has(found) || !Array.isArray(loaders)) return [];
|
|
const out = [];
|
|
for (const loader of loaders) {
|
|
(Array.isArray(loader?.rows) ? loader.rows : []).forEach((row, index) => {
|
|
const current = str(row?.name, 1000).replace(/\\/g, "/");
|
|
if (baseName(current).toLowerCase() === wanted && current !== found && !have.has(current)) {
|
|
out.push({ node: loader.node, widget: loader.widget, index });
|
|
}
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// A found file the way its folder spells it. Windows lists
|
|
// "Krea 2\\model.safetensors", and a loader only takes an entry exactly as
|
|
// it is listed, so the path with "/" the note works with is no choice there.
|
|
export function listedSpelling(foundPath, listed) {
|
|
const found = str(foundPath, 1000).replace(/\\/g, "/");
|
|
const hit = (Array.isArray(listed) ? listed : []).find((item) => str(item, 1000).replace(/\\/g, "/") === found);
|
|
return hit === undefined ? found : str(hit, 1000);
|
|
}
|