/********************************************************************** * * * **********************************************************************/ ((typeof define)[0]=='u'?function(f){module.exports=f(require)}:define) (function(require){ var module={} // make module AMD/node compatible... /*********************************************************************/ var object = require('lib/object') var util = require('lib/util') var actions = require('lib/actions') var features = require('lib/features') var data = require('imagegrid/data') var images = require('imagegrid/images') var core = require('features/core') var base = require('features/base') //require('features/all') if(typeof(process) != 'undefined'){ var pathlib = requirejs('path') var argv = requirejs('lib/argv') var progress = requirejs('cli-progress') var colors = requirejs('colors') var file = require('imagegrid/file') } /*********************************************************************/ var CLIActions = actions.Actions({ config: { // XXX do we care that something is not "ready" here??? 'declare-ready-timeout': 0, 'progress-done-delay': 1000, banner: '$APPNAME $VERSION:', }, // docs... // // XXX do a better set of examples... cliExamples: [[ 'Create/init index in current directory', '$ $SCRIPTNAME init', '', 'Export 500px previews from current index to ./preview directory', '$ $SCRIPTNAME export from=. to=./previews --image-size=500', ]], // the argvparser... // // this is set by argv's Parser on .onArgs(..) in .ready(..) handler below... argv: undefined, help: ['- System/Show action help', function(...actions){ Object.entries(this.getDoc(actions)) .forEach(function([action, [s, l]]){ console.log(l) console.log('') }) }], get cliActions(){ return this.actions .filter(function(action){ return this.getActionAttr(action, 'cli') }.bind(this)) }, // XXX should this be here??? // ...move this to progress... // XXX we are missing some beats, is this because we do not let the // bar update before closing??? // XXX need to reset this when done... __progress: null, showProgress: ['- System/', function(text, value, max){ // progress display is disabled... if(this.__progress === false){ return } var msg = text instanceof Array ? text.slice(1).join(': ') : null text = text instanceof Array ? text[0] : text var settings = this.__progress = this.__progress ?? {} var bars = settings.bars = settings.bars ?? {} var state = bars[text] = bars[text] ?? {} if(state.timeout){ clearTimeout(state.timeout) delete state.timeout } // actions... if(value == 'reset'){ // XXX this is not the same as ui-progress... // ...here we first set timeout then and close, // there we set to 0 and timeout and close... state.timeout = setTimeout( function(){ //this.showProgress(text, 0, 0) }.bind(this), this.showProgress(text, 'close') }.bind(this), this.config['progress-done-delay'] || 1000) return } if(value == 'close'){ delete bars[text] // check if no bars left... if(Object.keys(bars) == 0){ delete this.__progress } return } var l = Math.max(text.length, settings.__text_length || 0) // length changed -> update the bars... l != settings.__text_length && Object.entries(bars) .forEach(function([key, value]){ value.bar && value.bar.update({text: key.padEnd(l)}) }) settings.__text_length = l // normalize max and value... value = state.value = value != null ? (typeof(value) == typeof('str') && /[+-][0-9]+/.test(value) ? (state.value || 0) + parseInt(value) : value) : state.value max = state.max = max != null ? (typeof(max) == typeof('str') && /[+-][0-9]+/.test(max) ? (state.max || 0) + parseInt(max) : max) : state.max var container = settings.__multi_bar = settings.__multi_bar || (new progress.MultiBar({ // XXX make this simpler... format: '{text} {bar} {percentage}% ' +'| ETA: {eta_formatted} | {value}/{total}', autopadding: true, stopOnComplete: true, forceRedraw: true, }, progress.Presets.rect) // prepare for printing stuff... .run(function(){ this.on('redraw-pre', function(){ // XXX need to clear the line -- need to get term-width.... // XXX this requires a full draw (forceRedraw: true)... //console.log('moo'.padEnd(process.stdout.columns)) }) })) var bar = state.bar = state.bar || container.create(0, 0, {text: text.padEnd(l)}) // XXX for some reason this does not work under electron... bar.setTotal(Math.max(max, value)) bar.update(value) // auto-clear when complete... if(value >= max){ state.timeout = setTimeout( function(){ this.showProgress(text, 'close') }.bind(this), this.config['progress-done-delay'] || 1000) } }], // handle logger progress... // XXX reset is called at odd spots by the queue handler (see: features/core.js) // XXX this is a copy from ui-progress -- need to reuse if possible... handleLogItem: ['- System/', function(logger, path, status, ...rest){ var msg = path.join(': ') var l = (rest.length == 1 && rest[0] instanceof Array) ? rest[0].length : rest.length // only pass the relevant stuff... var attrs = {} logger.ondone && (attrs.ondone = logger.ondone) logger.onclose && (attrs.onclose = logger.onclose) // get keywords... var {add, done, skip, reset, close, error} = this.config['progress-logger-keywords'] || {} // setup default aliases... add = new Set([...(add || []), 'added']) done = new Set([...(done || [])]) skip = new Set([...(skip || []), 'skipped']) reset = new Set([...(reset || [])]) close = new Set([...(close || []), 'closed']) error = new Set([...(error || [])]) // close... if(status == 'close' || close.has(status)){ this.showProgress(path, 'close') // reset... // XXX this seems to be called before "Cache image metadata" is done // when called from .cliInitIndex(..) -- messing up the numbers... } else if(status == 'reset' || reset.has(status)){ this.showProgress(path, 'reset') // added new item -- increase max... // XXX show msg in the progress bar??? } else if(status == 'add' || add.has(status)){ this.showProgress(path, '+0', '+'+l) // resolved item -- increase done... } else if(status == 'done' || done.has(status)){ this.showProgress(path, '+'+l) // skipped item -- increase done... // XXX should we instead decrease max here??? // ...if not this is the same as done -- merge... } else if(status == 'skip' || skip.has(status)){ this.showProgress(path, '+'+l) // error... // XXX STUB... } else if(status == 'error' || error.has(status)){ this.showProgress(['Error'].concat(msg), '+0', '+'+l) } }], // XXX SETUP revise default... setupFeatures: ['- System/', core.doc`Load required features. NOTE: this is hete because cli is designed to be loaded in a very limited context and for some actions will need additional features. `, function(...tags){ var features = this.features.FeatureSet requirejs('features/all') features.setup(this, [ 'imagegrid-testing', ...(tags.length == 0 ? this.features.input : tags), ]) }], setupGlobals: ['- System/', function(){ // setup the global ns... global.ig = global.ImageGrid = this global.help = function(...actions){ global.ig.help(...actions) } global.ImageGridFeatures = core.ImageGridFeatures }], // basic code runner... cliDo: ['- System/CLI/run CODE', {cli: { name: '@do', arg: 'CODE', }}, function(code){ var AsyncFunction = (async function(){}).constructor this.setupFeatures() this.setupGlobals() AsyncFunction(code)() this.stop() }], // Interactive commands... // cliStartREPL: ['- System/CLI/start CLI interpreter', {cli: { name: '@repl', arg: 'PATH' //interactive: true, }}, function(path, options){ var that = this var package = nodeRequire('./package.json') // XXX SETUP this.setupFeatures() if(path){ this.loadIndex(path) } this.__keep_running = true this.setupGlobals() // start non-tty / script mode... if(!process.stdin.isTTY){ var fs = nodeRequire('fs') var AsyncFunction = (async function(){}).constructor AsyncFunction( fs.readFileSync(process.stdin.fd, 'utf-8'))() this.stop() // start repl mode... } else { var repl = nodeRequire('repl') // print banner... var banner = this.banner || this.config.banner banner && process.stdin.isTTY && process.stdout.isTTY && console.log(banner .replace(/\$APPNAME/g, package.name) .replace(/\$AUTHOR/g, package.author) .replace(/\$REPO/g, package.repository) .replace(/\$SCRIPTNAME/g, this.argv.scriptName) .replace(/\$VERSION/g, this.version)) // start the repl... repl .start({ prompt: 'ig> ', useGlobal: true, input: process.stdin, output: process.stdout, }) .on('exit', function(){ that.stop() }) } }], // XXX move this to a feature that requires electron... // ...and move electron to an optional dependency... cliStartGUI: ['- System/CLI/start viewer GUI', core.doc` NOTE: this will not wait for the viewer to exit.`, {cli: argv && argv.Parser({ key: '@gui', arg: 'PATH', doc: 'start viewer GUI', '-version': undefined, '-quiet': undefined, '-devtools': { doc: 'show DevTools', type: 'bool', }, '-show': { doc: 'force show interface', type: 'bool', }, })}, function(path, options={}){ var env = { ...process.env } path && (env.IMAGEGRID_PATH = util.normalizePath( pathlib.resolve(process.cwd(), path))) options.devtools && (env.IMAGEGRID_DEBUG = true) options.show && (env.IMAGEGRID_FORCE_SHOW = true) // already in electron... if(process.versions.electron){ // XXX this feels hackish... global.START_GUI = true // launch gui... } else { requirejs('child_process') .spawn(requirejs('electron'), [ pathlib.join( pathlib.dirname(nodeRequire.main.filename), 'e.js') ], { detached: true, env, }) } }], // XXX cliiGID: ['- System/GLI/generate GID', {cli: { name: '@gid', arg: 'IMAGE', valueRequired: true, // XXX REMOVE WHEN DONE... doc: false, }}, function(path){ // XXX console.warn('Not implemented yet...') }], cliListIndexes: ['- System/CLI/list indexes in PATH', {cli: argv && argv.Parser({ key: '@ls', arg: 'PATH', doc: 'list indexes in PATH', '-version': undefined, '-quiet': undefined, '-r': '-recursive', '-recursive': { doc: 'list nested/recursive indexes', type: 'bool', }, '-n': '-nested-only', '-nested-only': { doc: 'ignore the top-level index and only list the indexes below', type: 'bool', }, })}, function(path, options={}){ var that = this path = path ?? '.' // needed to get the default index dir name... this.setupFeatures('fs') //this.setupFeatures() file.listIndexes(path) .on('end', function(paths){ paths = paths .map(function(p){ return p .split(that.config['index-dir']) .shift() }) // normalize path... path.at(-1) != '/' && (path += '/') // handle --nested-only options['nested-only'] && paths.includes(path) && paths.splice(paths.indexOf(path), 1) paths = options.recursive ? paths : file.skipNested(paths) .sortAs(paths) for(var p of paths){ console.log(p) } }) }], // XXX metadata caching and preview creation are not in sync, can // this be a problem??? // ...if not, add a note... // XXX should we support creating multiple indexes at the same time??? // XXX this is reletively generic, might be useful globally... // XXX should we use a clean index or do this in-place??? // XXX add ability to disable sort... cliInitIndex: ['- System/CLI/make index', core.doc` Create index in current directory .cliInitIndex() .cliInitIndex('create') -> promise Create index in path... ,cliInitIndex(path) .cliInitIndex('create', path) -> promise Update index in current directory .cliInitIndex('update') -> promise Update index in path... .cliInitIndex('update', path) -> promise `, {cli: { name: '@init', arg: 'PATH', //valueRequired: true, }}, function(path, options){ // XXX SETUP this.setupFeatures() // get mode... if(path == 'create' || path == 'update'){ var [mode, path, options] = arguments } mode = mode || 'create' // normalize path... path = util.normalizePath( path ? pathlib.resolve(process.cwd(), path) : process.cwd()) options = options || {} // XXX should we use a clean index or do this in-place??? //var index = this.constructor(..) var index = this return (mode == 'create' ? index.loadImages(path) : index.loadNewImages(path)) // save base index... .then(function(){ return index.saveIndex() }) // sharp stuff... .then(function(){ if(index.makePreviews){ return Promise.all([ // NOTE: no need to call .cacheMetadata(..) as // it is already running after .loadImages(..) index.makePreviews('all') ])} }) .then(function(){ return index .sortImages() .saveIndex() }) }], // XXX does not work yet... cliUpdateIndex: ['- System/CLI/update index', {cli: { name: '@update', arg: 'PATH', }}, 'cliInitIndex: "update" ...'], // XXX handle errors... cliInfo: ['- System/CLI/show information about index in PATH', {cli: { name: '@info', arg: 'PATH', }}, function(path, options={}){ var that = this path = path ?? '.' this.setupFeatures() return this.loadIndex(path) .then( async function(){ var modified = Object.values( await that.loadSaveHistoryList()) .map(function(log){ return Object.keys(log) }) .flat() .sort() .pop() // calculate core.doc compatible offset for nested items. var offset = '\t'.repeat(` `.split('\t').length) console.log(core.doc` Load path: ${ path } Index path: ${ that.location.path } Loaded indexes: ${ ['', ...that.location.loaded].join('\n'+offset) } Current image: ${ that.current } Image count: ${ that.data.order.length } Collections: ${ that.collections ? ['', ...Object.keys(that.collections || [])].join('\n'+offset) : '-' } Modified date: ${ modified }`) }, function(err){ console.error('Can\'t find or load index at:', path) }) }], cliListCollections: ['- System/CLI/list collections in index', {cli: argv && argv.Parser({ key: '@collections', doc: 'list collection in index at PATH', arg: 'PATH', '-version': undefined, '-quiet': undefined, '-f': '-full', '-full': { doc: 'show full collection information', type: 'bool', }, })}, function(path, options={}){ var that = this this.setupFeatures() path = path || options.value path = util.normalizePath( path ? pathlib.resolve(process.cwd(), path) : process.cwd()) return this.loadIndex(path) .then( function(){ for(var name of that.collection_order || []){ // XXX revise output formatting... options.full ? console.log(that.collections[name].gid, name) : console.log(name) } }, function(err){ // XXX how do we handle rejection??? console.error('Can\'t find or load index at:', path) }) }], // XXX cliCloneIndex: ['- System/CLI/clone index', function(){ }], // XXX report that can't find an index... // XXX move options to generic object for re-use... // XXX how do we handle errors??? cliExportImages: ['- System/CLI/export images', {cli: argv && argv.Parser({ key: '@export', doc: 'export images', // help... '-help-pattern': { doc: 'show image filename pattern info and exit', priority: 89, handler: function(){ this.parent.context // XXX SETUP //.setupFeatures('fs', 'commandline') .setupFeatures() .help('formatImageName') return argv.STOP } }, '-version': undefined, '-quiet': undefined, // commands... '@from': { doc: 'source path', arg: 'PATH | from', default: '.', valueRequired: true, }, // XXX '@collection': { doc: 'source collection (name/gid)', arg: 'COLLECTION | collection', //default: 'ALL', valueRequired: false, }, //*/ '@to': { doc: 'destination path', arg: 'PATH | path', required: true, valueRequired: true, }, // bool options... // XXX these should get defaults from .config '-include-virtual': { doc: 'include virtual blocks', arg: '| include-virtual', type: 'bool', //value: true, default: true, }, '-clean-target': { doc: 'cleanup target before export (backup)', arg: '| clean-target', type: 'bool', //value: true, default: true, }, '-no-*': { doc: 'negate boolean option value', handler: function(rest, key, value, ...args){ rest.unshift(key.replace(/^-?-no/, '') +'=false') } }, // options... '-image-name': { doc: 'image name pattern', arg: 'PATTERN | preview-name-pattern', default: '%(fav)l%n%(-%c)c', valueRequired: true, }, '-mode': { // XXX get doc values from system... doc: 'export mode, can be "resize" or "copy best match"', arg: 'MODE | export-mode', //default: 'copy best match', default: 'resize', valueRequired: true, }, '-image-size': { doc: 'output image size', arg: 'SIZE | preview-size', default: 1000, valueRequired: true, }, })}, function(path, options={}){ var that = this // XXX SETUP this.setupFeatures() path = path || options.from path = util.normalizePath( path ? pathlib.resolve(process.cwd(), path) : process.cwd()) var collection = options.collection return this.loadIndex(path) .then( function(){ // export collection... if(collection){ if(!that.collections[collection]){ console.error( 'Can\'t find collection "'+collection+'" in index at:', path) // XXX how do we handle rejection??? //return Promise.reject('moo') return } var resolve var reject // XXX add a timeout??? that.one('collectionLoading.post', function(){ resolve(that.exportImages(options)) }) that.loadCollection(collection) return new Promise(function(res, rej){ resolve = res reject = rej }) } // export root... return that.exportImages(options) }, function(err){ // XXX how do we handle rejection??? console.error('Can\'t find or load index at:', path) }) }], cliRepairIndex: ['- System/CLI/repair index', {cli: argv && argv.Parser({ key: '@repair', doc: 'repair index', arg: 'PATH', '-version': undefined, '-quiet': undefined, '-read-only': '-ro', '-ro': { doc: 'only show possible fixes', type: 'bool', }, })}, async function(path, options){ this.setupFeatures() await this.loadIndex(path ?? '.') var changes = await this.checkIndex() // XXX print... console.log(options.ro, changes) options.ro //|| this.saveIndexHere() || console.log('save') }], // XXX this is still wrong... _cliMakeIndex: ['- System/', `chain: [ "loadImages: $1", "saveIndex", "makePreviews: 'all'", "sortImages", "saveIndex", ]`], cliCleanIndex: ['- System/', {}, function(path, options){}], /* XXX cliStartServer: ['- System/CLI/start as server', {cli: '-server'}, function(){ // XXX }], // Actions... // // XXX // XXX this should be a nested parser... // args: // from=PATH // to=PATH // ... cliExportIindex: ['- System/CLI/clone index', {cli: { name: '@clone', arg: 'PATH', valueRequired: true, }}, function(){ // XXX }], cliPullChanges: ['- System/CLI/pull changes', {cli: { name: '@pull', arg: 'PATH', valueRequired: true, }}, function(){ // XXX }], cliPushChanges: ['- System/CLI/push changes', {cli: { name: '@push', arg: 'PATH', valueRequired: true, }}, function(){ // XXX }], //*/ }) // XXX revise architecture.... // XXX move this to the argv parser used in object.js var CLI = module.CLI = core.ImageGridFeatures.Feature({ title: '', doc: '', tag: 'commandline', depends: [ 'lifecycle', 'logger', ], // XXX should this be ONLY node??? isApplicable: function(){ return this.runtime.node && !this.runtime.browser }, actions: CLIActions, handlers: [ // supress logging by default... ['start.pre', function(){ this.logger && (this.logger.quiet = true) }], // handle args... // XXX ['ready', function(){ var that = this //var pkg = require('package.json') var pkg = nodeRequire('./package.json') var wait_for = [] // XXX var interactive = false // XXX SETUP need to setup everything that has command-line features... //this.setupFeatures() // revise name... argv.Parser({ context: this, // XXX argv.js is not picking these up because // of the require(..) mixup... author: pkg.author, version: pkg.version, license: pkg.license, // examples... examples: CLIActions.cliExamples ? CLIActions.cliExamples.flat() : null, '-verbose': { doc: 'enable (very) verbose output', handler: function(){ that.logger && (that.logger.quiet = false) } }, // XXX merge this with -quiet... '-no-progress': { doc: 'disable progress bar display', handler: function(){ that.__progress = false } }, // XXX setup presets... // ...load sets of features and allow user // to block/add specific features... // XXX config editor... // ...get/set persistent config values... // build the action command list... ...this.cliActions .reduce(function(res, action){ var cmd = that.getActionAttr(action, 'cli') if(typeof(cmd) == typeof('str') || cmd === true){ var name = cmd var cmd = {name} } var name = name === true ? action : (cmd.key || cmd.name) // skip interactive commands in non-interactive // contexts... if(!interactive && cmd.interactive){ return res } res[name] = cmd instanceof argv.Parser ? // parser... cmd .then(function(unhandled, value, rest){ wait_for.push(that[action](value, this)) }) // single option definition... : { doc: (that.getActionAttr(action, 'doc') || '') .split(/[\\\/]/g).pop(), handler: function(rest, key, value){ var res = that[action](value) wait_for.push(res) return res }, ...cmd, } return res }, {}), }) .onArgs(function(){ that.argv = this }) .onNoArgs(function(args){ console.log('No args.') // XXX we should either start the GUI here or print help... args.push('-h') //args.push('gui') }) .stop(function(){ process.exit() }) .error(function(){ process.exit() }) .then(function(){ // XXX })() // XXX not all promises in the system resolve strictly // after all the work is done, some resolve before that // point and this calling process.exit() will interrupt // them... this.__keep_running || this.afterAction(function(){ this.stop() }) }], ], }) /********************************************************************** * vim:set ts=4 sw=4 : */ return module })