Jump to content

MediaWiki:Common.js

From Recipedius
Revision as of 16:02, 24 September 2026 by Recipedius deploy (talk | contribs) (0.013 T014: deploy AI ingredient draft import (ac09cdd))
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* AI-assisted Ingredient draft import. Runs only on Form:Ingredient. */
(function ($, mw) {
  'use strict';

  $(function () {
    var panel = document.getElementById('rp-ai-ingredient-import');
    if (!panel) return;

    var fields = {
      form: 'Ingredient[form]',
      role: 'Ingredient[role]',
      storage: 'Ingredient[storage]',
      season: 'Ingredient[season]',
      kcal100: 'Ingredient[kcal100]',
      g_tsp: 'Ingredient[g_tsp]',
      g_tbsp: 'Ingredient[g_tbsp]',
      g_cup: 'Ingredient[g_cup]',
      g_each: 'Ingredient[g_each]',
      allergens: 'Ingredient[allergens]',
      pantry: 'Ingredient[pantry]',
      price: 'Ingredient[price]',
      price_per: 'Ingredient[price_per]',
      summary: 'Ingredient[summary]'
    };
    var requiredStrings = ['name', 'form', 'role', 'storage', 'season', 'summary'];
    var optionalNumbers = ['g_tsp', 'g_tbsp', 'g_cup', 'g_each', 'price'];
    var allKeys = requiredStrings.concat(['kcal100'], optionalNumbers, ['allergens', 'pantry', 'price_per']);
    // FormEdit is a Special page, so wgPageName is the Special-page route.
    // Page Forms exposes the actual selected edit target separately.
    var expectedTitle = String(mw.config.get('wgPageFormsTargetName') || '').replace(/_/g, ' ');
    if (!expectedTitle) return;
    // The wikitext fallback is useful without JavaScript and to rendered-page
    // checks. Replace it only after the enhanced controls can be created.
    panel.textContent = '';

    function addTextElement(tag, text) {
      var element = document.createElement(tag);
      element.textContent = text;
      panel.appendChild(element);
      return element;
    }

    function addProviderLink(provider, url) {
      var link = document.createElement('a');
      link.href = url;
      link.target = '_blank';
      link.rel = 'noopener';
      link.setAttribute('data-rp-ai-provider', provider);
      link.textContent = provider;
      return link;
    }

    var title = addTextElement('strong', 'Create a draft with AI (optional)');
    title.className = 'rp-ai-heading';
    addTextElement('p', 'AI output is a draft. Verify nutrition, allergens, weights, and price against a reliable source before saving.');
    var actions = document.createElement('p');
    var copyButton = document.createElement('button');
    copyButton.type = 'button';
    copyButton.id = 'rp-ai-copy-prompt';
    copyButton.textContent = 'Copy prompt';
    actions.appendChild(copyButton);
    actions.appendChild(document.createTextNode(' '));
    [
      ['ChatGPT', 'https://chatgpt.com/'],
      ['Claude', 'https://claude.ai/new'],
      ['Gemini', 'https://gemini.google.com/app'],
      ['DeepSeek', 'https://chat.deepseek.com/']
    ].forEach(function (provider, index) {
      if (index) actions.appendChild(document.createTextNode(' · '));
      actions.appendChild(addProviderLink(provider[0], provider[1]));
    });
    panel.appendChild(actions);
    var importLabel = document.createElement('label');
    importLabel.htmlFor = 'rp-ai-ingredient-json';
    importLabel.textContent = 'Paste the returned JSON';
    var importArea = document.createElement('textarea');
    importArea.id = 'rp-ai-ingredient-json';
    importArea.rows = 12;
    importArea.cols = 70;
    importArea.spellcheck = false;
    importArea.placeholder = '{"name":"This page title", ...}';
    var importControl = document.createElement('p');
    importControl.appendChild(importLabel);
    importControl.appendChild(document.createElement('br'));
    importControl.appendChild(importArea);
    panel.appendChild(importControl);
    var importButton = document.createElement('button');
    importButton.type = 'button';
    importButton.id = 'rp-ai-import-json';
    importButton.textContent = 'Import draft into this form';
    var importAction = document.createElement('p');
    importAction.appendChild(importButton);
    panel.appendChild(importAction);
    var status = document.createElement('div');
    status.id = 'rp-ai-ingredient-status';
    status.className = 'rp-ai-status';
    status.setAttribute('role', 'status');
    status.setAttribute('aria-live', 'polite');
    panel.appendChild(status);
    var input = importArea;

    function setStatus(message, isError) {
      status.textContent = message;
      status.className = isError ? 'rp-ai-status rp-ai-status-error' : 'rp-ai-status rp-ai-status-success';
    }

    function promptFor(title) {
      return 'Create a draft Recipedius ingredient record for "' + title + '". ' +
        'Return only one JSON object with exactly these keys: ' +
        'name, form, role, storage, season, kcal100, g_tsp, g_tbsp, g_cup, g_each, allergens, pantry, price, price_per, summary. ' +
        'Use this title exactly as name. form, role, storage, season, and summary must be strings. ' +
        'kcal100 must be a non-negative number. g_tsp, g_tbsp, g_cup, g_each, and price must be non-negative numbers or null. ' +
        'price_per must be a string or null. allergens must be an array of strings. pantry must be true or false. ' +
        'Use null when a unit weight or typical price is unknown; do not invent values. ' +
        'Nutrition, allergens, weights, and price are a draft for the contributor to verify against a reliable source before saving.';
    }

    function copyText(text) {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        return navigator.clipboard.writeText(text);
      }
      var temporary = document.createElement('textarea');
      temporary.value = text;
      temporary.setAttribute('readonly', '');
      temporary.style.position = 'fixed';
      temporary.style.opacity = '0';
      document.body.appendChild(temporary);
      temporary.select();
      var copied = document.execCommand('copy');
      document.body.removeChild(temporary);
      return copied ? Promise.resolve() : Promise.reject(new Error('Clipboard unavailable'));
    }

    function copyPrompt() {
      return copyText(promptFor(expectedTitle)).then(function () {
        setStatus('Prompt copied. Review the AI draft before importing it.', false);
      }).catch(function () {
        setStatus('Your browser did not allow copying. Select and copy the prompt shown below.', true);
      });
    }

    function hasOwn(object, key) {
      return Object.prototype.hasOwnProperty.call(object, key);
    }

    function validString(value) {
      return typeof value === 'string' && value.trim() !== '';
    }

    function validNumber(value) {
      return typeof value === 'number' && Number.isFinite(value) && value >= 0;
    }

    function validate(record) {
      var i;
      if (!record || Array.isArray(record) || typeof record !== 'object') return 'The draft must be one JSON object.';
      for (i = 0; i < Object.keys(record).length; i++) {
        if (allKeys.indexOf(Object.keys(record)[i]) === -1) return 'Unknown property: ' + Object.keys(record)[i] + '.';
      }
      for (i = 0; i < allKeys.length; i++) {
        if (!hasOwn(record, allKeys[i])) return 'Missing property: ' + allKeys[i] + '.';
      }
      for (i = 0; i < requiredStrings.length; i++) {
        if (!validString(record[requiredStrings[i]])) return requiredStrings[i] + ' must be a non-empty string.';
      }
      if (record.name !== expectedTitle) return 'The JSON name must exactly match this page title: ' + expectedTitle + '.';
      if (!validNumber(record.kcal100)) return 'kcal100 must be a non-negative number.';
      for (i = 0; i < optionalNumbers.length; i++) {
        if (record[optionalNumbers[i]] !== null && !validNumber(record[optionalNumbers[i]])) {
          return optionalNumbers[i] + ' must be a non-negative number or null.';
        }
      }
      if (!Array.isArray(record.allergens) || !record.allergens.every(validString)) return 'allergens must be an array of non-empty strings.';
      if (typeof record.pantry !== 'boolean') return 'pantry must be true or false.';
      if (record.price_per !== null && !validString(record.price_per)) return 'price_per must be a non-empty string or null.';
      return null;
    }

    function setField(key, value) {
      var control = document.querySelector('[name="' + fields[key] + '"]');
      if (!control) throw new Error('The Ingredient form is missing the ' + key + ' field.');
      control.value = value === null ? '' : String(value);
    }

    copyButton.addEventListener('click', copyPrompt);
    Array.prototype.forEach.call(panel.querySelectorAll('[data-rp-ai-provider]'), function (link) {
      link.addEventListener('click', copyPrompt);
    });
    importButton.addEventListener('click', function () {
      var record;
      try {
        record = JSON.parse(input.value);
      } catch (error) {
        setStatus('Invalid JSON: ' + error.message, true);
        return;
      }
      var problem = validate(record);
      if (problem) {
        setStatus(problem, true);
        return;
      }
      try {
        Object.keys(fields).forEach(function (key) {
          var value = record[key];
          if (key === 'allergens') value = value.join(', ');
          if (key === 'pantry') value = value ? 'yes' : 'no';
          setField(key, value);
        });
      } catch (error) {
        setStatus(error.message, true);
        return;
      }
      setStatus('Draft imported. Review every field, then use the normal Save button to create ' + expectedTitle + '.', false);
    });
  });
}(jQuery, mediaWiki));