* 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
153 lines
12 KiB
JavaScript
153 lines
12 KiB
JavaScript
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;
|
||
}
|