Jump to content

MediaWiki:Common.js

From Recipedius

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));

/* AI-assisted Recipe draft import. Runs only on Form:Recipe. */
(function ($, mw) {
  'use strict';

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

    var fields = {
      serves: 'Recipe[serves]',
      time: 'Recipe[time]',
      tags: 'Recipe[tags]',
      contributor: 'Recipe[contributor]',
      lede: 'Recipe[lede]',
      ingredients: 'Recipe[ingredients]',
      instructions: 'Recipe[method]'
    };
    var requiredKeys = ['name', 'serves', 'time', 'tags', 'lede', 'ingredients', 'instructions'];
    var allKeys = requiredKeys.concat(['contributor']);
    var expectedTitle = String(mw.config.get('wgPageFormsTargetName') || '').replace(/_/g, ' ');
    if (!expectedTitle) return;
    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 recipe draft with AI (optional)');
    title.className = 'rp-ai-heading';
    addTextElement('p', 'AI output is a draft. Verify quantities, timings, allergens, and food-safety guidance before saving. Imported ingredient names become internal links.');
    var actions = document.createElement('p');
    var copyButton = document.createElement('button');
    copyButton.type = 'button';
    copyButton.id = 'rp-ai-recipe-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-recipe-json';
    importLabel.textContent = 'Paste the returned JSON';
    var input = document.createElement('textarea');
    input.id = 'rp-ai-recipe-json';
    input.rows = 16;
    input.cols = 70;
    input.spellcheck = false;
    input.placeholder = '{"name":"This page title", "ingredients":[...], ...}';
    var importControl = document.createElement('p');
    importControl.appendChild(importLabel);
    importControl.appendChild(document.createElement('br'));
    importControl.appendChild(input);
    panel.appendChild(importControl);
    var importButton = document.createElement('button');
    importButton.type = 'button';
    importButton.id = 'rp-ai-recipe-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-recipe-status';
    status.className = 'rp-ai-status';
    status.setAttribute('role', 'status');
    status.setAttribute('aria-live', 'polite');
    panel.appendChild(status);

    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(recipeTitle) {
      return 'Create a draft Recipedius recipe for "' + recipeTitle + '". ' +
        'Return only one JSON object with exactly these keys: name, serves, time, tags, contributor, lede, ingredients, instructions. ' +
        'Use this title exactly as name. serves must be a positive integer. time and lede must be non-empty strings. ' +
        'tags must be an array of non-empty strings. contributor must be a non-empty string or null. ' +
        'ingredients must be a non-empty array of objects with exactly name, quantity, unit, role, preparation. ' +
        'Each ingredient name must be a plain title, not wiki markup; quantity must be a positive number; unit, role, and preparation must be non-empty strings or null. ' +
        'instructions must be an array of non-empty step strings. Do not use semicolons or newlines in any string, commas in tags, or [, ], or | in ingredient names. ' +
        'Use null for unknown contributor, unit, role, or preparation; do not invent them. ' +
        'Quantities, timing, allergy information, and food-safety guidance are a draft for the contributor to verify 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 hasSeparator(value) {
      return /[;\r\n]/.test(value);
    }

    function validatePlainString(value, property) {
      if (!validString(value)) return property + ' must be a non-empty string.';
      if (hasSeparator(value)) return property + ' must not contain a semicolon or newline.';
      return null;
    }

    function validateIngredient(ingredient, index) {
      var keys = ['name', 'quantity', 'unit', 'role', 'preparation'];
      var key;
      if (!ingredient || Array.isArray(ingredient) || typeof ingredient !== 'object') return 'ingredients[' + index + '] must be an object.';
      for (key in ingredient) {
        if (hasOwn(ingredient, key) && keys.indexOf(key) === -1) return 'Unknown property: ingredients[' + index + '].' + key + '.';
      }
      for (var i = 0; i < keys.length; i++) {
        if (!hasOwn(ingredient, keys[i])) return 'Missing property: ingredients[' + index + '].' + keys[i] + '.';
      }
      var nameProblem = validatePlainString(ingredient.name, 'ingredients[' + index + '].name');
      if (nameProblem) return nameProblem;
      if (/[\[\]|]/.test(ingredient.name)) return 'ingredients[' + index + '].name must be a plain title without wiki-link markup.';
      if (typeof ingredient.quantity !== 'number' || !Number.isFinite(ingredient.quantity) || ingredient.quantity <= 0) {
        return 'ingredients[' + index + '].quantity must be a positive number.';
      }
      for (var j = 0; j < 3; j++) {
        key = keys[j + 2];
        if (ingredient[key] !== null) {
          var problem = validatePlainString(ingredient[key], 'ingredients[' + index + '].' + key);
          if (problem) return problem;
        }
      }
      return null;
    }

    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] + '.';
      }
      if (!validString(record.name)) return 'name must be a non-empty string.';
      if (record.name !== expectedTitle) return 'The JSON name must exactly match this page title: ' + expectedTitle + '.';
      if (!Number.isInteger(record.serves) || record.serves <= 0) return 'serves must be a positive integer.';
      for (i = 0; i < 2; i++) {
        var stringProblem = validatePlainString(record[i === 0 ? 'time' : 'lede'], i === 0 ? 'time' : 'lede');
        if (stringProblem) return stringProblem;
      }
      if (record.contributor !== null) {
        var contributorProblem = validatePlainString(record.contributor, 'contributor');
        if (contributorProblem) return contributorProblem;
      }
      if (!Array.isArray(record.tags) || !record.tags.length) return 'tags must be a non-empty array of strings.';
      for (i = 0; i < record.tags.length; i++) {
        var tagProblem = validatePlainString(record.tags[i], 'tags[' + i + ']');
        if (tagProblem) return tagProblem;
        if (record.tags[i].indexOf(',') !== -1) return 'tags[' + i + '] must not contain a comma.';
      }
      if (!Array.isArray(record.ingredients) || !record.ingredients.length) return 'ingredients must be a non-empty array.';
      for (i = 0; i < record.ingredients.length; i++) {
        var ingredientProblem = validateIngredient(record.ingredients[i], i);
        if (ingredientProblem) return ingredientProblem;
      }
      if (!Array.isArray(record.instructions) || !record.instructions.length) return 'instructions must be a non-empty array of strings.';
      for (i = 0; i < record.instructions.length; i++) {
        var instructionProblem = validatePlainString(record.instructions[i], 'instructions[' + i + ']');
        if (instructionProblem) return instructionProblem;
      }
      return null;
    }

    function ingredientLine(ingredient) {
      return '[[' + ingredient.name.trim() + ']]; ' + ingredient.quantity + '; ' +
        (ingredient.unit === null ? '' : ingredient.unit.trim()) + '; ' +
        (ingredient.role === null ? '' : ingredient.role.trim()) + '; ' +
        (ingredient.preparation === null ? '' : ingredient.preparation.trim());
    }

    function valuesFor(record) {
      return {
        serves: record.serves,
        time: record.time.trim(),
        tags: record.tags.map(function (tag) { return tag.trim(); }).join(', '),
        contributor: record.contributor === null ? '' : record.contributor.trim(),
        lede: record.lede.trim(),
        ingredients: record.ingredients.map(ingredientLine).join('\n'),
        instructions: record.instructions.map(function (step) { return '# ' + step.trim(); }).join('\n')
      };
    }

    function setFields(values) {
      var controls = {};
      Object.keys(fields).forEach(function (key) {
        var control = document.querySelector('[name="' + fields[key] + '"]');
        if (!control) throw new Error('The Recipe form is missing the ' + key + ' field.');
        controls[key] = control;
      });
      Object.keys(fields).forEach(function (key) {
        controls[key].value = String(values[key]);
      });
    }

    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 {
        setFields(valuesFor(record));
      } 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));