From e6931aa7b6a4b432bb062d0074fecd42dce0891b 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 | 169 +++++++++++++++++- 4 files changed, 307 insertions(+), 27 deletions(-) diff --git a/docs/girder_config_options.rst b/docs/girder_config_options.rst index 720aa56a0..1138fed09 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 86d55ffd5..321cf0a6a 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 ItemCollection from '@girder/core/collections/ItemCollection'; import FolderListWidget from '@girder/core/views/widgets/FolderListWidget'; @@ -20,22 +21,38 @@ import {MetadatumWidget, validateMetadataValue} from './metadataWidget'; ItemCollection.prototype.pageLimit = Math.max(250, ItemCollection.prototype.pageLimit); +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) { settings = settings || {}; if (settings.paginated === undefined) { settings.paginated = true; } + if (settings.onItemClick === undefined) { + 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')); @@ -96,6 +113,16 @@ 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; @@ -138,6 +165,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(); } @@ -217,6 +245,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(); @@ -335,6 +418,23 @@ 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 (let [gkey, gval] of Object.entries(group.counts)) { + if (!gkey.includes(',') && !gkey.includes(':') && !gval.includes(',') && !gval.includes(':')) { + if (gkey !== '_id') { + gkey = `meta.${gkey}`; + } + grouping += `,_count_,${gkey},meta.${gval}`; + } + } + } + filter = grouping + ':' + (filter || ''); + } + } if (this._recurse) { filter = '_recurse_:' + (filter || ''); } @@ -348,6 +448,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; @@ -355,13 +503,13 @@ wrap(ItemListWidget, 'render', function (render) { const root = this.$el.closest('.g-hierarchy-widget'); if (!root.find('.li-item-list-filter').length) { let base = root.find('.g-hierarchy-actions-header .g-folder-header-buttons').eq(0); - let func = 'after'; + let func = 'before'; if (!base.length) { base = root.find('.g-hierarchy-breadcrumb-bar>.breadcrumb>div').eq(0); func = 'before'; } if (base.length) { - base[func]('Filter: Filter: