Jump to content

Module:Lorebook

From example
Revision as of 19:13, 17 October 2025 by M4tsuri (talk | contribs) (Undo revision 65 by M4tsuri (talk))

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
    if #plistChunks > 0 then
      text = text .. '\n'  -- Add extra newline between plist and chat sections
    end
    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)
  local result = buildInjection(rows, triggered)
  
  -- Wrap result in <nowiki><pre> to preserve formatting and prevent wiki parsing
  if result and result ~= '' then
    return '<nowiki>' .. result .. '</nowiki>'
  else
    return ''
  end
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