Module:Lorebook
Appearance
Documentation for this module may be created at Module:Lorebook/doc
local p = {}
local function splitCSV(s)
if not s or s == '' then return {} end
local t = {}
for token in mw.text.gsplit(s, ',', true) do
token = mw.text.trim(token)
if token ~= '' then table.insert(t, token:lower()) end
end
return t
end
local function containsAny(haystack, needles)
local s = haystack:lower()
for _, n in ipairs(needles) do
if n ~= '' then
local needle = n:lower()
-- Escape special pattern characters
local escaped = needle:gsub('[%^%$%(%)%%%.%[%]%*%+%-%?]', '%%%1')
-- Try multiple matching strategies
-- 1. Exact match
if s == needle then
return true
end
-- 2. Word boundary patterns
local patterns = {
'^' .. escaped .. '%W', -- At start followed by non-word
'%W' .. escaped .. '%W', -- Surrounded by non-word chars
'%W' .. escaped .. '$', -- Preceded by non-word at end
'^' .. escaped .. '$' -- Exact match (redundant but safe)
}
for _, pattern in ipairs(patterns) do
if s:find(pattern) then
return true
end
end
-- 3. Simple substring match as last resort
if s:find(escaped, 1, true) then
return true
end
end
end
return false
end
local function anyMatch(haystack, primary, _secondary)
return containsAny(haystack, primary)
end
local function andMatch(haystack, primary, secondary)
return containsAny(haystack, primary) and containsAny(haystack, secondary)
end
local function notMatch(haystack, primary, secondary)
return containsAny(haystack, primary) and not containsAny(haystack, secondary)
end
local logicDispatch = {
ANY = anyMatch,
AND = andMatch,
NOT = notMatch,
}
local function normalizeValue(val)
-- SMW returns property values as tables sometimes
if type(val) == 'table' then
return val[1] or ''
end
return val or ''
end
local function getAllConcepts(world)
local query = {
'[[Category:Concept]][[Belongs to world::' .. world .. ']]',
'?Plist=plist',
'?AliChat=alichat',
'?Primary keys=primary',
'?Secondary keys=secondary',
'?Logic=logic',
'?Key mode=mode',
'?Parent concept=parent',
'?Non-recursable=nonrec',
'?Placement=placement',
limit = 999
}
local res = mw.smw.ask(query)
if not res then return {} end
-- Normalize all property values
local normalized = {}
for i, row in ipairs(res) do
normalized[i] = {
page = normalizeValue(row[1]), -- First element is the page title
plist = normalizeValue(row.plist),
alichat = normalizeValue(row.alichat),
primary = normalizeValue(row.primary),
secondary = normalizeValue(row.secondary),
logic = normalizeValue(row.logic),
mode = normalizeValue(row.mode),
parent = normalizeValue(row.parent),
nonrec = normalizeValue(row.nonrec),
placement = normalizeValue(row.placement)
}
end
return normalized
end
local function indexByTitle(rows)
local idx = {}
for _, r in ipairs(rows) do
if r.page and r.page ~= '' then
idx[r.page] = r
end
end
return idx
end
local function shouldTrigger(row, haystack)
local mode = (row.mode or 'conditional'):upper()
if mode == 'DISABLED' then return false end
if mode == 'CONSTANT' then return true end
local logic = (row.logic or 'ANY'):upper()
local f = logicDispatch[logic] or anyMatch
local primary = splitCSV(row.primary)
local secondary = splitCSV(row.secondary)
return f(haystack, primary, secondary)
end
local function walkRecursion(rows, idx, startPages)
local children = {}
for _, r in ipairs(rows) do
local parent = r.parent
if parent and parent ~= '' then
children[parent] = children[parent] or {}
table.insert(children[parent], r.page)
end
end
local visited = {}
local stack = {}
for _, ptitle in ipairs(startPages) do table.insert(stack, ptitle) end
local ordered = {}
while #stack > 0 do
local cur = table.remove(stack)
if not visited[cur] then
visited[cur] = true
table.insert(ordered, cur)
local row = idx[cur]
if row and (row.nonrec or ''):lower() ~= 'yes' then
for _, c in ipairs(children[cur] or {}) do
table.insert(stack, c)
end
end
end
end
return ordered
end
local function buildInjection(rows, triggered)
local plistChunks, chatChunks = {}, {}
for _, title in ipairs(triggered) do
local r
for _, row in ipairs(rows) do if row.page == title then r = row break end end
if r then
if r.plist and r.plist ~= '' then table.insert(plistChunks, r.plist) end
if r.alichat and r.alichat ~= '' then table.insert(chatChunks, r.alichat) end
end
end
local text = ''
if #plistChunks > 0 then
text = text .. table.concat(plistChunks, '\n') .. '\n'
end
if #chatChunks > 0 then
text = text .. table.concat(chatChunks, '\n')
end
return text
end
function p.inject(frame)
local world = frame.args.world or 'Farlandia'
local haystack = frame.args.context or ''
local rows = getAllConcepts(world)
local idx = indexByTitle(rows)
local start = {}
for _, r in ipairs(rows) do
if shouldTrigger(r, haystack) then table.insert(start, r.page) end
end
local triggered = walkRecursion(rows, idx, start)
return buildInjection(rows, triggered)
end
function p.export(frame)
local world = frame.args.world or 'Farlandia'
local rows = getAllConcepts(world)
local entries = {}
for _, r in ipairs(rows) do
table.insert(entries, {
title = r.page,
plist = r.plist or '',
alichat = r.alichat or '',
primary = r.primary or '',
secondary = r.secondary or '',
logic = (r.logic or 'ANY'),
mode = (r.mode or 'conditional'),
parent = r.parent or '',
nonrecursable = r.nonrec or '',
placement = r.placement or ''
})
end
return mw.text.jsonEncode(entries)
end
return p