From 9f8f52e572992b29db34fa6708386ab9f9367e68 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 2 Jun 2023 09:21:08 -0400 Subject: [PATCH 1/8] Allow configuring which metadata can be added. --- .../web_client/views/configView.js | 8 +- .../web_client/views/index.js | 4 +- .../web_client/views/metadataWidget.js | 91 ++++++++++++ .../web_client/views/metadatumWidget.js | 130 ++++++++++++++++++ 4 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 girder/girder_large_image/web_client/views/metadataWidget.js create mode 100644 girder/girder_large_image/web_client/views/metadatumWidget.js diff --git a/girder/girder_large_image/web_client/views/configView.js b/girder/girder_large_image/web_client/views/configView.js index f6c0b42aa..078315957 100644 --- a/girder/girder_large_image/web_client/views/configView.js +++ b/girder/girder_large_image/web_client/views/configView.js @@ -247,15 +247,15 @@ var ConfigView = View.extend({ } if (ConfigView._lastliconfig === folderId && !reload) { if (callback) { - callback(ConfigView._liconfig); + callback(ConfigView._liconfig || {}); } return $.Deferred().resolve(ConfigView._liconfig); } if (ConfigView._liconfigSettingsRequest) { if (ConfigView._nextliconfig === folderId) { if (callback) { - ConfigView._liconfigSettingsRequest.done(() => { - callback(ConfigView._liconfig); + ConfigView._liconfigSettingsRequest.done((val) => { + callback(val || {}); }); } return ConfigView._liconfigSettingsRequest; @@ -269,7 +269,7 @@ var ConfigView = View.extend({ val = val || {}; ConfigView._lastliconfig = folderId; ConfigView._liconfigSettingsRequest = null; - ConfigView._liconfig = val || {}; + ConfigView._liconfig = val; if (callback) { callback(ConfigView._liconfig); } diff --git a/girder/girder_large_image/web_client/views/index.js b/girder/girder_large_image/web_client/views/index.js index 7dd0a17d7..51e84437b 100644 --- a/girder/girder_large_image/web_client/views/index.js +++ b/girder/girder_large_image/web_client/views/index.js @@ -2,10 +2,12 @@ import ConfigView from './configView'; import ImageViewerSelectWidget from './imageViewerSelectWidget'; import * as imageViewerWidget from './imageViewerWidget'; import ItemViewWidget from './itemViewWidget'; +import MetadataWidget from './metadataWidget'; export { ConfigView, ImageViewerSelectWidget, imageViewerWidget, - ItemViewWidget + ItemViewWidget, + MetadataWidget }; diff --git a/girder/girder_large_image/web_client/views/metadataWidget.js b/girder/girder_large_image/web_client/views/metadataWidget.js new file mode 100644 index 000000000..526b4a6d1 --- /dev/null +++ b/girder/girder_large_image/web_client/views/metadataWidget.js @@ -0,0 +1,91 @@ +import $ from 'jquery'; +import {wrap} from '@girder/core/utilities/PluginUtils'; +import MetadataWidget from '@girder/core/views/widgets/MetadataWidget'; + +import MetadatumWidget from './metadatumWidget'; + +import largeImageConfig from './configView'; + +wrap(MetadataWidget, 'initialize', function (initialize, settings) { + const result = initialize.call(this, settings); + if (this.item.get('_modelType') === 'item') { + largeImageConfig.getConfigFile(this.item.get('folderId')).done((val) => { + this._limetadata = (val || {}).itemMetadata; + if (this._limetadata) { + this.render(); + } + }); + } else { + this._limetadata = null; + } + return result; +}); + +wrap(MetadataWidget, 'render', function (render) { + render.call(this); + const menu = this.$el.find('ul.dropdown-menu.pull-right[role="menu"]'); + menu.remove('li.li-metadata-menuitem'); + if (this._limetadata) { + let lastentry; + this._limetadata.forEach((entry) => { + if (!entry || !entry.value) { + return; + } + const menuitem = $(''); + const link = $(''); + link.attr('metadata-key', entry.value); + link.text(entry.title || entry.value); + menuitem.append(link); + if (!lastentry) { + menu.prepend(menuitem); + } else { + lastentry.after(menuitem); + } + lastentry = menuitem; + // we should probably render based on a template + // add an option to take away existing entries + }); + if (lastentry) { + this.events['click .li-add-metadata'] = (evt) => { + const key = $(evt.target).attr('metadata-key'); + // if this key already exists, just go to editing it + var EditWidget = this.modes.simple.editor; + var value = ''; // default from config? + + var widget = new MetadatumWidget({ + className: 'g-widget-metadata-row editing', + mode: 'simple', + key: key, + value: value, + item: this.item, + fieldName: this.fieldName, + apiPath: this.apiPath, + accessLevel: this.accessLevel, + parentView: this, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }); + widget.$el.appendTo(this.$('.g-widget-metadata-container')); + + new EditWidget({ + item: this.item, + key: key, + value: value, + fieldName: this.fieldName, + apiPath: this.apiPath, + accessLevel: this.accessLevel, + newDatum: true, + parentView: widget, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }) + .render() + .$el.appendTo(widget.$el); + }; + this.delegateEvents(); + } + } + return this; +}); + +export default MetadataWidget; diff --git a/girder/girder_large_image/web_client/views/metadatumWidget.js b/girder/girder_large_image/web_client/views/metadatumWidget.js new file mode 100644 index 000000000..59a899ad8 --- /dev/null +++ b/girder/girder_large_image/web_client/views/metadatumWidget.js @@ -0,0 +1,130 @@ +import _ from 'underscore'; + +import View from '@girder/core/views/View'; +import {AccessType} from '@girder/core/constants'; +import events from '@girder/core/events'; + +var MetadatumWidget = View.extend({ + className: 'g-widget-metadata-row', + + events: { + 'click .g-widget-metadata-edit-button': 'editMetadata' + }, + + initialize: function (settings) { + if (!_.has(this.parentView.modes, settings.mode)) { + throw new Error('Unsupported metadatum mode ' + settings.mode + ' detected.'); + } + + this.mode = settings.mode; + this.key = settings.key; + this.value = settings.value; + this.accessLevel = settings.accessLevel; + this.parentView = settings.parentView; + this.fieldName = settings.fieldName; + this.apiPath = settings.apiPath; + this.onMetadataEdited = settings.onMetadataEdited; + this.onMetadataAdded = settings.onMetadataAdded; + }, + + _validate: function (from, to, value) { + var newMode = this.parentView.modes[to]; + + if (_.has(newMode, 'validation') && + _.has(newMode.validation, 'from') && + _.has(newMode.validation.from, from)) { + var validate = newMode.validation.from[from][0]; + var msg = newMode.validation.from[from][1]; + + if (!validate(value)) { + events.trigger('g:alert', { + text: msg, + type: 'warning' + }); + return false; + } + } + + return true; + }, + + // @todo too much duplication with editMetadata + toggleEditor: function (event, newEditorMode, existingEditor, overrides) { + var fromEditorMode = 'simple'; + var newValue = (overrides || {}).value || existingEditor.$el.attr('g-value'); + if (!this._validate(fromEditorMode, newEditorMode, newValue)) { + return; + } + + var row = existingEditor.$el; + existingEditor.destroy(); + row.addClass('editing').empty(); + + var opts = _.extend({ + el: row, + item: this.parentView.item, + key: row.attr('g-key'), + value: row.attr('g-value'), + accessLevel: this.accessLevel, + newDatum: false, + parentView: this, + fieldName: this.fieldName, + apiPath: this.apiPath, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }, overrides || {}); + + this.parentView.modes[newEditorMode].editor(opts).render(); + }, + + editMetadata: function (event) { + this.$el.addClass('editing'); + this.$el.empty(); + + var opts = { + item: this.parentView.item, + key: this.$el.attr('g-key'), + value: this.$el.attr('g-value'), + accessLevel: this.accessLevel, + newDatum: false, + parentView: this, + fieldName: this.fieldName, + apiPath: this.apiPath, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }; + + // If they're trying to open false, null, 6, etc which are not stored as strings + if (this.mode === 'json') { + try { + var jsonValue = JSON.parse(this.$el.attr('g-value')); + + if (jsonValue !== undefined && !_.isObject(jsonValue)) { + opts.value = jsonValue; + } + } catch (e) {} + } + + this.parentView.modes[this.mode].editor(opts) + .render() + .$el.appendTo(this.$el); + }, + + render: function () { + this.$el.attr({ + 'g-key': this.key, + 'g-value': _.bind(this.parentView.modes[this.mode].displayValue, this)() + }).empty(); + + this.$el.html(this.parentView.modes[this.mode].template({ + key: this.key, + value: _.bind(this.parentView.modes[this.mode].displayValue, this)(), + accessLevel: this.accessLevel, + AccessType: AccessType + })); + + return this; + } +}); + +export default MetadatumWidget; From 0eba89bd845ac5a79cb3092e4324b7320e23dc46 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 9 Jun 2023 09:47:39 -0400 Subject: [PATCH 2/8] Duplicate Girder's metadata widget so we can wrap subcomponents. Also, add the ability to have configurable metadata. --- .../web_client/templates/metadataWidget.pug | 22 + .../web_client/views/index.js | 2 +- .../web_client/views/metadataWidget.js | 486 +++++++++++++++--- .../web_client/views/metadatumWidget.js | 130 ----- 4 files changed, 443 insertions(+), 197 deletions(-) create mode 100644 girder/girder_large_image/web_client/templates/metadataWidget.pug delete mode 100644 girder/girder_large_image/web_client/views/metadatumWidget.js diff --git a/girder/girder_large_image/web_client/templates/metadataWidget.pug b/girder/girder_large_image/web_client/templates/metadataWidget.pug new file mode 100644 index 000000000..1e364e452 --- /dev/null +++ b/girder/girder_large_image/web_client/templates/metadataWidget.pug @@ -0,0 +1,22 @@ +if (accessLevel >= AccessType.WRITE) + .btn-group.pull-right + button.g-widget-metadata-add-button.btn.btn-sm.btn-primary.btn-default.dropdown-toggle(data-toggle="dropdown", title="Add Metadata") + i.icon-plus + ul.dropdown-menu.pull-right(role="menu") + if limetadata + for entry in limetadata + if entry && entry.value + li.li-metadata-menuitem(role="presentation") + a.li-add-metadata(metadata-key=entry.value) + = entry.title || entry.value + li(role="presentation") + a.g-add-simple-metadata(role="menuitem") + | Simple + li(role="presentation") + a.g-add-json-metadata + | JSON + +.g-widget-metadata-header + i.icon-tags + | #{title} +.g-widget-metadata-container diff --git a/girder/girder_large_image/web_client/views/index.js b/girder/girder_large_image/web_client/views/index.js index 51e84437b..ca6ffd264 100644 --- a/girder/girder_large_image/web_client/views/index.js +++ b/girder/girder_large_image/web_client/views/index.js @@ -2,7 +2,7 @@ import ConfigView from './configView'; import ImageViewerSelectWidget from './imageViewerSelectWidget'; import * as imageViewerWidget from './imageViewerWidget'; import ItemViewWidget from './itemViewWidget'; -import MetadataWidget from './metadataWidget'; +import * as MetadataWidget from './metadataWidget'; export { ConfigView, diff --git a/girder/girder_large_image/web_client/views/metadataWidget.js b/girder/girder_large_image/web_client/views/metadataWidget.js index 526b4a6d1..b3fead1e3 100644 --- a/girder/girder_large_image/web_client/views/metadataWidget.js +++ b/girder/girder_large_image/web_client/views/metadataWidget.js @@ -1,11 +1,346 @@ import $ from 'jquery'; import {wrap} from '@girder/core/utilities/PluginUtils'; -import MetadataWidget from '@girder/core/views/widgets/MetadataWidget'; -import MetadatumWidget from './metadatumWidget'; +import _ from 'underscore'; + +import View from '@girder/core/views/View'; +import {AccessType} from '@girder/core/constants'; +import {confirm} from '@girder/core/dialog'; +import events from '@girder/core/events'; +import {localeSort} from '@girder/core/misc'; + +import JsonMetadatumEditWidgetTemplate from '@girder/core/templates/widgets/jsonMetadatumEditWidget.pug'; + +import MetadatumEditWidgetTemplate from '@girder/core/templates/widgets/metadatumEditWidget.pug'; + +import '@girder/core/stylesheets/widgets/metadataWidget.styl'; + +import JSONEditor from 'jsoneditor/dist/jsoneditor.js'; // can't 'jsoneditor' +import 'jsoneditor/dist/jsoneditor.css'; + +import 'bootstrap/js/dropdown'; +import MetadataWidget from '@girder/core/views/widgets/MetadataWidget'; + +import MetadataWidgetTemplate from '../templates/metadataWidget.pug'; import largeImageConfig from './configView'; +var MetadatumWidget = View.extend({ + className: 'g-widget-metadata-row', + + events: { + 'click .g-widget-metadata-edit-button': 'editMetadata' + }, + + initialize: function (settings) { + if (!_.has(this.parentView.modes, settings.mode)) { + throw new Error('Unsupported metadatum mode ' + settings.mode + ' detected.'); + } + + this.mode = settings.mode; + this.key = settings.key; + this.value = settings.value; + this.accessLevel = settings.accessLevel; + this.parentView = settings.parentView; + this.fieldName = settings.fieldName; + this.apiPath = settings.apiPath; + this.onMetadataEdited = settings.onMetadataEdited; + this.onMetadataAdded = settings.onMetadataAdded; + }, + + _validate: function (from, to, value) { + var newMode = this.parentView.modes[to]; + + if (_.has(newMode, 'validation') && + _.has(newMode.validation, 'from') && + _.has(newMode.validation.from, from)) { + var validate = newMode.validation.from[from][0]; + var msg = newMode.validation.from[from][1]; + + if (!validate(value)) { + events.trigger('g:alert', { + text: msg, + type: 'warning' + }); + return false; + } + } + + return true; + }, + + // @todo too much duplication with editMetadata + toggleEditor: function (event, newEditorMode, existingEditor, overrides) { + var fromEditorMode = (existingEditor instanceof JsonMetadatumEditWidget) ? 'json' : 'simple'; + var newValue = (overrides || {}).value || existingEditor.$el.attr('g-value'); + if (!this._validate(fromEditorMode, newEditorMode, newValue)) { + return; + } + + var row = existingEditor.$el; + existingEditor.destroy(); + row.addClass('editing').empty(); + + var opts = _.extend({ + el: row, + item: this.parentView.item, + key: row.attr('g-key'), + value: row.attr('g-value'), + accessLevel: this.accessLevel, + newDatum: false, + parentView: this, + fieldName: this.fieldName, + apiPath: this.apiPath, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }, overrides || {}); + + this.parentView.modes[newEditorMode].editor(opts).render(); + }, + + editMetadata: function (event) { + this.$el.addClass('editing'); + this.$el.empty(); + + var opts = { + item: this.parentView.item, + key: this.$el.attr('g-key'), + value: this.$el.attr('g-value'), + accessLevel: this.accessLevel, + newDatum: false, + parentView: this, + fieldName: this.fieldName, + apiPath: this.apiPath, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }; + + // If they're trying to open false, null, 6, etc which are not stored as strings + if (this.mode === 'json') { + try { + var jsonValue = JSON.parse(this.$el.attr('g-value')); + + if (jsonValue !== undefined && !_.isObject(jsonValue)) { + opts.value = jsonValue; + } + } catch (e) {} + } + + this.parentView.modes[this.mode].editor(opts) + .render() + .$el.appendTo(this.$el); + }, + + render: function () { + this.$el.attr({ + 'g-key': this.key, + 'g-value': _.bind(this.parentView.modes[this.mode].displayValue, this)() + }).empty(); + + this.$el.html(this.parentView.modes[this.mode].template({ + key: this.key, + value: _.bind(this.parentView.modes[this.mode].displayValue, this)(), + accessLevel: this.accessLevel, + AccessType: AccessType + })); + + return this; + } +}); + +var MetadatumEditWidget = View.extend({ + events: { + 'click .g-widget-metadata-cancel-button': 'cancelEdit', + 'click .g-widget-metadata-save-button': 'save', + 'click .g-widget-metadata-delete-button': 'deleteMetadatum', + 'click .g-widget-metadata-toggle-button': function (event) { + var editorType; + // @todo modal + // in the future this event will have the new editorType (assuming a dropdown) + if (this instanceof JsonMetadatumEditWidget) { + editorType = 'simple'; + } else { + editorType = 'json'; + } + + this.parentView.toggleEditor(event, editorType, this, { + // Save state before toggling editor + key: this.$el.find('.g-widget-metadata-key-input').val(), + value: this.getCurrentValue() + }); + } + }, + + initialize: function (settings) { + this.item = settings.item; + this.key = settings.key || ''; + this.fieldName = settings.fieldName || 'meta'; + this.value = (settings.value !== undefined) ? settings.value : ''; + this.accessLevel = settings.accessLevel; + this.newDatum = settings.newDatum; + this.fieldName = settings.fieldName; + this.apiPath = settings.apiPath; + this.onMetadataEdited = settings.onMetadataEdited; + this.onMetadataAdded = settings.onMetadataAdded; + }, + + editTemplate: MetadatumEditWidgetTemplate, + + getCurrentValue: function () { + return this.$el.find('.g-widget-metadata-value-input').val(); + }, + + deleteMetadatum: function (event) { + event.stopImmediatePropagation(); + const target = $(event.currentTarget); + var metadataList = target.parent().parent(); + var params = { + text: 'Are you sure you want to delete the metadatum ' + + _.escape(this.key) + '?', + escapedHtml: true, + yesText: 'Delete', + confirmCallback: () => { + this.item.removeMetadata(this.key, function () { + metadataList.remove(); + }, null, { + field: this.fieldName, + path: this.apiPath + }); + } + }; + confirm(params); + }, + + cancelEdit: function (event) { + event.stopImmediatePropagation(); + const target = $(event.currentTarget); + var curRow = target.parent().parent(); + if (this.newDatum) { + curRow.remove(); + } else { + this.parentView.render(); + } + }, + + save: function (event, value) { + event.stopImmediatePropagation(); + const target = $(event.currentTarget); + var curRow = target.parent(), + tempKey = curRow.find('.g-widget-metadata-key-input').val(), + tempValue = (value !== undefined) ? value : curRow.find('.g-widget-metadata-value-input').val(); + + if (this.newDatum && tempKey === '') { + events.trigger('g:alert', { + text: 'A key is required for all metadata.', + type: 'warning' + }); + return; + } + + var saveCallback = () => { + this.key = tempKey; + this.value = tempValue; + + this.parentView.key = this.key; + this.parentView.value = this.value; + + if (this instanceof JsonMetadatumEditWidget) { + this.parentView.mode = 'json'; + } else { + this.parentView.mode = 'simple'; + } + + this.parentView.render(); + + this.newDatum = false; + }; + + var errorCallback = function (out) { + events.trigger('g:alert', { + text: out.message, + type: 'danger' + }); + }; + + if (this.newDatum) { + if (this.onMetadataAdded) { + this.onMetadataAdded(tempKey, tempValue, saveCallback, errorCallback); + } else { + this.item.addMetadata(tempKey, tempValue, saveCallback, errorCallback, { + field: this.fieldName, + path: this.apiPath + }); + } + } else { + if (this.onMetadataEdited) { + this.onMetadataEdited(tempKey, this.key, tempValue, saveCallback, errorCallback); + } else { + this.item.editMetadata(tempKey, this.key, tempValue, saveCallback, errorCallback, { + field: this.fieldName, + path: this.apiPath + }); + } + } + }, + + render: function () { + this.$el.html(this.editTemplate({ + item: this.item, + key: this.key, + value: this.value, + accessLevel: this.accessLevel, + newDatum: this.newDatum, + AccessType: AccessType + })); + this.$el.find('.g-widget-metadata-key-input').trigger('focus'); + + return this; + } +}); + +var JsonMetadatumEditWidget = MetadatumEditWidget.extend({ + editTemplate: JsonMetadatumEditWidgetTemplate, + + getCurrentValue: function () { + return this.editor.getText(); + }, + + save: function (event) { + try { + MetadatumEditWidget.prototype.save.call( + this, event, this.editor.get()); + } catch (err) { + events.trigger('g:alert', { + text: 'The field contains invalid JSON and can not be saved.', + type: 'warning' + }); + } + }, + + render: function () { + MetadatumEditWidget.prototype.render.apply(this, arguments); + + const jsonEditorEl = this.$el.find('.g-json-editor'); + this.editor = new JSONEditor(jsonEditorEl[0], { + mode: 'tree', + modes: ['code', 'tree'], + onError: () => { + events.trigger('g:alert', { + text: 'The field contains invalid JSON and can not be viewed in Tree Mode.', + type: 'warning' + }); + } + }); + + if (this.value !== undefined) { + this.editor.setText(JSON.stringify(this.value)); + this.editor.expandAll(); + } + + return this; + } +}); + wrap(MetadataWidget, 'initialize', function (initialize, settings) { const result = initialize.call(this, settings); if (this.item.get('_modelType') === 'item') { @@ -22,70 +357,89 @@ wrap(MetadataWidget, 'initialize', function (initialize, settings) { }); wrap(MetadataWidget, 'render', function (render) { - render.call(this); - const menu = this.$el.find('ul.dropdown-menu.pull-right[role="menu"]'); - menu.remove('li.li-metadata-menuitem'); - if (this._limetadata) { - let lastentry; - this._limetadata.forEach((entry) => { - if (!entry || !entry.value) { - return; - } - const menuitem = $(''); - const link = $(''); - link.attr('metadata-key', entry.value); - link.text(entry.title || entry.value); - menuitem.append(link); - if (!lastentry) { - menu.prepend(menuitem); - } else { - lastentry.after(menuitem); - } - lastentry = menuitem; - // we should probably render based on a template - // add an option to take away existing entries - }); - if (lastentry) { - this.events['click .li-add-metadata'] = (evt) => { - const key = $(evt.target).attr('metadata-key'); - // if this key already exists, just go to editing it - var EditWidget = this.modes.simple.editor; - var value = ''; // default from config? - - var widget = new MetadatumWidget({ - className: 'g-widget-metadata-row editing', - mode: 'simple', - key: key, - value: value, - item: this.item, - fieldName: this.fieldName, - apiPath: this.apiPath, - accessLevel: this.accessLevel, - parentView: this, - onMetadataEdited: this.onMetadataEdited, - onMetadataAdded: this.onMetadataAdded - }); - widget.$el.appendTo(this.$('.g-widget-metadata-container')); - - new EditWidget({ - item: this.item, - key: key, - value: value, - fieldName: this.fieldName, - apiPath: this.apiPath, - accessLevel: this.accessLevel, - newDatum: true, - parentView: widget, - onMetadataEdited: this.onMetadataEdited, - onMetadataAdded: this.onMetadataAdded - }) - .render() - .$el.appendTo(widget.$el); - }; - this.delegateEvents(); - } - } + var metaDict = this.item.get(this.fieldName) || {}; + var metaKeys = Object.keys(metaDict); + metaKeys.sort(localeSort); + + // Metadata header + this.$el.html((this.MetadataWidgetTemplate || MetadataWidgetTemplate)({ + item: this.item, + title: this.title, + accessLevel: this.accessLevel, + AccessType: AccessType, + limetadata: this._limetadata + })); + + // Append each metadatum + _.each(metaKeys, function (metaKey) { + this.$el.find('.g-widget-metadata-container').append(new MetadatumWidget({ + mode: this.getModeFromValue(metaDict[metaKey]), + key: metaKey, + value: metaDict[metaKey], + accessLevel: this.accessLevel, + parentView: this, + fieldName: this.fieldName, + apiPath: this.apiPath, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }).render().$el); + }, this); + return this; }); -export default MetadataWidget; +MetadataWidget.prototype.modes.simple.editor = (args) => new MetadatumEditWidget(args); +MetadataWidget.prototype.modes.json.editor = (args) => { + if (args.value !== undefined) { + args.value = JSON.parse(args.value); + } + return new JsonMetadatumEditWidget(args); +}; + +MetadataWidget.prototype.events['click .li-add-metadata'] = function (evt) { + this.addMetadataByKey(evt); +}; + +MetadataWidget.prototype.addMetadataByKey = function (evt) { + const key = $(evt.target).attr('metadata-key'); + // if this key already exists, just go to editing it + var EditWidget = this.modes.simple.editor; + var value = ''; // default from config? + + var widget = new MetadatumWidget({ + className: 'g-widget-metadata-row editing', + mode: 'simple', + key: key, + value: value, + item: this.item, + fieldName: this.fieldName, + apiPath: this.apiPath, + accessLevel: this.accessLevel, + parentView: this, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }); + widget.$el.appendTo(this.$('.g-widget-metadata-container')); + + new EditWidget({ + item: this.item, + key: key, + value: value, + fieldName: this.fieldName, + apiPath: this.apiPath, + accessLevel: this.accessLevel, + newDatum: true, + parentView: widget, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }) + .render() + .$el.appendTo(widget.$el); +}; + +export default { + MetadataWidget, + MetadatumWidget, + MetadatumEditWidget, + JsonMetadatumEditWidget +}; diff --git a/girder/girder_large_image/web_client/views/metadatumWidget.js b/girder/girder_large_image/web_client/views/metadatumWidget.js deleted file mode 100644 index 59a899ad8..000000000 --- a/girder/girder_large_image/web_client/views/metadatumWidget.js +++ /dev/null @@ -1,130 +0,0 @@ -import _ from 'underscore'; - -import View from '@girder/core/views/View'; -import {AccessType} from '@girder/core/constants'; -import events from '@girder/core/events'; - -var MetadatumWidget = View.extend({ - className: 'g-widget-metadata-row', - - events: { - 'click .g-widget-metadata-edit-button': 'editMetadata' - }, - - initialize: function (settings) { - if (!_.has(this.parentView.modes, settings.mode)) { - throw new Error('Unsupported metadatum mode ' + settings.mode + ' detected.'); - } - - this.mode = settings.mode; - this.key = settings.key; - this.value = settings.value; - this.accessLevel = settings.accessLevel; - this.parentView = settings.parentView; - this.fieldName = settings.fieldName; - this.apiPath = settings.apiPath; - this.onMetadataEdited = settings.onMetadataEdited; - this.onMetadataAdded = settings.onMetadataAdded; - }, - - _validate: function (from, to, value) { - var newMode = this.parentView.modes[to]; - - if (_.has(newMode, 'validation') && - _.has(newMode.validation, 'from') && - _.has(newMode.validation.from, from)) { - var validate = newMode.validation.from[from][0]; - var msg = newMode.validation.from[from][1]; - - if (!validate(value)) { - events.trigger('g:alert', { - text: msg, - type: 'warning' - }); - return false; - } - } - - return true; - }, - - // @todo too much duplication with editMetadata - toggleEditor: function (event, newEditorMode, existingEditor, overrides) { - var fromEditorMode = 'simple'; - var newValue = (overrides || {}).value || existingEditor.$el.attr('g-value'); - if (!this._validate(fromEditorMode, newEditorMode, newValue)) { - return; - } - - var row = existingEditor.$el; - existingEditor.destroy(); - row.addClass('editing').empty(); - - var opts = _.extend({ - el: row, - item: this.parentView.item, - key: row.attr('g-key'), - value: row.attr('g-value'), - accessLevel: this.accessLevel, - newDatum: false, - parentView: this, - fieldName: this.fieldName, - apiPath: this.apiPath, - onMetadataEdited: this.onMetadataEdited, - onMetadataAdded: this.onMetadataAdded - }, overrides || {}); - - this.parentView.modes[newEditorMode].editor(opts).render(); - }, - - editMetadata: function (event) { - this.$el.addClass('editing'); - this.$el.empty(); - - var opts = { - item: this.parentView.item, - key: this.$el.attr('g-key'), - value: this.$el.attr('g-value'), - accessLevel: this.accessLevel, - newDatum: false, - parentView: this, - fieldName: this.fieldName, - apiPath: this.apiPath, - onMetadataEdited: this.onMetadataEdited, - onMetadataAdded: this.onMetadataAdded - }; - - // If they're trying to open false, null, 6, etc which are not stored as strings - if (this.mode === 'json') { - try { - var jsonValue = JSON.parse(this.$el.attr('g-value')); - - if (jsonValue !== undefined && !_.isObject(jsonValue)) { - opts.value = jsonValue; - } - } catch (e) {} - } - - this.parentView.modes[this.mode].editor(opts) - .render() - .$el.appendTo(this.$el); - }, - - render: function () { - this.$el.attr({ - 'g-key': this.key, - 'g-value': _.bind(this.parentView.modes[this.mode].displayValue, this)() - }).empty(); - - this.$el.html(this.parentView.modes[this.mode].template({ - key: this.key, - value: _.bind(this.parentView.modes[this.mode].displayValue, this)(), - accessLevel: this.accessLevel, - AccessType: AccessType - })); - - return this; - } -}); - -export default MetadatumWidget; From 04216c82f312f8bf13531e24f78fa686f9736696 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 9 Jun 2023 10:05:18 -0400 Subject: [PATCH 3/8] Some changes to help downstream consumers of the metadata widget --- .../web_client/views/metadataWidget.js | 72 +++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/girder/girder_large_image/web_client/views/metadataWidget.js b/girder/girder_large_image/web_client/views/metadataWidget.js index b3fead1e3..b8c3ea3b4 100644 --- a/girder/girder_large_image/web_client/views/metadataWidget.js +++ b/girder/girder_large_image/web_client/views/metadataWidget.js @@ -25,6 +25,20 @@ import MetadataWidget from '@girder/core/views/widgets/MetadataWidget'; import MetadataWidgetTemplate from '../templates/metadataWidget.pug'; import largeImageConfig from './configView'; +function getMetadataRecord(item, fieldName) { + if (item[fieldName]) { + return item[fieldName]; + } + let meta = item.attributes; + fieldName.split('.').forEach((part) => { + if (!meta[part]) { + meta[part] = {}; + } + meta = meta[part]; + }); + return meta; +} + var MetadatumWidget = View.extend({ className: 'g-widget-metadata-row', @@ -44,6 +58,7 @@ var MetadatumWidget = View.extend({ this.parentView = settings.parentView; this.fieldName = settings.fieldName; this.apiPath = settings.apiPath; + this.noSave = settings.noSave; this.onMetadataEdited = settings.onMetadataEdited; this.onMetadataAdded = settings.onMetadataAdded; }, @@ -91,6 +106,7 @@ var MetadatumWidget = View.extend({ parentView: this, fieldName: this.fieldName, apiPath: this.apiPath, + noSave: this.noSave, onMetadataEdited: this.onMetadataEdited, onMetadataAdded: this.onMetadataAdded }, overrides || {}); @@ -111,6 +127,7 @@ var MetadatumWidget = View.extend({ parentView: this, fieldName: this.fieldName, apiPath: this.apiPath, + noSave: this.noSave, onMetadataEdited: this.onMetadataEdited, onMetadataAdded: this.onMetadataAdded }; @@ -141,7 +158,7 @@ var MetadatumWidget = View.extend({ key: this.key, value: _.bind(this.parentView.modes[this.mode].displayValue, this)(), accessLevel: this.accessLevel, - AccessType: AccessType + AccessType })); return this; @@ -168,6 +185,7 @@ var MetadatumEditWidget = View.extend({ key: this.$el.find('.g-widget-metadata-key-input').val(), value: this.getCurrentValue() }); + return false; } }, @@ -180,6 +198,7 @@ var MetadatumEditWidget = View.extend({ this.newDatum = settings.newDatum; this.fieldName = settings.fieldName; this.apiPath = settings.apiPath; + this.noSave = settings.noSave; this.onMetadataEdited = settings.onMetadataEdited; this.onMetadataAdded = settings.onMetadataAdded; }, @@ -194,6 +213,11 @@ var MetadatumEditWidget = View.extend({ event.stopImmediatePropagation(); const target = $(event.currentTarget); var metadataList = target.parent().parent(); + if (this.noSave) { + delete getMetadataRecord(this.item, this.fieldName)[this.key]; + metadataList.remove(); + return; + } var params = { text: 'Are you sure you want to delete the metadatum ' + _.escape(this.key) + '?', @@ -202,6 +226,7 @@ var MetadatumEditWidget = View.extend({ confirmCallback: () => { this.item.removeMetadata(this.key, function () { metadataList.remove(); + // TODO: trigger an event? }, null, { field: this.fieldName, path: this.apiPath @@ -226,7 +251,7 @@ var MetadatumEditWidget = View.extend({ event.stopImmediatePropagation(); const target = $(event.currentTarget); var curRow = target.parent(), - tempKey = curRow.find('.g-widget-metadata-key-input').val(), + tempKey = curRow.find('.g-widget-metadata-key-input').val().trim(), tempValue = (value !== undefined) ? value : curRow.find('.g-widget-metadata-value-input').val(); if (this.newDatum && tempKey === '') { @@ -234,7 +259,7 @@ var MetadatumEditWidget = View.extend({ text: 'A key is required for all metadata.', type: 'warning' }); - return; + return false; } var saveCallback = () => { @@ -249,7 +274,7 @@ var MetadatumEditWidget = View.extend({ } else { this.parentView.mode = 'simple'; } - + // TODO: trigger an event this.parentView.render(); this.newDatum = false; @@ -266,6 +291,18 @@ var MetadatumEditWidget = View.extend({ if (this.onMetadataAdded) { this.onMetadataAdded(tempKey, tempValue, saveCallback, errorCallback); } else { + if (this.noSave) { + if (getMetadataRecord(this.item, this.fieldName)[tempKey] !== undefined) { + events.trigger('g:alert', { + text: tempKey + ' is already a metadata key', + type: 'warning' + }); + return false; + } + getMetadataRecord(this.item, this.fieldName)[tempKey] = tempValue; + // TODO: this.parentView.parentView.render(); + return; + } this.item.addMetadata(tempKey, tempValue, saveCallback, errorCallback, { field: this.fieldName, path: this.apiPath @@ -275,6 +312,20 @@ var MetadatumEditWidget = View.extend({ if (this.onMetadataEdited) { this.onMetadataEdited(tempKey, this.key, tempValue, saveCallback, errorCallback); } else { + if (this.noSave) { + tempKey = tempKey === '' ? this.key : tempKey; + if (tempKey !== this.key && getMetadataRecord(this.item, this.fieldName)[tempKey] !== undefined) { + events.trigger('g:alert', { + text: tempKey + ' is already a metadata key', + type: 'warning' + }); + return false; + } + delete getMetadataRecord(this.item, this.fieldName)[this.key]; + getMetadataRecord(this.item, this.fieldName)[tempKey] = tempValue; + // TODO: this.parentView.parentView.render(); + return; + } this.item.editMetadata(tempKey, this.key, tempValue, saveCallback, errorCallback, { field: this.fieldName, path: this.apiPath @@ -290,7 +341,7 @@ var MetadatumEditWidget = View.extend({ value: this.value, accessLevel: this.accessLevel, newDatum: this.newDatum, - AccessType: AccessType + AccessType })); this.$el.find('.g-widget-metadata-key-input').trigger('focus'); @@ -314,6 +365,7 @@ var JsonMetadatumEditWidget = MetadatumEditWidget.extend({ text: 'The field contains invalid JSON and can not be saved.', type: 'warning' }); + return false; } }, @@ -343,6 +395,7 @@ var JsonMetadatumEditWidget = MetadatumEditWidget.extend({ wrap(MetadataWidget, 'initialize', function (initialize, settings) { const result = initialize.call(this, settings); + this.noSave = settings.noSave; if (this.item.get('_modelType') === 'item') { largeImageConfig.getConfigFile(this.item.get('folderId')).done((val) => { this._limetadata = (val || {}).itemMetadata; @@ -388,6 +441,15 @@ wrap(MetadataWidget, 'render', function (render) { return this; }); +wrap(MetadataWidget, 'setItem', function (setItem, item) { + setItem.call(this, item); + this.item.on('g:changed', function () { + this.render(); + }, this); + this.render(); + return this; +}); + MetadataWidget.prototype.modes.simple.editor = (args) => new MetadatumEditWidget(args); MetadataWidget.prototype.modes.json.editor = (args) => { if (args.value !== undefined) { From 5a3659a48216204983c94d211080870c77729c3c Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 9 Jun 2023 11:25:55 -0400 Subject: [PATCH 4/8] Some config based metadata editing --- .../stylesheets/metadataWidget.styl | 5 + .../templates/metadatumEditWidget.pug | 30 +++++ .../web_client/views/metadataWidget.js | 114 ++++++++++++++++-- 3 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 girder/girder_large_image/web_client/stylesheets/metadataWidget.styl create mode 100644 girder/girder_large_image/web_client/templates/metadatumEditWidget.pug diff --git a/girder/girder_large_image/web_client/stylesheets/metadataWidget.styl b/girder/girder_large_image/web_client/stylesheets/metadataWidget.styl new file mode 100644 index 000000000..9592ee90b --- /dev/null +++ b/girder/girder_large_image/web_client/stylesheets/metadataWidget.styl @@ -0,0 +1,5 @@ +.g-widget-metadata-key-edit + font-weight bold + +.g-widget-metadata-row.editing .g-widget-metadata-value-input.g-widget-metadata-lientry + height inherit diff --git a/girder/girder_large_image/web_client/templates/metadatumEditWidget.pug b/girder/girder_large_image/web_client/templates/metadatumEditWidget.pug new file mode 100644 index 000000000..556a67be6 --- /dev/null +++ b/girder/girder_large_image/web_client/templates/metadatumEditWidget.pug @@ -0,0 +1,30 @@ +if !lientry + input.input-sm.form-control.g-widget-metadata-key-input(type="text", value=key, placeholder="Key") +else + span.g-widget-metadata-key-edit.g-widget-metadata-key-input(key=key) + = lientry.title || key + +if !lientry + - var rows = value.length <= 40 ? 1 : (value.length <= 100 ? 3 : 5) + textarea.input-sm.form-control.g-widget-metadata-value-input(placeholder="Value", rows=rows) + = value +else + if lientry.enum + select.input-sm.form-control.g-widget-metadata-value-input.g-widget-metadata-lientry(title=lientry.description) + for enumval in lientry.enum + option(value=enumval, selected=enumval === value ? 'selected' : null) + = enumval + else + input.input-sm.form-control.g-widget-metadata-value-input.g-widget-metadata-lientry(placeholder=lientry.description || "Value", value=value, title=lientry.description) + +button.btn.btn-sm.btn-warning.g-widget-metadata-cancel-button(title="Cancel") + i.icon-cancel +button.btn.btn-sm.btn-primary.g-widget-metadata-save-button(title="Accept") + i.icon-ok +if !newDatum + if !lientry + button.btn.btn-sm.btn-primary.g-widget-metadata-toggle-button(title="Convert to JSON") + i.icon-cog + if !lientry || !lientry.required + button.btn.btn-sm.btn-danger.g-widget-metadata-delete-button(title="Delete") + i.icon-trash diff --git a/girder/girder_large_image/web_client/views/metadataWidget.js b/girder/girder_large_image/web_client/views/metadataWidget.js index b8c3ea3b4..b5cc71633 100644 --- a/girder/girder_large_image/web_client/views/metadataWidget.js +++ b/girder/girder_large_image/web_client/views/metadataWidget.js @@ -11,7 +11,7 @@ import {localeSort} from '@girder/core/misc'; import JsonMetadatumEditWidgetTemplate from '@girder/core/templates/widgets/jsonMetadatumEditWidget.pug'; -import MetadatumEditWidgetTemplate from '@girder/core/templates/widgets/metadatumEditWidget.pug'; +import MetadatumViewTemplate from '@girder/core/templates/widgets/metadatumView.pug'; import '@girder/core/stylesheets/widgets/metadataWidget.styl'; @@ -22,7 +22,9 @@ import 'bootstrap/js/dropdown'; import MetadataWidget from '@girder/core/views/widgets/MetadataWidget'; +import '../stylesheets/metadataWidget.styl'; import MetadataWidgetTemplate from '../templates/metadataWidget.pug'; +import MetadatumEditWidgetTemplate from '../templates/metadatumEditWidget.pug'; import largeImageConfig from './configView'; function getMetadataRecord(item, fieldName) { @@ -39,6 +41,20 @@ function getMetadataRecord(item, fieldName) { return meta; } +function liMetadataKeyEntry(limetadata, key) { + if (!limetadata || !key) { + return; + } + let result; + limetadata.forEach((entry, idx) => { + if (entry.value === key) { + result = entry; + result.idx = idx; + } + }); + return result; +} + var MetadatumWidget = View.extend({ className: 'g-widget-metadata-row', @@ -59,6 +75,7 @@ var MetadatumWidget = View.extend({ this.fieldName = settings.fieldName; this.apiPath = settings.apiPath; this.noSave = settings.noSave; + this.limetadata = settings.limetadata; this.onMetadataEdited = settings.onMetadataEdited; this.onMetadataAdded = settings.onMetadataAdded; }, @@ -107,6 +124,7 @@ var MetadatumWidget = View.extend({ fieldName: this.fieldName, apiPath: this.apiPath, noSave: this.noSave, + limetadata: this.limetadata, onMetadataEdited: this.onMetadataEdited, onMetadataAdded: this.onMetadataAdded }, overrides || {}); @@ -128,6 +146,7 @@ var MetadatumWidget = View.extend({ fieldName: this.fieldName, apiPath: this.apiPath, noSave: this.noSave, + limetadata: this.limetadata, onMetadataEdited: this.onMetadataEdited, onMetadataAdded: this.onMetadataAdded }; @@ -153,9 +172,9 @@ var MetadatumWidget = View.extend({ 'g-key': this.key, 'g-value': _.bind(this.parentView.modes[this.mode].displayValue, this)() }).empty(); - + this.$el.removeClass('editing'); this.$el.html(this.parentView.modes[this.mode].template({ - key: this.key, + key: this.mode === 'key' && liMetadataKeyEntry(this.limetadata, this.key) ? liMetadataKeyEntry(this.limetadata, this.key).title || this.key : this.key, value: _.bind(this.parentView.modes[this.mode].displayValue, this)(), accessLevel: this.accessLevel, AccessType @@ -199,6 +218,7 @@ var MetadatumEditWidget = View.extend({ this.fieldName = settings.fieldName; this.apiPath = settings.apiPath; this.noSave = settings.noSave; + this.limetadata = settings.limetadata; this.onMetadataEdited = settings.onMetadataEdited; this.onMetadataAdded = settings.onMetadataAdded; }, @@ -251,7 +271,8 @@ var MetadatumEditWidget = View.extend({ event.stopImmediatePropagation(); const target = $(event.currentTarget); var curRow = target.parent(), - tempKey = curRow.find('.g-widget-metadata-key-input').val().trim(), + tempKey = curRow.find('.g-widget-metadata-key-input').val().trim() || curRow.find('.g-widget-metadata-key-input').attr('key'), + keyMode = curRow.find('.g-widget-metadata-key-input').attr('key'), tempValue = (value !== undefined) ? value : curRow.find('.g-widget-metadata-value-input').val(); if (this.newDatum && tempKey === '') { @@ -261,6 +282,37 @@ var MetadatumEditWidget = View.extend({ }); return false; } + const lientry = keyMode ? liMetadataKeyEntry(this.limetadata, this.key) : undefined; + if (keyMode && lientry) { + tempValue = tempValue.trim(); + } + if (lientry && lientry.regex && !(new RegExp(lientry.regex).exec(tempValue))) { + events.trigger('g:alert', { + text: 'The value does not match the required format.', + type: 'warning' + }); + return false; + } + if (lientry && (lientry.type === 'number' || lientry.type === 'integer')) { + if (!Number.isFinite(parseFloat(tempValue)) || (lientry.type === 'integer' && !Number.isInteger(parseFloat(tempValue)))) { + events.trigger('g:alert', { + text: `The value must be a ${lientry.type}.`, + type: 'warning' + }); + return false; + } + tempValue = parseFloat(tempValue); + if ((lientry.minimum !== undefined && tempValue < lientry.minimum) || + (lientry.exclusiveMinimum !== undefined && tempValue <= lientry.exclusiveMinimum) || + (lientry.maximum !== undefined && tempValue > lientry.maximum) || + (lientry.exclusiveMaximum !== undefined && tempValue >= lientry.exclusiveMaximum)) { + events.trigger('g:alert', { + text: 'The value is outside of the allowed range.', + type: 'warning' + }); + return false; + } + } var saveCallback = () => { this.key = tempKey; @@ -269,7 +321,9 @@ var MetadatumEditWidget = View.extend({ this.parentView.key = this.key; this.parentView.value = this.value; - if (this instanceof JsonMetadatumEditWidget) { + if (keyMode) { + this.parentView.mode = 'key'; + } else if (this instanceof JsonMetadatumEditWidget) { this.parentView.mode = 'json'; } else { this.parentView.mode = 'simple'; @@ -337,6 +391,7 @@ var MetadatumEditWidget = View.extend({ render: function () { this.$el.html(this.editTemplate({ item: this.item, + lientry: liMetadataKeyEntry(this.limetadata, this.key), key: this.key, value: this.value, accessLevel: this.accessLevel, @@ -413,6 +468,23 @@ wrap(MetadataWidget, 'render', function (render) { var metaDict = this.item.get(this.fieldName) || {}; var metaKeys = Object.keys(metaDict); metaKeys.sort(localeSort); + if (this._limetadata) { + const origOrder = metaKeys.slice(); + metaKeys.sort((a, b) => { + const aentry = liMetadataKeyEntry(this._limetadata, a); + const bentry = liMetadataKeyEntry(this._limetadata, b); + if (aentry && !bentry) { + return -1; + } + if (bentry && !aentry) { + return 1; + } + if (aentry && bentry) { + return aentry.idx - bentry.idx; + } + return origOrder.indexOf(a) - origOrder.indexOf(b); + }); + } // Metadata header this.$el.html((this.MetadataWidgetTemplate || MetadataWidgetTemplate)({ @@ -426,13 +498,14 @@ wrap(MetadataWidget, 'render', function (render) { // Append each metadatum _.each(metaKeys, function (metaKey) { this.$el.find('.g-widget-metadata-container').append(new MetadatumWidget({ - mode: this.getModeFromValue(metaDict[metaKey]), + mode: this.getModeFromValue(metaDict[metaKey], metaKey), key: metaKey, value: metaDict[metaKey], accessLevel: this.accessLevel, parentView: this, fieldName: this.fieldName, apiPath: this.apiPath, + limetadata: this._limetadata, onMetadataEdited: this.onMetadataEdited, onMetadataAdded: this.onMetadataAdded }).render().$el); @@ -457,20 +530,41 @@ MetadataWidget.prototype.modes.json.editor = (args) => { } return new JsonMetadatumEditWidget(args); }; +MetadataWidget.prototype.modes.key = { + editor: function (args) { + return new MetadatumEditWidget(args); + }, + displayValue: function () { + return this.value; + }, + template: MetadatumViewTemplate +}; MetadataWidget.prototype.events['click .li-add-metadata'] = function (evt) { this.addMetadataByKey(evt); }; +MetadataWidget.prototype.getModeFromValue = function (value, key) { + if (liMetadataKeyEntry(this._limetadata, key)) { + return 'key'; + } + return _.isString(value) ? 'simple' : 'json'; +}; + MetadataWidget.prototype.addMetadataByKey = function (evt) { const key = $(evt.target).attr('metadata-key'); // if this key already exists, just go to editing it - var EditWidget = this.modes.simple.editor; - var value = ''; // default from config? + if (this.$el.find(`.g-widget-metadata-row[g-key="${key}"]`).length) { + this.$el.find(`.g-widget-metadata-row[g-key="${key}"] button.g-widget-metadata-edit-button`).click(); + return false; + } + var EditWidget = this.modes.key.editor; + var lientry = liMetadataKeyEntry(this._limetadata, key) || {}; + var value = lientry.default ? lientry.default : ''; var widget = new MetadatumWidget({ className: 'g-widget-metadata-row editing', - mode: 'simple', + mode: 'key', key: key, value: value, item: this.item, @@ -478,6 +572,7 @@ MetadataWidget.prototype.addMetadataByKey = function (evt) { apiPath: this.apiPath, accessLevel: this.accessLevel, parentView: this, + limetadata: this._limetadata, onMetadataEdited: this.onMetadataEdited, onMetadataAdded: this.onMetadataAdded }); @@ -492,6 +587,7 @@ MetadataWidget.prototype.addMetadataByKey = function (evt) { accessLevel: this.accessLevel, newDatum: true, parentView: widget, + limetadata: this._limetadata, onMetadataEdited: this.onMetadataEdited, onMetadataAdded: this.onMetadataAdded }) From e59f7a85cbe69026d33be0584ba971c622dd3d6a Mon Sep 17 00:00:00 2001 From: David Manthey Date: Mon, 12 Jun 2023 09:08:42 -0400 Subject: [PATCH 5/8] Changes to make it easier to inherit the metadata widget --- CHANGELOG.md | 1 + docs/girder_config_options.rst | 43 +++++++ .../web_client/views/metadataWidget.js | 107 ++++++++++++++---- 3 files changed, 132 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b06d932..390b7a9bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added an internal field to report populated tile levels in some sources ([#1197](../../pull/1197), [#1199](../../pull/1199)) - Allow specifying an empty style dict ([#1200](../../pull/1200)) - Allow rounding histogram bin edges and reducing bin counts ([#1201](../../pull/1201)) +- Allow configuring which metadata can be added to items ([#1202](../../pull/1202)) ### Changes - Change how extensions and fallback priorities interact ([#1192](../../pull/1192)) diff --git a/docs/girder_config_options.rst b/docs/girder_config_options.rst index b89d27697..5cf0b659d 100644 --- a/docs/girder_config_options.rst +++ b/docs/girder_config_options.rst @@ -45,6 +45,9 @@ The yaml file has the following structure: .large_image_config.yaml ~~~~~~~~~~~~~~~~~~~~~~~~ +Items Lists +........... + This is used to specify how items appear in item lists. There are two settings, one for folders in the main Girder UI and one for folders in dialogs (such as when browsing in the file dialog). :: @@ -128,6 +131,46 @@ This is used to specify how items appear in item lists. There are two settings, If there are no large images in a folder, none of the image columns will appear. +Item Metadata +............. + +By default, item metadata can contain any keys and values. These can be given better titles and restricted in their data types. + +:: + + --- + # If present, offer to add these specific keys and restrict their datatypes + itemMetadata: + - + # value is the key name within the metadata + value: stain + # title is the displayed titles + title: Stain + # description is used as both a tooltip and as placeholder text + description: Staining method + # if required is true, the delete button does not appear + required: true + # If a regex is specified, the value must match + # regex: '^(Eosin|H&E|Other)$' + # If an enum is specified, the value is set via a dropdown select box + enum: + - Eosin + - H&E + - Other + # If a default is specified, when the value is created, it will show + # this value in the control + default: H&E + - + value: rating + # type can be "number", "integer", or "text" (default) + type: number + # minimum and maximum are inclusive + minimum: 0 + maximum: 10 + # Exclusive values can be specified instead + # exclusiveMinimum: 0 + # exclusiveMaximum: 10 + Editing Configuration Files --------------------------- diff --git a/girder/girder_large_image/web_client/views/metadataWidget.js b/girder/girder_large_image/web_client/views/metadataWidget.js index b5cc71633..cbf49f89d 100644 --- a/girder/girder_large_image/web_client/views/metadataWidget.js +++ b/girder/girder_large_image/web_client/views/metadataWidget.js @@ -84,8 +84,8 @@ var MetadatumWidget = View.extend({ var newMode = this.parentView.modes[to]; if (_.has(newMode, 'validation') && - _.has(newMode.validation, 'from') && - _.has(newMode.validation.from, from)) { + _.has(newMode.validation, 'from') && + _.has(newMode.validation.from, from)) { var validate = newMode.validation.from[from][0]; var msg = newMode.validation.from[from][1]; @@ -106,7 +106,7 @@ var MetadatumWidget = View.extend({ var fromEditorMode = (existingEditor instanceof JsonMetadatumEditWidget) ? 'json' : 'simple'; var newValue = (overrides || {}).value || existingEditor.$el.attr('g-value'); if (!this._validate(fromEditorMode, newEditorMode, newValue)) { - return; + return false; } var row = existingEditor.$el; @@ -246,7 +246,7 @@ var MetadatumEditWidget = View.extend({ confirmCallback: () => { this.item.removeMetadata(this.key, function () { metadataList.remove(); - // TODO: trigger an event? + this.parentView.parentView.trigger('li-metadata-widget-update', {}); }, null, { field: this.fieldName, path: this.apiPath @@ -328,7 +328,8 @@ var MetadatumEditWidget = View.extend({ } else { this.parentView.mode = 'simple'; } - // TODO: trigger an event + // event to re-render metadata panel header when metadata is edited + this.parentView.parentView.trigger('li-metadata-widget-update', {}); this.parentView.render(); this.newDatum = false; @@ -354,8 +355,7 @@ var MetadatumEditWidget = View.extend({ return false; } getMetadataRecord(this.item, this.fieldName)[tempKey] = tempValue; - // TODO: this.parentView.parentView.render(); - return; + this.parentView.parentView.render(); } this.item.addMetadata(tempKey, tempValue, saveCallback, errorCallback, { field: this.fieldName, @@ -377,7 +377,7 @@ var MetadatumEditWidget = View.extend({ } delete getMetadataRecord(this.item, this.fieldName)[this.key]; getMetadataRecord(this.item, this.fieldName)[tempKey] = tempValue; - // TODO: this.parentView.parentView.render(); + this.parentView.parentView.render(); return; } this.item.editMetadata(tempKey, this.key, tempValue, saveCallback, errorCallback, { @@ -413,7 +413,7 @@ var JsonMetadatumEditWidget = MetadatumEditWidget.extend({ save: function (event) { try { - MetadatumEditWidget.prototype.save.call( + return MetadatumEditWidget.prototype.save.call( this, event, this.editor.get()); } catch (err) { events.trigger('g:alert', { @@ -449,9 +449,12 @@ var JsonMetadatumEditWidget = MetadatumEditWidget.extend({ }); wrap(MetadataWidget, 'initialize', function (initialize, settings) { - const result = initialize.call(this, settings); + try { + initialize.call(this, settings); + } catch (err) { + } this.noSave = settings.noSave; - if (this.item.get('_modelType') === 'item') { + if (this.item && this.item.get('_modelType') === 'item') { largeImageConfig.getConfigFile(this.item.get('folderId')).done((val) => { this._limetadata = (val || {}).itemMetadata; if (this._limetadata) { @@ -461,11 +464,21 @@ wrap(MetadataWidget, 'initialize', function (initialize, settings) { } else { this._limetadata = null; } - return result; }); wrap(MetadataWidget, 'render', function (render) { - var metaDict = this.item.get(this.fieldName) || {}; + let metaDict; + if (this.item.get(this.fieldName)) { + metaDict = this.item.get(this.fieldName) || {}; + } else if (this.item[this.fieldName]) { + metaDict = this.item[this.fieldName] || {}; + } else { + const fieldParts = this.fieldName.split('.'); + metaDict = this.item.get(fieldParts[0]) || {}; + fieldParts.slice(1).forEach((part) => { + metaDict = metaDict[part] || {}; + }); + } var metaKeys = Object.keys(metaDict); metaKeys.sort(localeSort); if (this._limetadata) { @@ -485,15 +498,16 @@ wrap(MetadataWidget, 'render', function (render) { return origOrder.indexOf(a) - origOrder.indexOf(b); }); } - - // Metadata header - this.$el.html((this.MetadataWidgetTemplate || MetadataWidgetTemplate)({ + this._sortedMetaKeys = metaKeys; + this._renderedMetaDict = metaDict; + const contents = (this.MetadataWidgetTemplate || MetadataWidgetTemplate)({ item: this.item, title: this.title, accessLevel: this.accessLevel, AccessType: AccessType, limetadata: this._limetadata - })); + }); + this._renderHeader(contents); // Append each metadatum _.each(metaKeys, function (metaKey) { @@ -506,6 +520,7 @@ wrap(MetadataWidget, 'render', function (render) { fieldName: this.fieldName, apiPath: this.apiPath, limetadata: this._limetadata, + noSave: this.noSave, onMetadataEdited: this.onMetadataEdited, onMetadataAdded: this.onMetadataAdded }).render().$el); @@ -515,6 +530,17 @@ wrap(MetadataWidget, 'render', function (render) { }); wrap(MetadataWidget, 'setItem', function (setItem, item) { + if (item !== this.item) { + this._limetadata = null; + if (item && item.get('_modelType') === 'item') { + largeImageConfig.getConfigFile(item.get('folderId')).done((val) => { + this._limetadata = (val || {}).itemMetadata; + if (this._limetadata) { + this.render(); + } + }); + } + } setItem.call(this, item); this.item.on('g:changed', function () { this.render(); @@ -551,6 +577,43 @@ MetadataWidget.prototype.getModeFromValue = function (value, key) { return _.isString(value) ? 'simple' : 'json'; }; +MetadataWidget.prototype.addMetadata = function (evt, mode) { + var EditWidget = this.modes[mode].editor; + var value = (mode === 'json') ? '{}' : ''; + + var widget = new MetadatumWidget({ + className: 'g-widget-metadata-row editing', + mode: mode, + key: '', + value: value, + item: this.item, + fieldName: this.fieldName, + noSave: this.noSave, + apiPath: this.apiPath, + accessLevel: this.accessLevel, + parentView: this, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }); + widget.$el.appendTo(this.$('.g-widget-metadata-container')); + + new EditWidget({ + item: this.item, + key: '', + value: value, + fieldName: this.fieldName, + noSave: this.noSave, + apiPath: this.apiPath, + accessLevel: this.accessLevel, + newDatum: true, + parentView: widget, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }) + .render() + .$el.appendTo(widget.$el); +}; + MetadataWidget.prototype.addMetadataByKey = function (evt) { const key = $(evt.target).attr('metadata-key'); // if this key already exists, just go to editing it @@ -586,6 +649,7 @@ MetadataWidget.prototype.addMetadataByKey = function (evt) { apiPath: this.apiPath, accessLevel: this.accessLevel, newDatum: true, + noSave: this.noSave, parentView: widget, limetadata: this._limetadata, onMetadataEdited: this.onMetadataEdited, @@ -595,9 +659,14 @@ MetadataWidget.prototype.addMetadataByKey = function (evt) { .$el.appendTo(widget.$el); }; -export default { +MetadataWidget.prototype._renderHeader = function (contents) { + this.$el.html(contents); +}; + +export { MetadataWidget, MetadatumWidget, MetadatumEditWidget, - JsonMetadatumEditWidget + JsonMetadatumEditWidget, + liMetadataKeyEntry }; From 2ef0aa02d773e151a6663dbc35f74137eb67b3e1 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Mon, 12 Jun 2023 15:52:27 -0400 Subject: [PATCH 6/8] Fix a scope issue when deleting metadata --- girder/girder_large_image/web_client/views/metadataWidget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/girder/girder_large_image/web_client/views/metadataWidget.js b/girder/girder_large_image/web_client/views/metadataWidget.js index cbf49f89d..761f6cea6 100644 --- a/girder/girder_large_image/web_client/views/metadataWidget.js +++ b/girder/girder_large_image/web_client/views/metadataWidget.js @@ -244,7 +244,7 @@ var MetadatumEditWidget = View.extend({ escapedHtml: true, yesText: 'Delete', confirmCallback: () => { - this.item.removeMetadata(this.key, function () { + this.item.removeMetadata(this.key, () => { metadataList.remove(); this.parentView.parentView.trigger('li-metadata-widget-update', {}); }, null, { From c3d27c3818e05247e0be3d590aa76e8868b2e0b5 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Mon, 12 Jun 2023 17:05:39 -0400 Subject: [PATCH 7/8] Update CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 390b7a9bc..63ecf3084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## 1.22.4 + +### Bug Fixes +- Fix a scope issue when deleting metadata ([#1203](../../pull/1203)) + ## 1.22.3 ### Improvements From f23723b8dd18830dc5d16b21e948260b74af222a Mon Sep 17 00:00:00 2001 From: David Manthey Date: Wed, 14 Jun 2023 13:37:00 -0400 Subject: [PATCH 8/8] Harden the nd2 source to allow it to read more files. This also adapts to changes in the library that is used for reading such files. --- CHANGELOG.md | 5 ++ .../nd2/large_image_source_nd2/__init__.py | 57 ++++++++++++++++--- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ecf3084..745544963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## 1.22.5 + +### Improvements +- Harden the nd2 source to allow it to read more files ([#1207](../../pull/1207)) + ## 1.22.4 ### Bug Fixes diff --git a/sources/nd2/large_image_source_nd2/__init__.py b/sources/nd2/large_image_source_nd2/__init__.py index cff5edc17..291f5767a 100644 --- a/sources/nd2/large_image_source_nd2/__init__.py +++ b/sources/nd2/large_image_source_nd2/__init__.py @@ -165,6 +165,7 @@ def __init__(self, path, **kwargs): basis *= self._nd2.sizes[k] self.sizeX = self._nd2.sizes['X'] self.sizeY = self._nd2.sizes['Y'] + self._nd2sizes = self._nd2.sizes self.tileWidth = self.tileHeight = self._tileSize if self.sizeX <= self._singleTileThreshold and self.sizeY <= self._singleTileThreshold: self.tileWidth = self.sizeX @@ -174,14 +175,18 @@ def __init__(self, path, **kwargs): try: self._frameCount = ( self._nd2.metadata.contents.channelCount * self._nd2.metadata.contents.frameCount) + self._bandnames = { + chan.channel.name.lower(): idx + for idx, chan in enumerate(self._nd2.metadata.channels)} + self._channels = [chan.channel.name for chan in self._nd2.metadata.channels] except Exception: + self._frameCount = basis * self._nd2.sizes.get('C', 1) + self._channels = None + if not self._validateArrayAccess(): self._nd2.close() del self._nd2 raise TileSourceError( 'File cannot be parsed with the nd2 source. Is it a legacy nd2 file?') - self._bandnames = { - chan.channel.name.lower(): idx for idx, chan in enumerate(self._nd2.metadata.channels)} - self._channels = [chan.channel.name for chan in self._nd2.metadata.channels] self._tileLock = threading.RLock() def __del__(self): @@ -189,6 +194,38 @@ def __del__(self): self._nd2.close() del self._nd2 + def _validateArrayAccess(self): + check = [0] * len(self._nd2order) + count = 1 + for axisidx in range(len(self._nd2order) - 1, -1, -1): + axis = self._nd2order[axisidx] + axisSize = self._nd2.sizes[axis] + check[axisidx] = axisSize - 1 + try: + self._nd2array[tuple(check)].compute() + if axis not in {'X', 'Y', 'S'}: + count *= axisSize + continue + except Exception: + if axis in {'X', 'Y', 'S'}: + return False + minval = 0 + maxval = axisSize - 1 + while minval + 1 < maxval: + nextval = (minval + maxval) // 2 + check[axisidx] = nextval + try: + self._nd2array[tuple(check)].compute() + minval = nextval + except Exception: + maxval = nextval + check[axisidx] = minval + self._nd2sizes = {k: check[idx] + 1 for idx, k in enumerate(self._nd2order)} + self._frameCount = (minval + 1) * count + return True + self._frameCount = count + return True + def getNativeMagnification(self): """ Get the magnification at a particular level. @@ -223,7 +260,7 @@ def getMetadata(self): sizes = self._nd2.sizes axes = self._nd2order[:self._nd2order.index('Y')][::-1] - sizes = self._nd2.sizes + sizes = self._nd2sizes result['frames'] = frames = [] for idx in range(self._frameCount): frame = {'Frame': idx} @@ -255,9 +292,13 @@ def getInternalMetadata(self, **kwargs): result['nd2_experiment'] = namedtupleToDict(self._nd2.experiment) result['nd2_legacy'] = self._nd2.is_legacy result['nd2_rgb'] = self._nd2.is_rgb - result['nd2_frame_metadata'] = [ - diffObj(namedtupleToDict(self._nd2.frame_metadata(idx)), result['nd2']) - for idx in range(self._nd2.metadata.contents.frameCount)] + result['nd2_frame_metadata'] = [] + try: + for idx in range(self._nd2.metadata.contents.frameCount): + result['nd2_frame_metadata'].append(diffObj(namedtupleToDict( + self._nd2.frame_metadata(idx)), result['nd2'])) + except Exception: + pass if (len(result['nd2_frame_metadata']) and list(result['nd2_frame_metadata'][0].keys()) == ['channels']): result['nd2_frame_metadata'] = [ @@ -273,7 +314,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): fc = self._frameCount fp = frame for axis in self._nd2order[:self._nd2order.index('Y')]: - fc //= self._nd2.sizes[axis] + fc //= self._nd2sizes[axis] tileframe = tileframe[fp // fc] fp = fp % fc with self._tileLock: