diff --git a/ckanext/scheming/assets/js/scheming-suggestions.js b/ckanext/scheming/assets/js/scheming-suggestions.js
new file mode 100644
index 000000000..351df3bff
--- /dev/null
+++ b/ckanext/scheming/assets/js/scheming-suggestions.js
@@ -0,0 +1,606 @@
+if (typeof window._schemingSuggestionsGlobalState === 'undefined') {
+ window._schemingSuggestionsGlobalState = {
+ datasetId: null,
+ globalInitDone: false,
+ pollAttempts: 0,
+ isPolling: false,
+ isInitialLoadWithExistingSuggestions: false
+ };
+}
+
+ckan.module('scheming-suggestions', function($) {
+ var esc = function(str) { return typeof str === 'string' ? $('
').text(str).html() : str; };
+ var globalState = window._schemingSuggestionsGlobalState;
+
+ return {
+ options: {
+ pollingInterval: 2500,
+ maxPollAttempts: 40,
+ initialButtonTitle: 'Suggestion available (loading...)',
+ noSuggestionTitle: 'No suggestion currently available',
+ suggestionReadyTitle: 'Suggestion Available',
+ suggestionErrorTitle: 'Error in suggestion',
+ processingMessage: '
Processing dataset, suggestions will appear shortly...',
+ statusProcessingTextPrefix: '
Status: ',
+ statusDoneText: ' Suggestions processed. Fields updated.',
+ statusErrorText: ' Error processing suggestions. Status: ',
+ timeoutMessage: ' Suggestions are taking longer than usual to process.',
+ errorMessage: ' Could not retrieve suggestions at this time.',
+ errorPrefix: '#ERROR!:', // User confirmed prefix
+ terminalStatuses: ['DONE', 'ERROR', 'FAILED']
+ },
+ _popoverDivs: {},
+ _originalButtonTitles: {},
+
+ initialize: function() {
+ var self = this;
+ var el = this.el;
+ var $form = $(el).closest('form');
+ var foundDatasetId = null;
+
+ // Dataset ID acquisition (run only if global ID not set)
+ if (!globalState.datasetId) {
+ if ($form.length && $form.data('dataset-id')) foundDatasetId = $form.data('dataset-id');
+ else if ($form.length && $form.find('input[name="id"]').val()) foundDatasetId = $form.find('input[name="id"]').val();
+ else if ($form.length && $form.find('input[name="pkg_name"]').val()) foundDatasetId = $form.find('input[name="pkg_name"]').val();
+ else if ($('body').data('dataset-id')) foundDatasetId = $('body').data('dataset-id');
+ else {
+ var pathArray = window.location.pathname.split('/');
+ var datasetIndex = pathArray.indexOf('dataset');
+ var editIndex = pathArray.indexOf('edit');
+ if (datasetIndex !== -1 && editIndex !== -1 && editIndex === datasetIndex + 1 && pathArray.length > editIndex + 1) {
+ var potentialId = pathArray[editIndex + 1];
+ if (potentialId && potentialId.length > 5) foundDatasetId = potentialId;
+ }
+ }
+ if (foundDatasetId) {
+ globalState.datasetId = foundDatasetId;
+ }
+ }
+
+ var fieldName = $(el).data('field-name');
+ if (!fieldName) return;
+
+ this._originalButtonTitles[fieldName] = $(el).attr('title');
+ $(el).attr('title', this.options.initialButtonTitle);
+ var popoverId = 'custom-suggestion-popover-' + fieldName + '-' + Date.now();
+ this._popoverDivs[fieldName] = $('').appendTo('body');
+ $(el).hide();
+ this._showFieldLoadingIndicator(el);
+
+ if (!globalState.globalInitDone) {
+ globalState.globalInitDone = true;
+ if (!globalState.isPolling) this._pollForSuggestions(); // Start polling if not already
+ }
+ this._attachBaseEventHandlers(el, this._popoverDivs[fieldName], fieldName);
+ },
+
+ _showAllButtonsAsNoSuggestion: function() {
+ var self = this;
+ $('button[data-module="scheming-suggestions"]').each(function() {
+ var currentButton = $(this);
+ currentButton.attr('title', self.options.noSuggestionTitle).show();
+ self._hideFieldLoadingIndicator(currentButton);
+ });
+ },
+ _showFieldLoadingIndicator: function(buttonEl) {
+ var $controlGroup = $(buttonEl).closest('.control-group.has-suggestion');
+ if ($controlGroup.length === 0) $controlGroup = $(buttonEl).closest('.form-group.has-suggestion');
+ var $label = $controlGroup.find('.control-label, .form-label').first();
+ if ($label.length && $label.find('.suggestion-field-loader').length === 0) {
+ $label.append(' ');
+ }
+ },
+ _hideFieldLoadingIndicator: function(buttonEl) {
+ var $controlGroup = $(buttonEl).closest('.control-group.has-suggestion');
+ if ($controlGroup.length === 0) $controlGroup = $(buttonEl).closest('.form-group.has-suggestion');
+ $controlGroup.find('.suggestion-field-loader').remove();
+ },
+ _showProcessingBanner: function() {
+ var self = this;
+ if ($('#scheming-processing-banner').length === 0) {
+ var bannerHtml = '' +
+ self.options.processingMessage +
+ '
';
+ var $formContainer = $('.primary.span9').first();
+ if ($formContainer.length === 0) $formContainer = $('form.dataset-form, form#dataset-edit').first();
+ if ($formContainer.length === 0) $formContainer = $('main .container, #content .container').first();
+ if ($formContainer.length) $formContainer.prepend(bannerHtml);
+ else $('body').prepend(bannerHtml);
+ }
+ },
+ _updateProcessingBanner: function(message, alertClass) {
+ var $banner = $('#scheming-processing-banner');
+ if ($banner.length === 0 && message) { // If banner doesn't exist, create it
+ this._showProcessingBanner();
+ $banner = $('#scheming-processing-banner');
+ }
+ if ($banner.length) {
+ var timestamp = new Date().toLocaleTimeString();
+ $banner.html(message + ' (as of ' + timestamp + ')')
+ .removeClass('scheming-alert-info scheming-alert-warning scheming-alert-danger scheming-alert-success')
+ .addClass(alertClass || 'scheming-alert-info');
+ }
+ },
+ _removeProcessingBanner: function() {
+ $('#scheming-processing-banner').fadeOut(function() { $(this).remove(); });
+ },
+
+ _processDppButtonSuggestions: function(dppPackageSuggestions) {
+ var self = this;
+ if (!dppPackageSuggestions) {
+ return;
+ }
+ $('button[data-module="scheming-suggestions"]').each(function() {
+ var $buttonEl = $(this);
+ var fieldName = $buttonEl.data('field-name');
+ var fieldSchemaJson = $buttonEl.data('field-schema');
+ if (!fieldSchemaJson) {
+ self._hideFieldLoadingIndicator($buttonEl);
+ $buttonEl.attr('title', self.options.noSuggestionTitle).show();
+ return;
+ }
+ 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 suggestionLabel = fieldSchema.suggestion_label || fieldSchema.label || 'Suggestion';
+ var suggestionFormula = fieldSchema.suggestion_formula || 'N/A';
+ var isSelect = fieldSchema.is_select;
+ var isValidSuggestion = dppPackageSuggestions[fieldName + '_is_valid'];
+ if (isValidSuggestion === undefined) {
+ isValidSuggestion = true;
+ if (isSelect && fieldSchema.choices && fieldSchema.choices.length > 0) {
+ isValidSuggestion = fieldSchema.choices.some(function(choice){ return String(choice.value) === String(suggestionValue); });
+ }
+ }
+
+ if (isErrorSuggestion) {
+ $buttonEl.addClass('suggestion-btn-error'); $buttonEl.attr('title', self.options.suggestionErrorTitle); suggestionLabel = 'Suggestion Error';
+ } else {
+ $buttonEl.removeClass('suggestion-btn-error'); $buttonEl.attr('title', self.options.suggestionReadyTitle);
+ }
+
+ if (!self._popoverDivs[fieldName]) {
+ var popoverId = 'custom-suggestion-popover-' + fieldName + '-' + Date.now();
+ self._popoverDivs[fieldName] = $('').appendTo('body');
+ self._attachBaseEventHandlers($buttonEl, self._popoverDivs[fieldName], fieldName);
+ }
+
+ self._populatePopoverContent($buttonEl, self._popoverDivs[fieldName], {
+ value: suggestionValue, label: suggestionLabel, formula: suggestionFormula,
+ is_select: isSelect, is_valid: isErrorSuggestion ? false : isValidSuggestion,
+ field_name: fieldName, is_error: isErrorSuggestion
+ });
+ $buttonEl.show();
+
+ } else {
+ $buttonEl.attr('title', self.options.noSuggestionTitle).show();
+ }
+ self._hideFieldLoadingIndicator($buttonEl);
+ });
+ },
+
+ _setFieldValue: function($target, value, fieldNameForLog) {
+ var self = this;
+ var fieldId = fieldNameForLog || $target.data('scheming-field-name') || $target.attr('name') || $target.attr('id');
+ var success = false;
+
+ if ($target.is('textarea, input[type="text"], input[type="number"], input[type="email"], input[type="url"], input[type="hidden"], input:not([type="button"], [type="submit"], [type="reset"], [type="image"], [type="file"], [type="radio"], [type="checkbox"])')) {
+ var currentValue = $target.val();
+ var newValueString = (value === null || value === undefined) ? "" : String(value);
+ if (currentValue !== newValueString) {
+ $target.val(newValueString).trigger('change');
+ }
+ success = true;
+ } else if ($target.is('select')) {
+ var originalValueStr = (value === null || value === undefined) ? "" : String(value);
+ var $optionToSelect = null;
+ var $optionByValueExact = $target.find("option[value='" + originalValueStr.replace(/'/g, "'") + "']");
+ if ($optionByValueExact.length > 0) $optionToSelect = $optionByValueExact.first();
+ else {
+ var foundLoose = false;
+ $target.find("option").each(function() {
+ if ($(this).val() == value) { $optionToSelect = $(this); foundLoose = true; return false; }
+ });
+ if (!foundLoose) {
+ var $optionByText = $target.find("option").filter(function() { return $(this).text() === originalValueStr; });
+ if ($optionByText.length > 0) $optionToSelect = $optionByText.first();
+ }
+ }
+ if ($optionToSelect) {
+ if ($target.val() !== $optionToSelect.val()) $target.val($optionToSelect.val()).trigger('change');
+ if (typeof $target.chosen === 'function') $target.trigger("chosen:updated");
+ if (typeof $target.select2 === 'function') $target.trigger('change.select2');
+ success = true;
+ } else {
+ console.warn("SchemingSuggestions: Value '" + esc(value) + "' not valid for select:", fieldId);
+ }
+ }
+ return success;
+ },
+
+ _updateLiveDatasetAndFormulaFields: function(datasetObject, dppSuggestionsObject) {
+ var self = this;
+ var updatedFieldsLog = [];
+
+ if (!datasetObject || typeof datasetObject !== 'object') {
+ console.warn("SchemingSuggestions: _updateLiveDatasetAndFormulaFields called with invalid datasetObject.");
+ return;
+ }
+
+ var dppFieldName = 'dpp_suggestions';
+ var $dppTextarea = $('#field-' + dppFieldName);
+ if ($dppTextarea.length === 0) $dppTextarea = $('textarea[data-scheming-field-name="' + dppFieldName + '"], textarea[name="' + dppFieldName + '"]');
+ if ($dppTextarea.length && dppSuggestionsObject && typeof dppSuggestionsObject === 'object') {
+ try {
+ var prettyJson = JSON.stringify(dppSuggestionsObject, null, 2);
+ if (self._setFieldValue($dppTextarea, prettyJson, dppFieldName)) {
+ $dppTextarea.addClass('formula-auto-applied');
+ setTimeout(function() { $dppTextarea.removeClass('formula-auto-applied'); }, 1200);
+ updatedFieldsLog.push(dppFieldName + " (JSON content)");
+ }
+ } catch (e) { console.error("SchemingSuggestions: Error handling '"+dppFieldName+"' field:", e); }
+ }
+
+ $('form.dataset-form [data-scheming-field-name][data-is-formula-field="true"]').each(function() {
+ var $target = $(this);
+ var fieldName = $target.data('scheming-field-name');
+
+ if ($target.closest('.scheming-resource-fields, .resource-item, .repeating-template').length > 0) return; // Skip resource fields here
+
+ if (datasetObject.hasOwnProperty(fieldName)) {
+ var newValue = datasetObject[fieldName];
+ var isError = typeof newValue === 'string' && newValue.startsWith(self.options.errorPrefix);
+ $target.removeClass('formula-auto-applied formula-apply-error');
+ if (!isError) {
+ if (self._setFieldValue($target, newValue, fieldName)) {
+ $target.addClass('formula-auto-applied');
+ setTimeout(function() { $target.removeClass('formula-auto-applied'); }, 1200);
+ updatedFieldsLog.push("Dataset Formula: " + fieldName);
+ } else $target.addClass('formula-apply-error');
+ } else {
+ self._setFieldValue($target, newValue, fieldName); $target.addClass('formula-apply-error');
+ updatedFieldsLog.push("Dataset Formula (error set): " + fieldName);
+ }
+ } else {
+ }
+ });
+
+ if (datasetObject.resources && Array.isArray(datasetObject.resources)) {
+ datasetObject.resources.forEach(function(resource, resourceIndex) {
+ // Try to find the DOM element for this resource
+ var $resourceForm = null;
+ if (resource.id) {
+ $resourceForm = $('.resource-item input[name$="].id"][value="'+resource.id+'"]').closest('.resource-item');
+ if ($resourceForm.length === 0) { // Try another common selector
+ $resourceForm = $('.scheming-resource-fields input[name$="].id"][value="'+resource.id+'"]').closest('.scheming-resource-fields');
+ }
+ }
+ if (!$resourceForm || $resourceForm.length === 0) {
+ $resourceForm = $('.resource-item, .scheming-resource-fields').eq(resourceIndex);
+ }
+
+ if ($resourceForm && $resourceForm.length) {
+ $resourceForm.find('[data-scheming-field-name][data-is-formula-field="true"]').each(function() {
+ var $target = $(this);
+ var fieldName = $target.data('scheming-field-name');
+ if (resource.hasOwnProperty(fieldName)) {
+ var newValue = resource[fieldName];
+ var isError = typeof newValue === 'string' && newValue.startsWith(self.options.errorPrefix);
+ $target.removeClass('formula-auto-applied formula-apply-error');
+ if (!isError) {
+ if (self._setFieldValue($target, newValue, fieldName)) {
+ $target.addClass('formula-auto-applied');
+ setTimeout(function() { $target.removeClass('formula-auto-applied'); }, 1200);
+ updatedFieldsLog.push("Resource[" + (resource.id || resourceIndex) + "] Formula: " + fieldName);
+ } else $target.addClass('formula-apply-error');
+ } else {
+ self._setFieldValue($target, newValue, fieldName); $target.addClass('formula-apply-error');
+ updatedFieldsLog.push("Resource[" + (resource.id || resourceIndex) + "] Formula (error set): " + fieldName);
+ }
+ }
+ });
+ } else {
+ // console.warn("SchemingSuggestions: Could not find DOM for resource index " + resourceIndex + " (ID: " + (resource.id || 'N/A') + ")");
+ }
+ });
+ }
+
+ if (updatedFieldsLog.length > 0) console.log("SchemingSuggestions: _updateLiveDatasetAndFormulaFields updated:", updatedFieldsLog);
+ },
+
+ _pollForSuggestions: function() {
+ var self = this;
+
+ if (!globalState.datasetId) {
+ if (globalState.pollAttempts === 0) {
+ globalState.pollAttempts++;
+ setTimeout(function() { self._pollForSuggestions(); }, self.options.pollingInterval + 1000);
+ return;
+ }
+ self._updateProcessingBanner(self.options.errorMessage + " (Dataset ID missing)", 'scheming-alert-danger');
+ self._showAllButtonsAsNoSuggestion(); globalState.isPolling = false; return;
+ }
+
+ if (globalState.isPolling && globalState.pollAttempts > 0 && !this._isFirstPollerInstance) {
+ }
+
+
+ if (globalState.pollAttempts >= self.options.maxPollAttempts) {
+ if (!$('#scheming-processing-banner').hasClass('scheming-alert-success') && !$('#scheming-processing-banner').hasClass('scheming-alert-danger')) {
+ self._updateProcessingBanner(self.options.timeoutMessage, 'scheming-alert-warning');
+ }
+ self._showAllButtonsAsNoSuggestion(); globalState.isPolling = false; return;
+ }
+
+ if (globalState.pollAttempts === 0) { // First actual poll attempt for a dataset ID
+ if (globalState.isPolling) { // Already polling from another instance, this one should not start another.
+ return;
+ }
+ globalState.isPolling = true;
+ this._isFirstPollerInstance = true; // This instance is managing the poll loop.
+ } else if (!globalState.isPolling) { // Should not happen if logic is correct, but as a safeguard
+ globalState.isPolling = true;
+ }
+ if (globalState.pollAttempts === 0 && $('#scheming-processing-banner').length === 0) {
+ var self = this;
+ // Quick check to see if we should show the banner
+ $.ajax({
+ url: (ckan.SITE_ROOT || '') + '/api/3/action/package_show',
+ data: { id: globalState.datasetId, include_tracking: false },
+ dataType: 'json',
+ cache: false,
+ async: false, // Make synchronous just for this initial check
+ success: function(response) {
+ if (response.success && response.result && response.result.dpp_suggestions) {
+ var status = response.result.dpp_suggestions.STATUS;
+ if (!status || !self.options.terminalStatuses.includes(status.toUpperCase())) {
+ self._showProcessingBanner();
+ }
+ } else {
+ self._showProcessingBanner();
+ }
+ },
+ error: function() {
+ self._showProcessingBanner();
+ }
+ });
+ }
+
+ $.ajax({
+ url: (ckan.SITE_ROOT || '') + '/api/3/action/package_show',
+ data: { id: globalState.datasetId, include_tracking: false },
+ dataType: 'json',
+ cache: false,
+ success: function(response) {
+ globalState.pollAttempts++;
+
+ if (response.success && response.result) {
+ var datasetObject = response.result;
+ var dppSuggestionsData = datasetObject.dpp_suggestions; // This is the direct JSON object
+
+ if (dppSuggestionsData && dppSuggestionsData.package) {
+ self._processDppButtonSuggestions(dppSuggestionsData.package);
+ } else {
+ self._processDppButtonSuggestions(null);
+ }
+ var currentDppStatus = (dppSuggestionsData && dppSuggestionsData.STATUS) ? dppSuggestionsData.STATUS.toUpperCase() : null;
+
+ if (currentDppStatus === 'DONE') {
+ console.log("SchemingSuggestions: STATUS is DONE. Applying final updates to formula fields from main dataset object.");
+ self._updateLiveDatasetAndFormulaFields(datasetObject, dppSuggestionsData);
+ globalState.isInitialLoadWithExistingSuggestions = (globalState.pollAttempts === 1);
+ } else if (dppSuggestionsData) { // If dpp_suggestions exists, update its textarea
+ self._updateLiveDatasetAndFormulaFields(null, dppSuggestionsData); // Only update dpp_suggestions field
+ }
+
+
+ if (currentDppStatus) {
+ if (self.options.terminalStatuses.includes(currentDppStatus)) {
+ if (currentDppStatus === 'DONE') {
+ if (!globalState.isInitialLoadWithExistingSuggestions) {
+ self._updateProcessingBanner(self.options.statusDoneText, 'scheming-alert-success');
+ setTimeout(function() { self._removeProcessingBanner(); }, 5000);
+ }
+ } else { // ERROR, FAILED
+ if ($('#scheming-processing-banner').length > 0) {
+ self._updateProcessingBanner(self.options.statusErrorText + esc(dppSuggestionsData.STATUS) + '', '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
+ console.error("SchemingSuggestions: Poll " + globalState.pollAttempts + ": API error/unexpected structure.", response);
+ if (globalState.pollAttempts < self.options.maxPollAttempts) {
+ setTimeout(function() { self._pollForSuggestions(); }, self.options.pollingInterval * 1.5);
+ } else {
+ self._updateProcessingBanner(self.options.errorMessage + " (API response error)", 'scheming-alert-danger');
+ self._showAllButtonsAsNoSuggestion(); globalState.isPolling = false;
+ }
+ }
+ },
+ error: function(jqXHR, textStatus, errorThrown) {
+ console.error("SchemingSuggestions: Poll " + (globalState.pollAttempts +1) + ": AJAX Error:", textStatus, errorThrown, jqXHR.status, jqXHR.responseText);
+ globalState.pollAttempts++;
+ if (globalState.pollAttempts < self.options.maxPollAttempts) {
+ var nextPollDelay = self.options.pollingInterval * Math.pow(1.2, Math.min(globalState.pollAttempts, 7));
+ setTimeout(function() { self._pollForSuggestions(); }, nextPollDelay);
+ } else {
+ if (!$('#scheming-processing-banner').hasClass('scheming-alert-success')) { // Don't overwrite if it somehow succeeded then errored
+ self._updateProcessingBanner(self.options.errorMessage + " (API connection error)", 'scheming-alert-danger');
+ }
+ self._showAllButtonsAsNoSuggestion(); globalState.isPolling = false;
+ }
+ }
+ });
+ },
+ _populatePopoverContent: function($buttonEl, $popoverDiv, suggestionData) {
+ var popoverContentHtml = `
+
+
${esc(suggestionData.label)}
+ ${suggestionData.is_error ? `
+
+
+ The suggestion could not be generated correctly:
+
` : ''}
+ ${suggestionData.is_select && !suggestionData.is_valid && !suggestionData.is_error ? `
+
+
+ This value is not a valid choice for this field.
+
` : ''}
+
${esc(suggestionData.value)}
+ ${!suggestionData.is_error ? `
+
+
+
+
+
+ ${esc(suggestionData.formula)}
+
` : ''}
+
+
`;
+ $popoverDiv.html(popoverContentHtml);
+ this._attachActionHandlers($popoverDiv, suggestionData.field_name);
+ },
+ _attachBaseEventHandlers: function(el, $popoverDiv, fieldName) {
+ var self = this;
+ $(el).on('click', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ if ($popoverDiv.is(':empty') && !$popoverDiv.html().trim()) { return; } // Check if truly empty
+ $('.custom-suggestion-popover').not($popoverDiv).hide();
+ var buttonPos = $(el).offset();
+ var windowWidth = $(window).width();
+ var popoverCalculatedWidth = Math.min(380, windowWidth - 40);
+ var leftPos = buttonPos.left;
+ if (buttonPos.left + popoverCalculatedWidth > windowWidth - 20) {
+ leftPos = Math.max(20, windowWidth - popoverCalculatedWidth - 20);
+ }
+ var topPos = buttonPos.top + $(el).outerHeight() + 10;
+ if (topPos < $(window).scrollTop()){ topPos = $(window).scrollTop() + 10; }
+ $popoverDiv.css({ position: 'absolute', top: topPos, left: leftPos, width: popoverCalculatedWidth + 'px', zIndex: 1050 }).toggle();
+ });
+ },
+ _attachActionHandlers: function($popoverDiv, fieldName) {
+ var self = this;
+ $(document).off('click.schemingSuggestionsGlobal.' + fieldName).on('click.schemingSuggestionsGlobal.' + fieldName, function(e) {
+ if (!$(e.target).closest($popoverDiv).length && !$(e.target).closest('button[data-field-name="'+fieldName+'"]').length) {
+ $popoverDiv.hide();
+ }
+ });
+ $popoverDiv.off('click.formulaToggle').on('click.formulaToggle', '.formula-toggle-btn', function(e) {
+ e.preventDefault(); e.stopPropagation();
+ var $formulaSection = $(this).closest('.suggestion-popover-content').find('.suggestion-formula');
+ var $toggleIcon = $(this).find('.formula-toggle-icon');
+ var $toggleText = $(this).find('.formula-toggle-text');
+ $formulaSection.slideToggle(200, function() {
+ if ($formulaSection.is(':visible')) {
+ $toggleIcon.html('▲'); $toggleText.text('Hide formula'); $(this).closest('.formula-toggle').addClass('formula-toggle-active');
+ } else {
+ $toggleIcon.html('▼'); $toggleText.text('Show formula'); $(this).closest('.formula-toggle').removeClass('formula-toggle-active');
+ }
+ });
+ });
+ $popoverDiv.off('click.copyFormula').on('click.copyFormula', '.copy-formula-btn', function(e) {
+ e.preventDefault(); e.stopPropagation();
+ var formula = $(this).data('formula');
+ var $copyBtn = $(this);
+ navigator.clipboard.writeText(formula).then(function() {
+ var $iconContainer = $copyBtn.find('svg').parent();
+ var originalIcon = $iconContainer.html();
+ $iconContainer.html('
');
+ $copyBtn.addClass('copy-success');
+ setTimeout(function() { $copyBtn.removeClass('copy-success'); $iconContainer.html(originalIcon); }, 2000);
+ }).catch(function(err) {
+ console.error('Could not copy formula: ', err);
+ self._showTemporaryMessage(null, "Could not copy formula.", 'suggestion-warning-message', '#e67e22');
+ });
+ });
+ $popoverDiv.off('click.applySugg').on('click.applySugg', '.suggestion-apply-btn', function(e) {
+ e.preventDefault(); e.stopPropagation();
+ if ($(this).hasClass('suggestion-apply-btn-disabled')) return;
+ var targetId = $(this).data('target');
+ 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; }
+
+ var applySuccess = false;
+ if (self._setFieldValue($target, suggestionValue, targetId.substring(6))) {
+ applySuccess = true;
+ } else if (!$target.is('select')) {
+ self._showTemporaryMessage($target, "Could not apply suggestion to this field type.", 'suggestion-warning-message', '#e67e22');
+ }
+
+ if (applySuccess) {
+ $target.addClass('suggestion-applied');
+ setTimeout(function() { $target.removeClass('suggestion-applied'); }, 1200);
+ self._showTemporaryMessage($target, "Suggestion applied!", 'suggestion-success-message', 'rgba(40, 167, 69, 0.95)');
+ } else if ($target.is('select') && !isValid) {
+ self._showTemporaryMessage($target, "The suggested value is not a valid option.", 'suggestion-warning-message', '#e67e22');
+ $target.addClass('suggestion-invalid');
+ setTimeout(function() { $target.removeClass('suggestion-invalid'); }, 3000);
+ }
+ $popoverDiv.hide();
+ });
+ },
+ _showTemporaryMessage: function($targetElement, message, cssClass, bgColor) {
+ $('.scheming-temp-message').remove();
+ var $msg = $('
').addClass('scheming-temp-message ' + cssClass).text(message).css({
+ position: 'fixed', bottom: '20px', left: '50%', transform: 'translateX(-50%)',
+ backgroundColor: bgColor || '#333', color: 'white', padding: '10px 20px',
+ borderRadius: '6px', fontSize: '14px', fontWeight: '500', zIndex: 2000,
+ opacity: 0, boxShadow: '0 5px 15px rgba(0,0,0,0.2)'
+ });
+ $('body').append($msg);
+ $msg.animate({opacity: 1, bottom: '30px'}, 300, 'swing')
+ .delay(2500)
+ .animate({opacity: 0, bottom: '20px'}, 300, 'swing', function() { $(this).remove(); });
+ },
+ finalize: function() {
+ var fieldName = this.el && $(this.el).data('field-name');
+ if (fieldName) {
+ $(document).off('click.schemingSuggestionsGlobal.' + fieldName);
+ if (this._popoverDivs[fieldName]) {
+ this._popoverDivs[fieldName].remove();
+ delete this._popoverDivs[fieldName];
+ }
+ }
+ }
+ };
+});
diff --git a/ckanext/scheming/assets/resource.config b/ckanext/scheming/assets/resource.config
index 50d1622f5..38996f515 100644
--- a/ckanext/scheming/assets/resource.config
+++ b/ckanext/scheming/assets/resource.config
@@ -5,3 +5,4 @@ main = base/main
main =
js/scheming-multiple-text.js
js/scheming-repeating-subfields.js
+ js/scheming-suggestions.js
\ No newline at end of file
diff --git a/ckanext/scheming/assets/styles/scheming.css b/ckanext/scheming/assets/styles/scheming.css
index 9e30967a8..af450f031 100644
--- a/ckanext/scheming/assets/styles/scheming.css
+++ b/ckanext/scheming/assets/styles/scheming.css
@@ -39,3 +39,302 @@ a.btn.btn-multiple-remove {
.radio-group label {
font-weight: normal;
}
+
+
+.form-label label::after {
+ content: none !important;
+}
+.control-group.has-suggestion .control-label {
+ position: relative; /* For potential loader positioning */
+ display: flex; /* To align label text and button nicely */
+ align-items: center; /* Vertical alignment */
+}
+
+
+.suggestion-field-loader {
+ margin-left: 5px; /* Space from label text */
+}
+
+.suggestion-field-loader i.fa {
+ color: #007bff; /* CKAN blue */
+ font-size: 0.9em;
+}
+
+/* Processing Banner & Temporary Messages */
+.scheming-alert {
+ padding: 15px 20px;
+ margin-bottom: 20px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ box-shadow: 0 3px 10px rgba(0,0,0,0.08);
+ transition: background-color 0.3s ease, color 0.3s ease; /* Smooth transitions */
+}
+.scheming-alert i.fa { margin-right: 12px; font-size: 1.3em; }
+
+.scheming-alert-info { background-color: #e7f3fe; border-color: #d0eaff; color: #0c5460; }
+.scheming-alert-success { background-color: #d4edda; border-color: #c3e6cb; color: #155724; }
+.scheming-alert-warning { background-color: #fff3cd; border-color: #ffeeba; color: #856404; }
+.scheming-alert-danger { background-color: #f8d7da; border-color: #f5c6cb; color: #721c24; }
+
+
+.suggestion-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ vertical-align: middle;
+ border: none;
+ background-color: transparent;
+ color: #2a9134; /* darker green */
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ position: relative;
+ top: -1px;
+}
+
+.suggestion-btn:hover, .suggestion-btn:focus {
+ background-color: rgba(0, 123, 255, 0.1);
+ transform: scale(1.1);
+ box-shadow: 0 2px 8px rgba(0,123,255,0.2);
+ outline: none;
+}
+
+.suggestion-btn svg {
+ width: 20px;
+ height: 20px;
+ fill: #2a9134; /* darker green */
+ transition: stroke 0.25s ease-in-out; /* Smooth color transition for icon */
+}
+/* Suggestion Button Error State */
+.suggestion-btn.suggestion-btn-error {
+ color: #dc3545; /* Bootstrap danger red */
+}
+.suggestion-btn.suggestion-btn-error svg {
+ stroke: #dc3545; /* Red icon stroke */
+}
+.suggestion-btn.suggestion-btn-error:hover,
+.suggestion-btn.suggestion-btn-error:focus {
+ background-color: rgba(220, 53, 69, 0.1); /* Light red background on hover */
+ box-shadow: 0 2px 8px rgba(220, 53, 69, 0.2);
+}
+
+
+.custom-suggestion-popover {
+ max-width: 380px;
+ width: auto;
+ background-color: #ffffff;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12), 0 4px 10px rgba(0,0,0,0.08);
+ padding: 18px;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ line-height: 1.5;
+}
+.custom-suggestion-popover:before { display: none; }
+
+.suggestion-popover-content strong {
+ display: block;
+ margin-bottom: 12px;
+ color: #212529;
+ font-size: 17px;
+ font-weight: 600;
+}
+
+.suggestion-value {
+ margin: 15px 0;
+ padding: 12px;
+ background-color: #f8f9fa;
+ border-radius: 6px;
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
+ word-break: break-all;
+ border-left: 4px solid #007bff;
+ font-size: 14px;
+ color: #343a40;
+ box-shadow: inset 0 1px 3px rgba(0,0,0,0.04);
+ max-height: 150px;
+ overflow-y: auto;
+}
+
+.formula-toggle { margin: 10px 0 5px 0; }
+.formula-toggle-btn {
+ background: none; border: none; color: #007bff; font-size: 13px;
+ font-weight: 500; cursor: pointer; padding: 5px 0; display: flex; align-items: center;
+ opacity: 0.9; transition: opacity 0.2s ease;
+}
+.formula-toggle-btn:hover { opacity: 1; text-decoration: underline; }
+.formula-toggle-active { opacity: 1; } /* Class for active (expanded) state */
+.formula-toggle-icon { font-size: 11px; margin-right: 6px; transition: transform 0.2s ease; }
+.formula-toggle-active .formula-toggle-icon { transform: rotate(180deg); } /* Icon rotation */
+
+
+.suggestion-formula {
+ margin: 8px 0 15px 0; padding: 12px; background-color: #e9ecef;
+ border-radius: 4px; font-size: 12px; line-height: 1.5; color: #495057;
+ border-left: 3px solid #28a745;
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
+}
+.formula-header {
+ display: flex; justify-content: space-between; align-items: center;
+ margin-bottom: 8px; font-weight: 500; color: #333; font-size: 12px;
+ text-transform: uppercase; letter-spacing: 0.5px;
+}
+.copy-formula-btn {
+ background: none; border: none; color: #007bff; cursor: pointer; padding: 4px;
+ display: flex; align-items: center; justify-content: center; opacity: 0.7;
+ transition: all 0.2s ease; border-radius: 4px;
+}
+.copy-formula-btn:hover { opacity: 1; background-color: rgba(0, 123, 255, 0.08); }
+.copy-formula-btn.copy-success { color: #28a745; opacity: 1; background-color: rgba(40, 167, 69, 0.1); }
+.copy-formula-btn svg { width: 16px; height: 16px; }
+.suggestion-formula code { white-space: pre-wrap; word-break: break-all; display: block; }
+
+/* Apply Button */
+.suggestion-apply-btn {
+ margin-top: 15px; padding: 10px 15px; font-size: 14px;
+ background-color: #28a745; color: white; border: none; border-radius: 5px;
+ cursor: pointer; display: block; width: 100%; font-weight: 500;
+ transition: all 0.2s ease; text-transform: none;
+ letter-spacing: 0; box-shadow: 0 2px 5px rgba(40, 167, 69, 0.25);
+}
+.suggestion-apply-btn:hover:not(.suggestion-apply-btn-disabled) {
+ background-color: #218838; transform: translateY(-1px);
+ box-shadow: 0 4px 8px rgba(40, 167, 69, 0.3);
+}
+.suggestion-apply-btn:active:not(.suggestion-apply-btn-disabled) {
+ transform: translateY(0); box-shadow: 0 1px 3px rgba(40, 167, 69, 0.3);
+}
+.suggestion-apply-btn-disabled {
+ background-color: #adb5bd !important; color: #495057 !important; /* Darker text on grey */
+ cursor: not-allowed !important; box-shadow: none !important; opacity: 0.65;
+}
+
+/* Popover Error State Styling */
+.suggestion-popover-error strong {
+ color: #721c24; /* Darker red for title in error popover */
+}
+.suggestion-error-text {
+ display: flex;
+ align-items: flex-start; /* Align icon and text nicely */
+ margin-top: 8px;
+ margin-bottom: 8px; /* Consistent margin */
+ padding: 10px;
+ background-color: #f8d7da;
+ border-radius: 6px;
+ border-left: 3px solid #dc3545;
+ font-size: 13px;
+ line-height: 1.4;
+ color: #721c24;
+}
+.suggestion-error-text svg {
+ flex-shrink: 0;
+ margin-right: 8px;
+ stroke: #dc3545;
+ margin-top: 2px;
+}
+.suggestion-value-error {
+ border-left-color: #dc3545 !important;
+ background-color: #f8d7da !important;
+ color: #721c24 !important;
+}
+
+
+/* Warning for invalid select */
+.suggestion-warning {
+ display: flex; /* For icon alignment */
+ align-items: flex-start;
+ padding: 10px; background-color: #fff3cd; border-left: 3px solid #ffc107;
+ color: #856404; border-radius: 4px; margin: 10px 0; font-size: 13px;
+}
+.suggestion-warning svg { stroke: #ffc107; margin-right: 8px; flex-shrink: 0; margin-top: 2px;}
+
+
+
+/* Applied field animation (for manual suggestion apply) */
+@keyframes highlightSuccessPulse {
+ 0% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.4); }
+ 70% { box-shadow: 0 0 0 10px rgba(40, 167, 69, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); }
+}
+input.suggestion-applied, textarea.suggestion-applied, select.suggestion-applied {
+ animation: highlightSuccessPulse 1s ease-out;
+ border-color: #28a745 !important;
+}
+
+/* NEW: Styles for auto-applied formula fields */
+@keyframes highlightAutoApplied {
+ 0% { background-color: rgba(0, 123, 255, 0); } /* Blueish highlight */
+ 50% { background-color: rgba(0, 123, 255, 0.12); }
+ 100% { background-color: rgba(0, 123, 255, 0); }
+}
+input.formula-auto-applied, textarea.formula-auto-applied, select.formula-auto-applied {
+ animation: highlightAutoApplied 1.2s ease-in-out;
+}
+
+/* NEW: Style for fields where auto-apply formula resulted in an error */
+@keyframes highlightFormulaError {
+ 0% { background-color: rgba(220, 53, 69, 0); } /* Reddish highlight */
+ 50% { background-color: rgba(220, 53, 69, 0.12); }
+ 100% { background-color: rgba(220, 53, 69, 0); }
+}
+input.formula-apply-error, textarea.formula-apply-error, select.formula-apply-error {
+ animation: highlightFormulaError 1.5s ease-in-out;
+ border-color: #dc3545 !important; /* Persistent red border for a bit if needed */
+}
+
+
+
+/* Responsive adjustments */
+@media (max-width: 767px) {
+ .custom-suggestion-popover {
+ width: calc(100% - 30px) !important;
+ max-width: none;
+ left: 15px !important;
+ right: 15px !important;
+ padding: 15px;
+ }
+ .suggestion-btn { width: 30px; height: 30px; }
+ .suggestion-btn svg { width: 18px; height: 18px; }
+ .control-group.has-suggestion .control-label {
+ flex-wrap: wrap; /* Allow button to wrap if label is very long */
+ }
+ .suggestion-btn {
+ margin-top: 4px; /* Add some space if it wraps */
+ margin-left: 0; /* Align to left if wrapped */
+ }
+}
+
+/* Styles for preformulated fields (from original CSS, ensure they are still relevant) */
+.preformulated-field {
+ position: relative;
+ background-color: #f9f9f9;
+ border-left: 3px solid #2a9134;
+ padding-left: 10px;
+}
+.preformulated-field input,
+.preformulated-field textarea,
+.preformulated-field select {
+ background-color: #f9f9f9 !important;
+ border-color: #e0e0e0 !important;
+ color: #555 !important;
+}
+.preformulated-field-notice {
+ display: block;
+ margin-top: 5px;
+ font-size: 12px;
+ color: #555;
+ font-style: italic;
+ background-color: #f3f3f3;
+ padding: 5px 10px;
+ border-radius: 3px;
+}
+
+
+#dataset-edit div[class*="control-group"] > label > span.preformulated-icon > svg {
+ vertical-align: middle;
+ margin-left: 5px;
+}
\ No newline at end of file
diff --git a/ckanext/scheming/assets/webassets.yml b/ckanext/scheming/assets/webassets.yml
index b3710959c..a6f0450be 100644
--- a/ckanext/scheming/assets/webassets.yml
+++ b/ckanext/scheming/assets/webassets.yml
@@ -20,3 +20,12 @@ multiple_text:
- base/main
contents:
- js/scheming-multiple-text.js
+
+suggestions:
+ filters: rjsmin
+ output: ckanext-scheming/%(version)s_scheming_suggestions.js
+ extra:
+ preload:
+ - base/main
+ contents:
+ - js/scheming-suggestions.js
\ No newline at end of file
diff --git a/ckanext/scheming/helpers.py b/ckanext/scheming/helpers.py
index 80166d24f..b9fd08c7c 100644
--- a/ckanext/scheming/helpers.py
+++ b/ckanext/scheming/helpers.py
@@ -5,6 +5,7 @@
import pytz
import json
import six
+import logging
from jinja2 import Environment
from ckan.plugins.toolkit import config, _, h
@@ -12,6 +13,7 @@
from ckanapi import LocalCKAN, NotFound, NotAuthorized
all_helpers = {}
+logger = logging.getLogger(__name__)
def helper(fn):
"""
@@ -459,3 +461,79 @@ def scheming_missing_required_fields(pages, data=None, package_id=None):
if f.get('required') and not data.get(f['field_name'])
])
return missing
+
+@helper
+def scheming_field_suggestion(field):
+ """
+ Returns suggestion data for a field if it exists
+ """
+ suggestion_label = field.get('suggestion_label', field.get('label', ''))
+ suggestion_formula = field.get('suggestion_formula', field.get('suggest_jinja2', None))
+
+ if suggestion_formula:
+ return {
+ 'label': suggestion_label,
+ 'formula': suggestion_formula
+ }
+ return None
+
+
+
+@helper
+def scheming_get_suggestion_value(field_name, data=None, errors=None, lang=None):
+ if not data:
+ return ''
+
+ try:
+ # Log the field name
+ logger.info(f"Field name extracted: {field_name}")
+
+ # Get package data (where dpp_suggestions is stored)
+ package_data = data
+ logger.info(f"Data passed to scheming_get_suggestion_value: {data}")
+
+ # Check if dpp_suggestions exists and has the package section
+ if (package_data and 'dpp_suggestions' in package_data and
+ isinstance(package_data['dpp_suggestions'], dict) and
+ 'package' in package_data['dpp_suggestions']):
+
+ # Get the suggestion value if it exists
+ if field_name in package_data['dpp_suggestions']['package']:
+ logger.info(f"Suggestion value found for field '{field_name}': {package_data['dpp_suggestions']['package'][field_name]}")
+ return package_data['dpp_suggestions']['package'][field_name]
+
+ # No suggestion value found
+ return ''
+ except Exception as e:
+ # Log the error but don't crash
+ logger.warning(f"Error getting suggestion value: {e}")
+ return ''
+
+@helper
+def scheming_is_valid_suggestion(field, value):
+ """
+ Check if a suggested value is valid for a field, particularly for select fields
+ """
+ # If not a select/choice field, always valid
+ if not field.get('choices') and not field.get('choices_helper'):
+ return True
+
+ # Get all valid choices for this field
+ choices = scheming_field_choices(field)
+ if not choices:
+ return True
+
+ # Check if the value is in the list of valid choices
+ for choice in choices:
+ if choice['value'] == value:
+ return True
+
+ return False
+
+@helper
+def is_preformulated_field(field):
+ """
+ Check if a field is preformulated (has formula attribute)
+ This helper returns True only if the field has a 'formula' key with a non-empty value
+ """
+ return bool(field.get('formula', False))
diff --git a/ckanext/scheming/suggestion_test_schema.yaml b/ckanext/scheming/suggestion_test_schema.yaml
new file mode 100644
index 000000000..02737103b
--- /dev/null
+++ b/ckanext/scheming/suggestion_test_schema.yaml
@@ -0,0 +1,123 @@
+scheming_version: 2
+dataset_type: dataset
+about: A reimplementation of the default CKAN dataset schema
+about_url: http://github.com/ckan/ckanext-scheming
+
+
+dataset_fields:
+
+- field_name: title
+ label: Title
+ preset: title
+ form_placeholder: eg. A descriptive title Custom
+
+- field_name: name
+ label: URL
+ preset: dataset_slug
+ form_placeholder: eg. my-dataset
+
+- field_name: notes
+ label: Description
+ form_snippet: markdown.html
+ form_placeholder: eg. Some useful notes/blurb about the data
+ suggestion_formula: Latitudinal range {{ (dpps[dpp.LAT_FIELD].stats.max|float) - (dpps[dpp.LAT_FIELD].stats.min|float) }} {{"the quick brown fox"|truncate_with_ellipsis(5)}}
+
+- field_name: spatial_extent
+ label: Spatial Extent
+ form_snippet: markdown.html
+ suggestion_formula: '{{ spatial_extent_wkt(resource.LONGITUDE.stats.min, resource.LATITUDE.stats.min, resource.LONGITUDE.stats.max, resource.LATITUDE.stats.max) }}'
+
+- field_name: test_field
+ label: Test Field
+ form_snippet: markdown.html
+ formula: 'Test field value:{{package.author}}: {{package.author_email}}'
+
+- field_name: frequency_info
+ label: Frequency Information
+ form_snippet: markdown.html
+ form_placeholder: eg. Daily, Weekly, Monthly, Annually, etc.
+ formula: '{{ get_frequency_top_values("LATITUDE") }}'
+
+- field_name: tag_string
+ label: Tags
+ preset: tag_string_autocomplete
+ form_placeholder: eg. economy, mental health, government
+
+- field_name: license_id
+ label: License
+ form_snippet: license.html
+ help_text: License definitions and additional information can be found at http://opendefinition.org/
+
+- field_name: owner_org
+ label: Organization
+ preset: dataset_organization
+
+- field_name: url
+ label: Source
+ form_placeholder: http://example.com/dataset.json
+ display_property: foaf:homepage
+ display_snippet: link.html
+
+- field_name: version
+ label: Version
+ validators: ignore_missing unicode_safe package_version_validator
+ form_placeholder: '1.0'
+
+- field_name: author
+ label: Author
+ form_placeholder: Joe Bloggs
+ display_property: dc:creator
+
+- field_name: author_email
+ label: Author Email
+ form_placeholder: joe@example.com
+ display_property: dc:creator
+ display_snippet: email.html
+ display_email_name_field: author
+
+- field_name: maintainer
+ label: Maintainer
+ form_placeholder: Joe Bloggs
+ display_property: dc:contributor
+
+- field_name: maintainer_email
+ label: Maintainer Email
+ form_placeholder: joe@example.com
+ display_property: dc:contributor
+ display_snippet: email.html
+ display_email_name_field: maintainer
+
+- field_name: dpp_suggestions
+ label: DPP Suggestions
+ preset: json_object
+
+resource_fields:
+
+- field_name: url
+ label: URL
+ preset: resource_url_upload
+
+- field_name: name
+ label: Name
+ form_placeholder: eg. January 2011 Gold Prices
+
+- field_name: description
+ label: Description
+ form_snippet: markdown.html
+ form_placeholder: Some useful notes about the data
+
+- field_name: spatial_extent
+ label: Spatial Extent
+ form_snippet: markdown.html
+
+- field_name: spatial_extent_fc_inferred
+ label: Spatial Extent Feature Collection Inferred
+ form_snippet: markdown.html
+
+- field_name: spatial_extent_wkt_inferred
+ label: Spatial Extent WKT Inferred
+ form_snippet: markdown.html
+
+- field_name: format
+ label: Format
+ preset: resource_format_autocomplete
\ No newline at end of file
diff --git a/ckanext/scheming/templates/scheming/form_snippets/markdown.html b/ckanext/scheming/templates/scheming/form_snippets/markdown.html
index 798ae96ea..3228fd0df 100644
--- a/ckanext/scheming/templates/scheming/form_snippets/markdown.html
+++ b/ckanext/scheming/templates/scheming/form_snippets/markdown.html
@@ -1,15 +1,53 @@
{% import 'macros/form.html' as form %}
+{% include 'scheming/snippets/suggestions_asset.html' %}
+
+{# Check if this is a preformulated field #}
+{% set is_preformulated = h.is_preformulated_field(field) %}
+
+{# Create a custom label with suggestion button(s) and icons #}
+{% set suggestion = h.scheming_field_suggestion(field) %}
+{% set label_text = h.scheming_language_text(field.label) %}
+{% set label_with_extras %}
+
{{ label_text }}:
+ {%- if suggestion -%}
+ {%- snippet 'scheming/snippets/suggestion_button.html', field=field, data=data -%}
+ {%- endif -%}
+ {%- if is_preformulated -%}
+
+
+
+ {%- endif -%}
+{% endset %}
+
+{# Prepare attributes for the form macro #}
+{% set current_attrs = {} %}
+{% if supports_ai_suggestion %}
+ {% set _ = current_attrs.update({'data-field-supports-ai-suggestion': 'true'}) %}
+{% endif %}
+{% if is_preformulated %} {# is_preformulated means it has a 'formula' #}
+ {% set _ = current_attrs.update({'data-is-formula-field': 'true'}) %}
+{% endif %}
+{# Always add data-scheming-field-name for easier targeting by JS #}
+{% set _ = current_attrs.update({'data-scheming-field-name': field.field_name}) %}
+
{% call form.markdown(
field.field_name,
id='field-' + field.field_name,
- label=h.scheming_language_text(field.label),
+ label=label_with_extras|safe,
placeholder=h.scheming_language_text(field.form_placeholder),
value=data[field.field_name],
error=errors[field.field_name],
- attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))),
+ attrs=current_attrs,
is_required=h.scheming_field_required(field)
)
%}
- {%- snippet 'scheming/form_snippets/help_text.html', field=field -%}
-{% endcall %}
+
+{% endcall %}
\ No newline at end of file
diff --git a/ckanext/scheming/templates/scheming/form_snippets/select.html b/ckanext/scheming/templates/scheming/form_snippets/select.html
index 619e8fec2..cae25aea4 100644
--- a/ckanext/scheming/templates/scheming/form_snippets/select.html
+++ b/ckanext/scheming/templates/scheming/form_snippets/select.html
@@ -1,4 +1,5 @@
{% import 'macros/form.html' as form %}
+{% include 'scheming/snippets/suggestions_asset.html' %}
{%- set options=[] -%}
{%- set form_restrict_choices_to=field.get('form_restrict_choices_to') -%}
@@ -16,18 +17,25 @@
{%- if field.get('sorted_choices') -%}
{%- set options = options|sort(case_sensitive=false, attribute='text') -%}
{%- endif -%}
-{%- if data[field.field_name] is defined -%}
+{%- if data[field.field_name] -%}
{%- set option_selected = data[field.field_name]|string -%}
-{%- elif field.default is defined -%}
- {%- set option_selected = field.default|string -%}
{%- else -%}
{%- set option_selected = None -%}
{%- endif -%}
+{% set suggestion = h.scheming_field_suggestion(field) %}
+{% set label_text = h.scheming_language_text(field.label) %}
+{% set label_with_suggestion %}
+
{{ label_text }}:
+ {%- if suggestion -%}
+ {%- snippet 'scheming/snippets/suggestion_button.html', field=field, data=data -%}
+ {%- endif -%}
+{% endset %}
+
{% call form.select(
field.field_name,
id='field-' + field.field_name,
- label=h.scheming_language_text(field.label),
+ label=label_with_suggestion|safe,
options=options,
selected=option_selected,
error=errors[field.field_name],
@@ -36,5 +44,7 @@
is_required=h.scheming_field_required(field)
)
%}
- {%- snippet 'scheming/form_snippets/help_text.html', field=field -%}
-{% endcall %}
+
+ {%- snippet 'scheming/form_snippets/help_text.html', field=field -%}
+
+{% endcall %}
\ No newline at end of file
diff --git a/ckanext/scheming/templates/scheming/form_snippets/text.html b/ckanext/scheming/templates/scheming/form_snippets/text.html
index 9d276da09..c78b75fe9 100644
--- a/ckanext/scheming/templates/scheming/form_snippets/text.html
+++ b/ckanext/scheming/templates/scheming/form_snippets/text.html
@@ -1,9 +1,29 @@
{% import 'macros/form.html' as form %}
+{% include 'scheming/snippets/suggestions_asset.html' %}
+
+{# Check if this is a preformulated field (i.e., has a 'formula') #}
+{% set is_preformulated = h.is_preformulated_field(field) %}
+
+{# Create a custom label with suggestion button(s) and icons #}
+{% set suggestion = h.scheming_field_suggestion(field) %}
+{% set label_text = h.scheming_language_text(field.label) %}
+{% set label_with_extras %}
+
{{ label_text }}:
+ {%- if suggestion -%}
+ {%- snippet 'scheming/snippets/suggestion_button.html', field=field, data=data -%}
+ {%- endif -%}
+ {%- if is_preformulated -%}
+
+
+
+ {%- endif -%}
+{% endset %}
+
{% call form.input(
field.field_name,
id='field-' + field.field_name,
- label=h.scheming_language_text(field.label),
+ label=label_with_extras|safe,
placeholder=h.scheming_language_text(field.form_placeholder),
value=data[field.field_name],
error=errors[field.field_name],
@@ -12,5 +32,12 @@
is_required=h.scheming_field_required(field)
)
%}
- {%- snippet 'scheming/form_snippets/help_text.html', field=field -%}
+
{% endcall %}
diff --git a/ckanext/scheming/templates/scheming/snippets/suggestion_button.html b/ckanext/scheming/templates/scheming/snippets/suggestion_button.html
new file mode 100644
index 000000000..9433e0d4a
--- /dev/null
+++ b/ckanext/scheming/templates/scheming/snippets/suggestion_button.html
@@ -0,0 +1,30 @@
+{% set suggestion_data_for_js = h.scheming_field_suggestion(field) %}
+{% if suggestion_data_for_js %}
+ {% set field_id = 'field-' + field.field_name %} {# Used for targetting by apply button #}
+
+ {% set field_schema_dict = {
+ "field_name": field.field_name,
+ "label": h.scheming_language_text(field.label),
+ "suggestion_label": h.scheming_language_text(suggestion_data_for_js.label or field.label),
+ "suggestion_formula": suggestion_data_for_js.formula,
+ "choices": h.scheming_field_choices(field) if (field.choices or field.choices_helper) else none,
+ "is_select": True if (field.choices or field.choices_helper) else False
+ } %}
+ {# Use h.scheming_display_json_value helper instead of |json filter #}
+ {% set field_schema_for_js_json_string = h.scheming_display_json_value(field_schema_dict) %}
+
+
+{% endif %}
diff --git a/ckanext/scheming/templates/scheming/snippets/suggestions_asset.html b/ckanext/scheming/templates/scheming/snippets/suggestions_asset.html
new file mode 100644
index 000000000..6254967b5
--- /dev/null
+++ b/ckanext/scheming/templates/scheming/snippets/suggestions_asset.html
@@ -0,0 +1 @@
+{% asset 'ckanext-scheming/suggestions' %}
\ No newline at end of file