Module:Recipedius
Appearance
Documentation for this module may be created at Module:Recipedius/doc
--[[
Module:Recipedius
The computed layer of Recipedius. Reads ingredient records and substitution rules
from Cargo tables and renders recipe pages, the weekly sale ranking, and cookbooks.
Entry points (all via {{#invoke:Recipedius|fn}} from templates):
recipe - renders a recipe from Template:Recipe args and stores its rows
sale - ranks recipes by how many of their shopping items are on sale
cookbook - renders a printable book from a list of recipe pages
]]
local p = {}
local cargo = mw.ext.cargo
-- ---------------------------------------------------------------- helpers
local FRACTIONS = {
{ 0.125, '⅛' }, { 0.25, '¼' }, { 1/3, '⅓' }, { 0.375, '⅜' }, { 0.5, '½' },
{ 0.625, '⅝' }, { 2/3, '⅔' }, { 0.75, '¾' }, { 0.875, '⅞' },
}
local FRACTION_CHARS = { ['⅛']=0.125, ['¼']=0.25, ['⅓']=1/3, ['⅜']=0.375, ['½']=0.5, ['⅝']=0.625, ['⅔']=2/3, ['¾']=0.75, ['⅞']=0.875 }
local function trim(s) return mw.text.trim(s or '') end
-- AI-assisted recipe imports persist ingredient titles as ordinary wiki links
-- so the form value itself remains navigable. Keep the existing plain-title
-- format compatible, while resolving that narrow importer-owned syntax before
-- data lookup, Cargo storage, pluralization, and generated display links.
local function ingredientTitle(s)
s = trim(s)
local linked = s:match('^%[%[([^%[%]|]+)%]%]$')
return linked and trim(linked) or s
end
-- "1 1/2", "3/4", "0.5", "1 ½" and "¾" all parse.
local function parseQty(s)
s = trim(s)
local total, found = 0, false
for tok in s:gmatch('%S+') do
if FRACTION_CHARS[tok] then total = total + FRACTION_CHARS[tok]; found = true
else
local a, b = tok:match('^(%d+)/(%d+)$')
if a then total = total + tonumber(a) / tonumber(b); found = true
elseif tonumber(tok) then total = total + tonumber(tok); found = true end
end
end
if found then return total end
return nil
end
local function fmtQty(q)
if not q then return '' end
local w = math.floor(q + 1e-6)
local f = q - w
if f > 0.94 then w = w + 1; f = 0 end
local fs = ''
if f > 0.06 then
local best, bd = nil, 1
for _, pair in ipairs(FRACTIONS) do
local d = math.abs(pair[1] - f)
if d < bd then bd = d; best = pair[2] end
end
if bd < 0.06 then fs = best else fs = string.format('%.2f', f):gsub('^0', '') end
end
if w == 0 then return fs ~= '' and fs or '0' end
if fs ~= '' then return w .. ' ' .. fs end
return tostring(w)
end
local PLURALS = { ['Garlic'] = 'Garlic cloves', ['Whole chicken'] = 'Whole chickens', ['Russet potato'] = 'Russet potatoes' }
-- Display name for a count-based ingredient: "2 eggs", "3 bananas", "6 garlic cloves".
local function countLabel(name, q, u)
if u ~= 'each' or not q or q <= 1 then return name end
if PLURALS[name] then return PLURALS[name] end
if name:sub(-1) == 's' then return name end
return name .. 's'
end
local function unitLabel(q, u)
if u == 'each' or u == '' then return '' end
if u == 'cup' and q and q > 1 then return 'cups' end
return u
end
local function sqlEsc(s) return (tostring(s):gsub("'", "\\'")) end
local function splitList(s)
local out = {}
for item in (s or ''):gmatch('[^,]+') do
item = trim(item)
if item ~= '' then table.insert(out, item) end
end
return out
end
local function round(x) return math.floor(x + 0.5) end
-- ---------------------------------------------------------------- data access
local ingredientCache = {}
local function getIngredient(name)
if ingredientCache[name] ~= nil then return ingredientCache[name] or nil end
local rows = cargo.query('Ingredients',
'_pageName=name,kcal100,g_tsp,g_tbsp,g_cup,g_each,allergens,pantry,price,price_per',
{ where = "_pageName='" .. sqlEsc(name) .. "'", limit = 1 })
ingredientCache[name] = rows[1] or false
return rows[1]
end
local recipeCache = {}
local function getRecipe(name)
if recipeCache[name] ~= nil then return recipeCache[name] or nil end
local rows = cargo.query('Recipes', '_pageName=name',
{ where = "_pageName='" .. sqlEsc(name) .. "'", limit = 1 })
recipeCache[name] = rows[1] or false
return rows[1]
end
local function gramsPer(ing, unit)
if not ing then return nil end
local key = ({ tsp = 'g_tsp', tbsp = 'g_tbsp', cup = 'g_cup', each = 'g_each' })[unit]
if not key then return nil end
return tonumber(ing[key])
end
local function kcalOf(ing, q, unit)
local g = gramsPer(ing, unit)
local k = ing and tonumber(ing.kcal100)
if not (g and k and q) then return 0 end
return q * g * k / 100
end
-- Substitution rules for an ingredient, resolved for the role it plays in this recipe.
-- A base row (role blank) applies everywhere; a row with a role overrides the base for that role.
-- An override with applicable=no removes the option for that role.
local subsCache = {}
local function rulesFor(name, role)
if not subsCache[name] then
subsCache[name] = cargo.query('Substitutions',
'sub_to,alt,role,ratio,conv,confidence,effect,warning,applicable',
{ where = "sub_from='" .. sqlEsc(name) .. "'", orderBy = 'sub_to', limit = 200 })
end
local groups, order = {}, {}
for _, r in ipairs(subsCache[name]) do
local key = (r.sub_to or '') .. '|' .. (r.alt or '')
if not groups[key] then groups[key] = {}; table.insert(order, key) end
if trim(r.role) == '' then groups[key].base = r
elseif trim(r.role) == role then groups[key].override = r end
end
local out = {}
for _, key in ipairs(order) do
local b, o = groups[key].base, groups[key].override
local blocked = o and trim(o.applicable):lower() == 'no'
if (b or o) and not blocked then
local m = {}
for _, k in ipairs({ 'sub_to', 'alt', 'ratio', 'conv', 'confidence', 'effect', 'warning' }) do
local ov = o and trim(o[k]) or ''
if ov ~= '' then m[k] = ov elseif b then m[k] = trim(b[k]) else m[k] = '' end
end
if m.warning == 'none' then m.warning = '' end
m.roleSpecific = o ~= nil
table.insert(out, m)
end
end
return out
end
-- ---------------------------------------------------------------- recipe
-- Ingredient lines are written one per line as: Ingredient; quantity; unit; role; preparation
local function parseLines(text)
local lines = {}
for raw in (text or ''):gmatch('[^\r\n]+') do
raw = trim(raw)
if raw ~= '' and not raw:match('^<!%-%-') then
local parts = mw.text.split(raw, ';', true)
local name = ingredientTitle(parts[1] or '')
if name ~= '' then
table.insert(lines, {
name = name,
qty = parseQty(parts[2] or ''),
unit = trim(parts[3] or ''),
role = trim(parts[4] or ''),
prep = trim(parts[5] or ''),
})
end
end
end
return lines
end
local function resolvedArg(args, preferred, legacy)
local value = trim(args[preferred])
if value ~= '' then return value end
return trim(args[legacy])
end
local CONF_DOTS = { high = '●●●', medium = '●●○', low = '●○○' }
local function confBadge(c)
c = trim(c):lower()
return '<span class="rp-conf rp-conf-' .. c .. '" title="confidence: ' .. c .. '">' .. (CONF_DOTS[c] or '○○○') .. ' ' .. c .. '</span>'
end
function p.recipe(frame)
local args = frame:getParent().args
local title = mw.title.getCurrentTitle()
local page = title.prefixedText
local serves = tonumber(args.serves) or 1
local description = resolvedArg(args, 'description', 'lede')
local instructions = resolvedArg(args, 'instructions', 'method')
local lines = parseLines(args.ingredients)
-- totals from the ingredient records
local kcalTotal, allergens, seen, missing = 0, {}, {}, {}
for _, l in ipairs(lines) do
local ing = getIngredient(l.name)
if ing then
kcalTotal = kcalTotal + kcalOf(ing, l.qty, l.unit)
for _, a in ipairs(splitList(ing.allergens)) do
if not seen[a] then seen[a] = true; table.insert(allergens, a) end
end
elseif not getRecipe(l.name) then
table.insert(missing, l.name)
end
end
local out = {}
local function add(s) table.insert(out, s) end
-- infobox
add('{| class="wikitable rp-infobox"\n|+ ' .. page)
add('|-\n! Serves\n| ' .. serves)
if trim(args.time) ~= '' then add('|-\n! Time\n| ' .. trim(args.time)) end
add('|-\n! Per serving\n| <b>' .. round(kcalTotal / serves) .. ' kcal</b> <small>(' .. round(kcalTotal) .. ' for the batch)</small>')
add('|-\n! Contains\n| ' .. (#allergens > 0 and table.concat(allergens, ', ') or "''nothing flagged''"))
if trim(args.contributor) ~= '' then add('|-\n! Contributed by\n| ' .. trim(args.contributor)) end
if trim(args.tags) ~= '' then
local tagLinks = {}
for _, t in ipairs(splitList(args.tags)) do table.insert(tagLinks, '[[:Category:' .. t .. '|' .. t .. ']]') end
add('|-\n! Tags\n| ' .. table.concat(tagLinks, ' · '))
end
add('|-\n| colspan="2" | <small>Calories are computed from the [[:Category:Ingredients|ingredient records]]. Approximate.</small>')
add('|}')
add('== Description ==')
if description ~= '' then add("<div class=\"rp-lede\">''" .. description .. "''</div>")
else add("''No description yet.''") end
if #missing > 0 then
-- Each name links to the ingredient form already targeted at that title, so
-- a contributor lands on the right form rather than being asked for a name
-- the page already knows. A bare Special:FormEdit link has no target and
-- answers "No target page specified".
local createLinks = {}
for _, name in ipairs(missing) do
table.insert(createLinks, '[[Special:FormEdit/Ingredient/' .. name .. '|' .. name .. ']]')
end
add('<div class="rp-missing">These ingredients have no record yet, so they are left out of the nutrition and substitution tables: ' ..
table.concat(createLinks, ', ') .. '. Follow a name to create its record.</div>')
end
-- ingredients table
add('== Ingredients ==')
add('{| class="wikitable rp-ingredients"\n! Quantity !! Ingredient !! Its job here !! Swaps')
local swapSections = {}
for i, l in ipairs(lines) do
local ing = getIngredient(l.name)
local shown = countLabel(l.name, l.qty, l.unit)
local isRecipe = not ing and getRecipe(l.name)
local link = (ing or isRecipe) and ('[[' .. l.name .. '|' .. shown .. ']]') or ('[[' .. l.name .. '|' .. shown .. ']]<sup class="rp-missing-mark">?</sup>')
local qty = fmtQty(l.qty) .. ' ' .. unitLabel(l.qty, l.unit)
local prep = l.prep ~= '' and (', <span class="rp-prep">' .. l.prep .. '</span>') or ''
local rules = ing and rulesFor(l.name, l.role) or {}
local swapCell = "''none''"
if #rules > 0 then
swapCell = '[[#Substitutions for this recipe|' .. #rules .. (#rules == 1 and ' option' or ' options') .. ']]'
table.insert(swapSections, { line = l, rules = rules })
end
add('|-\n| class="rp-qty" | ' .. trim(qty) .. '\n| ' .. link .. prep .. '\n| class="rp-role" | ' .. l.role .. '\n| ' .. swapCell)
end
add('|}')
add('== Instructions ==')
if instructions ~= '' then add(instructions)
else add("''No instructions yet.''") end
-- substitutions resolved for this recipe
if #swapSections > 0 then
add('== Substitutions for this recipe ==')
add('<p class="rp-note">Rules come from each ingredient\'s record and are resolved for the job the ingredient does in this recipe. Confidence is the community\'s, not a guarantee.</p>')
add('{| class="wikitable rp-subs"\n! Instead of !! Use !! How much !! What changes !! Confidence')
for _, s in ipairs(swapSections) do
for _, r in ipairs(s.rules) do
local useName = r.alt ~= '' and (r.alt .. ' ([[' .. r.sub_to .. ']])') or ('[[' .. r.sub_to .. ']]')
local howMuch
if r.conv ~= '' then howMuch = r.conv
else
local ratio = tonumber(r.ratio) or 1
local nq = s.line.qty and (s.line.qty * ratio) or nil
howMuch = nq and (fmtQty(nq) .. ' ' .. unitLabel(nq, s.line.unit) .. ' <small>(× ' .. ratio .. ')</small>') or ('× ' .. ratio)
end
local what = r.effect
if r.warning ~= '' then what = what .. '<br /><span class="rp-warn">⚠ ' .. r.warning .. '</span>' end
if r.roleSpecific then what = what .. ' <small class="rp-role-tag">rule for: ' .. s.line.role .. '</small>' end
add('|-\n| [[' .. s.line.name .. ']]\n| ' .. useName .. '\n| class="rp-qty" | ' .. howMuch .. '\n| ' .. what .. '\n| ' .. confBadge(r.confidence))
end
end
add('|}')
end
-- store rows (skipped when the page is being previewed inside another page)
if title.namespace == 0 then
for i, l in ipairs(lines) do
frame:callParserFunction('#cargo_store', {
'_table=RecipeIngredients', recipe = page, ingredient = l.name,
qty = l.qty and tostring(l.qty) or '', unit = l.unit, role = l.role, prep = l.prep, pos = tostring(i),
})
end
frame:callParserFunction('#cargo_store', {
'_table=Recipes', serves = tostring(serves), time = trim(args.time), tags = trim(args.tags),
contributor = trim(args.contributor), lede = description, method = instructions,
kcal_total = tostring(round(kcalTotal)), allergens = table.concat(allergens, ','), ingredient_count = tostring(#lines),
})
end
-- Categories belong to real recipe pages. Without this guard a /testcases
-- page that exercises the template would file itself under Category:Recipes
-- and every tag category it demonstrates.
if title.namespace == 0 then
local cats = { '[[Category:Recipes]]' }
for _, t in ipairs(splitList(args.tags)) do table.insert(cats, '[[Category:' .. t .. ']]') end
add(table.concat(cats, ''))
end
return table.concat(out, '\n')
end
-- ---------------------------------------------------------------- sale
function p.sale(frame)
local args = frame.args
local store = trim(args.store)
local where = store ~= '' and ("store='" .. sqlEsc(store) .. "'") or nil
local sale = cargo.query('SaleItems', 'ingredient,price,was,per,store', { where = where, limit = 200 })
if #sale == 0 then return "''No sale items have been entered yet. Add them with [[Template:SaleItem]].''" end
local onSale = {}
for _, s in ipairs(sale) do onSale[s.ingredient] = s end
local rows = cargo.query('RecipeIngredients=RI,Ingredients=I', 'RI.recipe=recipe,RI.ingredient=ingredient,I.pantry=pantry',
{ join = 'RI.ingredient=I._pageName', limit = 2000 })
local recipes, order = {}, {}
for _, r in ipairs(rows) do
if not recipes[r.recipe] then recipes[r.recipe] = { hits = {}, toBuy = 0, save = 0 }; table.insert(order, r.recipe) end
local rec = recipes[r.recipe]
local pantry = trim(r.pantry):lower() == 'yes'
if not pantry then
rec.toBuy = rec.toBuy + 1
local s = onSale[r.ingredient]
if s then
table.insert(rec.hits, r.ingredient)
rec.save = rec.save + ((tonumber(s.was) or 0) - (tonumber(s.price) or 0))
end
end
end
local ranked = {}
for _, name in ipairs(order) do
local rec = recipes[name]
if #rec.hits > 0 then rec.name = name; table.insert(ranked, rec) end
end
table.sort(ranked, function(a, b)
if #a.hits ~= #b.hits then return #a.hits > #b.hits end
return (#a.hits / math.max(1, a.toBuy)) > (#b.hits / math.max(1, b.toBuy))
end)
local out = { '{| class="wikitable rp-sale"\n! Recipe !! On sale !! Shopping list !! You save' }
for _, rec in ipairs(ranked) do
local links = {}
for _, h in ipairs(rec.hits) do table.insert(links, '[[' .. h .. ']]') end
table.insert(out, '|-\n| [[' .. rec.name .. ']]\n| ' .. table.concat(links, ', ') .. '\n| ' .. #rec.hits .. ' of ' .. rec.toBuy ..
' items <small>(pantry staples excluded)</small>\n| class="rp-qty" | $' .. string.format('%.2f', rec.save))
end
table.insert(out, '|}')
return table.concat(out, '\n')
end
-- ---------------------------------------------------------------- cookbook
local function chapterFor(tags)
local t = {}
for _, x in ipairs(splitList(tags)) do t[x:lower()] = true end
if t['breakfast'] then return 'Mornings' end
if t['dessert'] then return 'Sweets' end
if t['dinner'] or t['side'] or t['soup'] then return 'Suppers and sides' end
return 'Pantry and dressings'
end
function p.cookbook(frame)
local args = frame:getParent().args
local names = splitList(args.recipes)
local title = trim(args.title) ~= '' and trim(args.title) or 'Our kitchen'
local out = {}
local function add(s) table.insert(out, s) end
add('<div class="rp-book">')
add('<div class="rp-page rp-cover"><div class="rp-orn">✦</div><div class="rp-cover-title">' .. title .. '</div><div class="rp-cover-sub">' ..
#names .. (#names == 1 and ' recipe' or ' recipes') .. ' · assembled on Recipedius</div></div>')
if trim(args.dedication) ~= '' then
add('<div class="rp-page rp-dedication">' .. trim(args.dedication) .. '</div>')
end
-- gather
local recs, chapters, chapterOrder = {}, {}, {}
for _, n in ipairs(names) do
local r = cargo.query('Recipes', '_pageName=name,serves,time,tags,contributor,lede,method',
{ where = "_pageName='" .. sqlEsc(n) .. "'", limit = 1 })[1]
if r then
r.lines = cargo.query('RecipeIngredients', 'ingredient,qty,unit,prep,pos',
{ where = "recipe='" .. sqlEsc(n) .. "'", orderBy = 'pos', limit = 100 })
local ch = chapterFor(r.tags)
if not chapters[ch] then chapters[ch] = {}; table.insert(chapterOrder, ch) end
table.insert(chapters[ch], r)
else
add('<div class="rp-missing">No recipe page called "' .. n .. '" was found.</div>')
end
end
-- contents
add('<div class="rp-page rp-toc"><div class="rp-chap-label">Contents</div>')
for _, ch in ipairs(chapterOrder) do
add('<div class="rp-toc-chapter">' .. ch .. '</div>')
for _, r in ipairs(chapters[ch]) do add('<div class="rp-toc-row"><span>' .. r.name .. '</span><span>' .. r.time .. '</span></div>') end
end
add('</div>')
for _, ch in ipairs(chapterOrder) do
add('<div class="rp-page rp-chapter"><div class="rp-chap-label">Chapter</div><div class="rp-chapter-title">' .. ch .. '</div></div>')
for _, r in ipairs(chapters[ch]) do
add('<div class="rp-page rp-recipe-page"><div class="rp-chap-label">' .. ch .. '</div><div class="rp-recipe-title">' .. r.name .. '</div>')
if trim(r.lede) ~= '' then add("<div class=\"rp-lede\">''" .. r.lede .. "''</div>") end
add('<div class="rp-book-cols"><div class="rp-book-ings">')
for _, l in ipairs(r.lines) do
local q = tonumber(l.qty)
add('<div class="rp-book-ing"><span class="rp-qty">' .. fmtQty(q) .. ' ' .. unitLabel(q, l.unit) .. '</span><span>' .. countLabel(l.ingredient, q, l.unit) ..
(trim(l.prep) ~= '' and (', ' .. l.prep) or '') .. '</span></div>')
end
add('</div><div class="rp-book-method">\n' .. (r.method or '') .. '\n</div></div>')
add('<div class="rp-book-foot">Serves ' .. r.serves .. ' · ' .. r.time .. (trim(r.contributor) ~= '' and (' · contributed by ' .. r.contributor) or '') .. '</div></div>')
end
end
add('</div>')
return table.concat(out, '\n')
end
return p