diff --git a/ckanext/scheming/assets/js/scheming-suggestions.js b/ckanext/scheming/assets/js/scheming-suggestions.js index 31b38ec20..97014277b 100644 --- a/ckanext/scheming/assets/js/scheming-suggestions.js +++ b/ckanext/scheming/assets/js/scheming-suggestions.js @@ -149,11 +149,97 @@ ckan.module('scheming-suggestions', function($) { path.match(/\/dataset\/[^\/]+\/resource\/[^\/]+(\/edit)?$/) !== null; }, - _processDppButtonSuggestions: function(dppPackageSuggestions) { + _isEmptySuggestion: function(value) { + return value === null || value === undefined || value === '' || value === 'None'; + }, + + _isErrorSuggestion: function(value) { + return typeof value === 'string' && value.startsWith(this.options.errorPrefix); + }, + + // A single resource can already offer several candidates for one field — + // a CSV with more than one date column, say — so each raw value may + // expand into multiple options. + _expandSuggestionOptions: function(rawValue, resourceName) { var self = this; - if (!dppPackageSuggestions) { - return; + var options = []; + if (self._isEmptySuggestion(rawValue)) return options; + + var parsed = null; + try { parsed = JSON.parse(rawValue); } catch (e) { /* a plain value, not a candidate list */ } + + if ($.isArray(parsed) && parsed.length && parsed[0] && parsed[0].field_name && parsed[0].date) { + parsed.forEach(function(candidate) { + if (self._isEmptySuggestion(candidate.date)) return; + options.push({ value: candidate.date, column: candidate.field_name, resource: resourceName || '' }); + }); + } else { + options.push({ value: rawValue, column: '', resource: resourceName || '' }); + } + return options; + }, + + // DP+ records what each resource inferred on that resource, so the policy + // declared in the schema decides whether a field is offered every + // resource's answer or only the primary resource's. + // See TNRIS/texaswaterhub_CKAN#1088. + _collectFieldSuggestions: function(datasetObject, fieldName, policy) { + var self = this; + var collected = { present: false, options: [], is_valid: undefined, is_error: false }; + if (!datasetObject) return collected; + + var sources = (datasetObject.resources || []).filter(function(resource) { + return resource && resource.dpp_suggestions && resource.dpp_suggestions.package && + resource.dpp_suggestions.package.hasOwnProperty(fieldName); + }).map(function(resource) { + return { suggestions: resource.dpp_suggestions.package, name: resource.name || '' }; + }); + + // Datasets analysed before suggestions moved to the resource level + if (!sources.length) { + var legacy = datasetObject.dpp_suggestions && datasetObject.dpp_suggestions.package; + if (legacy && legacy.hasOwnProperty(fieldName)) { + sources = [{ suggestions: legacy, name: '' }]; + } } + if (!sources.length) return collected; + + // Anything but all_resources takes the primary resource. sources is in + // resource order, so that is the first entry — falling through to the + // first resource that has an answer when the primary has none. + if (policy !== 'all_resources') { + sources = sources.slice(0, 1); + } + + collected.present = true; + collected.is_valid = sources[0].suggestions[fieldName + '_is_valid']; + + var errors = []; + sources.forEach(function(source) { + var rawValue = source.suggestions[fieldName]; + if (self._isErrorSuggestion(rawValue)) { + errors.push({ value: rawValue, column: '', resource: source.name }); + return; + } + self._expandSuggestionOptions(rawValue, source.name).forEach(function(option) { + var duplicate = collected.options.some(function(existing) { + return String(existing.value) === String(option.value); + }); + if (!duplicate) collected.options.push(option); + }); + }); + + // Only surface an error when no resource produced anything usable + if (!collected.options.length && errors.length) { + collected.options = errors.slice(0, 1); + collected.is_error = true; + } + + return collected; + }, + + _processDppButtonSuggestions: function(datasetObject) { + var self = this; $('button[data-module="scheming-suggestions"]').each(function() { var $buttonEl = $(this); var fieldName = $buttonEl.data('field-name'); @@ -165,13 +251,15 @@ ckan.module('scheming-suggestions', function($) { } var fieldSchema = typeof fieldSchemaJson === 'string' ? JSON.parse(fieldSchemaJson) : fieldSchemaJson; - if (dppPackageSuggestions.hasOwnProperty(fieldName)) { - var suggestionValue = dppPackageSuggestions[fieldName]; - var isErrorSuggestion = typeof suggestionValue === 'string' && suggestionValue.startsWith(self.options.errorPrefix); + var collected = self._collectFieldSuggestions(datasetObject, fieldName, fieldSchema.suggestion_policy); + + if (collected.present && collected.options.length) { + var isErrorSuggestion = collected.is_error; + var suggestionValue = collected.options[0].value; var suggestionLabel = fieldSchema.suggestion_label || fieldSchema.label || 'Suggestion'; - var suggestionFormula = fieldSchema.suggestion_formula || 'N/A'; + var suggestionFormula = fieldSchema.suggestion_formula || 'N/A'; var isSelect = fieldSchema.is_select; - var isValidSuggestion = dppPackageSuggestions[fieldName + '_is_valid']; + var isValidSuggestion = collected.is_valid; if (isValidSuggestion === undefined) { isValidSuggestion = true; if (isSelect && fieldSchema.choices && fieldSchema.choices.length > 0) { @@ -192,7 +280,8 @@ ckan.module('scheming-suggestions', function($) { } self._populatePopoverContent($buttonEl, self._popoverDivs[fieldName], { - value: suggestionValue, label: suggestionLabel, formula: suggestionFormula, + value: suggestionValue, options: collected.options, + label: suggestionLabel, formula: suggestionFormula, is_select: isSelect, is_valid: isErrorSuggestion ? false : isValidSuggestion, field_name: fieldName, is_error: isErrorSuggestion }); @@ -334,6 +423,66 @@ ckan.module('scheming-suggestions', function($) { if (updatedFieldsLog.length > 0) console.log("SchemingSuggestions: _updateLiveDatasetAndFormulaFields updated:", updatedFieldsLog); }, + _checkDatapusherStatus: function() { + var self = this; + + // Get the first resource from the dataset to check its status + $.ajax({ + url: (ckan.SITE_ROOT || '') + '/api/3/action/package_show', + data: { id: globalState.datasetId, include_tracking: false }, + dataType: 'json', + cache: false, + success: function(response) { + if (response.success && response.result && response.result.resources && response.result.resources.length > 0) { + // Check the first resource's datapusher status + var resourceId = response.result.resources[0].id; + + $.ajax({ + url: (ckan.SITE_ROOT || '') + '/api/3/action/datapusher_status', + data: { resource_id: resourceId }, + dataType: 'json', + cache: false, + success: function(statusResponse) { + if (statusResponse.success && statusResponse.result) { + var status = statusResponse.result; + var errorMessage = null; + + // Check for errors in the same way the datastore page does + if (status.error && status.error.message) { + errorMessage = status.error.message; + } else if (status.task_info && status.task_info.error) { + if (typeof status.task_info.error === 'string') { + errorMessage = status.task_info.error; + } else if (status.task_info.error.message) { + errorMessage = status.task_info.error.message; + } + } + + // If there's an error, show it on the banner + if (errorMessage) { + var packageId = response.result.id || response.result.name; + var datastoreUrl = (ckan.SITE_ROOT || '') + '/dataset/' + packageId + '/resource/' + resourceId; + var errorBannerMsg = ' Error processing dataset: ' + + $('
').text(errorMessage).html() + + '
View details in Datastore tab'; + + self._updateProcessingBanner(errorBannerMsg, 'scheming-alert-danger'); + globalState.isPolling = false; + } + } + }, + error: function() { + // Silently fail - not critical + } + }); + } + }, + error: function() { + // Silently fail - not critical + } + }); + }, + _pollForSuggestions: function() { var self = this; @@ -393,6 +542,9 @@ ckan.module('scheming-suggestions', function($) { }); } + // Also check datapusher_status for errors + self._checkDatapusherStatus(); + $.ajax({ url: (ckan.SITE_ROOT || '') + '/api/3/action/package_show', data: { id: globalState.datasetId, include_tracking: false }, @@ -413,11 +565,7 @@ ckan.module('scheming-suggestions', function($) { return; } - if (dppSuggestionsData && dppSuggestionsData.package) { - self._processDppButtonSuggestions(dppSuggestionsData.package); - } else { - self._processDppButtonSuggestions(null); - } + self._processDppButtonSuggestions(datasetObject); var currentDppStatus = (dppSuggestionsData && dppSuggestionsData.STATUS) ? dppSuggestionsData.STATUS.toUpperCase() : null; if (currentDppStatus === 'DONE') { @@ -428,7 +576,6 @@ ckan.module('scheming-suggestions', function($) { self._updateLiveDatasetAndFormulaFields(null, dppSuggestionsData); // Only update dpp_suggestions field } - if (currentDppStatus) { if (self.options.terminalStatuses.includes(currentDppStatus)) { if (currentDppStatus === 'DONE') { @@ -438,23 +585,21 @@ ckan.module('scheming-suggestions', function($) { } } else { // ERROR, FAILED if ($('#scheming-processing-banner').length > 0) { - self._updateProcessingBanner(self.options.statusErrorText + esc(dppSuggestionsData.STATUS) + '', 'scheming-alert-danger'); + var errorMsg = self.options.statusErrorText + esc(dppSuggestionsData.STATUS) + ''; + + // If there's an ERROR_MESSAGE field, display it with a link to datastore + if (dppSuggestionsData.ERROR_MESSAGE) { + var packageId = datasetObject.id || datasetObject.name; + var datastoreUrl = (ckan.SITE_ROOT || '') + '/dataset/' + packageId + '#datastore-tab'; + errorMsg = ' Error processing dataset: ' + + esc(dppSuggestionsData.ERROR_MESSAGE) + + '
View details in Datastore tab
'; + } + + self._updateProcessingBanner(errorMsg, 'scheming-alert-danger'); } } globalState.isPolling = false; return; - } else { // Ongoing status - self._updateProcessingBanner(self.options.statusProcessingTextPrefix + esc(dppSuggestionsData.STATUS) + '', 'scheming-alert-info'); - setTimeout(function() { self._pollForSuggestions(); }, self.options.pollingInterval); - } - } else { // No STATUS in dpp_suggestions - console.warn("SchemingSuggestions: Poll " + globalState.pollAttempts + ": dpp_suggestions object has no STATUS field."); - if (globalState.pollAttempts < self.options.maxPollAttempts) { - setTimeout(function() { self._pollForSuggestions(); }, self.options.pollingInterval); - } else { // Max attempts reached with no status - if (!$('#scheming-processing-banner').hasClass('scheming-alert-success') && !$('#scheming-processing-banner').hasClass('scheming-alert-danger')) { - self._updateProcessingBanner(' Processing status unclear. Max attempts reached.', 'scheming-alert-warning'); - } - globalState.isPolling = false; } } } else { // API success:false or no result @@ -483,6 +628,59 @@ ckan.module('scheming-suggestions', function($) { }); }, _populatePopoverContent: function($buttonEl, $popoverDiv, suggestionData) { + // More than one candidate — from several resources, several date + // columns, or both — so let the curator pick which one to apply. + var suggestionOptions = suggestionData.options || []; + if (suggestionOptions.length > 1) { + var radioOptionsHtml = suggestionOptions.map(function(option, index) { + var radioId = 'date-option-' + suggestionData.field_name + '-' + index; + var originParts = []; + if (option.resource) originParts.push(option.resource); + if (option.column) originParts.push(option.column); + var origin = originParts.join(' › '); + return ` +
+ + +
`; + }).join(''); + + $popoverDiv.html(` +
+ ${esc(suggestionData.label)} +
+

${suggestionOptions.length} suggestions found. Select the one to apply:

+ ${radioOptionsHtml} +
+
+ +
+ + +
`); + this._attachActionHandlers($popoverDiv, suggestionData.field_name); + return; + } + var popoverContentHtml = `
${esc(suggestionData.label)} @@ -586,12 +784,23 @@ ckan.module('scheming-suggestions', function($) { e.preventDefault(); e.stopPropagation(); if ($(this).hasClass('suggestion-apply-btn-disabled')) return; var targetId = $(this).data('target'); + var isMultipleOptions = $(this).data('multiple-options'); var suggestionValue = $(this).data('value'); var isValid = $(this).data('is-valid') !== false; var $target = $('#' + targetId); if (!$target.length) { console.error("Scheming Popover Apply: Target not found:", targetId); return; } + if (isMultipleOptions) { + var $selectedRadio = $popoverDiv.find('input[name="date-options-' + fieldName + '"]:checked'); + if (!$selectedRadio.length) { + self._showTemporaryMessage($target, "Please select a suggestion.", 'suggestion-warning-message', '#e67e22'); + return; + } + suggestionValue = $selectedRadio.val(); + isValid = true; + } + var applySuccess = false; if (self._setFieldValue($target, suggestionValue, targetId.substring(6))) { applySuccess = true; diff --git a/ckanext/scheming/assets/styles/scheming.css b/ckanext/scheming/assets/styles/scheming.css index af450f031..811ea992f 100644 --- a/ckanext/scheming/assets/styles/scheming.css +++ b/ckanext/scheming/assets/styles/scheming.css @@ -337,4 +337,41 @@ input.formula-apply-error, textarea.formula-apply-error, select.formula-apply-er #dataset-edit div[class*="control-group"] > label > span.preformulated-icon > svg { vertical-align: middle; margin-left: 5px; -} \ No newline at end of file +} +/* Popover list shown when a field has more than one suggestion — several + resources each offering a date range, several date columns, or both. */ +.multiple-date-options { + margin: 15px 0; + padding: 16px; + background-color: #f8f9fa; + border-radius: 8px; + border-left: 4px solid #007bff; +} + +.multiple-date-options p { + margin: 0 0 12px 0; + font-weight: 500; + color: #495057; + font-size: 13px; +} + +.date-option-row { + display: flex; + align-items: center; + margin: 6px 0; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid transparent; + transition: all 0.2s ease; +} + +.date-option-row:hover { + background-color: rgba(0, 123, 255, 0.08); + border-color: rgba(0, 123, 255, 0.2); +} + +.date-option-row label { + margin: 0 0 0 8px; + font-weight: normal; + cursor: pointer; +} diff --git a/ckanext/scheming/assets/webassets.yml b/ckanext/scheming/assets/webassets.yml index 841b59392..b5da09cc3 100644 --- a/ckanext/scheming/assets/webassets.yml +++ b/ckanext/scheming/assets/webassets.yml @@ -1,4 +1,5 @@ scheming_css: + filters: cssmin output: ckanext-scheming/%(version)s_scheming_css.css contents: - styles/scheming.css @@ -28,4 +29,4 @@ multiple_text: # preload: # - base/main # contents: -# - js/scheming-suggestions.js \ No newline at end of file +# - js/scheming-suggestions.js