MediaWiki:Common.js
Appearance
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));