From b6cb9a59a8ca6e7c8dd9b492ad54100b0d4499f3 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 10 Sep 2024 08:34:44 -0400 Subject: [PATCH] Add additional item list options --- docs/girder_config_options.rst | 6 + girder/girder_large_image/rest/__init__.py | 155 ++++++++++++-- .../web_client/templates/itemList.pug | 4 +- .../web_client/views/itemList.js | 201 +++++++++++++++++- 4 files changed, 336 insertions(+), 30 deletions(-) diff --git a/docs/girder_config_options.rst b/docs/girder_config_options.rst index 601504624..05a874a78 100644 --- a/docs/girder_config_options.rst +++ b/docs/girder_config_options.rst @@ -61,6 +61,12 @@ This is used to specify how items appear in item lists. There are two settings, itemList: # layout does not need to be specified. layout: + # The default list (with flatten: false) shows only the items in the + # current folder; flattening the list shows items in the current folder + # and all subfolders. This can also be "only", in which case the + # flatten option will start enabled and, when flattened, the folder + # list will be hidden. + flatten: true # The default layout is a list. This can optionally be "grid" mode: grid # max-width is only used in grid mode. It is the maximum width in diff --git a/girder/girder_large_image/rest/__init__.py b/girder/girder_large_image/rest/__init__.py index f06174673..05ebfd072 100644 --- a/girder/girder_large_image/rest/__init__.py +++ b/girder/girder_large_image/rest/__init__.py @@ -1,3 +1,4 @@ +import collections import json from girder import logger @@ -9,7 +10,7 @@ from girder.models.item import Item -def addSystemEndpoints(apiRoot): +def addSystemEndpoints(apiRoot): # noqa """ This adds endpoints to routes that already exist in Girder. @@ -29,6 +30,9 @@ def altItemFind(self, folderId, text, name, limit, offset, sort, filters=None): if text and text.startswith('_recurse_:'): recurse = True text = text.split('_recurse_:', 1)[1] + group = None + if text and text.startswith('_group_:') and len(text.split(':', 2)) >= 3: + _, group, text = text.split(':', 2) if filters is None and text and text.startswith('_filter_:'): try: filters = json.loads(text.split('_filter_:', 1)[1].strip()) @@ -40,9 +44,10 @@ def altItemFind(self, folderId, text, name, limit, offset, sort, filters=None): logger.debug('Item find filters: %s', json.dumps(filters)) except Exception: pass - if recurse: + if recurse or group: return _itemFindRecursive( - self, origItemFind, folderId, text, name, limit, offset, sort, filters) + self, origItemFind, folderId, text, name, limit, offset, sort, + filters, recurse, group) return origItemFind(folderId, text, name, limit, offset, sort, filters) @boundHandler(apiRoot.item) @@ -58,7 +63,55 @@ def altFolderFind(self, parentType, parentId, text, name, limit, offset, sort, f altFolderFind._origFunc = origFolderFind -def _itemFindRecursive(self, origItemFind, folderId, text, name, limit, offset, sort, filters): +def _groupingPipeline(initialPipeline, cbase, grouping, sort=None): + """ + Modify the recursive pipeline to add grouping and counts. + + :param initialPipeline: a pipeline to extend. + :param cbase: a unique value for each grouping set. + :param grouping: a dictionary where 'keys' is a list of data to group by + and, optionally, 'counts' is a dictionary of data to count as keys and + names where to add the results. For instance, this could be + {'keys': ['meta.dicom.PatientID'], 'counts': { + 'meta.dicom.StudyInstanceUID': 'meta._count.studycount', + 'meta.dicom.SeriesInstanceUID': 'meta._count.seriescount'}} + :param sort: an optional lost of (key, direction) tuples + """ + for gidx, gr in enumerate(grouping['keys']): + grsort = [(gr, 1)] + (sort or []) + initialPipeline.extend([{ + '$match': {gr: {'$exists': True}}, + }, { + '$sort': collections.OrderedDict(grsort), + }, { + '$group': { + '_id': f'${gr}', + 'firstOrder': {'$first': '$$ROOT'}, + }, + }]) + groupStep = initialPipeline[-1]['$group'] + if not gidx and grouping['counts']: + for cidx, (ckey, cval) in enumerate(grouping['counts'].items()): + groupStep[f'count_{cbase}_{cidx}'] = {'$addToSet': f'${ckey}'} + cparts = cval.split('.') + centry = {cparts[-1]: {'$size': f'$count_{cbase}_{cidx}'}} + for cidx in range(len(cparts) - 2, -1, -1): + centry = { + cparts[cidx]: { + '$mergeObjects': [ + '$firstOrder.' + '.'.join(cparts[:cidx + 1]), + centry, + ], + }, + } + initialPipeline.append({'$set': {'firstOrder': { + '$mergeObjects': ['$firstOrder', centry]}}}) + initialPipeline.append({'$replaceRoot': {'newRoot': '$firstOrder'}}) + + +def _itemFindRecursive( # noqa + self, origItemFind, folderId, text, name, limit, offset, sort, filters, + recurse=True, group=None): """ If a recursive search within a folderId is specified, use an aggregation to find all folders that are descendants of the specified folder. If there @@ -73,20 +126,23 @@ def _itemFindRecursive(self, origItemFind, folderId, text, name, limit, offset, from bson.objectid import ObjectId if folderId: - pipeline = [ - {'$match': {'_id': ObjectId(folderId)}}, - {'$graphLookup': { - 'from': 'folder', - 'connectFromField': '_id', - 'connectToField': 'parentId', - 'depthField': '_depth', - 'as': '_folder', - 'startWith': '$_id', - }}, - {'$group': {'_id': '$_folder._id'}}, - ] - children = [ObjectId(folderId)] + next(Folder().collection.aggregate(pipeline))['_id'] - if len(children) > 1: + if recurse: + pipeline = [ + {'$match': {'_id': ObjectId(folderId)}}, + {'$graphLookup': { + 'from': 'folder', + 'connectFromField': '_id', + 'connectToField': 'parentId', + 'depthField': '_depth', + 'as': '_folder', + 'startWith': '$_id', + }}, + {'$group': {'_id': '$_folder._id'}}, + ] + children = [ObjectId(folderId)] + next(Folder().collection.aggregate(pipeline))['_id'] + else: + children = [ObjectId(folderId)] + if len(children) > 1 or group: filters = (filters.copy() if filters else {}) if text: filters['$text'] = { @@ -98,6 +154,69 @@ def _itemFindRecursive(self, origItemFind, folderId, text, name, limit, offset, user = self.getCurrentUser() if isinstance(sort, list): sort.append(('parentId', 1)) + + # This is taken from girder.utility.acl_mixin.findWithPermissions, + # except it adds a grouping stage + initialPipeline = [ + {'$match': filters}, + {'$lookup': { + 'from': 'folder', + 'localField': Item().resourceParent, + 'foreignField': '_id', + 'as': '__parent', + }}, + {'$match': Item().permissionClauses(user, AccessType.READ, '__parent.')}, + {'$project': {'__parent': False}}, + ] + if group is not None: + if not isinstance(group, list): + group = [gr for gr in group.split(',') if gr] + groups = [] + idx = 0 + while idx < len(group): + if group[idx] != '_count_': + if not len(groups) or groups[-1]['counts']: + groups.append({'keys': [], 'counts': {}}) + groups[-1]['keys'].append(group[idx]) + idx += 1 + else: + if idx + 3 <= len(group): + groups[-1]['counts'][group[idx + 1]] = group[idx + 2] + idx += 3 + for gidx, grouping in enumerate(groups): + _groupingPipeline(initialPipeline, gidx, grouping, sort) + fullPipeline = initialPipeline + countPipeline = initialPipeline + [ + {'$count': 'count'}, + ] + if sort is not None: + fullPipeline.append({'$sort': collections.OrderedDict(sort)}) + if limit: + fullPipeline.append({'$limit': limit + (offset or 0)}) + if offset: + fullPipeline.append({'$skip': offset}) + + logger.debug('Find item pipeline %r', fullPipeline) + + options = { + 'allowDiskUse': True, + 'cursor': {'batchSize': 0}, + } + result = Item().collection.aggregate(fullPipeline, **options) + + def count(): + try: + return next(iter( + Item().collection.aggregate(countPipeline, **options)))['count'] + except StopIteration: + # If there are no values, this won't return the count, in + # which case it is zero. + return 0 + + result.count = count + result.fromAggregate = True + return result + return Item().findWithPermissions(filters, offset, limit, sort=sort, user=user) return origItemFind(folderId, text, name, limit, offset, sort, filters) diff --git a/girder/girder_large_image/web_client/templates/itemList.pug b/girder/girder_large_image/web_client/templates/itemList.pug index 66f4b38b1..a618f4111 100644 --- a/girder/girder_large_image/web_client/templates/itemList.pug +++ b/girder/girder_large_image/web_client/templates/itemList.pug @@ -42,7 +42,7 @@ ul.g-item-list.li-item-list(layout_mode=(itemList.layout || {}).mode || '') skip = true; } }); - #{divtype}.li-item-list-cell(class=classes.join(' '), g-item-cid=item.cid, href=`#item/${item.id}`, title=colNames[colidx]) + #{divtype}.li-item-list-cell(class=classes.join(' '), g-item-cid=item.cid, href=item._href ? item._href : `#item/${item.id}`, title=colNames[colidx]) if !skip && column.label span.g-item-list-label = column.label @@ -92,7 +92,7 @@ ul.g-item-list.li-item-list(layout_mode=(itemList.layout || {}).mode || '') != String(value).replace(/&/g, '&').replace(//, '>').replace(/"/, '"').replace(/'/, ''').replace(/\./g, '.­').replace(/_/g, '_­') else = value - if value + if value && column.format !== 'count' span.li-item-list-cell-filter(title="Only show items that match this metadata value exactly", filter-value=value, column-value=column.value) i.icon-filter if (hasMore && !paginated) diff --git a/girder/girder_large_image/web_client/views/itemList.js b/girder/girder_large_image/web_client/views/itemList.js index 99920e962..e4c29bbee 100644 --- a/girder/girder_large_image/web_client/views/itemList.js +++ b/girder/girder_large_image/web_client/views/itemList.js @@ -6,6 +6,7 @@ import {wrap} from '@girder/core/utilities/PluginUtils'; import {getApiRoot} from '@girder/core/rest'; import {AccessType} from '@girder/core/constants'; import {formatSize, parseQueryString, splitRoute} from '@girder/core/misc'; +import router from '@girder/core/router'; import HierarchyWidget from '@girder/core/views/widgets/HierarchyWidget'; import FolderListWidget from '@girder/core/views/widgets/FolderListWidget'; import ItemListWidget from '@girder/core/views/widgets/ItemListWidget'; @@ -17,14 +18,37 @@ import '../stylesheets/itemList.styl'; import ItemListTemplate from '../templates/itemList.pug'; import {MetadatumWidget, validateMetadataValue} from './metadataWidget'; +function onItemClick(item) { + if (this.itemListView && this.itemListView.onItemClick) { + if (this.itemListView.onItemClick(item)) { + return; + } + } + router.navigate('item/' + item.get('_id'), {trigger: true}); +} + +wrap(HierarchyWidget, 'initialize', function (initialize, settings) { + if (!settings) { + settings = {}; + } + if (!settings.onItemClick) { + settings.onItemClick = onItemClick; + } + return initialize.call(this, settings); +}); + wrap(HierarchyWidget, 'render', function (render) { render.call(this); + if (this.parentModel.resourceName !== 'folder') { + this.$('.g-folder-list-container').toggleClass('hidden', false); + } if (!this.$('#flattenitemlist').length && this.$('.g-item-list-container').length && this.itemListView && this.itemListView.setFlatten) { $('button.g-checked-actions-button').parent().after( '
' ); - if ((this.itemListView || {})._recurse) { + if ((this.itemListView || {})._recurse && this.parentModel.resourceName === 'folder') { this.$('#flattenitemlist').prop('checked', true); + this.$('.g-folder-list-container').toggleClass('hidden', this.itemListView._hideFoldersOnFlatten); } this.events['click #flattenitemlist'] = (evt) => { this.itemListView.setFlatten(this.$('#flattenitemlist').is(':checked')); @@ -50,6 +74,32 @@ wrap(ItemListWidget, 'initialize', function (initialize, settings) { const result = initialize.call(this, settings); delete this._hasAnyLargeImage; + this._confList = () => { + let list; + if (!this._liconfig) { + return undefined; + } + const namedList = this._namedList || this._liconfig.defaultItemList; + if (this.$el.closest('.modal-dialog').length) { + list = this._liconfig.itemListDialog; + } else if (namedList && this._liconfig.namedItemLists && this._liconfig.namedItemLists[namedList]) { + list = this._liconfig.namedItemLists[namedList]; + } else { + list = this._liconfig.itemList; + } + if (list.group) { + let group = list.group; + group = !group.keys ? {keys: group} : group; + group.keys = Array.isArray(group.keys) ? group.keys : [group.keys]; + group.keys = group.keys.filter((g) => !g.includes(',') && !g.includes(':')); + if (!group.keys.length) { + group = undefined; + } + list.group = group; + } + return list; + }; + largeImageConfig.getConfigFile(settings.folderId, true, (val) => { if (!settings.folderId) { this._liconfig = val; @@ -59,11 +109,22 @@ wrap(ItemListWidget, 'initialize', function (initialize, settings) { this.render(); return; } + if (!_.isEqual(val, this._liconfig) && !this.$el.closest('.modal-dialog').length && val) { + this._liconfig = val; + const list = this._confList(); + if (list.layout && list.layout.flatten !== undefined) { + this._recurse = !!list.layout.flatten; + this.parentView.$('#flattenitemlist').prop('checked', this._recurse); + } + this._hideFoldersOnFlatten = !!(list.layout && list.layout.flatten === 'only'); + this.parentView.$('.g-folder-list-container').toggleClass('hidden', this._hideFoldersOnFlatten); + } delete this._lastSort; this._liconfig = val; const curRoute = Backbone.history.fragment; const routeParts = splitRoute(curRoute); const query = parseQueryString(routeParts.name); + this._namedList = query.namedList || undefined; let update = false; if (query.sort) { this._lastSort = query.sort.split(',').map((chunk) => { @@ -100,6 +161,7 @@ wrap(ItemListWidget, 'initialize', function (initialize, settings) { this.setFlatten = (flatten) => { if (!!flatten !== !!this._recurse) { this._recurse = !!flatten; + this.parentView.$('.g-folder-list-container').toggleClass('hidden', this._hideFoldersOnFlatten && this._recurse); this._setFilter(); this.render(); } @@ -139,10 +201,6 @@ wrap(ItemListWidget, 'render', function (render) { } } - this._confList = () => { - return this._liconfig ? (this.$el.closest('.modal-dialog').length ? this._liconfig.itemListDialog : this._liconfig.itemList) : undefined; - }; - /** * Set sort on the collection and perform a debounced re-fetch. */ @@ -167,7 +225,6 @@ wrap(ItemListWidget, 'render', function (render) { return item.has('largeImage'); }); this._inFetch = false; - itemListRender.apply(this, _.rest(arguments)); if (oldPages !== pages || this.collection.offset !== this.collection.size()) { this.collection.offset = this.collection.size(); this.trigger('g:paginated'); @@ -184,6 +241,61 @@ wrap(ItemListWidget, 'render', function (render) { } }; + /** + * Return true if we handle the click + */ + this.onItemClick = (item) => { + const list = this._confList(); + const nav = (list || {}).navigate; + if (!nav || (!nav.type && !nav.name) || nav.type === 'item') { + return false; + } + if (nav.type === 'itemList') { + if ((nav.name || '') === (self._namedList || '')) { + return false; + } + if (!this._liconfig || !this._liconfig.namedItemLists || (nav.name && !this._liconfig.namedItemLists[nav.name])) { + return false; + } + this._updateNamedList(nav.name, false); + if (list.group) { + this._generalFilter = ''; + list.group.keys.forEach((key) => { + const cell = this.$el.find(`[g-item-cid="${item.cid}"] [column-value="${key}"]`); + if (cell.length) { + addCellToFilter.call(this, cell, false); + } + }); + } + this._setFilter(false); + this._setSort(); + addToRoute({namedList: this._namedList, filter: this._generalFilter}); + return true; + } + if (nav.type === 'open') { + // TODO: handle open type + // we probably need to get all the grouped items to pass them to + // the .open-in-volview button via that _getCheckedResourceParam + // call OR modify the volview plugin to have an open item with less + // context. The current folder context would ideally be the + // deepest common parent rather than our current folder. Where + // does volview store its zip file? + console.log(item, nav); // DWM:: + } + return false; + }; + + this._updateNamedList = (name, update) => { + name = name || ''; + if ((this._namedList || '') !== name) { + this._namedList = name; + if (update !== false) { + addToRoute({namedList: this._namedList}); + this._setSort(); + } + } + }; + this._updateFilter = (evt) => { this._generalFilter = $(evt.target).val().trim(); this._setFilter(); @@ -302,6 +414,20 @@ wrap(ItemListWidget, 'render', function (render) { filter = '_filter_:' + JSON.stringify(filter); } } + const group = (this._confList() || {}).group || undefined; + if (group) { + if (group.keys.length) { + let grouping = '_group_:meta.' + group.keys.join(',meta.'); + if (group.counts) { + for (const [gkey, gval] of Object.entries(group.counts)) { + if (!gkey.includes(',') && !gkey.includes(':') && !gval.includes(',') && !gval.includes(':')) { + grouping += `,_count_,meta.${gkey},meta.${gval}`; + } + } + } + filter = grouping + ':' + (filter || ''); + } + } if (this._recurse) { filter = '_recurse_:' + (filter || ''); } @@ -315,6 +441,54 @@ wrap(ItemListWidget, 'render', function (render) { } }; + /** + * For each item in the collection, if we are navigating to something other + * than the item, set an href property. + */ + function adjustItemHref() { + this.collection.forEach((item) => { + item._href = undefined; + }); + const list = this._confList(); + const nav = (list || {}).navigate; + if (!nav || (!nav.type && !nav.name) || nav.type === 'item') { + return; + } + if (nav.type === 'itemList') { + if ((nav.name || '') === (self._namedList || '')) { + return; + } + if (!this._liconfig || !this._liconfig.namedItemLists || (nav.name && !this._liconfig.namedItemLists[nav.name])) { + return; + } + this.collection.forEach((item) => { + item._href = `#folder/${this.parentView.parentModel.id}?namedList=` + (nav.name ? encodeURIComponent(nav.name) : ''); + let filter = ''; + if (list.group) { + list.group.keys.forEach((col) => { + let val = item.get('meta') || {}; + col.split('.').forEach((part) => { + val = (val || {})[part]; + }); + if (/[ '\\]/.exec(col)) { + col = "'" + col.replace('\\', '\\\\').replace("'", "\\'") + "'"; + } + if (val) { + val = val.replace('\\', '\\\\').replace('"', '\\"'); + filter += ` ${col}:"${val}"`; + } + }); + } + filter = filter.trim(); + if (filter !== '') { + item._href += '&filter=' + encodeURIComponent(filter); + } + }); + } + // TODO: handle nav.type open + // DWM:: + } + function itemListRender() { if (this._inInit || this._inFetch) { return; @@ -358,6 +532,7 @@ wrap(ItemListWidget, 'render', function (render) { this._setSort(); return; } + adjustItemHref.call(this); this.$el.html(ItemListTemplate({ items: this.collection.toArray(), isParentPublic: this.public, @@ -452,9 +627,7 @@ function sortColumn(evt) { } } -function itemListCellFilter(evt) { - evt.preventDefault(); - const cell = $(evt.target).closest('.li-item-list-cell-filter'); +function addCellToFilter(cell, update) { let filter = this._generalFilter || ''; let val = cell.attr('filter-value'); let col = cell.attr('column-value'); @@ -466,7 +639,15 @@ function itemListCellFilter(evt) { filter = filter.trim(); this.$el.closest('.g-hierarchy-widget').find('.li-item-list-filter-input').val(filter); this._generalFilter = filter; - this._setFilter(); + if (update !== false) { + this._setFilter(); + } +} + +function itemListCellFilter(evt) { + evt.preventDefault(); + const cell = $(evt.target).closest('.li-item-list-cell-filter'); + addCellToFilter.call(this, cell); addToRoute({filter: this._generalFilter}); this._setSort(); return false;