diff --git a/Dockerfile b/Dockerfile index 12297a0d3c8..3bfac100f39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -177,6 +177,7 @@ COPY ./addons/iqbrims/static/ ./addons/iqbrims/static/ COPY ./addons/binderhub/static/ ./addons/binderhub/static/ COPY ./addons/metadata/static/ ./addons/metadata/static/ COPY ./addons/onlyoffice/static/ ./addons/onlyoffice/static/ +COPY ./addons/workflow/static/ ./addons/workflow/static/ RUN \ # OSF yarn install --frozen-lockfile \ diff --git a/addons.json b/addons.json index 98ce5044863..21eff15cf2b 100644 --- a/addons.json +++ b/addons.json @@ -32,7 +32,8 @@ "binderhub", "onedrivebusiness", "metadata", - "onlyoffice" + "onlyoffice", + "workflow" ], "addons_default": [ "osfstorage", @@ -138,7 +139,8 @@ "binderhub": "GakuNin Federated Computing Services (Jupyter) is a web-based interactive computational environment. Files on a GakuNin RDM project can be imported to/exported from GakuNin Federated Computing Services (Jupyter)", "onedrivebusiness": "OneDrive for Office365 is a file storage add-on. Connect your Microsoft OneDrive account to a GakuNin RDM project to interact with files hosted on Microsoft OneDrive via the GakuNin RDM.", "metadata": "The Metadata addon provides the functionality to register metadata to files and generate reports.", - "onlyoffice": "ONLYOFFICE document server." + "onlyoffice": "ONLYOFFICE document server.", + "workflow": "Workflow gateway integration that serves engine key material to trusted services." }, "addons_url": { "box": "http://www.box.com", @@ -166,7 +168,8 @@ "binderhub": "https://binder.cs.rcos.nii.ac.jp", "onedrivebusiness": "https://onedrive.live.com", "metadata": "https://rcos.nii.ac.jp/service/rdm/", - "onlyoffice": "https://onlyoffice.com/" + "onlyoffice": "https://onlyoffice.com/", + "workflow": "https://rcos.nii.ac.jp/service/rdm/" }, "institutional_storage_add_on_method": [ "nextcloudinstitutions", diff --git a/addons/base/views.py b/addons/base/views.py index acc7cb1f9ab..c1bb834b7b0 100644 --- a/addons/base/views.py +++ b/addons/base/views.py @@ -1127,6 +1127,7 @@ def addon_view_file(auth, node, file_node, version): 'file_id': file_node._id, 'allow_comments': file_node.provider in settings.ADDONS_COMMENTABLE, 'checkout_user': file_node.checkout._id if file_node.checkout else None, + 'locked_user': file_node.locked._id if file_node.locked else None, 'version_names': list(version_names), 'wopi_onlyoffice': onlyoffice_settings.WOPI_CLIENT_ONLYOFFICE, }) diff --git a/addons/metadata/SCHEMA-EXTENSION.md b/addons/metadata/SCHEMA-EXTENSION.md new file mode 100644 index 00000000000..6a9816a4f0a --- /dev/null +++ b/addons/metadata/SCHEMA-EXTENSION.md @@ -0,0 +1,25 @@ +# Metadata Schema Extensions + +This document describes extensions to the standard metadata schema format. + +## display_template + +For `type: "array"` fields, you can specify a `display_template` to control how the data is displayed in collapsed view. + +**Note**: `display_template` for `type: "object"` fields is not currently supported. The implementation focuses on array fields only. + +### Behavior + +#### For `type: "array"` +- **View mode**: Table display where `display_template` is split by `|` to define each column + - Example: `"{{prop1}}|{{prop2}}|{{prop3}}"` creates 3 columns +- **Edit mode**: Row expands to show all fields vertically + +#### For `type: "object"` +- **Not supported**: `display_template` is ignored for object type fields +- Object fields always display all properties in a standard table format + +### Template Variables +- Use `{{property_id}}` to reference properties +- For nested properties, use dot notation: `{{object_id.child_id}}` +- Empty values are rendered as empty strings \ No newline at end of file diff --git a/addons/metadata/report_format/report_en.csv.j2 b/addons/metadata/report_format/report_en.csv.j2 index aad8f552e4e..a0fceb0a29b 100644 --- a/addons/metadata/report_format/report_en.csv.j2 +++ b/addons/metadata/report_format/report_en.csv.j2 @@ -1,5 +1,6 @@ Funder,Funding stream code in Japan Grant Number,Program name,Japan Grant Number,Project name,Data No.,Title,Date (Issued / Updated),Description,Research field,Data type,File size,Data utilization and provision policy(Free/Consideration),Data utilization and provision policy(License),Data utilization and provision policy(citation information),Access rights,Publication date (for embargoed access),Repository information,Repository URL/ DOI link,Creator Name,Creator name identifier (e-Rad),Hosting institution,Hosting institution Identifier,Data manager,Data manager identifier (e-Rad),Contact organization of data manager,Contact address of data manager,Contact phone number of data manager,Contact mail address of data manager,Remarks {% for grdm_file in grdm_files -%} +{%- if grdm_file.file_type != 'manuscript' -%} {{funder_tooltip_1 | quotecsv}},{{funding_stream_code | quotecsv}},{{program_name_en | quotecsv}},{{japan_grant_number | quotecsv}},{{project_name_en | quotecsv}},{{grdm_file.data_number | quotecsv}},{{grdm_file.title_en | quotecsv}},{{grdm_file.date_issued_updated | quotecsv}},{{grdm_file.data_description_en | quotecsv}}, {%- if grdm_file.data_research_field == 'project' -%} {{project_research_field_tooltip_1 | quotecsv}} @@ -7,4 +8,5 @@ Funder,Funding stream code in Japan Grant Number,Program name,Japan Grant Number {{grdm_file.data_research_field_tooltip_1 | quotecsv}} {%- endif -%} ,{{grdm_file.data_type_tooltip_1 | quotecsv}},{{grdm_file.file_size | quotecsv}},{{grdm_file.data_policy_free_tooltip_1 | quotecsv}},{{grdm_file.data_policy_license | quotecsv}},{{grdm_file.data_policy_cite_en | quotecsv}},{{grdm_file.access_rights_tooltip_1 | quotecsv}},{{grdm_file.available_date | quotecsv}},{{grdm_file.repo_information_en | quotecsv}},{{grdm_file.repo_url_doi_link | quotecsv}},{{grdm_file.creators | map(attribute="name_en") | join(";") | quotecsv}},{{grdm_file.creators | map(attribute="number") | join(";") | quotecsv}},{{grdm_file.hosting_inst_en | quotecsv}},{{grdm_file.hosting_inst_id | quotecsv}},{{grdm_file.data_man_name_en | quotecsv}},{{grdm_file.data_man_number | quotecsv}},{{grdm_file.data_man_org_en | quotecsv}},{{grdm_file.data_man_address_en | quotecsv}},{{grdm_file.data_man_tel | quotecsv}},{{grdm_file.data_man_email | quotecsv}},{{grdm_file.remarks_en | quotecsv}} +{%- endif -%} {% endfor %} diff --git a/addons/metadata/report_format/report_ja.csv.j2 b/addons/metadata/report_format/report_ja.csv.j2 index 6fa0e04dd3f..54e26f1f1c8 100644 --- a/addons/metadata/report_format/report_ja.csv.j2 +++ b/addons/metadata/report_format/report_ja.csv.j2 @@ -1,5 +1,6 @@ 資金配分機関情報,体系的番号におけるプログラム情報コード,プログラム名,体系的番号,プロジェクト名,データNo.,データの名称,掲載日・掲載更新日,データの説明,データの分野,データ種別,概略データ量,管理対象データの利活用・提供方針 (有償/無償),管理対象データの利活用・提供方針(ライセンス),管理対象データの利活用・提供方針(引用方法等),アクセス権,公開予定日 (公開期間猶予の場合),リポジトリ情報,リポジトリURL・DOIリンク,データ作成者,データ作成者の研究者番号,データ管理機関,データ管理機関コード,データ管理者,データ管理者の研究者番号,データ管理者の所属組織名,データ管理者の所属機関の連絡先住所,データ管理者の所属機関の連絡先電話番号,データ管理者の所属機関の連絡先メールアドレス,備考 {% for grdm_file in grdm_files -%} +{%- if grdm_file.file_type != 'manuscript' -%} {{funder_tooltip_0 | quotecsv}},{{funding_stream_code | quotecsv}},{{program_name_ja | quotecsv}},{{japan_grant_number | quotecsv}},{{project_name_ja | quotecsv}},{{grdm_file.data_number | quotecsv}},{{grdm_file.title_ja | quotecsv}},{{grdm_file.date_issued_updated | quotecsv}},{{grdm_file.data_description_ja | quotecsv}}, {%- if grdm_file.data_research_field == 'project' -%} {{project_research_field_tooltip_0 | quotecsv}} @@ -7,4 +8,5 @@ {{grdm_file.data_research_field_tooltip_0 | quotecsv}} {%- endif -%} ,{{grdm_file.data_type_tooltip_0 | quotecsv}},{{grdm_file.file_size | quotecsv}},{{grdm_file.data_policy_free_tooltip_0 | quotecsv}},{{grdm_file.data_policy_license | quotecsv}},{{grdm_file.data_policy_cite_ja | quotecsv}},{{grdm_file.access_rights_tooltip_0 | quotecsv}},{{grdm_file.available_date | quotecsv}},{{grdm_file.repo_information_ja | quotecsv}},{{grdm_file.repo_url_doi_link | quotecsv}},{{grdm_file.creators | map(attribute="name_ja") | join(";") | quotecsv}},{{grdm_file.creators | map(attribute="number") | join(";") | quotecsv}},{{grdm_file.hosting_inst_ja | quotecsv}},{{grdm_file.hosting_inst_id | quotecsv}},{{grdm_file.data_man_name_ja | quotecsv}},{{grdm_file.data_man_number | quotecsv}},{{grdm_file.data_man_org_ja | quotecsv}},{{grdm_file.data_man_address_ja | quotecsv}},{{grdm_file.data_man_tel | quotecsv}},{{grdm_file.data_man_email | quotecsv}},{{grdm_file.remarks_ja | quotecsv}} +{%- endif -%} {% endfor %} diff --git a/addons/metadata/settings/defaults.py b/addons/metadata/settings/defaults.py index 1fac6efec40..ce4b4899950 100644 --- a/addons/metadata/settings/defaults.py +++ b/addons/metadata/settings/defaults.py @@ -21,7 +21,6 @@ EXCLUDED_ADDONS_FOR_EXPORT = ['mendeley', 'zotero', 'iqbrims'] EXCLUDED_ADDONS_FOR_EXPORT += ['dropboxbusiness', 'nextcloudinstitutions', 'ociinstitutions', 'onedrivebusiness', 's3compatinstitutions'] - # KAKEN Elasticsearch settings # If None, KAKEN functionality is disabled KAKEN_ELASTIC_URI = os.getenv('KAKEN_ELASTIC_URI') @@ -52,3 +51,6 @@ } } } + +# PubMed API key for accessing external metadata (Optional) +PUBMED_API_KEY = None diff --git a/addons/metadata/static/files.js b/addons/metadata/static/files.js index a9425c50eaa..efc8f2422f8 100644 --- a/addons/metadata/static/files.js +++ b/addons/metadata/static/files.js @@ -283,14 +283,13 @@ function MetadataButtons() { self.lastQuestionPage = self.createQuestionPage( schema.attributes.schema, lastMetadataItem, - { + Object.assign({}, options, { readonly: !((context.projectMetadata || {}).editable) || lastMetadataItem.readonly, - multiple: options.multiple, context: context, filepath: filepath, wbcache: context.wbcache, fileitem: fileitem - } + }) ); self.lastFields = self.lastQuestionPage.fields; container.empty(); @@ -570,6 +569,9 @@ function MetadataButtons() { {} ); dialog.container.append(fieldContainer); + dialog.dialog.one('shown.bs.modal', function() { + dialog.dialog.find('.metadata-scroll-area').scrollTop(0); + }); dialog.dialog.modal('show'); }; @@ -602,6 +604,47 @@ function MetadataButtons() { const targetItem = targetItems.filter(Boolean)[0] || {}; const selector = self.createSchemaSelector(targetItem); self.currentSchemaId = selector.currentSchemaId; + + // Compute common values and default values for selected files + function computeValuesForMultipleEdit(schemaId) { + const schema = self.findSchemaById(schemaId); + const schemaObj = schema.attributes.schema; + const defaultValues = {}; + const allQids = []; + (schemaObj.pages || []).forEach(function(page) { + (page.questions || []).forEach(function(question) { + allQids.push(question.qid); + (question.options || []).forEach(function(opt) { + if (opt.default) { + defaultValues[question.qid] = opt.text; + } + }); + }); + }); + const commonValues = {}; + allQids.forEach(function(qid) { + const values = filepaths.map(function(filepath) { + const metadata = self.findMetadataByPath(context.nodeId, filepath); + if (!metadata) { + return defaultValues[qid] || null; + } + const item = (metadata.items || []).find(function(item) { + return item.schema === schemaId; + }); + if (!item) { + return defaultValues[qid] || null; + } + const field = item.data[qid]; + return (field && field.value) || defaultValues[qid] || null; + }); + const first = values[0]; + if (first && values.every(function(v) { return v === first; })) { + commonValues[qid] = first; + } + }); + return {commonValues: commonValues, defaultValues: defaultValues}; + } + selector.schema.change(function(event) { if (event.target.value === self.currentSchemaId) { return; @@ -613,7 +656,7 @@ function MetadataButtons() { self.findSchemaById(self.currentSchemaId), filepaths, items, - {multiple: true} + Object.assign({multiple: true}, computeValuesForMultipleEdit(self.currentSchemaId)) ); }); dialog.toolbar.empty(); @@ -628,10 +671,12 @@ function MetadataButtons() { self.findSchemaById(self.currentSchemaId), filepaths, items, - {multiple: true} + Object.assign({multiple: true}, computeValuesForMultipleEdit(self.currentSchemaId)) ); dialog.container.append(fieldContainer); - + dialog.dialog.one('shown.bs.modal', function() { + dialog.dialog.find('.metadata-scroll-area').scrollTop(0); + }); dialog.dialog.modal('show'); }; @@ -1094,14 +1139,18 @@ function MetadataButtons() { if (self.lastQuestionPage.hasValidationError) { message.text(_('There are errors in some fields.')).css('color', 'red'); } - if (self.selectDraftDialog) { - self.selectDraftDialog.select.attr('disabled', self.lastQuestionPage.hasValidationError); - } draftSelectionContainer.empty(); draftSelectionContainer.append(message); - draftSelectionContainer.append( - self.createDraftsSelect(schema, self.lastQuestionPage.hasValidationError).css('margin', '1em 0') - ); + const draftsSelect = self.createDraftsSelect(schema, false).css('margin', '1em 0'); + draftSelectionContainer.append(draftsSelect); + if (self.selectDraftDialog) { + const updateSelectButton = function() { + const hasChecked = draftsSelect.find('input[type="checkbox"]:checked').length > 0; + self.selectDraftDialog.select.attr('disabled', !hasChecked); + }; + draftsSelect.find('input[type="checkbox"]').on('change', updateSelectButton); + updateSelectButton(); + } }; self.openDraftModal = function(currentMetadata) { @@ -2180,7 +2229,7 @@ function MetadataButtons() { .append($('
') .append($('
') .append(toolbar)) - .append($('
') + .append($('
') .css('overflow-y', 'scroll') .css('height', '66vh') .append(container)))) @@ -2237,7 +2286,7 @@ function MetadataButtons() { .append($('
') .append($('
') .append(toolbar)) - .append($('
') + .append($('
') .css('overflow-y', 'scroll') .css('height', '70vh') .append(container)))) diff --git a/addons/metadata/static/metadata-fields.js b/addons/metadata/static/metadata-fields.js index 61858012f34..9b841b247b2 100644 --- a/addons/metadata/static/metadata-fields.js +++ b/addons/metadata/static/metadata-fields.js @@ -11,6 +11,9 @@ ******************************************************************************************/ const $ = require('jquery'); + +// Style definitions +const AUTOFILLED_BG_COLOR = '#fffbf0'; const $osf = require('js/osfHelpers'); const fangorn = require('js/fangorn'); const rdmGettext = require('js/rdmGettext'); @@ -96,7 +99,7 @@ const QuestionPage = oop.defclass({ } const value = suggestion.value[autofillMap[path]]; if (value != null) { - field.setValue(value); + field.setValue(value, true); // Mark as autofilled } }); }, @@ -144,9 +147,9 @@ const QuestionPage = oop.defclass({ walk(childField, field.fields); }); } else if (field instanceof ArrayFormField) { - field.fields.forEach(function (row) { - row.forEach(function (childField) { - walk(childField, row); + field.fields.forEach(function (fieldGroup) { + fieldGroup.subFormFields.forEach(function (childField) { + walk(childField, fieldGroup.subFormFields); }); }); } @@ -158,8 +161,11 @@ const QuestionPage = oop.defclass({ }, _updateEnabledQuestionField: function(questionField, questionFields) { + const self = this; const cond = questionField.question.enabled_if; - questionField.updateEnabled(!cond || evaluateCond(cond, questionFields)); + const commonValues = self.options.commonValues; + const defaultValues = self.options.defaultValues; + questionField.updateEnabled(!cond || evaluateCond(cond, questionFields, commonValues, defaultValues)); }, }); @@ -281,6 +287,7 @@ const QuestionField = oop.extend(Emitter, { } else { self.formField.disable(false); } + self.emit('change'); }); header.append(clearFormBlock); } @@ -333,9 +340,9 @@ const QuestionField = oop.extend(Emitter, { return self.formField.getValue(); }, - setValue: function(value) { + setValue: function(value, isAutofilled) { const self = this; - self.formField.setValue(value); + self.formField.setValue(value, isAutofilled); }, checkedClear: function() { @@ -405,6 +412,9 @@ const FormFieldInterface = oop.extend(Emitter, { setValue: noImplementation, reset: noImplementation, disable: noImplementation, + getChildFields: function() { + return []; + }, }); const TextFormField = oop.extend(FormFieldInterface, { @@ -424,6 +434,10 @@ const TextFormField = oop.extend(FormFieldInterface, { if (self.options.readonly) { self.input.attr('readonly', true); } + self.input.on('input', function() { + // Reset background color when user edits the field + self.input.css('background-color', ''); + }); self.input.change(function(event) { const value = event.target.value; if (value && self.question.space_normalization) { @@ -441,17 +455,29 @@ const TextFormField = oop.extend(FormFieldInterface, { return suggestion.button; }); if (!self.options.readonly && !self.options.multiple && buttonSuggestions.length) { - function onSuggested(value) { - self.setValue(value); + function onSuggested(value, suggestion) { + // If value is null (no suggestions found for autofill), don't update anything + if (value === null) { + return; + } + // If there's autofill configuration, emit suggestionSelected event for autofill + if (suggestion && suggestion.autofill && value) { + self.emit('suggestionSelected', { + suggestion: suggestion, + value: value + }, [self]); + } else if (value !== undefined) { + // Otherwise, just set the value on the current field (but not if undefined) + self.setValue(value); + } } - function enteredValue() { - const value = self.getValue(); - return value != null && value !== ''; + function getFieldValue() { + return self.getValue(); } const suggestionContainer = createSuggestionButton( self.container, self.question, buttonSuggestions, self.options, - onSuggested, enteredValue + onSuggested, getFieldValue ); self.container .css('display', 'flex') @@ -501,12 +527,15 @@ const TextFormField = oop.extend(FormFieldInterface, { return self.input.val(); }, - setValue: function(value) { + setValue: function(value, isAutofilled) { const self = this; if (self.getValue() === '' && value === '') { // to avoid typehead bug return; } + if (isAutofilled) { + self.input.css('background-color', AUTOFILLED_BG_COLOR); + } if (self.usedTypeahead) { self.input.typeahead('val', value).change(); } else { @@ -541,6 +570,10 @@ const TextareaFormField = oop.extend(FormFieldInterface, { if (self.options.readonly) { self.input.attr('readonly', true); } + self.input.on('input', function() { + // Reset background color when user edits the field + self.input.css('background-color', ''); + }); self.input.change(function(event) { const value = event.target.value; if (value && self.question.space_normalization) { @@ -560,9 +593,12 @@ const TextareaFormField = oop.extend(FormFieldInterface, { return self.input.val(); }, - setValue: function(value) { + setValue: function(value, isAutofilled) { const self = this; self.input.val(value); + if (isAutofilled) { + self.input.css('background-color', AUTOFILLED_BG_COLOR); + } }, reset: function() { @@ -594,6 +630,10 @@ const DatePickerFormField = oop.extend(FormFieldInterface, { if (self.options.readonly) { self.input.attr('readonly', true); } + self.input.on('input', function() { + // Reset background color when user edits the field + self.input.css('background-color', ''); + }); self.input.change(function(event) { self.emit('change', event.target.value); }); @@ -605,9 +645,12 @@ const DatePickerFormField = oop.extend(FormFieldInterface, { return self.input.val(); }, - setValue: function(value) { + setValue: function(value, isAutofilled) { const self = this; self.input.datepicker('update', value); + if (isAutofilled) { + self.input.css('background-color', AUTOFILLED_BG_COLOR); + } }, reset: function() { @@ -666,6 +709,10 @@ const SingleSelectFormField = oop.extend(FormFieldInterface, { } } }); + self.select.on('input change', function() { + // Reset background color when user edits the field + self.select.css('background-color', ''); + }); self.select.change(function(event) { self.emit('change', event.target.value); }); @@ -688,7 +735,7 @@ const SingleSelectFormField = oop.extend(FormFieldInterface, { return defaultValue; }, - setValue: function(value) { + setValue: function(value, isAutofilled) { const self = this; // assign default value if value is not in the options const defaultValue = self.getDefaultValue(); @@ -697,6 +744,9 @@ const SingleSelectFormField = oop.extend(FormFieldInterface, { return; } self.select.val(value); + if (isAutofilled) { + self.select.css('background-color', AUTOFILLED_BG_COLOR); + } }, reset: function() { @@ -756,13 +806,50 @@ const ArrayFormField = oop.extend(FormFieldInterface, { } }, - addRow: function(value) { + _createVerticalEditCell: function(subFormFields) { + const self = this; + // Calculate colspan from display_template + const columnCount = self.question.display_template.split('|').length + 1; // +1 for button column + const editCell = $('').attr('colspan', columnCount); + const fieldsContainer = $('
').css('padding', '10px'); + + // Add each field with its label in vertical layout + self.question.properties.forEach(function(prop, index) { + const fieldWrapper = $('
').addClass('form-group'); + + // Create label + const fieldLabel = $('