diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b06d932..745544963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 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 +- Fix a scope issue when deleting metadata ([#1203](../../pull/1203)) + ## 1.22.3 ### Improvements @@ -7,6 +17,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/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/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/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/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..ca6ffd264 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 * as 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..761f6cea6 --- /dev/null +++ b/girder/girder_large_image/web_client/views/metadataWidget.js @@ -0,0 +1,672 @@ +import $ from 'jquery'; +import {wrap} from '@girder/core/utilities/PluginUtils'; + +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 MetadatumViewTemplate from '@girder/core/templates/widgets/metadatumView.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 '../stylesheets/metadataWidget.styl'; +import MetadataWidgetTemplate from '../templates/metadataWidget.pug'; +import MetadatumEditWidgetTemplate from '../templates/metadatumEditWidget.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; +} + +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', + + 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.noSave = settings.noSave; + this.limetadata = settings.limetadata; + 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 false; + } + + 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, + noSave: this.noSave, + limetadata: this.limetadata, + 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, + noSave: this.noSave, + limetadata: this.limetadata, + 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.removeClass('editing'); + this.$el.html(this.parentView.modes[this.mode].template({ + 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 + })); + + 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() + }); + return false; + } + }, + + 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.noSave = settings.noSave; + this.limetadata = settings.limetadata; + 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(); + 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) + '?', + escapedHtml: true, + yesText: 'Delete', + confirmCallback: () => { + this.item.removeMetadata(this.key, () => { + metadataList.remove(); + this.parentView.parentView.trigger('li-metadata-widget-update', {}); + }, 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().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 === '') { + events.trigger('g:alert', { + text: 'A key is required for all metadata.', + type: 'warning' + }); + 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; + this.value = tempValue; + + this.parentView.key = this.key; + this.parentView.value = this.value; + + if (keyMode) { + this.parentView.mode = 'key'; + } else if (this instanceof JsonMetadatumEditWidget) { + this.parentView.mode = 'json'; + } else { + this.parentView.mode = 'simple'; + } + // 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; + }; + + 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 { + 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; + this.parentView.parentView.render(); + } + 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 { + 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; + this.parentView.parentView.render(); + return; + } + 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, + lientry: liMetadataKeyEntry(this.limetadata, this.key), + key: this.key, + value: this.value, + accessLevel: this.accessLevel, + newDatum: this.newDatum, + 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 { + return 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' + }); + return false; + } + }, + + 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) { + try { + initialize.call(this, settings); + } catch (err) { + } + this.noSave = settings.noSave; + if (this.item && 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; + } +}); + +wrap(MetadataWidget, 'render', function (render) { + 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) { + 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); + }); + } + 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) { + this.$el.find('.g-widget-metadata-container').append(new MetadatumWidget({ + 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, + noSave: this.noSave, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }).render().$el); + }, this); + + return this; +}); + +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(); + }, this); + this.render(); + return this; +}); + +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.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.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 + 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: 'key', + key: key, + value: value, + item: this.item, + fieldName: this.fieldName, + apiPath: this.apiPath, + accessLevel: this.accessLevel, + parentView: this, + limetadata: this._limetadata, + 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, + noSave: this.noSave, + parentView: widget, + limetadata: this._limetadata, + onMetadataEdited: this.onMetadataEdited, + onMetadataAdded: this.onMetadataAdded + }) + .render() + .$el.appendTo(widget.$el); +}; + +MetadataWidget.prototype._renderHeader = function (contents) { + this.$el.html(contents); +}; + +export { + MetadataWidget, + MetadatumWidget, + MetadatumEditWidget, + JsonMetadatumEditWidget, + liMetadataKeyEntry +}; 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: