Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/library-state.mjs
T
Vito 0126705045 feat(library): filter prompts by output folder and browse tags with Any (#444, PR 2) (#456)
* feat(vocabulary): tag search, tag sort and an Untagged prompts entry (#444)

Tags & categories gains a server-side tag search and sort (keyset pages,
cursor scoped to the query) and an "Untagged prompts" row whose Browse opens
the Library with tag_mode=untagged. Refine this view gets Tag match, saved
Library views keep it, and the routes echo the applied query so a stale
server is an error, never an unfiltered list. Recorded as D149.

Refs #444

* feat(library): filter prompts by output folder and browse tags with Any (#444)

Refine this view on the Prompt library gains the concept's Folder (folders
below an output root, read when the dialog opens) and a Tag match select
(All / Any). The listing takes folder and tag_mode, matches folders on the
normalized file_path under every spelling of each output root, echoes both
so a stale server is an error, and saved Library views keep them. Recorded
as D150.

Refs #444

* fix(library): let saved-view defaults fill the missing #444 filters

libraryViewState passed `?? 'all'` and `?? ''` fallbacks that
normalizeViewState already applies to an undefined value. The redundant
branches were never taken in the page, which dropped saved-views.mjs below
its 95% branch floor. A unit now covers a shelf without the new filters.

Refs #444

* test(saved-views): give the D153 Library view the tag_mode key (#444)

The Library saved-view state now holds tag_mode, and the API requires
every key, so main's new "Highest rated" spec posts it too.

Refs #444

* test(saved-views): give the D153 Library view the folder key (#444)

Refs #444

* fix(library): nest Refine folders by path segment; backslash separates only on Windows (#444)

Live test B1: the Folder list indented a server list sorted as full-path
text, so "-" and "_" sorted before "/" and children landed under the wrong
parent (kiko/char under kiko-v6), with case variants interleaved. The
options are now a tree built by segment: each folder directly under its own
parent, case variants distinct siblings.

On POSIX a folder named "Metadata\Klein..." was listed but filtered to no
prompts, because normalize_folder read "\" as a separator. It now does so
only on Windows (ntpath), as the listing already did.

Refs #444
2026-10-29 14:10:29 +00:00

153 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function validId(value) { return Number.isSafeInteger(value) && value > 0; }
function parent(value) {
return value && validId(value.id) && typeof value.text==='string';
}
function parameters(filters,limit) {
const query=new URLSearchParams({limit:String(limit)});
for(const key of ['text','category']) {
if(filters[key]!==undefined && typeof filters[key]!=='string') throw new TypeError('Invalid library filter');
if(filters[key]) query.set(key,filters[key]);
}
if(filters.tags!==undefined && (!Array.isArray(filters.tags) || filters.tags.some(tag=>typeof tag!=='string'))) throw new TypeError('Invalid library tags');
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
const sort=filters.sort || 'newest';
if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort');
// #444: how Tags match (every tag, any of them, or parents no tag's Browse returns, which never goes with tags) and
// the output folder below a root.
const tagMode=filters.tagMode ?? 'all',folder=filters.folder ?? '';
if(!LIBRARY_TAG_MODES.includes(tagMode) || (tagMode==='untagged' && query.has('tag'))) throw new TypeError('Invalid library tag mode');
if(typeof folder!=='string' || folder.length>1024) throw new TypeError('Invalid library folder');
if(tagMode!=='all') query.set('tag_mode',tagMode);
if(folder) query.set('folder',folder);
query.set('sort',sort);return query;
}
/** The Library's tag match values (#444); the first is the default, sent as no parameter. */
export const LIBRARY_TAG_MODES=Object.freeze(['all','any','untagged']);
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
function validatePage(page,cursor,query) {
// A tag match or folder the server did not echo was not applied (an older server): never show the whole shelf as filtered.
if(query?.has('tag_mode') && page?.tag_mode!==query.get('tag_mode')) throw new TypeError('Unconfirmed library tag mode');
if(query?.has('folder') && page?.folder!==query.get('folder')) throw new TypeError('Unconfirmed library folder');
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
}
export function createLibraryStream({api,onChange=()=>{},restore}) {
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
let query=parameters({},40),request,disposed=false,seen=new Set();
if(restore) {
query=parameters(restore.filters,40);const saved=restore.state;
// A shelf left before its first page answered (or after it failed) has no page yet: it restores as a fresh stream.
const firstPagePending=saved?.variantTotal===null && Array.isArray(saved.items) && !saved.items.length && saved.total===0 && saved.cursor===null && saved.hasMore===true;
if(!firstPagePending) validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent');
state=Object.freeze({...saved,epoch:saved.epoch ?? null,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
}
const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);};
async function loadMore() {
if(disposed || state.loading || !state.hasMore) return 0;
const current=request=new AbortController();const cursor=state.cursor;
emit({loading:true,error:null});const params=new URLSearchParams(query);if(cursor!==null) params.set('cursor',cursor);
try {
const page=await api.get(`/prompt_manager/library?${params}`,{signal:current.signal});
if(disposed || current!==request) return 0;
validatePage(page,cursor,query);
// #437: the storage epoch the shelf's items were read under, which a bulk write sends as its fence; a later
// page from a restored storage is refused (its items would belong to another database).
const epoch=typeof page.library_epoch==='string'?page.library_epoch:null;
if(state.epoch!==null && epoch!==state.epoch) throw new TypeError('Prompt storage changed while the library was loading');
const incoming=[];const added=new Set();
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
for(const value of added) seen.add(value);
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
}
return {get state(){return state;},loadMore,async reset(filters={}) {
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
},dispose(){disposed=true;request?.abort();request=null;}};
}
/**
* Saved roles for one selection. A pair outside the bounded variant page is
* passed separately as `selectedPair` (an exact-ID read) and must belong to the
* same positive; it never enters `detail.variants`.
*/
export function libraryPromptRoles(detail,pairId=null,selectedPair=null) {
let variant=pairId===null?null:detail.variants.find(item=>item.id===pairId);
if(pairId!==null && !variant && selectedPair?.id===pairId) {
if(selectedPair.positive_prompt_id!==detail.positive.id || selectedPair.positive_text!==detail.positive.text || typeof selectedPair.negative_text!=='string') throw new TypeError('Saved negative variant belongs to another prompt');
variant=selectedPair;
}
if(pairId!==null && !variant) throw new TypeError('Saved negative variant is unavailable');
return {positive:{status:'resolved',text:detail.positive.text,source:'library'},negative:variant?{status:'resolved',text:variant.negative_text,source:'library'}:{status:'unresolved',text:null,source:null,message:'Choose a saved negative variant to inspect or send it.'}};
}
const ARCHIVE_SCOPES=new Set(['active','archived','all']);
export function createLibraryDetail({api,onChange,archive='active'}) {
if(!ARCHIVE_SCOPES.has(archive)) throw new TypeError('Invalid archive scope');
let request,disposed=false,id;
let state=Object.freeze({detail:null,loading:false,error:null,loadingVariants:false,variantsError:null});
const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);};
function validate(detail) {
if(!parent(detail.positive) || detail.positive.id!==id || !Array.isArray(detail.variants) || detail.variants.some(item=>!validId(item.id) || typeof item.negative_text!=='string' || typeof item.explicit_empty!=='boolean' || item.explicit_empty!==(item.negative_text==='')) || new Set(detail.variants.map(item=>item.id)).size!==detail.variants.length) throw new TypeError('Invalid prompt history');
const total=detail.variant_total ?? detail.variants.length,more=detail.variants_has_more ?? false,cursor=detail.variants_next_cursor ?? null;
if(!Number.isSafeInteger(total) || total<detail.variants.length || typeof more!=='boolean' || !(cursor===null || (typeof cursor==='string' && cursor.length>0)) || more!==(cursor!==null)) throw new TypeError('Invalid negative variant page');
return {...detail,variant_total:total,variants_has_more:more,variants_next_cursor:cursor};
}
async function fetch(append) {
if(disposed || (append && (!state.detail?.variants_has_more || state.loadingVariants || state.loading))) return 0;
request?.abort();const current=request=new AbortController();const previous=state.detail;const cursor=append?previous.variants_next_cursor:null;
emit(append?{loadingVariants:true,variantsError:null}:{detail:null,loading:true,error:null,loadingVariants:false,variantsError:null});
try {
if(!validId(id)) throw new TypeError('Invalid positive prompt');
const query=new URLSearchParams({variant_limit:'40'});if(archive!=='active') query.set('archive',archive);if(cursor) query.set('variant_cursor',cursor);
const raw=await api.get(`/prompt_manager/library/${id}?${query}`,{signal:current.signal});
if(disposed || request!==current) return 0;
const detail=validate(raw);
if(append) {
if(detail.positive.text!==previous.positive.text || (detail.variants_has_more && detail.variants_next_cursor===cursor)) throw new TypeError('Prompt history changed. Reload it before continuing.');
const variants=new Map(previous.variants.map(item=>[item.id,item]));for(const item of detail.variants) if(!variants.has(item.id)) variants.set(item.id,item);detail.variants=[...variants.values()];
}
request=null;emit({detail,loading:false,error:null,loadingVariants:false,variantsError:null});return detail.variants.length-(append?previous.variants.length:0);
} catch(error) {if(!disposed && request===current) {request=null;emit(append?{loadingVariants:false,variantsError:error}:{loading:false,error});}return 0;}
}
/** Same-parent reconciliation: a validated authoritative detail replaces the current one and supersedes any read. */
function accept(detail) {
if(disposed) throw new TypeError('Prompt history is disposed');
const next=validate(detail);
request?.abort();request=null;
emit({detail:next,loading:false,error:null,loadingVariants:false,variantsError:null});
}
return {get state(){return state;},load(value){id=value;return fetch(false);},loadMore(){return fetch(true);},accept,dispose(){disposed=true;request?.abort();}};
}
export function libraryWindow(count,columns,top,height,focused=-1) {
if(count<=120) return Array.from({length:count},(_,index)=>index);
const rows=Math.ceil(count/columns),first=Math.max(0,Math.floor(top/578)-2),last=Math.min(rows-1,Math.ceil((top+height)/578)+2);
const keep=new Set();
for(let row=first;row<=last;row++) keep.add(row);
if(focused>=0 && focused<count) keep.add(Math.floor(focused/columns));
return [...keep].sort((a,b)=>a-b).flatMap(row=>Array.from({length:columns},(_,column)=>row*columns+column).filter(index=>index<count));
}
const byFolderName=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
/**
* Refine › Folder options (#444 D150): the concept's "All output folders" first, then the folders as a tree built by
* path segment, so each folder sits directly under its own parent (never under a longer sibling such as `kiko-v6`) and
* folders differing only in case stay distinct siblings. Siblings sort case-insensitively, then by exact spelling. A
* label is the last segment indented by depth (the legacy admin select). The chosen folder is kept even when the list
* lacks it, with its parents.
*/
export function libraryFolderOptions(folders,current='') {
const root=new Map();
for(const folder of current?[...folders,current]:folders) {
let level=root;
for(const segment of folder.split('/')) {if(!level.has(segment)) level.set(segment,new Map());level=level.get(segment);}
}
const options=[['','All output folders']];
(function walk(level,prefix,depth) {
for(const segment of [...level.keys()].sort(byFolderName)) {
const path=prefix?`${prefix}/${segment}`:segment;
options.push([path,`${'\u00a0\u00a0'.repeat(depth)}${segment}`]);
walk(level.get(segment),path,depth+1);
}
})(root,'',0);
return options;
}