ImageGrid/ui (gen4)/features/collections.js
Alex A. Naanou 6f388e30df cleanup, tweaking and refactoring...
Signed-off-by: Alex A. Naanou <alex.nanou@gmail.com>
2017-08-28 00:50:37 +03:00

1005 lines
27 KiB
JavaScript
Executable File

/**********************************************************************
*
*
*
**********************************************************************/
((typeof define)[0]=='u'?function(f){module.exports=f(require)}:define)
(function(require){ var module={} // make module AMD/node compatible...
/*********************************************************************/
var data = require('imagegrid/data')
var toggler = require('lib/toggler')
var actions = require('lib/actions')
var features = require('lib/features')
var browse = require('lib/widget/browse')
var core = require('features/core')
var widgets = require('features/ui-widgets')
/*********************************************************************/
// XXX should collections be in the Crop menu????
// ...essentially a collection is a saved crop, so this would be
// logical, would simplify control, etc.
//
var MAIN_COLLECTION_TITLE = 'All'
// XXX things we need to do to collections:
// - auto-collections
// - tags -- adding/removing images adds/removes tags
// - ribbons -- top / bottom / n-m / top+2 / ..
// XXX might be a good idea to make collection loading part of the
// .load(..) protocol...
// ...this could be done via a url suffix, as a shorthand.
// something like:
// /path/to/index:collection
// -> /path/to/index/sub/path/.ImageGrid/collections/collection
// XXX loading collections by direct path would require us to look
// in the containing index for missing parts (*images.json, ...)
// XXX saving a local collection would require us to save to two
// locations:
// - collection specific stuff (data) to collection path
// - global stuff (images, tags, ...) to base index...
// XXX handle tags here???
// ...keep them global or local to collection???
// global sounds better...
// XXX undo...
var CollectionActions = actions.Actions({
config: {
// can be:
// 'all' - save crop state for all collections (default)
// 'main' - save crop state for main state only
// 'none' - do not save crop state
'collection-save-crop-state': 'all',
// XXX add default collection list to config...
'default-collections': [
],
},
// Format:
// {
// <title>: {
// title: <title>,
// gid: <gid>,
//
// crop_stack: [ .. ],
//
// // base collection format -- raw data...
// data: <data>,
//
// ...
// },
// ...
// }
collections: null,
get collection(){
return this.location.collection },
set collection(value){
this.loadCollection(value) },
// XXX should this check consistency???
get collection_order(){
if(this.collections == null){
return null
}
var collections = this.collections
var keys = Object.keys(collections)
var order = this.__collection_order = this.__collection_order || []
// add unsorted things to the head of the list...
var res = keys
.concat(order)
.reverse()
.unique()
.reverse()
// remove stuff not present...
if(res.length > keys.length){
res = res.filter(function(e){ return e in collections })
}
this.__collection_order.splice.apply(this.__collection_order,
[0, this.__collection_order.length].concat(res))
return this.__collection_order
},
set collection_order(value){
this.__collection_order = value },
// Format:
// {
// // NOTE: this is always the first handler...
// 'data': <action-name>,
//
// <format>: <action-name>,
// ...
// }
//
// XXX should these get auto-sorted???
get collection_handlers(){
var handlers = this.__collection_handlers || {}
if(Object.keys(handlers).length == 0){
var that = this
handlers['data'] = null
this.actions.forEach(function(action){
var fmt = that.getActionAttr(action, 'collectionFormat')
if(fmt){
handlers[fmt] = action
}
})
}
return handlers
},
collectionDataLoader: ['- Collections/',
core.doc`Collection data loader
.collectionDataLoader(title, data)
-> promise
The resulting promise will resolve to a Data object that will get
loaded as the collection.
data is of the .collections item format.
This will not clone .data, this all changes made to it are
persistent.
`,
{collectionFormat: 'data'},
function(title, data){
return new Promise(function(resolve){ resolve(data.data) }) }],
loadCollection: ['- Collections/',
core.doc`Load collection...
This will get collection data and crop into it.
If .data for a collection is not available this will do nothing,
this enables extending actions to handle the collection in
different ways.
Protocol:
- collection format handlers: .collection_handlers
- built from actions that define .collectionFormat attr to
contain the target format string.
- format is determined by matching it to a key in .collections[collection]
e.g. 'data' is applicable if .collections[collection].data is not null
- the format key's value is passed to the handler action
- the handler is expected to return a promise
- only the first matching handler is called
- the data handler is always first to get checked
For an example handler see:
.collectionDataLoader(..)
The .data handler is always first to enable caching, i.e. once some
non-data handler is done, it can set the .data which will be loaded
directly the next time.
To invalidate such a cache .data should simply be deleted.
NOTE: cached collection state is persistent.
`,
function(collection){
var that = this
if(collection == null
|| this.collections == null
|| !(collection in this.collections)){
return
}
var crop_mode = this.config['collection-save-crop-state'] || 'all'
var current = this.current
var ribbon = this.current_ribbon
var prev = this.collection
var collection_data = this.collections[collection]
var handlers = this.collection_handlers
// save current collection state...
// main view -> save it...
if(this.collection == null){
var main = this.collections[MAIN_COLLECTION_TITLE] = {
title: MAIN_COLLECTION_TITLE,
}
// mode 'none' -> do not save crop state...
if(crop_mode == 'none'){
//this.saveCollection(this.collection, 'base')
main.data = (this.crop_stack || [])[0] || this.data
// modes 'all' and 'main' -> save crop state...
} else {
//this.saveCollection(this.collection, 'crop')
main.data = this.data
main.crop_stack = this.crop_stack
}
} else if(crop_mode == 'all'){
this.saveCollection(this.collection, 'crop')
//this.collections[this.collection].data = this.data
//this.collections[this.collection].crop_stack = this.crop_stack
}
// load collection...
for(var format in handlers){
if(collection_data[format]){
return this[handlers[format]](collection, collection_data)
.then(function(data){
if(!data){
return
}
// current...
data.current = data.getImage(current)
// current is not in collection -> try and keep
// the ribbon context...
|| that.data.getImage(
current,
data.getImages(that.data.getImages(ribbon)))
// get closest image from collection...
|| that.data.getImage(current, data.order)
|| data.current
data.tags = that.data.tags
// NOTE: tags and other position dependant
// data needs to be updated as collections
// may contain different numbers/orders of
// images...
data.updateImagePositions()
that.load({
data: data,
crop_stack: collection_data.crop_stack,
collections: that.collections,
collection_order: that.collection_order,
}, true)
// maintain the .collection state...
if(collection == MAIN_COLLECTION_TITLE){
// no need to maintain the main data in two
// locations...
delete that.collections[MAIN_COLLECTION_TITLE]
delete this.location.collection
} else {
that.data.collection = that.location.collection = collection
// cleanup...
if(collection == null){
delete this.location.collection
}
}
// collection events...
that
.collectionUnloaded(prev || MAIN_COLLECTION_TITLE)
.collectionLoaded(collection)
})
}
}
}],
// events...
collectionLoaded: ['- Collections/',
core.doc`This is called by .loadCollection(..) or one of the
overloading actions when collection load is done...
`,
core.notUserCallable(function(collection){
// This is the window resize event...
//
// Not for direct use.
})],
collectionUnloaded: ['- Collections/',
core.doc`This is called when unloading a collection.
`,
core.notUserCallable(function(collection){
// This is the window resize event...
//
// Not for direct use.
})],
// XXX saving into current collection will leave the viewer in an
// inconsistent state:
// - collection X is indicated as loaded
// - collection X has different state than what is loaded
// ...not sure how to deal with this yet...
saveCollection: ['- Collections/',
core.doc`Save current state to collection
Save Current state to current collection
.saveCollection()
.saveCollection('current')
-> this
NOTE: this will do nothing if no collection is loaded.
Save Current state as collection
.saveCollection(collection)
-> this
Save new empty collection
.saveCollection(collection, 'empty')
-> this
Save current crop state to collection
.saveCollection(collection, 'crop')
-> this
Save top depth crops from current crop stack to collection
.saveCollection(collection, depth)
-> this
Save base crop state to collection
.saveCollection(collection, 'base')
-> this
`,
function(collection, mode){
var that = this
collection = collection || this.collection
collection = collection == 'current' ? this.collection : collection
if(collection == null || collection == MAIN_COLLECTION_TITLE){
return
}
var depth = typeof(mode) == typeof(123) ? mode : null
mode = depth == 0 ? null
: depth ? 'crop'
: mode
var collections = this.collections = this.collections || {}
var state = collections[collection] = {
title: collection,
// NOTE: we do not need to care about tags here as they
// will get overwritten on load...
data: (mode == 'empty' ?
(new this.data.constructor())
: mode == 'base' && this.crop_stack ?
(this.crop_stack[0] || this.data.clone())
: mode == 'crop' ?
this.data.clone()
: this.data.clone()
.removeUnloadedGIDs())
.run(function(){
this.collection = collection
// NOTE: we are doing this manually after .removeUnloadedGIDs(..)
// as the later will mess-up the structures
// inherited from the main .data, namely tags...
//this.tags = that.data.tags
}),
}
if(mode == 'crop' && this.crop_stack && depth != 0){
depth = depth || this.crop_stack.length
depth = this.crop_stack.length - Math.min(depth, this.crop_stack.length)
state.crop_stack = this.crop_stack.slice(depth)
}
}],
newCollection: ['- Collections/',
function(collection){ return this.saveCollection(collection, 'empty') }],
// XXX should we do anything special if collection is loaded???
removeCollection: ['- Collections/',
function(collection){
delete this.collections[collection]
}],
inCollections: ['- Image/',
core.doc`Get list of collections containing item`,
function(gid){
var that = this
gid = this.data.getImage(gid)
//return Object.keys(this.collections || {})
return this.collection_order
.filter(function(c){
return !gid
|| that.collections[c].data.getImage(gid) })
}],
collect: ['- Collections/',
core.doc`Add items to collection
NOTE: this will not account for item topology.`,
function(gids, collection){
var that = this
gids = gids == 'loaded' ? this.data.getImages('loaded')
: gids instanceof Array ? gids
: [gids]
gids = gids
.map(function(gid){
return gid in that.data.ribbons ?
// when adding a ribbon gid expand to images...
that.data.ribbons[gid].compact()
: [that.data.getImage(gid)] })
.reduce(function(a, b){ return a.concat(b) }, [])
collection = collection || this.collection
if(collection == null){
return
}
// add to collection...
var data = this.data.constructor.fromArray(gids)
return this.joinCollect(null, collection, data)
}],
joinCollect: ['- Collections/Merge to collection',
core.doc`Merge/Join current state to collection
Join current state into collection
.joinCollect(collection)
-> this
Join current state with specific alignment into collection
.joinCollect(align, collection)
-> this
Join data to collection with specific alignment
.joinCollect(align, collection, data)
-> this
This is like .collect(..) but will preserve topology.
NOTE: for align docs see Data.join(..)
NOTE: if align is set to null or not given then it will be set
to default value.
NOTE: this will join to the left (prepend) of the collections, this is
different from how basic .join(..) works (appends)
`,
function(align, collection, data){
collection = arguments.length == 1 ? align : collection
if(collection == null){
return
}
// if only collection is given, reset align to null...
align = align === collection ? null : align
if(this.collections && this.collections[collection]){
//this.collections[collection].data.join(align, data || this.data.clone())
this.collections[collection].data = (data || this.data)
.clone()
.join(align, this.collections[collection].data)
} else {
this.saveCollection(collection)
}
}],
uncollect: ['Collections|Image/$Uncollect image',
{browseMode: function(){ return !this.collection && 'disabled' }},
function(gids, collection){
var that = this
gids = gids == 'loaded' ? this.data.getImages('loaded')
: gids instanceof Array ? gids
: [gids]
gids = gids
.map(function(gid){
return gid in that.data.ribbons ?
// when adding a ribbon gid expand to images...
that.data.ribbons[gid].compact()
: [that.data.getImage(gid)] })
.reduce(function(a, b){ return a.concat(b) }, [])
collection = collection || this.collection
if(collection == null){
return
}
/*/ NOTE: we are not using .data.updateImagePositions(gids, 'hide')
// here because it will remove the gids from everything
// while we need them removed only from ribbons...
var hideGIDs = function(){
var d = this
gids.forEach(function(gid){
var i = d.order.indexOf(gid)
Object.keys(d.ribbons).forEach(function(r){
delete d.ribbons[r][i]
})
})
}
//*/
if(this.collection == collection){
this.data
//.run(hideGIDs)
.removeGIDs(gids)
.removeEmptyRibbons()
}
this.collections[collection].data
//.run(hideGIDs)
.removeGIDs(gids)
.removeEmptyRibbons()
}],
// NOTE: this will handle collection title and data only, the rest
// is copied in as-is.
// It is the responsibility of the extending features to transform
// their data on load as needed.
load: [function(json){
var that = this
var collections = {}
var c = json.collections || {}
var order = json.collection_order || Object.keys(c)
if((json.location || {}).collection){
this.location.collection = json.location.collection
}
Object.keys(c).forEach(function(title){
var d = c[title].data instanceof data.Data ?
c[title].data
: data.Data
.fromJSON(c[title].data
.run(function(){
this.tags = this.tags || that.data.tags
}))
var state = collections[title] = {
title: title,
data: d,
}
// copy the rest of collection data as-is...
Object.keys(c[title])
.forEach(function(key){
if(key in state){
return
}
state[key] = c[title][key]
})
})
return function(){
if(Object.keys(collections).length > 0){
this.collection_order = order
this.collections = collections
}
}
}],
//
// Supported modes:
// current (default) - ignore collections
// base - save only base data in each collection and
// the main collection is saved as current
// full - full current state.
//
// NOTE: we do not store .collection_order here, because we order
// the collections in the object.
// ...when saving a partial collection set, for example in
// .prepareIndexForWrite(..) it would be necessary to add it
// in to maintain the correct order when merging... (XXX)
// NOTE: currently this only stores title and data, it is the
// responsibility of extending features to store their specific
// data in collections...
json: [function(mode){ return function(res){
mode = mode || 'current'
var collections = this.collections
// NOTE: if mode is 'current' ignore collections...
if(mode != 'current' && collections){
var order = this.collection_order
// in base mode save the main view as current...
if(mode == 'base' && this.collection){
var main = collections[MAIN_COLLECTION_TITLE]
res.data = (main.crop_stack ?
(main.crop_stack[0] || main.data)
: main.data)
.dumpJSON()
delete res.location.collection
}
res.collections = {}
order.forEach(function(title){
// in base mode skip the main collection...
if(mode == 'base' && title == MAIN_COLLECTION_TITLE){
return
}
var state = collections[title]
var data = ((mode == 'base' && state.crop_stack) ?
(state.crop_stack[0] || state.data)
: state.data)
.dumpJSON()
delete data.tags
var s = res.collections[title] = {
title: title,
data: data,
}
// handle .crop_stack of collection...
// NOTE: in base mode, crop_stack is ignored...
if(mode != 'base' && state.crop_stack){
s.crop_stack = state.crop_stack
.map(function(d){ return d.dumpJSON() })
}
})
}
} }],
clear: [function(){
this.collection
&& this.collectionUnloaded('*')
delete this.collections
delete this.__collection_order
delete this.location.collection
}],
clone: [function(full){
return function(res){
if(this.collections){
var cur = this.collections
if(this.collection){
res.location.collection = this.collection
}
collections = res.collections = {}
this.collection_order
.forEach(function(title){
var c = collections[title] = {
title: title,
}
if(cur[title].data){
c.data = cur[title].data.clone()
}
if(cur[title].crop_stack){
c.crop_stack = cur[title].crop_stack
.map(function(d){ return d.clone() })
}
})
}
} }],
})
var Collection =
module.Collection = core.ImageGridFeatures.Feature({
title: '',
doc: '',
tag: 'collections',
depends: [
'base',
'location',
'crop',
],
suggested: [
'ui-collections',
'fs-collections',
],
actions: CollectionActions,
handlers: [
// XXX maintain changes...
// - collection-level: mark collections as changed...
// - in-collection:
// - save/restore parent changes when loading/exiting collections
// - move collection chnages to collections
[[
'collect',
'joinCollect',
'uncollect',
'saveCollection',
'removeCollection',
],
function(){
// XXX mark changed collections...
// XXX added/removed collection -> mark collection index as changed...
}],
['prepareIndexForWrite',
function(res, _, full){
var changed = full == true
|| res.changes === true
|| res.changes.collections
if(changed && res.raw.collections){
// select the actual changed collection list...
changed = changed === true ?
Object.keys(res.raw.collections)
: changed
// collection index...
res.index['collection-index'] = Object.keys(this.collections)
Object.keys(changed)
// skip the raw field...
.filter(function(k){ return changed.indexOf(k) >= 0 })
.forEach(function(k){
// XXX use collection gid...
res.index['collections/' + k] = res.raw.collections[k]
})
}
}],
['prepareJSONForLoad',
function(res, json, base_path){
// XXX
}],
],
})
//---------------------------------------------------------------------
// XXX show collections in image metadata...
var UICollectionActions = actions.Actions({
browseCollections: ['Collections|Crop/$Collec$tions...',
widgets.makeUIDialog(function(action){
var that = this
var to_remove = []
return browse.makeLister(null,
function(path, make){
var dialog = this
.on('update', function(){
that.collection
&& dialog.filter(JSON.stringify(that.collection))
.addClass('highlighted')
})
//var collections = Object.keys(that.collections || {})
var collections = that.collection_order = that.collection_order || []
make.EditableList(collections,
{
unique: true,
sortable: 'y',
to_remove: to_remove,
itemopen: function(title){
var gid = that.current
action ?
action.call(that, title)
: that.loadCollection(title)
that.focusImage(gid)
dialog.close()
},
normalize: function(title){
return title.trim() },
check: function(title){
return title.length > 0 },
// XXX should this be "on close"???
itemadded: function(title){
action ?
that.newCollection(title)
: that.saveCollection(title) },
disabled: action ? [MAIN_COLLECTION_TITLE] : false,
})
}, {
// focus current collection...
selected: that.collection || MAIN_COLLECTION_TITLE,
})
.close(function(){
to_remove.forEach(function(title){
that.removeCollection(title)
})
})
})],
browseImageCollections: ['Image/$Collections...',
{dialogTitle: 'Image Collections...'},
widgets.makeUIDialog(function(gid){
var that = this
gid = this.data.getImage(gid)
var all
var collections
var to_remove
return browse.makeLister(null,
function(path, make){
var dialog = this
.on('update', function(){
that.collection
&& dialog.filter(JSON.stringify(that.collection))
.addClass('highlighted')
})
//all = Object.keys(that.collections || {})
all = that.collection_order = that.collection_order || []
collections = collections
|| that.inCollections(gid || null)
// build the disabled list...
if(!to_remove){
to_remove = []
all.forEach(function(title){
collections.indexOf(title) < 0
&& to_remove.push(title)
})
}
all.length > 0 ?
make.EditableList(all,
{
new_item: false,
to_remove: to_remove,
itemopen: function(title){
var i = to_remove.indexOf(title)
i >= 0 ?
to_remove.splice(i, 1)
: to_remove.push(title)
dialog.update()
},
})
: make.Empty()
})
.close(function(){
all.forEach(function(title){
collections.indexOf(title) < 0
&& to_remove.indexOf(title) < 0
&& that.collect(gid, title)
})
to_remove.forEach(function(title){
that.uncollect(gid, title)
})
})
})],
// Collections actions with collection selection...
// XXX should we warn the user when overwriting???
saveAsCollection: ['Collections|Crop/$Save as collection...',
widgets.uiDialog(function(){
return this.browseCollections(function(title){
this.saveCollection(title) }) })],
addToCollection: ['Collections|Crop|Image/Add $image to collection...',
widgets.uiDialog(function(gids){
return this.browseCollections(function(title){
this.collect(gids || this.current, title) }) })],
addLoadedToCollection: ['Collections|Crop/$Add loaded images to collection...',
widgets.uiDialog(function(){ return this.addToCollection('loaded') })],
joinToCollection: ['Collections|Crop/$Merge view to collection...',
widgets.uiDialog(function(){
return this.browseCollections(function(title){
this.joinCollect(title) }) })],
/*/ XXX this is not used by metadata yet...
metadataSection: ['- Image/',
function(gid, make){
}],
//*/
})
var UICollection =
module.UICollection = core.ImageGridFeatures.Feature({
title: '',
doc: '',
tag: 'ui-collections',
depends: [
'ui',
'collections',
],
actions: UICollectionActions,
handlers: [
// update view when removing from current collection...
['uncollect',
function(_, gids, collection){
(collection == null || this.collection == collection)
&& this.reload(true)
}],
// maintain crop viewer state when loading/unloading collections...
['collectionLoaded collectionUnloaded',
function(){
this.dom[this.collection ?
'addClass'
: 'removeClass']('collection-mode')
this.dom[this.cropped ?
'addClass'
: 'removeClass']('crop-mode')
}],
],
})
//---------------------------------------------------------------------
// XXX Things to try/do:
// - save collection on exit/write (?)
// - lazy load collections (load list, lazy-load data)
// - collection index
// - load directories as collections (auto?)...
// - export collections to directories...
// - auto-export collections (on save)...
// - add new images
// - remove old images...
// - collection history (same as ctrl-shift-h)...
var FileSystemCollectionActions = actions.Actions({
// Format:
// {
// path: <string>,
// ...
// }
collections: null,
collectionPathLoader: ['- Collections/',
{collectionFormat: 'path'},
function(data, loader){
// XXX
}],
importCollectionsFromPath: ['- Collections|File/Import collections from path',
function(path){
// XXX
}],
})
// XXX manage format...
// XXX manage changes...
var FileSystemCollection =
module.FileSystemCollection = core.ImageGridFeatures.Feature({
title: '',
doc: '',
tag: 'fs-collections',
depends: [
'index-format',
'fs',
'collections',
],
actions: FileSystemCollectionActions,
handlers: [
],
})
//---------------------------------------------------------------------
// XXX localstorage-collections (???)
/**********************************************************************
* vim:set ts=4 sw=4 : */ return module })