@@ -0,0 +1,241 @@
/**
* Where the library lives (#249 System S6b, D129): the workspace banner, System › Overview's "Library location" row,
* the Move library review and its start.
*
* - The server says where the database is and why (`GET /prompt_manager/system/library` and the Overview's
* `location`): `default` (ComfyUI's user folder), `environment` (PROMPT_MANAGER_DATA_DIR), `config`
* (config.json's database path) or `node_folder`, where an update or reinstall can delete it.
* - D129: while a node-folder library is not kept there, the shell shows the concept's `.notice` (`h2` + `p` +
* `.row-actions`) above every page: "Keep it here" saves the choice on the server (the banner stops; the Overview row
* stays), "Move library" opens the review. The concept has no such notice; its markup and classes are the Sources
* notice's (app.js 680).
* - The review is the shared record-changing review (`openReviewDialog`): From, To, Size, what moves with it and what
* happens to the old files. Move library starts the server's exclusive "Move library" job, tracks it in Activity and
* opens its view; a refusal (another job running, no room, a library already there) says why and nothing starts.
* - When a Move library job ends, the banner and the Overview read the location again.
*/
import { createButton } from '../shared/toolkit/core.js' ;
import { busyToast } from './activity-scan.mjs' ;
import { closeWorkspaceDialog , openContentDialog , openReviewDialog } from './workspace-dialogs.mjs' ;
import { navigateTo } from './state.mjs' ;
import { showWorkspaceToast } from './toast.mjs' ;
export const LIBRARY _URL = '/prompt_manager/system/library' ;
export const MOVE _URL = ` ${ LIBRARY _URL } /move ` ;
export const KEEP _URL = ` ${ LIBRARY _URL } /keep ` ;
export const MOVE _KIND = 'library_move' ;
export const BANNER _TITLE = 'Your library is inside the node folder' ;
export const BANNER _TEXT = 'Your library is stored inside the node folder, where an update or reinstall can delete it. '
+ 'Move it to ComfyUI\'s user folder: it is copied and verified first, and nothing is deleted.' ;
export const KEEP _LABEL = 'Keep it here' ;
export const MOVE _LABEL = 'Move library' ;
export const ROW _TITLE = 'Library location' ;
export const START _FAILED = 'Move library could not start. Try again.' ;
export const KEEP _FAILED = 'Your choice could not be saved. Try again.' ;
/** Why the library is where it is, by `source`. */
export const SOURCE _TEXT = Object . freeze ( {
default : 'ComfyUI\'s user folder, which updates and reinstalls of the node leave alone' ,
environment : 'The folder PROMPT_MANAGER_DATA_DIR names' ,
config : 'The database path set in config.json' ,
node _folder : 'The node folder, where an update or reinstall can delete it' ,
} ) ;
/** A `problem` code as a sentence. */
export const PROBLEM _TEXT = Object . freeze ( {
'data-dir-not-absolute' : 'PROMPT_MANAGER_DATA_DIR is not an absolute folder, so it was ignored.' ,
'data-dir-served' : 'PROMPT_MANAGER_DATA_DIR names a folder whose files are served over the web, so it was ignored.' ,
'data-dir-empty' : 'PROMPT_MANAGER_DATA_DIR has no library yet; Move library puts this one there.' ,
'move-unfinished' : 'An earlier move did not finish; this library is still the one in use.' ,
} ) ;
const number = value => Number ( value ) . toLocaleString ( 'en-US' ) ;
/** "1.6 GB" or "120.0 MB" or "512 KB". */
export function sizeText ( bytes ) {
if ( ! Number . isFinite ( bytes ) || bytes < 0 ) return 'Unknown' ;
if ( bytes >= 2 * * 30 ) return ` ${ ( bytes / 2 * * 30 ) . toLocaleString ( 'en-US' , { maximumFractionDigits : 1 , minimumFractionDigits : 1 } )} GB ` ;
if ( bytes >= 2 * * 20 ) return ` ${ ( bytes / 2 * * 20 ) . toLocaleString ( 'en-US' , { maximumFractionDigits : 1 , minimumFractionDigits : 1 } )} MB ` ;
return ` ${ number ( Math . ceil ( bytes / 1024 ) ) } KB ` ;
}
/** Whether `location` is a well-formed server answer. */
export function isLocation ( location ) {
return Boolean ( location ) && typeof location === 'object' && typeof location . path === 'string'
&& Object . hasOwn ( SOURCE _TEXT , location . source ) && typeof location . movable === 'boolean' ;
}
/** The Overview row's description: the path, why it is there, the user's choice and any problem. */
export function locationDescription ( location ) {
const parts = [ ` ${ location . path } · ${ SOURCE _TEXT [ location . source ] } . ` ] ;
if ( location . movable && location . keep ) parts . push ( 'You chose to keep it here.' ) ;
const problem = PROBLEM _TEXT [ location . problem ] ;
if ( problem ) parts . push ( problem ) ;
return parts . join ( ' ' ) ;
}
/** The review's `[term, value]` rows. */
export function reviewGrid ( location ) {
return [
[ 'From' , location . path ] ,
[ 'To' , ` ${ location . target } /prompts.db ` ] ,
[ 'Size' , sizeText ( location . size _bytes ) ] ,
[ 'Moves with it' , 'config.json and the database backups' ] ,
[ 'Old files' , 'Renamed *.moved-to-…, never deleted' ] ,
] ;
}
export const REVIEW _SENTENCE = 'The copy is verified before Prompt Manager switches to it. No other job can run until it '
+ 'finishes, and closing this page does not stop it.' ;
function el ( tag , text , className ) {
const node = document . createElement ( tag ) ;
if ( text !== undefined ) node . textContent = text ;
if ( className ) node . className = className ;
return node ;
}
/**
* Start the Move library job, follow it in Activity and open its view. A busy server toasts the D57 link to the running
* job; any other refusal says the server's reason. Nothing is said once `scope` ended.
*/
export async function startLibraryMove ( { api , activity , scope , notify = showWorkspaceToast , navigate = navigateTo } ) {
try {
const { job } = await api . post ( MOVE _URL , { } , { signal : scope . signal } ) ;
if ( scope . signal . aborted ) return null ;
const tracked = activity . track ( job ) ;
navigate ( ` #/activity/ ${ tracked . id } ` ) ;
return tracked ;
} catch ( error ) {
if ( scope . signal . aborted ) return null ;
if ( error ? . status === 409 && error ? . code === 'busy' ) {
await activity . load ( ) ;
if ( ! scope . signal . aborted ) notify ( busyToast ( activity . running ( ) [ 0 ] ? ? null ) ) ;
return null ;
}
notify ( typeof error ? . code === 'string' && error . message ? error . message : START _FAILED ) ;
return null ;
}
}
/** The Move library review over `location`; Move library starts the job. */
export function openMoveReview ( { api , activity , scope , location , trigger = null , notify = showWorkspaceToast ,
navigate = navigateTo } ) {
return openReviewDialog ( { scope , trigger , title : 'Move library' , grid : reviewGrid ( location ) , sentence : REVIEW _SENTENCE ,
cancelLabel : 'Cancel' , primary : { label : MOVE _LABEL } ,
onStart : ( ) => { startLibraryMove ( { api , activity , scope , notify , navigate } ) ; } } ) ;
}
/** Save "Keep it here" (`keep` true) or take it back; resolves the new location, or null when it was not saved. */
export async function keepLibrary ( { api , scope , keep = true , notify = showWorkspaceToast } ) {
try {
const { location } = await api . post ( KEEP _URL , { keep } , { signal : scope . signal } ) ;
return isLocation ( location ) ? location : null ;
} catch ( error ) {
if ( ! scope . signal . aborted ) notify ( KEEP _FAILED ) ;
return null ;
}
}
/** The details of a library that does not move: where it is, why, and what lives beside it. */
export function openLocationDetails ( { scope , location , trigger = null } ) {
const list = el ( 'dl' , undefined , 'meta-grid' ) ;
for ( const [ term , value ] of [ [ 'Database' , location . path ] , [ 'Why here' , SOURCE _TEXT [ location . source ] ] ,
[ 'Beside it' , 'config.json, the database backups and the logs' ] , [ 'Size' , sizeText ( location . size _bytes ) ] ] ) {
const entry = el ( 'div' ) ;
entry . append ( el ( 'dt' , term ) , el ( 'dd' , value ) ) ;
list . append ( entry ) ;
}
const close = createButton ( 'Close' ) ;
scope . listen ( close , 'click' , ( ) => closeWorkspaceDialog ( ) ) ;
const actions = el ( 'div' , undefined , 'dialog-actions' ) ;
actions . append ( close ) ;
const nodes = [ list ] ;
const problem = PROBLEM _TEXT [ location . problem ] ;
if ( problem ) nodes . push ( el ( 'p' , problem , 'small' ) ) ;
nodes . push ( actions ) ;
openContentDialog ( { scope , title : ROW _TITLE , trigger , nodes } ) ;
}
/** D129: the Overview's "Library location" `.operation` row; Move library when it can move, else Details. */
export function locationRow ( location , { api , activity , scope , notify = showWorkspaceToast , navigate = navigateTo } ) {
const row = el ( 'div' , undefined , 'operation' ) ;
const text = el ( 'div' ) ;
text . append ( el ( 'h3' , ROW _TITLE ) , el ( 'p' , locationDescription ( location ) ) ) ;
const actions = el ( 'div' , undefined , 'row-actions' ) ;
const action = createButton ( location . movable ? MOVE _LABEL : 'Details' ) ;
scope . listen ( action , 'click' , ( ) => {
if ( location . movable ) openMoveReview ( { api , activity , scope , location , trigger : action , notify , navigate } ) ;
else openLocationDetails ( { scope , location , trigger : action } ) ;
} ) ;
actions . append ( action ) ;
row . append ( text , actions ) ;
return row ;
}
/** D129: the concept's `.notice` for a node-folder library: Keep it here and Move library. */
export function libraryNotice ( { scope , onKeep , onMove } ) {
const notice = el ( 'div' , undefined , 'notice' ) ;
const text = el ( 'div' ) ;
text . append ( el ( 'h2' , BANNER _TITLE ) , el ( 'p' , BANNER _TEXT ) ) ;
const keep = createButton ( KEEP _LABEL ) ;
const move = createButton ( MOVE _LABEL , 'ca-primary' ) ;
scope . listen ( keep , 'click' , ( ) => onKeep ( keep ) ) ;
scope . listen ( move , 'click' , ( ) => onMove ( move ) ) ;
const actions = el ( 'div' , undefined , 'row-actions' ) ;
actions . append ( keep , move ) ;
notice . append ( text , actions ) ;
return notice ;
}
/** Calls `onEnded()` whenever a Move library job this page sees stops running. Returns the unsubscribe. */
export function onMoveEnded ( activity , onEnded ) {
const running = new Set ( ) ;
return activity . subscribe ( ( ) => {
let ended = false ;
for ( const job of activity . jobs ( ) ) {
if ( job . kind !== MOVE _KIND ) continue ;
if ( job . state === 'running' ) running . add ( job . id ) ;
else if ( running . delete ( job . id ) ) ended = true ;
}
if ( ended ) onEnded ( ) ;
} ) ;
}
/**
* Mount the banner in `container` (hidden until the server offers the move). It reads the location once, again when a
* Move library job ends, and hides for good this page once the user keeps the library where it is.
*/
export function mountLibraryBanner ( container , { api , activity , scope , notify = showWorkspaceToast , navigate = navigateTo ,
latestOnly = window . ApiClient . latestOnly , STALE = window . ApiClient . STALE } ) {
const read = latestOnly ( { lifetime : scope . signal } ) ;
const show = location => {
container . replaceChildren ( ) ;
container . hidden = ! ( isLocation ( location ) && location . offer === true ) ;
if ( container . hidden ) return ;
container . append ( libraryNotice ( { scope ,
onKeep : async button => {
button . setAttribute ( 'aria-disabled' , 'true' ) ;
const saved = await keepLibrary ( { api , scope , notify } ) ;
if ( scope . signal . aborted ) return ;
button . removeAttribute ( 'aria-disabled' ) ;
if ( saved ) show ( saved ) ;
} ,
onMove : button => openMoveReview ( { api , activity , scope , location , trigger : button , notify , navigate } ) } ) ) ;
} ;
async function load ( ) {
try {
const answer = await read ( signal => api . get ( LIBRARY _URL , { signal , cache : 'no-store' } ) ) ;
if ( answer === STALE || scope . signal . aborted ) return ;
show ( answer . location ) ;
} catch {
// The banner is advisory: an unreadable location shows nothing (the Overview row reports it).
if ( ! scope . signal . aborted ) show ( null ) ;
}
}
container . hidden = true ;
scope . cleanup ( onMoveEnded ( activity , load ) ) ;
load ( ) ;
return { reload : load } ;
}