Alex A. Naanou 1bcf4b5e93 some cleanup and tweaking...
Signed-off-by: Alex A. Naanou <alex.nanou@gmail.com>
2020-12-27 12:07:48 +03:00

3230 lines
84 KiB
JavaScript
Executable File

/**********************************************************************
*
* Core features...
* Setup the life-cycle and the base interfaces for features to use...
*
* Defined here:
* - protocol action constructor
* - config value toggler constructor
* - meta actions
* - .isToggler(..) predicate
* - .preActionHandler(..) used to toggler special args
* - ImageGrid root object/constructor
* - ImageGridFeatures object
*
*
* Features:
* - util
* - introspection
* - logger
* - lifecycle
* base life-cycle events (start/stop/..)
* base abort api
* - serialization
* base methods to handle loading, serialization and cloning...
* - cache
* basic action/prop caching api...
* - timers
* wrapper around setInterval(..), setTimeout(..) and friends,
* provides persistent timer triggers and introspection...
* - journal
* action journaling and undo/redo functionality
* XXX needs revision...
* - changes
* change tracking
* - workspace
* XXX needs revision...
* - tasks
* tasks -- manage long running actions
* queue -- manage lots of small actions as a single task
* - self-test
* basic framework for running test actions at startup...
*
*
* XXX some actions use the .clone(..) action/protocol, should this be
* defined here???
* XXX should this be split into a generic app building lib?
*
**********************************************************************/
((typeof define)[0]=='u'?function(f){module.exports=f(require)}:define)
(function(require){ var module={} // make module AMD/node compatible...
/*********************************************************************/
// XXX
var DEBUG = typeof(DEBUG) != 'undefined' ? DEBUG : true
var types = require('lib/types')
var runner = require('lib/types/runner')
var util = require('lib/util')
var object = require('lib/object')
var actions = require('lib/actions')
var features = require('lib/features')
var toggler = require('lib/toggler')
// code/text normalization...
var doc = module.doc = actions.doc
var text = module.text = object.text
/*********************************************************************/
// Root ImageGrid.viewer object constructor...
//
// This adds:
// - toggler as action compatibility
//
var ImageGridMetaActions =
module.ImageGridMetaActions = {
__proto__: actions.MetaActions,
// Test if the action is a Toggler...
//
isToggler:
actions.doWithRootAction(function(action){
return action instanceof toggler.Toggler }),
// Handle special cases where we need to get the action result early,
// without calling handlers...
//
// These include:
// - toggler action special command handling (like: '?', '??', ..)
//
preActionHandler: actions.doWithRootAction(function(action, name, handlers, args){
// Special case: do not call handlers for toggler state queries...
//
// NOTE: if the root handler is instance of Toggler (jli) and
// the action is called with '?'/'??' as argument, then the
// toggler will be called with the argument and return the
// result bypassing the handlers.
// NOTE: an action is considered a toggler only if it's base action
// is a toggler (instance of Toggler), thus, the same "top"
// action can be or not be a toggler in different contexts.
//
// For more info on togglers see: lib/toggler.js
if(this.isToggler(name)
&& args.length == 1
&& (args[0] == '?' || args[0] == '??')){
return {
result: handlers.slice(-1)[0].pre.apply(this, args),
}
}
}),
}
var ImageGrid =
module.ImageGrid =
object.Constructor('ImageGrid', ImageGridMetaActions)
// Root ImageGrid feature set....
var ImageGridFeatures =
module.ImageGridFeatures =
new features.FeatureSet()
// setup base instance constructor...
ImageGridFeatures.__actions__ =
function(){
return actions.Actions(ImageGrid()) }
//---------------------------------------------------------------------
// Setup runtime info...
//
// XXX add PWA / chrome-app...
// XXX add cordova...
// XXX add mobile...
// XXX add widget...
// XXX should this contain feature versions???
var runtime = ImageGridFeatures.runtime = {}
// nw or node...
if(typeof(process) != 'undefined'){
// node...
runtime.node = true
// Electron...
if(process.versions['electron'] != null
// node mode...
&& typeof(document) != 'undefined'){
runtime.electron = true
runtime.desktop = true
// nw.js 0.13+
} else if(typeof(nw) != 'undefined'){
runtime.nw = true
runtime.desktop = true
// NOTE: jli is patching the Date object and with two separate
// instances we'll need to sync things up...
// XXX HACK: if node and chrome Date implementations ever
// significantly diverge this will break things + this is
// a potential data leak between contexts...
//global.Date = window.Date
// XXX this is less of a hack but it is still an implicit
util.patchDate(global.Date)
util.patchDate(window.Date)
// node...
} else {
// XXX patch Date...
// XXX this will not work directly as we will need to explicitly
// require jli...
//patchDate(global.Date)
} }
// browser...
// NOTE: we're avoiding detecting browser specifics for as long as possible,
// this will minimize the headaches of supporting several non-standard
// versions of code...
if(typeof(window) != 'undefined'){
runtime.browser = true }
/*********************************************************************/
// Util...
// Toggle value in .config...
//
// NOTE: if no toggler state is set this assumes that the first state
// is the default...
// NOTE: default states is [false, true]
var makeConfigToggler =
module.makeConfigToggler =
function(attr, states, a, b){
states = states || [false, true]
var pre = a
// XXX is this a good default???
//var post = b || function(action){ action != null && this.focusImage() }
var post = b
return toggler.Toggler(null,
function(_, action){
var lst = states instanceof Array ? states
: states instanceof Function ? states.call(this)
: states
// get attr path...
var a = attr.split(/\./g)
var cfg = a.slice(0, -1)
.reduce(function(res, cur){
return res[cur] }, this.config)
if(action == null){
var val = cfg[a.pop()]
return val == null ?
(lst[lst.indexOf('none')] || lst[0])
: val
} else {
cfg[a[a.length-1]] = action
this.config[a[0]] = this.config[a[0]]
}
},
states, pre, post)
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
var UtilActions = actions.Actions({
mergeConfig: ['- System/',
doc`Merge a config object into .config
`,
function(config){
config = config instanceof Function ? config.call(this)
: typeof(config) == typeof('str') ? this.config[config]
: config
Object.assign(this.config, config) }],
})
var Util =
module.Util = ImageGridFeatures.Feature({
title: '',
doc: '',
tag: 'util',
actions: UtilActions,
})
//---------------------------------------------------------------------
// Introspection...
// Indicate that an action is not intended for direct use...
//
// NOTE: this will not do anything but mark the action.
var notUserCallable =
module.notUserCallable =
function(func){
func.__not_user_callable__ = true
return func }
// NOTE: this is the same as event but user-callable...
var UserEvent =
module.UserEvent =
function(func){
func.__event__ = true
return func }
// XXX rename???
var Event =
module.Event =
function(func){
func.__event__ = true
return notUserCallable(func) }
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
var IntrospectionActions = actions.Actions({
get useractions(){
return this.cache('useractions', function(d){
return d instanceof Array ?
d.slice()
: this.actions.filter(this.isUserCallable.bind(this)) }) },
get events(){
return this.cache('events', function(d){
return d instanceof Array ?
d.slice()
: this.actions.filter(this.isEvent.bind(this)) }) },
isUserCallable:
// XXX should this check only the root action or the whole set???
// ...in other words: can we make an action non-user-callable
// anywhere other than the root action?
// IMO no...
//function(action){
// return this.getActionAttr(action, '__not_user_callable__') != true }],
actions.doWithRootAction(function(action){
return action.__not_user_callable__ != true }),
isEvent:
actions.doWithRootAction(function(action){
return !!action.__event__ }),
// XXX revise...
getActionMode: ['- Interface/',
doc`Get action browse mode...
Get and action's .mode(..) method and return its result.
Action .mode can be:
<function> - action method.
<action-name> - alias, name of action to get the
method from.
The action .mode(..) method is called in the context of actions.
Basic example:
someAction: ['Path/To/Some action',
{mode: function(){ ... }},
function(){
...
}],
someOtherAction: ['Path/To/Some action',
// alias
{mode: 'someAction'},
function(){
...
}],
Usage pattern:
// for cases where we need to define an explicit mode...
actionModeX: ['- System/',
{mode: function(){
return this.actionModeX() }},
core.notUserCallable(function(){
return ...
})],
someAction: [
// use the mode...
{mode: 'actionModeX'},
function(){
...
}],
`,
function(action, mode_cache){
var m = action
var visited = [m]
var last
// check cache...
if(m in (mode_cache || {})){
return mode_cache[m] }
// handle aliases...
do {
last = m
m = this.getActionAttr(m, 'mode')
// check cache...
if(m in (mode_cache || {})){
return mode_cache[m] }
// check for loops...
if(m && visited[m] != null){
m = null
break
}
visited.push(m)
} while(typeof(m) == typeof('str'))
//return m ? m.call(this) : undefined
return m ?
// no cache...
(mode_cache == null ?
m.call(this)
// cache hit...
: last in mode_cache ?
mode_cache[last]
// call check and populate cache...
: (mode_cache[action] =
mode_cache[last] =
m.call(this)))
: actions.UNDEFINED }],
})
var Introspection =
module.Introspection = ImageGridFeatures.Feature({
title: '',
tag: 'introspection',
depends: [
'cache'
],
actions: IntrospectionActions,
})
//---------------------------------------------------------------------
// Logger...
var LoggerActions = actions.Actions({
config: {
// NOTE: if set to 0 no log limit is applied...
'log-size': 10000,
},
Logger: object.Constructor('BaseLogger', {
doc: `Logger object constructor...`,
root: null,
parent: null,
// Quiet mode...
//
// NOTE: if local mode is not defined this will get the mode of
// the nearest parent...
// XXX need these to be persistent...
// XXX add support for log levels...
__quiet: null,
get quiet(){
var cur = this
while(cur.__quiet == null && cur.parent){
cur = cur.parent }
return !!cur.__quiet },
set quiet(value){
value == null ?
(delete this.__quiet)
: (this.__quiet = !!value) },
__context: null,
get context(){
return this.__context || this.root.__context },
get isRoot(){
return this === this.root },
__path: null,
get path(){
return (this.__path =
this.__path == null ?
[]
: this.__path) },
set path(value){
this.__path = value },
// NOTE: if set to 0 no log limit is applied...
// NOTE: writing to this will modify .context.config['log-size']
// if a available and .__max_size otherwise...
__max_size: null,
get max_size(){
return this.__max_size != null ?
this.__max_size
// this.context.config['log-size']
: ((this.context || {}).config || {})['log-size'] || 10000 },
set max_size(value){
return this.context ?
(this.context.config['log-size'] = value)
: this.__max_size = value },
// NOTE: to disable log retention in .log set this to false...
__log: null,
get log(){
return this.__log === false ?
false
: this.__log ?
this.__log
: this.isRoot ?
(this.__log = this.__log || [])
: this.root.log },
// log management...
clear: function(){
this.log
&& this.log.splice(0, this.log.length)
return this },
// Format log to string...
//
// Full log...
// .log2str()
// -> str
//
// Slice log...
// .log2str(from)
// .log2str(from, to)
// -> str
//
// Specific item...
// .log2str(date, path, status, rest)
// .log2str([date, path, status, rest])
// -> str
// NOTE: this form does not depend on context...
//
//
// NOTE: the later form is useful for filtering:
// logger.log
// .filter(..)
// .map(logger.log2str)
// .join('\n')
//
log2str: function(){
return (arguments.length == 0 ?
(this.log || [])
: arguments[0] instanceof Array ?
[arguments[0]]
: arguments.length < 2 ?
(this.log ?
this.log.slice(arguments[0], arguments[1])
: [])
: [arguments])
.map(function([date, path, status, rest]){
return `[${ new Date(date).getTimeStamp(true) }] `
+ path.join(': ') + (path.length > 0 ? ': ' : '')
+ status
+ (rest.length > 1 ?
':\n\t'
: rest.length == 1 ?
': '
: '')
+ rest.join(': ') })
.join('\n') },
print: function(...args){
console.log(this.log2str(...args))
return this },
// main API...
//
// .push(str, ...)
//
// .push(str, ..., attrs)
//
push: function(...msg){
// settings...
var attrs = typeof(msg.last()) != typeof('str') ?
msg.pop()
: {}
return msg.length == 0 ?
this
: Object.assign(
this.constructor(),
attrs,
{
root: this.root,
parent: this,
path: this.path.concat(msg),
}) },
pop: function(){
return (this.root === this || this.path.length == 1) ?
this
: Object.assign(
this.constructor(),
{
root: this.root,
path: this.path.slice(0, -1),
}) },
emit: function(status, ...rest){
// write to log...
this.log !== false
&& this.log.push([
Date.now(),
this.path,
status,
rest])
// maintain log size...
this.log !== false
&& (this.max_size > 0
&& this.log.length > this.max_size
&& this.log.splice(0, this.log.length - this.max_size))
// call context log handler...
this.context
&& this.context.handleLogItem
&& this.context.handleLogItem(this, this.path, status, ...rest)
return this },
__call__: function(_, status, ...rest){
return this.emit(status, ...rest) },
__init__: function(context){
this.__context = context
this.root = this
},
}),
__logger: null,
get logger(){
return (this.__logger =
this.__logger
|| this.Logger(this)) },
set logger(value){
this.__logger = value },
// XXX move this to console-logger???
// XXX should this be an action???
handleLogItem: ['- System/',
function(logger, path, status, ...rest){
logger.quiet
|| logger.root.quiet
|| console.log(
path.join(': ') + (path.length > 0 ? ': ' : '')
+ status
+ (rest.length > 1 ?
':\n\t'
: rest.length == 1 ?
': '
: ''), ...rest) }],
})
var Logger =
module.Logger = ImageGridFeatures.Feature({
title: '',
tag: 'logger',
depends: [],
actions: LoggerActions,
})
//---------------------------------------------------------------------
// System life-cycle...
// XXX potential pitfall: setting up new features without restarting...
// this can happen for instance in ig.js when starting a minimal
// imagegrid instance and then adding new features -- these new
// features will not get their .start() (and friends) run...
// There are three stages here:
// - feature setup
// things the feature needs to run -- <feature>.setup(..)
// - app start
// things the app wants to do on start
// - ???
// things feature action needs to run in cli should be
// documented and not depend on .start(..)
// ...or there should be a way to "start" the new features...
// XXX put this in the docs...
// XXX should his have state???
// ...if so, should this be a toggler???
var LifeCycleActions = actions.Actions({
config: {
// if set indicates the timeput after which the application quits
// wating for .declareReady() and forcefully triggers .ready()
'declare-ready-timeout': 15000,
},
__stop_handler: null,
__ready: null,
__ready_announce_requested: null,
__ready_announce_requests: null,
// introspection...
isStarted: function(){
return !!this.__stop_handler },
isStopped: function(){
return !this.__stop_handler },
isReady: function(){
return !!this.__ready },
// XXX is this the right name for this???
get runtimeState(){
return this.isStopped() ?
'stopped'
: this.isReady() ?
'ready'
: this.isStarted() ?
'started'
: undefined },
// XXX not implemented...
// ...this should be triggered on first run and after updates...
setup: ['- System/',
doc``,
Event(function(mode){
// System started event...
//
// Not intended for direct use.
})],
start: ['- System/',
doc`Start core action/event
.start()
This action triggers system start, sets up basic runtime, prepares
for shutdown (stop) and handles the .ready() event.
Attributes set here:
.runtime - indicates the runtime ImageGrid is running.
this currently supports:
node, browser, nw, unknown
This will trigger .declareReady() if no action called
.requestReadyAnnounce()
This will trigger .started() event when done.
NOTE: .runtime attribute will not be available on the .pre handler
phase.
NOTE: .requestReadyAnnounce() should be called exclusively on the
.pre handler phase as this will check and trigger the .ready()
event before the .post phase starts.
NOTE: handlers bound to this action/event will get called on the
start *event* thus handlers bound when the system is already
started will not get called until next start, to bind a handler
to the started *state* bind to 'started' / .started()
`,
function(){
var that = this
this.logger
&& this.logger.push('System').emit('start')
// NOTE: jQuery currently provides no way to check if an event
// is bound so we'll need to keep track manually...
if(this.__stop_handler == null){
var stop = this.__stop_handler = function(){ that.stop() }
} else {
return }
// set the runtime...
var runtime = this.runtime = ImageGridFeatures.runtime
// nw.js...
if(runtime.nw){
// this handles both reload and close...
$(window).on('beforeunload', stop)
// NOTE: we are using both events as some of them do not
// get triggered in specific conditions and some do,
// for example, this gets triggered when the window's
// 'X' is clicked while does not on reload...
this.__nw_stop_handler = function(){
var w = this
try{
that
// wait till ALL the handlers finish before
// exiting...
.on('stop.post', function(){
// XXX might be broken in nw13 -- test!!!
//w.close(true)
nw.App.quit()
})
.stop()
// in case something breaks exit...
// XXX not sure if this is correct...
} catch(e){
console.log('ERROR:', e)
DEBUG || nw.App.quit()
//this.close(true)
}
}
nw.Window.get().on('close', this.__nw_stop_handler)
// electron...
} else if(runtime.electron){
$(window).on('beforeunload', stop)
// node...
} else if(runtime.node){
process.on('exit', stop)
// browser...
} else if(runtime.browser){
$(window).on('beforeunload', stop)
// other...
} else {
// XXX
console.warn('Unknown runtime:', runtime) }
// handle ready event...
// ...if no one requested to do it.
if(this.__ready_announce_requested == null
|| this.__ready_announce_requested <= 0){
// in the browser world trigger .declareReady(..) on load event...
if(runtime.browser){
$(function(){ that.declareReady('start') })
} else {
this.declareReady('start') } }
// ready timeout -> force ready...
this.config['declare-ready-timeout'] > 0
&& !this.__ready_announce_timeout
&& (this.__ready_announce_timeout =
setTimeout(function(){
// cleanup...
delete this.__ready_announce_timeout
if((this.__ready_announce_requests || new Set()).size == 0){
delete this.__ready_announce_requests
}
// force start...
if(!this.isReady()){
// report...
this.logger
&& this.logger.push('start')
.emit('forcing ready.')
.emit('stalled:',
this.__ready_announce_requested,
...(this.__ready_announce_requests || []))
// force ready...
this.__ready = !!this.ready()
// cleanup...
delete this.__ready_announce_requested
delete this.__ready_announce_requests
}
}.bind(this), this.config['declare-ready-timeout']))
// trigger the started event...
this.started() }],
started: ['- System/System started event',
doc`
`,
Event(function(){
// System started event...
//
// Not intended for direct use.
})],
// NOTE: it is recommended to use this protocol in such a way that
// the .ready() handler would recover from a stalled
// .requestReadyAnnounce() call...
ready: ['- System/System ready event',
doc`Ready core event
The ready event is fired right after start is done.
Any feature can request to announce 'ready' itself when it is
done by calling .requestReadyAnnounce().
If .requestReadyAnnounce() is called, then the caller is required
to also call .declareReady().
.ready() will actually be triggered only after when .declareReady()
is called the same number of times as .requestReadyAnnounce().
NOTE: at this point the system does not track the caller
"honesty", so it is the caller's responsibility to follow
the protocol.
`,
Event(function(){
// System ready event...
//
// Not intended for direct use, use .declareReady() to initiate.
this.logger
&& this.logger.push('System').emit('ready') })],
// NOTE: this calls .ready() once per session.
declareReady: ['- System/Declare system ready',
doc`Declare ready state
.declareReady()
This will call .ready() but only in the following conditions:
- .requestReadyAnnounce() has not been called.
- .requestReadyAnnounce() has been called the same number of
times as .declareReady()
NOTE: this will call .ready() only once per start/stop cycle.
`,
function(message){
this.__ready_announce_requested
&& (this.__ready_announce_requested -= 1)
message
&& this.__ready_announce_requests instanceof Set
&& this.__ready_announce_requests.delete(message)
if(!this.__ready_announce_requested
|| this.__ready_announce_requested <= 0){
this.__ready = this.__ready
|| !!this.ready()
delete this.__ready_announce_requested } }],
requestReadyAnnounce: ['- System/',
doc`Request to announce the .ready() event.
.requestReadyAnnounce()
.requestReadyAnnounce(message)
This enables a feature to delay the .ready() call until it is
ready, this is useful for async or long stuff that can block
or slow down the .ready() phase.
To indicate readiness, .declareReady() should be used.
The system will call .ready() automatically when the last
subscriber who called .requestReadyAnnounce() calls
.declareReady(), i.e. .declareReady() must be called at least
as many times as .requestReadyAnnounce()
The actual .ready() should never get called directly.
NOTE: if this is called, .ready() will not get triggered
automatically by the system.
`,
function(message){
message
&& (this.__ready_announce_requests =
this.__ready_announce_requests || new Set())
&& this.__ready_announce_requests.add(message)
return (this.__ready_announce_requested = (this.__ready_announce_requested || 0) + 1)
}],
stop: ['- System/',
doc`Stop core action
.stop()
This will cleanup and unbind stop events.
The goal of this is to prepare for system shutdown.
NOTE: it is good practice for the bound handlers to set the
system to a state from which their corresponding start/ready
handlers can run cleanly.
`,
function(){
// browser...
if(this.__stop_handler && this.runtime.browser){
$(window).off('beforeunload', this.__stop_handler)
}
// nw...
if(this.__nw_stop_handler && this.runtime.nw){
nw.Window.get().removeAllListeners('close')
delete this.__nw_stop_handler
}
// node...
if(this.__stop_handler && this.runtime.node){
process.removeAllListeners('exit')
}
delete this.__ready
delete this.__stop_handler
this.logger
&& this.logger.push('System').emit('stop')
// trigger the stopped event...
this.stopped()
}],
stopped: ['- System/System stopped event',
doc`
`,
Event(function(){
// System stopped event...
//
// Not intended for direct use.
})],
// XXX not implemented...
// ...this should be triggered before uninstall...
cleanup: ['- System/',
doc``,
Event(function(){
// System started event...
//
// Not intended for direct use.
})],
// trigger core events...
//
// NOTE: we do not need to do .one(..) as it is implemented via .on(..)
//
// XXX EXPERIMENTAL...
// ...should this be an action???
on: ['- System/',
function(evt, ...rest){
var func = rest.slice().pop()
evt = typeof(evt) == typeof('') ? evt.split(/\s/g) : evt
// we trigger the handler AFTER it is registered...
return function(){
// started...
Math.max(
evt.indexOf('started'),
evt.indexOf('started.pre'),
evt.indexOf('started.post')) >= 0
&& this.isStarted()
&& func.call(this)
// ready...
// NOTE: we are ignoring the '.pre' events here as we are already
// in the specific state...
Math.max(
evt.indexOf('ready'),
evt.indexOf('ready.post')) >= 0
&& this.isReady()
&& func.call(this)
// started...
Math.max(
evt.indexOf('stopped'),
evt.indexOf('stopped.pre'),
evt.indexOf('stopped.post')) >= 0
&& this.isStopped()
&& func.call(this) } }],
// helpers...
//
restart: ['System/Soft restart',
doc`Soft restart
This will stop, clear and then start ImageGrid.
`,
function(){
this
.stop()
.clear()
.start() }],
})
var LifeCycle =
module.LifeCycle = ImageGridFeatures.Feature({
title: '',
doc: '',
tag: 'lifecycle',
suggested: [
'logger',
],
priority: 'high',
actions: LifeCycleActions,
})
//---------------------------------------------------------------------
// Serialization...
var SerializationActions = actions.Actions({
clone: ['- System/',
function(full){
return actions.MetaActions.clone.call(this, full) }],
json: ['- System/',
function(){ return {} }],
load: ['- System/',
function(data, merge){ !merge && this.clear() }],
clear: ['- Sustem/',
function(){ }],
})
var Serialization =
module.Serialization = ImageGridFeatures.Feature({
title: '',
tag: 'serialization',
actions: SerializationActions,
})
//---------------------------------------------------------------------
// Cache...
// XXX revise: cache group naming...
// currently the used groups are:
// Session groups -- cleared on .clear() ('cache')
// session-*
// view-*
// View groups -- cleared by crop/collection ('crop', 'collections')
// view-*
// Changes groups -- cleared when specific changes are made ('changes')
// *-data
// *-images
// ...
// This approach seems not flexible enough...
// Ideas:
// - use keywords in group names??
// XXX should we consider persistent caches -- localStorage???
// XXX would be nice to have a simple cachedAction(name, cache-tag, expire, func)
// action wrapper that would not require anything from the action and
// just not call it if already called...
// ...to do this we'll need to be able to select a value by args
// from the cache this will require a diff mattch or something
// similar...
var CacheActions = actions.Actions({
config: {
// Enable/disable caching...
'cache': true,
// Control pre-caching...
//
// This can be:
// true - sync pre-cache (recursion)
// 0 - semi-sync pre-cache
// number - delay in milliseconds between pre-cache chunks
// false - pre-caching disabled
'pre-cache': 0,
// Cache chunk length in ms...
//
// Caching is done in a series of chunks set by this separated by
// timeouts set by .config['pre-cache'] to let other stuff run...
'pre-cache-chunk': 8,
// Control pre-cache progress display...
//
// This can be:
// false - never show progress
// true - always show progress
// number - show progress if number of milliseconds has
// passed and we are not done yet...
//
// NOTE: progress will only be displayed if .showProgress(..)
// action is available...
'pre-cache-progress': 3000,
// Groups to be cleared at the longest on session change...
//
// These include by default:
// 'session' - will live through the whole session.
// 'view' - cleared when view changes
//
'cache-session-groups': [
'session',
'view',
],
// XXX handler cache..
},
__cache: null,
cache: doc('Get or set cache value',
doc`Get or set cache value
.cache(title, handler)
-> value
.cache(group, title, handler)
-> value
Currently the used groups are:
Session groups -- cleared on .clear() (feature: 'cache')
session-*
view-*
View groups -- cleared by crop/collection (feature: 'crop', 'collections')
view-*
Changes groups -- cleared when specific changes are made (feature: 'changes')
*-data
*-images
...
Example use:
someAction: [
function(){
return this.cache('someAction',
function(data){
if(data){
// clone/update the data...
// NOTE: this should be faster than the construction
// branch below or this will defeat the purpose
// of caching...
...
} else {
// get the data...
...
}
return data
}) }],
NOTE: since this is here to help speed things up, introducing a
small but not necessary overhead by making this an action is
not logical...
`,
function(title, handler){
var group = 'global'
// caching disabled...
if(!(this.config || {}).cache){
return handler.call(this) }
arguments.length > 2
&& ([group, title, handler] = arguments)
var cache = this.__cache = this.__cache || {}
cache = cache[group] = cache[group] || {}
return (cache[title] =
title in cache ?
// pass the cached data for cloning/update to the handler...
handler.call(this, cache[title])
: handler.call(this)) }),
clearCache: ['System/Clear cache',
doc`
Clear cache fully...
.clearCache()
Clear title (global group)...
.clearCache(title)
Clear title from group...
.clearCache(group, title)
Clear out the full group...
.clearCache(group, '*')
NOTE: a group can be a string, list or a regexp object.
`,
function(title){
var that = this
// full clear...
if(arguments.length == 0
|| (arguments[0] == '*'
&& arguments[1] == '*')){
delete this.__cache
// partial clear...
} else {
var group = 'global'
// both group and title given...
arguments.length > 1
&& ([group, title] = arguments)
// regexp...
// NOTE: these are only supported in groups...
if(group != '*' && group.includes('*')){
group = new RegExp('^'+ group +'$', 'i')
group = Object.keys(this.__cache || {})
.filter(function(g){
return group.test(g) }) }
// clear title from each group...
if(group == '*' || group instanceof Array || group instanceof RegExp){
;(group instanceof Array ?
group
: group instanceof RegExp ?
Object.keys(this.__cache || {})
.filter(function(g){
return group.test(g) })
: Object.keys(this.__cache || {}))
.forEach(function(group){
that.clearCache(group, title) })
// clear multiple titles...
} else if(title instanceof Array){
title.forEach(function(title){
delete ((that.__cache || {})[group] || {})[title] })
// clear group...
} else if(title == '*'){
delete (this.__cache || {})[group]
// clear title from group...
} else {
delete ((this.__cache || {})[group] || {})[title] } } }],
// special caches...
//
sessionCache: ['- System/',
doc`Add to session cache...
.sessionCache(title, handler)
-> value
This is a shorthand to:
.cache('session', title, handler)
-> value
NOTE: also see .cache(..)
`,
'cache: "session" ...'],
// XXX doc: what are we precaching???
preCache: ['System/Run pre-cache',
doc`Run pre-cache...
Do an async pre-cache...
.preCache()
Do a sync pre-cache...
.preCache(true)
NOTE: both "modes" of doing a pre-cache run in the main thread,
the difference is that the "async" version lets JS run frames
between processing sync chunks...
NOTE: this will not drop the existing cache, to do this run
.clearCache() first or run .reCache(..).
`,
function(t){
if(this.config.cache){
var t = t || this.config['pre-cache'] || 0
var c = this.config['pre-cache-chunk'] || 8
var done = 0
var attrs = []
for(var k in this){
attrs.push(k) }
var l = attrs.length
var started = Date.now()
var show = this.config['pre-cache-progress']
var tick = function(){
var a = Date.now()
var b = a
if(attrs.length == 0){
return }
while(b - a < c){
this[attrs.pop()]
b = Date.now()
done += 1
this.showProgress
&& (show === true || (show && b - started > show))
&& this.showProgress('Caching', done, l) }
t === true ?
tick()
: setTimeout(tick, t) }.bind(this)
tick() } }],
reCache: ['System/Re-cache',
function(t){
this
.clearCache()
.preCache(t) }],
toggleHandlerCache: ['System/Action handler cache',
makeConfigToggler('action-handler-cache',
['off', 'on']/*,
function(state){}*/)],
resetHanlerCache: ['System/Reset action handler cache',
function(){
delete this.__handler_cache }],
})
var Cache =
module.Cache = ImageGridFeatures.Feature({
title: '',
doc: '',
tag: 'cache',
// NOTE: we use .showProgress(..) of 'ui-progress' but we do not
// need it to work, thus we do not declare it as a dependency...
//depends: [],
actions: CacheActions,
handlers: [
// System...
['start.pre',
function(){
this.clearCache()
var t = this.config['pre-cache']
t === true ?
this.preCache('now')
: t >= 0 ?
this.preCache()
: false }],
['start',
function(){
// XXX this breaks loading...
// ...not sure why, but when switched on manually
// there seems to be no problems...
//this.toggleHandlerCache(this.config['action-handler-cache'] || 'on')
}],
/*/ XXX clear cache when feature/action topology changes...
[[
'inlineMixin',
'inlineMixout',
// XXX not sure about this...
'mixout',
],
function(){
// XXX should this trigger a recache???
this.clearCache()
}],
//*/
// clear session cache...
['clear',
//'clearCache: "(session|view)(-.*)?" "*" -- Clear session cache'],
function(){
this.clearCache(`(${
(this.config['cache-session-groups']
|| ['session', 'view'])
.join('|') })(-.*)?`) }],
],
})
//---------------------------------------------------------------------
// Timers...
// Create a debounced action...
//
// debounce(<func>)
// debounce(<timeout>, <func>)
// debounce(<options>, <func>)
// -> function
//
// options format:
// {
// timeout: number,
// returns: 'cached' | 'dropped',
// callback: function(retriggered, args),
//
// postcall: true,
// }
//
// XXX might be a good ide to move this someplace generic...
// XXX this is not debouncing pre/post calls, just the base action...
var debounce =
module.debounce =
function(options, func){
// parse args...
var args = [...arguments]
func = args.pop()
options = args.pop() || {}
typeof(options) == typeof(123)
&& (options.timeout = options)
// closure state...
var res
var last_args
var debounced = false
var retriggered = 0
// call the action...
var call = function(context, ...args){
return func instanceof Function ?
func.call(context, ...args)
// alias...
: this.parseStringAction.callAction(context, func, ...args) }
return object.mixin(
function(...args){
var retrigger
// call...
if(!debounced){
res = call(this, ...args)
res = options.returns != 'cahced' ?
res
: undefined
// start the timer...
debounced = setTimeout(
function(){
var c
// callback...
options.callback instanceof Function
&& (c = options.callback.call(this, retriggered, args))
// retrigger...
options.postcall
&& retriggered > 0
&& c !== false
// XXX should this be a debounced call or a normal call...
// XXX this is not the actual action thus no
// handlers will be triggered...
&& call(this, ...last_args)
// cleanup...
retriggered = 0
res = undefined
debounced = false }.bind(this),
options.timeout
|| this.config['debounce-action-timeout']
|| 200)
// skip...
} else {
retriggered++
last_args = args
return res } },
{
toString: function(){
return `// debounced...\n${
doc([ func instanceof Function ?
func.toString()
: func ])}` },
}) }
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
var TimersActions = actions.Actions({
config: {
//
// Format:
// {
// <id>: {
// // action code (string)...
// action: <action>,
// // interval in milliseconds...
// ms: <ms>,
// },
// ...
// }
'persistent-intervals': null,
// A timeout to wait between calls to actions triggered via
// .debounce(..)
'debounce-action-timeout': 200,
},
// XXX should we store more metadata (ms?) and provide introspection
// for these???
__timeouts: null,
__intervals: null,
__persistent_intervals: null,
// Introspection...
//
// NOTE: these are not editable...
get timeouts(){
return Object.assign({}, this.__timeouts || {}) },
get intervals(){
return {
volatile: Object.assign({}, this.__intervals || {}),
persistent: JSON.parse(JSON.stringify(
this.config['persistent-intervals'] || {})),
} },
// XXX should these be actions???
isTimeout: function(id){
return id in (this.__timeouts || {}) },
isInterval: function(id){
return id in (this.__intervals || {}) },
isPersistentInterval: function(id){
return id in (this.config['persistent-intervals'] || {}) },
isPersistentIntervalActive: function(id){
return this.isPersistentInterval(id)
&& (id in (this.__persistent_intervals || {})) },
isTimer: function(id){
return this.isInterval(id)
|| this.isPersistentInterval(id)
|| this.isTimeout(id) },
// General API...
//
// NOTE: we are not trying to re-implement the native scheduler here
// just extend it and unify it's uses...
setTimeout: ['- System/',
function(id, func, ms){
var timeouts = this.__timeouts = this.__timeouts || {}
this.clearTimeout(id)
timeouts[id] = setTimeout(
function(){
// cleanup...
// NOTE: we are doing this before we run to avoid
// leakage due to errors...
delete timeouts[id]
// run...
func instanceof Function ?
func.call(this)
: this.call(func)
}.bind(this),
ms || 0)
}],
clearTimeout: ['- System/',
function(id){
var timeouts = this.__timeouts = this.__timeouts || {}
clearTimeout(timeouts[id])
delete timeouts[id]
}],
setInterval: ['- System/',
function(id, func, ms){
var intervals = this.__intervals = this.__intervals || {}
id in intervals
&& clearInterval(intervals[id])
intervals[id] = setInterval(
(func instanceof Function ? func : function(){ this.call(func) })
.bind(this),
ms || 0)
}],
clearInterval: ['- System/',
function(id){
var intervals = this.__intervals = this.__intervals || {}
clearInterval(intervals[id])
delete intervals[id]
}],
setPersistentInterval: ['- System/',
doc`
Restart interval id...
.setPersistentInterval(id)
Save/start interval id...
.setPersistentInterval(id, action, ms)
`,
function(id, action, ms){
var intervals =
this.__persistent_intervals =
this.__persistent_intervals || {}
// NOTE: we set this later iff we make a change...
var cfg = this.config['persistent-intervals'] || {}
// get defaults...
action = action ? action : cfg[id].action
ms = ms ? ms : cfg[id].ms
// checks...
if(!ms || !action){
console.error('Persistent interval: both action and ms must be set.')
return
}
if(typeof(action) != typeof('str')){
console.error('Persistent interval: handler must be a string.')
return
}
id in intervals
&& clearInterval(intervals[id])
this.config['persistent-intervals'] = cfg
cfg[id] = {
action: action,
ms: ms,
}
intervals[id] = setInterval(
function(){ this.call(action) }.bind(this),
ms || 0)
}],
clearPersistentInterval: ['- System/',
function(id, stop_only){
var intervals =
this.__persistent_intervals =
this.__persistent_intervals || {}
clearInterval(intervals[id])
delete intervals[id]
if(!stop_only){
delete this.config['persistent-intervals'][id]
}
}],
// XXX revise name (???)
// XXX do we need actions other than start/stop ???
persistentIntervals: ['- System/',
doc`
Start/restart all persistent interval timers...
.persistentIntervals('start')
.persistentIntervals('restart')
Stop all persistent interval timers...
.persistentIntervals('stop')
NOTE: 'start' and 'restart' are the same, both exist for mnemonics.
`,
function(action){
var ids = Object.keys(this.config['persistent-intervals'] || {})
// start/restart...
;(action == 'start' || action == 'restart') ?
ids.forEach(function(id){
this.setPersistentInterval(id) }.bind(this))
// stop...
: action == 'stop' ?
ids.forEach(function(id){
this.clearPersistentInterval(id, true) }.bind(this))
// unknown action...
: console.error('persistentIntervals: unknown action:', action)
}],
// Events...
//
// XXX should these be "aligned" to real time???
// ...i.e. everyHour is triggered on the XX:00:00 and not relative
// to start time?
// XXX should we macro these???
/*/ XXX would be nice to trigger these ONLY if there are handlers...
everySecond: ['- System/',
Event(function(){
// XXX
})],
//*/
everyMinute: ['- System/',
Event(function(){
// XXX
})],
every2Minutes: ['- System/',
Event(function(){
// XXX
})],
every5Minutes: ['- System/',
Event(function(){
// XXX
})],
every10Minutes: ['- System/',
Event(function(){
// XXX
})],
every30Minutes: ['- System/',
Event(function(){
// XXX
})],
everyHour: ['- System/',
Event(function(){
// XXX
})],
// Action debounce...
//
debounce: ['- System/',
doc`Debounce action call...
Debouncing prevents an action from being called more than once
every timeout milliseconds.
Debounce call an action...
.debounce(action, ...)
.debounce(timeout, action, ...)
.debounce(tag, action, ...)
.debounce(timeout, tag, action, ...)
Debounce call a function...
.debounce(tag, func, ...)
.debounce(timeout, tag, func, ...)
Generic debounce:
.debounce(options, action, ...)
.debounce(options, func, ...)
options format:
{
// debounce timeout...
timeout: <milliseconds>,
// tag to group action call debouncing (optional)
tag: <string>,
// controls how the return value is handled:
// 'cached' - during the timeout the first return value
// is cached and re-returned on each call
// during the timeout.
// 'dropped' - all return values are ignored/dropped
//
// NOTE: these, by design, enable only stable/uniform behavior
// without introducing any special cases and gotchas...
returns: 'cached' | 'dropped',
// if true the action will get retriggered after the timeout
// is over but only if it was triggered during the timeout...
//
// NOTE: if the action is triggered more often than timeout/200
// times, then it will not retrigger, this prevents an extra
// call after, for example, sitting on a key and triggering
// key repeat...
retrigger: <bool>,
// a function, if given will be called when the timeout is up.
callback: function(<retrigger-count>, <args>),
}
NOTE: when using a tag, it must not resolve to and action, i.e.
this[tag] must not be callable...
NOTE: this ignores action return value and returns this...
NOTE: this uses core.debounce(..) adding a retrigger option to it...
`,
function(...args){
// parse the args...
if(!(args[0] instanceof Function
|| typeof(args[0]) == typeof(123)
|| typeof(args[0]) == typeof('str'))){
var options = args.shift()
var tag = options.tag || args[0].name || args[0]
} else {
var options = {
timeout: typeof(args[0]) == typeof(123) ?
args.shift()
: (this.config['debounce-action-timeout'] || 200),
}
// NOTE: this[tag] must not be callable, otherwise we treat it
// as an action...
var tag = (args[0] instanceof Function
|| this[args[0]] instanceof Function) ?
args[0]
: args.shift()
}
// sanity check: when debouncing a function a tag is required...
if(tag instanceof Function){
throw new TypeError('debounce: when passing a function a tag is required.')
}
var action = args.shift()
var attr = '__debounce_'+ tag
options = Object.assign(Object.create(options), {
callback: function(retriggered, args){
// cleanup...
delete this[attr]
// call the original callback...
options.__proto__.callback
&& options.__proto__.callback.call(that, ...args)
if(options.retrigger
&& retriggered > 0
// this prevents an extra action after "sitting"
// on the keyboard and triggering key repeat...
&& retriggered < (options.timeout || 200) / 200){
var func = this[attr] = this[attr] || debounce(options, action)
func.call(this, ...args)
}
},
})
var func = this[attr] = this[attr] || debounce(options, action)
return func.call(this, ...args)
}],
})
var Timers =
module.Timers = ImageGridFeatures.Feature({
title: '',
doc: '',
tag: 'timers',
depends: [
],
actions: TimersActions,
handlers: [
// start persistent timers...
// XXX should this be start or ready???
['start',
function(){ this.persistentIntervals('start') }],
// stop all timers...
['stop',
function(){
Object.keys(this.__intervals || {})
.forEach(function(id){ this.clearInterval(id) }.bind(this))
Object.keys(this.__timeouts || {})
.forEach(function(id){ this.clearTimeout(id) }.bind(this))
this.persistentIntervals('stop')
}],
// fixed timer actions...
// XXX not sure about these...
['start',
function(){
var m = 1000*60
this
.setInterval('everyMinute', 'everyMinute', m)
.setInterval('every2Minutes', 'every2Minutes', m*2)
.setInterval('every5Minutes', 'every5Minutes', m*5)
.setInterval('every10Minutes', 'every10Minutes', m*10)
.setInterval('every30Minutes', 'every30Minutes', m*30)
.setInterval('everyHour', 'everyHour', m*60)
}],
],
})
//---------------------------------------------------------------------
// Journal...
//
// This feature logs actions that either have the journal attribute set
// to true or have an undo method/alias...
//
// Example:
// someAction: ['Path/to/Some action',
// // just journal the action, but it can't be undone...
// {journal: true},
// function(){
// ...
// }],
//
// otherAction: ['Path/to/Other action',
// // journal and provide undo functionality...
// {undo: function(){
// ...
// }},
// function(){
// ...
// }],
//
// someOtherAction: ['Path/to/Some other action',
// // use .otherAction(..) to undo...
// {undo: 'otherAction'},
// function(){
// ...
// }],
//
// NOTE: .undo has priority over .journal, so there is no point of
// defining both .journal and .undo action attributes, one is
// enough.
//
//
// XXX would be great to add a mechanism define how to reverse actions...
// ...one way to do this at this point is to revert to last state
// and re-run the journal until the desired event...
// XXX need to define a clear journaling strategy in the lines of:
// - save state clears journal and adds a state load action
// - .load(..) clears journal
// XXX need a way to store additional info in the journal...
// can either be done as:
// - a hook (action handler and/or attr)
// - inline code inside the action...
// can't say I like #2 as it will mess the code up...
// XXX needs careful testing...
var JournalActions = actions.Actions({
clone: [function(full){
return function(res){
res.rjournal = null
res.journal = null
if(full && this.hasOwnProperty('journal') && this.journal){
res.journal = JSON.parse(JSON.stringify(this.journal))
}
}
}],
journal: null,
rjournal: null,
journalable: null,
// XXX doc supported attrs:
// undo
// undoable
// getUndoState
// XXX should the action have control over what gets journaled and how???
// XXX should aliases support explicit undo???
updateJournalableActions: ['System/Update list of journalable actions',
doc`
NOTE: action aliases can not handle undo.
`,
function(){
var that = this
var handler = function(action){
return function(){
var cur = this.current
var args = [...arguments]
var data = {
type: 'basic',
action: action,
args: args,
// the current image before the action...
current: cur,
// the target (current) image after action...
target: this.current,
}
// test if we need to journal this action signature...
var test = that.getActionAttr(action, 'undoable')
if(test && !test.call(that, data)){
return
}
// get additional undo state...
var update = that.getActionAttr(action, 'getUndoState')
while(typeof(update) == typeof('str')){
update = that.getActionAttr(update, 'getUndoState')
}
update
&& update instanceof Function
&& update.call(that, data)
// journal after the action is done...
return function(){ this.journalPush(data) }
}
}
this.journalable = this.actions
.filter(function(action){
// skip aliases...
return !(that[action] instanceof actions.Alias)
&& (!!that.getActionAttr(action, 'undo')
|| !!that.getActionAttr(action, 'journal'))
})
// reset the handler
.map(function(action){
that
.off(action+'.pre', 'journal-handler')
.on(action+'.pre', 'journal-handler', handler(action))
return action
})
}],
journalPush: ['- System/Journal/Add an item to journal',
function(data){
// clear the reverse journal...
this.rjournal
&& (this.rjournal = null)
this.journal = (this.hasOwnProperty('journal') || this.journal) ?
this.journal || []
: []
this.journal.push(data)
}],
clearJournal: ['System/Journal/Clear the action journal',
function(){
// NOTE: overwriting here is better as it will keep
// shadowing the parent's .journal in case we
// are cloned.
// NOTE: either way this will have no effect as we
// only use the local .journal but the user may
// get confused...
//delete this.journal
this.journal
&& (this.journal = null)
this.rjournal
&& (this.rjournal = null)
}],
runJournal: ['- System/Journal/Run journal',
//{journal: true},
function(journal){
var that = this
journal.forEach(function(e){
// load state...
that
.focusImage(e.current)
// run action...
[e.action].apply(that, e.args)
})
}],
// XXX needs very careful revision...
// - should this be thread safe??? (likely not)
// - should the undo action have side-effects on the
// journal/rjournal or should we clean them out???
// (currently cleaned)
// XXX should we control what gets pushed to the journal???
// XXX should we run undo of every action that supports it in the chain???
// ...i.e. multiple extending actions can support undo
// XXX will also need to handle aliases in chain...
undo: ['Edit/Undo',
doc`Undo last action from .journal that can be undone
.undo()
This will shift the action from .journal to .rjournal preparing
it for .redo()
NOTE: this will remove all the non undoable actions from the
.journal up until and including the undone action.
NOTE: only the undone action is pushed to .rjournal
`,
{mode: function(){
return (this.journal && this.journal.length > 0) || 'disabled' }},
function(){
var journal = this.journal.slice() || []
var rjournal = this.rjournal =
(this.hasOwnProperty('rjournal') || this.rjournal) ?
this.rjournal || []
: []
for(var i = journal.length-1; i >= 0; i--){
var a = journal[i]
// see if the action has an explicit undo attr...
var undo = this.getActionAttr(a.action, 'undo')
// general undo...
if(undo){
// restore focus to where it was when the action
// was called...
this.focusImage(a.current)
// call the undo method/action...
// NOTE: this is likely to have side-effect on the
// journal and maybe even rjournal...
// NOTE: these side-effects are cleaned out later.
var undo = undo instanceof Function ?
// pass the action name...
undo.call(this, a)
: typeof(undo) == typeof('str') ?
// XXX pass journal structure as-is... (???)
this[undo].apply(this, a.args)
: null
// push the undone command to the reverse journal...
rjournal.push(journal.splice(i, 1)[0])
// restore journal state...
// NOTE: calling the undo action would have cleared
// the rjournal and added stuff to the journal
// so we will need to restore things...
this.journal = journal
this.rjournal = rjournal
break
}
}
}],
redo: ['Edit/Redo',
doc`Redo an action from .rjournal
.redo()
Essentially this will remove and re-run the last action in .rjournal
`,
{mode: function(){
return (this.rjournal && this.rjournal.length > 0) || 'disabled' }},
function(){
if(!this.rjournal || this.rjournal.length == 0){
return
}
this.runJournal([this.rjournal.pop()])
}],
})
var Journal =
module.Journal = ImageGridFeatures.Feature({
title: 'Action Journal',
tag: 'journal',
depends: [
'serialization',
],
actions: JournalActions,
// XXX need to drop journal on save...
// XXX rotate/truncate journal???
// XXX need to check that all the listed actions are clean -- i.e.
// running the journal will produce the same results as user
// actions that generated the journal.
handlers: [
// log state, action and its args...
['start',
function(){ this.updateJournalableActions() }],
],
})
//---------------------------------------------------------------------
// Changes...
var ChangesActions = actions.Actions({
// This can be:
// - null/undefined - write all
// - true - write all
// - false - write nothing
// - {
// // write/skip data...
// data: <bool>,
//
// // write/skip images or write a diff including the given
// // <gid>s only...
// images: <bool> | [ <gid>, ... ],
//
// // write/skip tags...
// tags: <bool>,
//
// // write/skip bookmarks...
// bookmarked: <bool>,
//
// // write/skip selected...
// selected: <bool>,
//
// // feature specific custom flags...
// ...
// }
//
// NOTE: in the complex format all fields ar optional; if a field
// is not included it is not written (same as when set to false)
// NOTE: .current is written always.
//
// XXX this should be a prop to enable correct changes tracking via
// events...
chages: null,
get _changes(){
return this.__changes },
// XXX proxy to .markChanged(..)
set _changes(value){},
clone: [function(full){
return function(res){
res.changes = null
if(full && this.hasOwnProperty('changes') && this.changes){
res.changes = JSON.parse(JSON.stringify(this.changes))
}
}
}],
// XXX this should also track .changes...
// ...would also need to make this applicable to changes,
// i.e. x.markChanged(x.changes)
markChanged: ['- System/',
doc`Mark data sections as changed...
Mark everything changed...
.markChanged('all')
Mark nothing changed...
.markChanged('none')
Mark section(s) as changed...
.markChanged(<section>)
.markChanged(<section>, ..)
.markChanged([<section>, ..])
Mark item(s) of section as changed...
.markChanged(<section>, [<item>, .. ])
NOTE: items must be strings...
NOTE: when marking section items, the new items will be added to
the set of already marked items.
NOTE: when .changes is null (i.e. everything changed, marked via
.markChanged('all')) then calling this with anything other
than 'none' will have no effect.
`,
function(section, items){
var that = this
var args = section instanceof Array ?
section
: [...arguments]
//var changes = this.changes =
var changes =
this.hasOwnProperty('changes') ?
this.changes || {}
: {}
// all...
if(args.length == 1 && args[0] == 'all'){
this.changes = true
// none...
} else if(args.length == 1 && args[0] == 'none'){
this.changes = false
// everything is marked changed, everything will be saved
// anyway...
// NOTE: to reset this use .markChanged('none') and then
// manually add the desired changes...
} else if(this.changes == null){
return
// section items...
} else if(items instanceof Array) {
if(changes[section] === true){
return
}
changes[section] = (changes[section] || [])
.concat(items)
.unique()
this.changes = changes
// section(s)...
} else {
args.forEach(function(arg){
changes[arg] = true
})
this.changes = changes
}
}],
})
var Changes =
module.Changes = ImageGridFeatures.Feature({
title: '',
doc: '',
tag: 'changes',
depends: [
'serialization',
],
actions: ChangesActions,
handlers: [
// handle changes...
['json',
function(res, mode){
if(this.changes != null){
res.changes = JSON.parse(JSON.stringify(this.changes))
}
}],
['load',
function(_, data){
if(data.changes){
this.changes = JSON.parse(JSON.stringify(data.changes))
}
}],
// clear caches relating to stuff we just changed...
['markChanged',
function(_, section){
section = (section instanceof Array ?
section
: [section])
.map(function(section){
return '.*-'+section })
this.clearCache(section, '*') }],
],
})
//---------------------------------------------------------------------
// Workspace...
//
// Basic protocol:
// A participating feature should:
// - react to .saveWorkspace(..) by saving it's relevant state data to the
// object returned by the .saveWorkspace() action.
// NOTE: it is recommended that a feature save its relevant .config
// data as-is.
// NOTE: no other action or state change should be triggered by this.
// - react to .loadWorkspace(..) by loading it's state from the returned
// object...
// NOTE: this can be active, i.e. a feature may call actions when
// handling this.
// - react to .toggleChrome(..) and switch on and off the chrome
// visibility... (XXX)
//
//
// Helpers...
var makeWorkspaceConfigWriter =
module.makeWorkspaceConfigWriter = function(keys, callback){
return function(workspace){
var that = this
var data = keys instanceof Function ? keys.call(this) : keys
// store data...
data.forEach(function(key){
workspace[key] = JSON.parse(JSON.stringify(that.config[key]))
})
callback && callback.call(this, workspace)
}
}
// XXX should this delete a prop if it's not in the loading workspace???
// XXX only replace a prop if it has changed???
// XXX handle defaults -- when a workspace was just created...
var makeWorkspaceConfigLoader =
module.makeWorkspaceConfigLoader = function(keys, callback){
return function(workspace){
var that = this
var data = keys instanceof Function ? keys.call(this) : keys
// load data...
data.forEach(function(key){
// the key exists...
if(key in workspace){
that.config[key] = JSON.parse(JSON.stringify(workspace[key]))
// no key set...
// XXX is this the right way to go???
} else {
delete that.config[key]
}
})
callback
&& callback.call(this, workspace)
}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// XXX need a way to handle defaults...
var WorkspaceActions = actions.Actions({
config: {
'load-workspace': 'default',
'workspace': 'default',
'workspaces': {},
},
get workspace(){
return this.config.workspace },
set workspace(value){
this.loadWorkspace(value) },
get workspaces(){
return this.config.workspaces },
getWorkspace: ['- Workspace/',
function(){ return this.saveWorkspace(null) }],
// NOTE: these are mainly triggers for other features to save/load
// their specific states...
// NOTE: handlers should only set data on the workspace object passively,
// no activity is recommended.
// NOTE: if null is passed this will only get the data, but will
// save nothing. this us useful for introspection and temporary
// context storage.
//
// XXX for some reason this does not get saved with .config...
saveWorkspace: ['Workspace/Save Workspace',
function(name){
if(!this.config.hasOwnProperty('workspaces')){
this.config['workspaces'] = JSON.parse(JSON.stringify(this.config['workspaces']))
}
var res = {}
if(name !== null){
this.config['workspaces'][name || this.config.workspace] = res
}
return res
}],
// NOTE: merging the state data is the responsibility of the feature
// ...this is done so as not to restrict the feature to one
// specific way to do stuff...
loadWorkspace: ['Workspace/Load Workspace',
function(name){
name = name || this.config.workspace
// get a workspace by name and load it...
if(typeof(name) == typeof('str')){
this.config.workspace = name
return this.workspaces[name] || {}
// we got the workspace object...
} else {
return name
}
}],
// NOTE: this will not save the current workspace...
toggleWorkspace: ['Workspace/workspace',
makeConfigToggler('workspace',
function(){ return Object.keys(this.config['workspaces']) },
function(state){ this.loadWorkspace(state) })],
// XXX should we keep the stack unique???
pushWorkspace: ['- Workspace/',
function(name){
name = name || this.workspace
var stack = this.__workspace_stack = this.__workspace_stack || []
this.saveWorkspace()
if(stack.slice(-1)[0] == name){
return
}
this.workspace != name && this.loadWorkspace(name)
stack.push(name)
}],
popWorkspace: ['- Workspace/',
function(){
var stack = this.__workspace_stack
if(!stack || stack.length == 0){
return
}
this.saveWorkspace()
this.loadWorkspace(stack.pop())
}],
})
var Workspace =
module.Workspace = ImageGridFeatures.Feature({
title: '',
tag: 'workspace',
depends: [
'lifecycle',
],
actions: WorkspaceActions,
handlers: [
['start',
function(){
this.loadWorkspace(this.config['load-workspace'] || 'default') }],
// NOTE: this needs to be done before the .config is saved...
['stop.pre',
function(){
this.saveWorkspace() }],
],
})
//---------------------------------------------------------------------
// Tasks and Queues...
// Task wrapper...
//
// This simply makes tasks actions discoverable...
var Task =
module.Task =
function(func){
func.__task__ = true
return func }
// Task action helpers...
//
// NOTE: for examples see:
// features/examples.js:
// ExampleActions.exampleTask(..)
// ExampleActions.exampleSessionTask(..)
// NOTE: we can pass sync/async to this in two places, in definition:
// var action = taskAction('some title', 'sync', function(..){ .. })
// or
// var action = taskAction('sync', 'some title', function(..){ .. })
// and on call:
// action('sync', ..)
// during the later form 'sync' is passed to .Task(..) in the correct
// position...
// (see ig-types' runner.TaskManager(..) for more info)
var taskAction =
module.taskAction =
function(title, func){
var pre_args = [...arguments]
func = pre_args.pop()
title = pre_args
.filter(function(t){
return t != 'sync' && t != 'async' })
.pop()
var action
return (object.mixin(
action = Task(function(...args){
if(args[0] == 'sync' || args[0] == 'async'){
pre_args = [args.shift(), title] }
return Object.assign(
this.tasks.Task(...pre_args, func.bind(this), ...args),
// make this searchable by .tasks.named(..)...
{
__session_task__: !!action.__session_task__,
name: action.name,
}) }),
{
title,
toString: function(){
return `core.taskAction('${ action.name }', \n\t${
object.normalizeIndent('\t'+func.toString()) })` },
})) }
var sessionTaskAction =
module.sessionTaskAction =
function(title, func){
return object.mixin(
taskAction(...arguments),
{ __session_task__: true }) }
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Queued wrapper...
var Queued =
module.Queued =
function(func){
func.__queued__ = true
return Task(func) }
// Queued action...
//
// queuedAction(title, func)
// queuedAction(title, options, func)
// -> action
//
// func(..)
// -> res
//
// action(..)
// -> promise(res)
//
//
// The idea here is that each time a queued action is called it is run
// in a queue, and while it is running all consecutive calls are queued
// and run according to the queue policy.
//
//
// NOTE: for examples see:
// features/examples.js:
// ExampleActions.exampleQueuedAction(..)
// ExampleActions.exampleMultipleQueuedAction(..)
//
// XXX handle errors... (???)
// XXX revise logging and logger passing...
var queuedAction =
module.queuedAction =
function(title, func){
var args = [...arguments]
func = args.pop()
var [title, opts] = args
var action
return object.mixin(
action = Queued(function(...args){
var that = this
return new Promise(function(resolve, reject){
Object.assign(
that.queue(title, opts || {})
.push(function(){
var res = func.call(that, ...args)
resolve(res)
return res }),
{
__session_task__: !!action.__session_task__,
title: action.name,
}) }) }),
{
title,
toString: function(){
return `core.queuedAction('${action.name}',\n\t${
object.normalizeIndent( '\t'+ func.toString() ) })` },
}) }
var sessionQueueAction =
module.sessionQueueAction =
function(title, func){
return object.mixin(
queuedAction(...arguments),
{ __session_task__: true }) }
// Queue action handler...
//
// queueHandler(title[, opts][, arg_handler], func)
// -> action
//
//
// Prepare args...
// arg_handler(queue, items, ...args)
// -> [items, ...args]
//
// Prepare args in sync mode...
// arg_handler('sync', items, ...args)
// -> [items, ...args]
//
//
// Call action...
// action(items, ...args)
// -> promise
//
// Call action in sync mode...
// action('sync', items, ...args)
// -> promise
//
//
// Action function...
// func(item, ...args)
// -> res
//
//
// This is different from queuedAction(..) in that what is queued is not
// the action itself but rather the first argument to that action and the
// action is used by the queue to handle each item. The rest of the
// arguments are passed to each call.
//
// In 'sync' mode the action is run outside of queue/task right away, this
// is done because for a queue we can only control the sync start, i.e.
// the first task execution, the rest depends on queue configuration
// thus making the final behaviour unpredictable.
//
//
// NOTE: sync-mode actions do not externally log anything, basic progress
// logging is handled by the queue/task which is not created in sync
// mode.
// NOTE: since the sync-mode can block it must be used very carefully.
// NOTE: for an example of chaining several queues see features/examples's:
// .exampleChainedQueueHandler(..)
// NOTE: when chaining queues, in 'sync' mode all queues in the chain will
// be run sync...
// NOTE: when chaining arg_handler(..) will get one queue per level of
// chaining, but in 'sync' mode only one 'sync' is passed...
//
// XXX might be a good idea to split this into a generic and domain parts
// and move the generic part into types/runner...
// XXX check if item is already in queue...
var queueHandler =
module.queueHandler =
function(title, func){
var args = [...arguments]
func = args.pop()
var arg_handler =
typeof(args.last()) == 'function'
&& args.pop()
var [title, opts] = args
var action
return object.mixin(
action = Queued(function(items, ...args){
var that = this
// sync start...
if(arguments[0] == 'sync' || arguments[0] == 'async'){
var [sync, items, ...args] = arguments }
var q
var inputs = [items, ...args]
// pre-process args...
arg_handler
&& (inputs = arg_handler.call(this,
sync == 'sync' ?
sync
: q,
...inputs))
// special-case: empty inputs -- no need to handle anything...
if(inputs instanceof Array
&& inputs[0]
&& inputs[0].length == 0){
return Promise.resolve(inputs) }
// Define the runner and prepare...
//
// sync mode -- run action outside of queue...
// NOTE: running the queue in sync mode is not practical as
// the results may depend on queue configuration and
// size...
if(sync == 'sync'){
var run = function([items, ...args]){
return Promise.all(
(items instanceof Array ?
items
: [items])
.map(function(item){
var res = func.call(that, item, ...args)
return res === runner.SKIP ?
[]
: [res] })
.flat()) }
// queue mode...
} else {
// prep queue...
q = that.queue(title,
Object.assign(
{},
opts || {},
{
__session_task__: !!action.__session_task__,
handler: function([item, args]){
return func.call(that, item, ...(args || [])) },
}))
q.title = action.name
var run = function([items, ...args]){
// fill the queue...
// NOTE: we are also adding a ref to args here to keep things consistent...
args.length > 0
&& (args = [args])
q.add(items instanceof Array ?
items.map(function(e){
return [e, ...args] })
: [[items, ...args]])
return q.promise() } }
// run...
return (inputs instanceof Promise
|| inputs instanceof runner.FinalizableQueue) ?
inputs.then(
function(items){
return run([items, ...args]) },
function(){
q && q.abort() })
: run(inputs) }),
{
title,
arg_handler,
handler: func,
toString: function(){
// XXX add opts of given...
return `core.queueHandler('${action.name}',\n${
(arg_handler ?
object.normalizeIndent('\t'+arg_handler.toString()).indent('\t') + ',\n'
: '')
+ object.normalizeIndent('\t'+func.toString()).indent('\t') })` },
}) }
var sessionQueueHandler =
module.sessionQueueHandler =
function(title, func){
return object.mixin(
queueHandler(...arguments),
{ __session_task__: true }) }
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// XXX revise logging and logger passing...
// XXX add a task manager UI...
// XXX might be a good idea to confirm session task stops when loading a
// new index...
var TaskActions = actions.Actions({
config: {
'context-exclude-attrs': [
'features',
],
},
// Tasks...
//
isTask: function(action){
return !!this.getActionAttr(action, '__task__') },
isSessionTask: function(action){
return !!this.getActionAttr(action, '__session_task__') },
// list actions that generate tasks...
// XXX cache these???
get taskActions(){
var test = this.isTask.bind(this)
return this.actions.filter(test) },
get sessionTaskActions(){
var test = this.isSessionTask.bind(this)
return this.actions.filter(test) },
// task manager...
//
__task_manager__: runner.TaskManager,
__tasks: null,
get tasks(){
return (this.__tasks =
this.__tasks
|| this.__task_manager__()) },
// session tasks are stopped when the index is cleared...
// XXX need to get running tasks by action name...
get sessionTasks(){
//return this.tasks.titled(...this.sessionTaskActions) },
return this.tasks
.filter(function(task){
return task.__session_task__ }) },
// Queue (task)...
//
isQueued: function(action){
return !!this.getActionAttr(action, '__queued__') },
// XXX cache this???
// XXX need to get running tasks by action name...
get queuedActions(){
var test = this.isQueued.bind(this)
return this.actions.filter(test) },
// XXX need a way to reference the queue again...
// .tasks.titled(name) will return a list...
__queues: null,
get queues(){
return (this.__queues = this.__queues || {}) },
// XXX test hidden progress...
// XXX revise logging and logger passing...
// XXX need better error flow...
queue: doc('Get or create a queue task',
doc`Get or create a queue task...
.queue(name)
.queue(name, options)
-> queue
If a queue with the given name already exits it will be returned
and options and logger are ignored.
options format:
{
nonAbortable: <bool>,
quiet: <bool>,
hideProgress: <bool>,
...
}
NOTE: when a task queue is stopped it will clear and cleanup, this is
different to how normal queue behaves.
NOTE: for queue-specific options see ig-types/runner's Queue(..)
`,
function(name, options){
var that = this
var queue = this.queues[name]
// create a new queue...
if(queue == null){
var abort = function(){
options.nonAbortable
|| queue
.abort() }
var cleanup = function(){
return function(){
queue.stop()
// XXX handle error state...
//logger
// && logger.emit('close')
delete that.queues[name] } }
options = options || {}
var logger = options.logger || this.logger
//logger = logger && logger.push(name)
logger = logger
&& logger.push(name, {onclose: abort, quiet: !!options.quiet})
logger
&& (options.logger = logger)
queue = this.queues[name] =
runner.FinalizableQueue(options || {})
// setup logging...
var suffix = (options || {}).hideProgress ?
' (hidden)'
: ''
queue
.on('tasksAdded', function(evt, t){
this.logger && this.logger.emit('added'+suffix, t) })
// NOTE: t can be anything including an array, so to
// avoid confusion we wrap it in an array this
// one call means one emit...
.on('taskCompleted', function(evt, t, r){
this.logger && this.logger.emit('done'+suffix, [t]) })
.on('taskFailed', function(evt, t, err){
this.logger && this.logger.emit('skipped'+suffix, t, err) })
.on('stop', function(){
this.logger && this.logger.emit('reset') })
.on('abort', function(){
this.logger && this.logger.emit('reset') })
// cleanup...
queue
.then(
cleanup('done'),
cleanup('error')) }
// add queue as task...
this.tasks.includes(queue)
|| this.tasks.Task(name, queue)
return queue }),
// contexts (XXX EXPERIMENTAL)
//
// XXX would be nice to have a context manager:
// - context id's (index? ...sparse array?)
// - manager API
// - create/remove
// - context api (feature)
// .then(..)/.catch(..)/.finally(..)
// XXX is peer stuff just a special context???
// ...feels like yes
// XXX is context manager a special case of task manager???
// XXX move to a separate feature...
__contexts: null,
get contexts(){},
// XXX this should delete the clone when done...
// XXX need a common context API to make management possible...
ContextTask: ['- System/',
doc``,
function(type, action, ...args){
var that = this
var context = this[type]
var res = context[action](...args)
var cleanup = function(){
// XXX
}
res.then ?
res.finally(cleanup)
: cleanup()
return res === context ?
undefined
: res }],
// Links...
//
// XXX after this is stabilized, do we need session tasks and its complexities???
__links: null,
get links(){
var links = this.__linked = this.__linked || {}
// remove 'current' if it does not match the current index...
// XXX revise the test...
var c = links.current
if(c && (c.data !== this.data || c.images !== this.images)){
links.previous = c
delete links.current }
return links },
get linked(){
return this.link() },
link: ['- System/',
doc`Get/create links...
Get/create link to current state...
.link()
.link('current')
-> current-link
Get link to previous state if present...
.link('previous')
-> previous-link
-> undefined
Get/create a titled link...
.link(title)
-> link
A link is a separate ImageGrid instance that links to the parent's
state and explicitly disabled ui features.
A link will reflect the data changes but when the main index is
cleared or reloaded it will retain the old data.
Care must be taken as this is true in both directions and changes
to link state are reflected on the link .parent, this is useful
when updating state in the background but can bite the user if not
used carefully.
This effectively enables us to isolate a context for long running
actions/tasks and make them independent of the main state.
Example:
ig.linked.readAllMetadata()
NOTE: links are relatively cheap as almost no data is copied but
they can be a source of a memory "leak" if not cleaned out
as they prevent data from being garbage collected...
NOTE: 'current' and 'previous' links are reserved.
NOTE: 'previous' are a special case as they can not be created
via .link(..).
`,
function(title='current'){
var that = this
var links = this.links
// get link already created...
// NOTE: 'current' and 'previous' links are handled by the
// .links prop...
var link = links[title]
if(link){
return link }
// prevent creating previous links...
if(title == 'previous'){
return actions.UNDEFINED }
// create a link...
// NOTE: we intentionally disable ui here and do not trigger .start()...
return (links[title] =
Object.assign(
// XXX add a 'link' feature...
ImageGridFeatures.setup([
...this.features.input,
'-ui',
'link-context',
]),
// NOTE: this can shadow parts of the new base object
// so we'll need to exclude some stuff...
Object.assign({}, this)
.run(function(){
// remove excluded attrs...
;(that.config['context-exclude-attrs']
|| [ 'features' ])
.forEach(function(key){
delete this[key] }.bind(this)) }),
{
// link metadata...
parent: this,
title: title,
// XXX change this when data/images changes...
// ...a prop in the link feature...
type: 'link',
// link configuration...
logger: that.logger
.push(`Linked ${ Object.keys(links).length }`),
})) }],
// XXX would be nice to have an ability to partially clone the instance...
// ...currently we can do a full clone and remove things we do
// not want but that still takes time and memory...
// XXX this does not copy aliases...
// XXX might be a good idea to add a 'IsolatedTask' feature/mixin to
// handle cleanup (via .done() action)
// XXX should this be a prop -- .isolated???
__isolated: null,
get isolated(){
return (this.__isolated = this.__isolated || []) },
isolate: ['- System/',
function(){
var clones = this.isolated
var clone = this.clone(true)
// reset actions to exclude UI...
clone.__proto__ = ImageGridFeatures.setup([...this.features.input, '-ui'])
clone.parent = this
// link clone in...
clone.logger = this.logger.push(['Task', clones.length].join(' '))
clone.context_id = clones.push(clone)
return clone }],
})
var Tasks =
module.Tasks = ImageGridFeatures.Feature({
title: '',
tag: 'tasks',
depends: [ ],
actions: TaskActions,
handlers: [
// stop session tasks...
['clear',
// XXX BUG: for some reason calling .abort here does not work...
//'sessionTasks.stop'],
'sessionTasks.abort'],
],
})
//---------------------------------------------------------------------
// Self test framework...
// Indicate an action to be a self-test action...
//
// Self test actions are run by .selfTest(..)
//
// XXX should we set an action attr or a func attr here???
var selfTest =
module.selfTest = function(func){
func.__self_test__ = true
return func
}
var SelfTestActions = actions.Actions({
config: {
'run-selftest-on-start': true,
},
selfTest: ['System/Run self test',
selfTest(function(mode){
var that = this
var logger = this.logger && this.logger.push('Self test')
var tests = this.actions
.filter(function(action){
return action != 'selfTest'
&& (that[action].func.__self_test__
|| that.getActionAttr(action, 'self_test'))})
logger
&& tests.forEach(function(action){
logger.emit('found', action) })
tests.forEach(function(action){
that[action]()
logger
&& logger.emit('done', action)
})
})],
})
var SelfTest =
module.SelfTest = ImageGridFeatures.Feature({
title: '',
doc: '',
tag: 'self-test',
depends: [
'lifecycle'
],
suggested: [
'logger',
],
priority: 'low',
actions: SelfTestActions,
handlers: [
['start',
function(){
this.config['run-selftest-on-start']
&& this.selfTest() }]
],
})
/**********************************************************************
* vim:set ts=4 sw=4 : */ return module })