memory alpha

Documentation for this module may be created at Module:JSONutil/doc

local JSONutil = {
  item = 63869449,
  serial = "2020-11-08",
  suite = "JSONutil"
}

--[=[
      Preprocess or generate JSON data.
]=]

local Failsafe = JSONutil

JSONutil.Encoder = {
  scream = "@error@JSONencoder@",
  sep = string.char(10),
  stab = string.char(9)
}

JSONutil.more = 50  -- Length of trailing context.

local Fallback = function()
  -- Retrieve current default language code.
  -- Returns string.
  return mw.language.getContentLanguage():getCode():lower()
end -- Fallback()

local flat = function(adjust)
  -- Clean template argument string.
  -- Parameter:
  --     adjust  -- string, or not
  -- Returns:
  --     string
  local r
  if adjust then
    r = mw.text.trim(mw.text.unstripNoWiki(adjust))
  else
    r = ""
  end
  return r
end -- flat()

local flip = function(frame)
  -- Retrieve template argument indent.
  -- Parameter:
  --     frame  -- object
  -- Returns:
  --     Number of indentation level, or not.
  local r
  if frame.args.indent and frame.args.indent:match("^%d+$") then
    r = tonumber(frame.args.indent)
  end
  return r
end -- flip()

JSONutil.Encoder.Array = function(apply, adapt, alert)
  -- Convert table to JSON array.
  -- Parameter:
  --     apply  -- table, with sequence of raw elements, or
  --               string, with formatted Array, or empty
  --     adapt  -- string, with requested type, or not
  --     alert  -- true, if non-numeric elements shall trigger errors
  -- Returns:
  --     String, with JSON array.
  local r = type(apply)
  if r == "string" then
    r = mw.text.trim(apply)
    if r == "" then
      r = "[]"
    elseif r:byte(1, 1) ~= 0x5B or r:byte(-1, -1) ~= 0x5D then
      r = false
    end
  elseif r == "table" then
    local n = 0
    local strange
    for k, v in pairs(apply) do
      if type(k) == "number" then
        if k > n then
          n = k
        end
      elseif alert then
        if strange then
          strange = strange .. " "
        else
          strange = ""
        end
        strange = strange .. tostring(k)
      end
    end -- for k, v
    if strange then
      r = string.format('{ "%s": "%s" }', JSONutil.Encoder.scream, JSONutil.Encoder.string(strange))
    elseif n > 0 then
      local sep = ""
      local scope = adapt or "string"
      local s
      if type(JSONutil.Encoder[scope]) ~= "function" then
        scope = "string"
      end
      r = " ]"
      for i = n, 1, -1 do
        s = JSONutil.Encoder[scope](apply[i])
        r = string.format("%s%s%s", s, sep, r)
        sep = ",\n  "
      end -- for i = n, 1, -1
      r = "[ " .. r
    else
      r = "[]"
    end
  else
    r = false
  end
  if not r then
    r = string.format('[ "%s * %s" ]', JSONutil.Encoder.scream, "Bad Array")
  end
  return r
end -- JSONutil.Encoder.Array()

JSONutil.Encoder.boolean = function(apply)
  -- Convert string to JSON boolean.
  -- Parameter:
  --     apply  -- string, with value
  -- Returns:
  --     Boolean, as string.
  local r = mw.text.trim(apply)
  if r == "" or r == "null" or r == "false" or r == "0" or r == "-" then
    r = "false"
  else
    r = "true"
  end
  return r
end -- JSONutil.Encoder.boolean()

JSONutil.Encoder.Component = function(access, apply, adapt, align, alert)
  -- Create single entry for mapping object.
  -- Parameter:
  --     access  -- string, with component name
  --     apply   -- component value
  --     adapt   -- string, with value type, or not
  --     align   -- number, of indentation level, or not
  --     alert   --
  -- Returns:
  --     String, with JSON fragment and comma.
  local v = apply
  local types = adapt
  local indent, liner, scope, sep, sign
  if type(access) == "string" then
    sign = mw.text.trim(access)
    if sign == "" then
      sign = false
    end
  end
  if type(types) == "string" then
    types = mw.text.split(mw.text.trim(types), "%s+")
  end
  if type(types) ~= "table" then
    types = {}
    table.insert(types, "string")
  end
  if #types == 1 then
    scope = types[1]
  else
    for i = 1, #types do
      if types[i] == "boolean" then
        if v == "1" or v == 1 or v == true then
          v = "true"
          scope = "boolean"
        elseif v == "0" or v == 0 or v == false then
          v = "false"
          scope = "boolean"
        end
        if scope then
          types = {}
          break -- for i
        else
          table.remove(types, i)
        end
      end
    end -- for i
    for i = 1, #types do
      if types[i] == "number" then
        if tonumber(v) then
          v = tostring(v)
          scope = "number"
          types = {}
          break -- for i
        else
          table.remove(types, i)
        end
      end
    end -- for i
  end
  scope = scope or "string"
  if type(JSONutil.Encoder[scope]) ~= "function" then
    scope = "string"
  elseif scope == "I18N" then
    scope = "Polyglott"
  end
  if scope == "string" then
    v = v or ""
  end
  if type(align) == "number" and align > 0 then
    indent = math.floor(align)
    if indent == 0 then
      indent = false
    end
  end
  if scope == "object" or not sign then
    liner = true
  elseif scope == "string" then
    local k = mw.ustring.len(sign) + mw.ustring.len(v)
    if k > 60 then
      liner = true
    end
  end
  if liner then
    if indent then
      sep = "\n" .. string.rep("  ", indent)
    else
      sep = "\n"
    end
  else
    sep = " "
  end
  if indent then
    indent = indent + 1
  end
  return string.format(' "%s":%s%s,\n', sign or "???", sep, JSONutil.Encoder[scope](v, indent))
end -- JSONutil.Encoder.Component()

JSONutil.Encoder.Hash = function(apply, adapt, alert)
  -- Create entries for mapping object.
  -- Parameter:
  --     apply  -- table, with element value assignments
  --     adapt  -- table, with value types assignment, or not
  -- Returns:
  --     String, with JSON fragment and comma.
  local r = ""
  local s
  for k, v in pairs(apply) do
    if type(adapt) == "table" then
      s = adapt[k]
    end
    r = r .. JSONutil.Encoder.Component(tostring(k), v, s)
  end -- for k, v
  return
end -- JSONutil.Encoder.Hash()

JSONutil.Encoder.I18N = function(apply, align)
  -- Convert multilingual string table to JSON.
  -- Parameter:
  --     apply  -- table, with mapping object
  --     align  -- number, of indentation level, or not
  -- Returns:
  --     String, with JSON object.
  local r = type(apply)
  if r == "table" then
    local strange
    local fault = function(a)
      if strange then
        strange = strange .. " *\n "
      else
        strange = ""
      end
      strange = strange .. a
    end
    local got, sep, indent
    for k, v in pairs(apply) do
      if type(k) == "string" then
        k = mw.text.trim(k)
        if type(v) == "string" then
          v = mw.text.trim(v)
          if v == "" then
            fault(string.format("%s %s=", "Empty text", k))
          end
          if not (k:match("%l%l%l?") or k:match("%l%l%l?-%u%u") or k:match("%l%l%l?-%u%l%l%l+")) then
            fault(string.format("%s %s=", "Strange language code", k))
          end
        else
          v = tostring(v)
          fault(string.format("%s %s=%s", "Bad type for text", k, type(v)))
        end
        got = got or {}
        got[k] = v
      else
        fault(string.format("%s %s: %s", "Bad language code type", type(k), tostring(k)))
      end
    end -- for k, v
    if not got then
      fault("No language codes")
      got = {}
    end
    if strange then
      got[JSONutil.Encoder.scream] = strange
    end
    r = false
    if type(align) == "number" and align > 0 then
      indent = math.floor(align)
    else
      indent = 0
    end
    sep = string.rep("  ", indent + 1)
    for k, v in pairs(got) do
      if r then
        r = r .. ",\n"
      else
        r = ""
      end
      r = string.format("%s  %s%s: %s", r, sep, JSONutil.Encoder.string(k), JSONutil.Encoder.string(v))
    end -- for k, v
    r = string.format("{\n%s\n%s}", r, sep)
  elseif r == "string" then
    r = JSONutil.Encoder.string(apply)
  else
    r = string.format('{ "%s": "%s: %s" }', JSONutil.Encoder.scream, "Bad Lua type", r)
  end
  return r
end -- JSONutil.Encoder.I18N()

JSONutil.Encoder.number = function(apply)
  -- Convert string to JSON number.
  -- Parameter:
  --     apply  -- string, with presumable number
  -- Returns:
  --     Number or "NaN".
  local s = mw.text.trim(apply)
  JSONutil.Encoder.minus = JSONutil.Encoder.minus or mw.ustring.char(0x2212)
  s = s:gsub(JSONutil.Encoder.minus, "-")
  return tonumber(s:lower()) or "NaN"
end -- JSONutil.Encoder.number()

JSONutil.Encoder.object = function(apply, align)
  -- Create mapping object.
  -- Parameter:
  --     apply  -- string, with components, may end with comma
  --     align  -- number, of indentation level, or not
  -- Returns:
  --     String, with JSON fragment.
  local story = mw.text.trim(apply)
  local start = ""
  if story:sub(-1) == "," then
    story = story:sub(1, -2)
  end
  if type(align) == "number" and align > 0 then
    local indent = math.floor(align)
    if indent > 0 then
      start = string.rep("  ", indent)
    end
  end
  return string.format("%s{ %s\n%s}", start, story, start)
end -- JSONutil.Encoder.object()

JSONutil.Encoder.Polyglott = function(apply, align)
  -- Convert string or multilingual string table to JSON.
  -- Parameter:
  --     apply  -- string, with string or object
  --     align  -- number, of indentation level, or not
  -- Returns:
  --     string
  local r = type(apply)
  if r == "string" then
    r = mw.text.trim(apply)
    if not r:match('^{%s*"') or not r:match('"%s*}$') then
      r = JSONutil.Encoder.string(r)
    end
  else
    r = string.format('{ "%s": "%s: %s" }', JSONutil.Encoder.scream, "Bad Lua type", r)
  end
  return r
end -- JSONutil.Encoder.Polyglott()

JSONutil.Encoder.string = function(apply)
  -- Convert plain string to strict JSON string.
  -- Parameter:
  --     apply  -- string, with plain string
  -- Returns:
  --     string, with quoted trimmed JSON string
  return string.format(
    '"%s"',
    mw.text
      .trim(apply)
      :gsub("\\", "\\\\")
      :gsub('"', '\\"')
      :gsub(JSONutil.Encoder.sep, "\\n")
      :gsub(JSONutil.Encoder.stab, "\\t")
  )
end -- JSONutil.Encoder.string()

JSONutil.fair = function(apply)
  -- Reduce enhanced JSON data to strict JSON.
  -- Parameter:
  --     apply  -- string, with enhanced JSON
  -- Returns:
  --     1    -- string|nil|false, with error keyword
  --     2    -- string, with JSON or context
  local m = 0
  local n = 0
  local s = mw.text.trim(apply)
  local i, j, last, r, scan, sep0, sep1, start, stub, suffix
  local framework = function(a)
    -- Syntax analysis outside strings.
    local k = 1
    local c
    while k do
      k = a:find("[{%[%]}]", k)
      if k then
        c = a:byte(k, k)
        if c == 0x7B then -- {
          m = m + 1
        elseif c == 0x7D then -- }
          m = m - 1
        elseif c == 0x5B then -- [
          n = n + 1
        else -- ]
          n = n - 1
        end
        k = k + 1
      end
    end -- while k
  end -- framework()
  local free = function(a, at, f)
    -- Throws: error if /* is not matched by */
    local s = a
    local i = s:find("//", at, true)
    local k = s:find("/*", at, true)
    if i or k then
      local m = s:find(sep0, at)
      if i and (not m or i < m) then
        k = s:find("\n", i + 2, true)
        if k then
          if i == 1 then
            s = s:sub(k + 1)
          else
            s = s:sub(1, i - 1) .. s:sub(k + 1)
          end
        elseif i > 1 then
          s = s:sub(1, i - 1)
        else
          s = ""
        end
      elseif k and (not m or k < m) then
        i = s:find("*/", k + 2, true)
        if i then
          if k == 1 then
            s = s:sub(i + 2)
          else
            s = s:sub(1, k - 1) .. s:sub(i + 2)
          end
        else
          error(s:sub(k + 2), 0)
        end
        i = k
      else
        i = false
      end
      if i then
        s = mw.text.trim(s)
        if s:find("/", 1, true) then
          s = f(s, i, f)
        end
      end
    end
    return s
  end -- free()
  if s:sub(1, 1) == "{" then
    s = s:gsub(string.char(13, 10), JSONutil.Encoder.sep):gsub(string.char(13), JSONutil.Encoder.sep)
    stub = s:gsub(JSONutil.Encoder.sep, ""):gsub(JSONutil.Encoder.stab, "")
    scan = string.char(0x5B, 0x01, 0x2D, 0x1F, 0x5D) -- [ \-\ ]
    j = stub:find(scan)
    if j then
      r = "ControlChar"
      s = mw.text.trim(s:sub(j + 1))
      s = mw.ustring.sub(s, 1, JSONutil.more)
    else
      i = true
      j = 1
      last = (stub:sub(-1) == "}")
      sep0 = string.char(0x5B, 0x22, 0x27, 0x5D) -- [ " ' ]
      sep1 = string.char(0x5B, 0x5C, 0x22, 0x5D) -- [ \ " ]
    end
  else
    r = "Bracket0"
    s = mw.ustring.sub(s, 1, JSONutil.more)
  end
  while i do
    i, s = pcall(free, s, j, free)
    if i then
      i = s:find(sep0, j)
    else
      r = "CommentEnd"
      s = mw.text.trim(s)
      s = mw.ustring.sub(s, 1, JSONutil.more)
    end
    if i then
      if j == 1 then
        framework(s:sub(1, i - 1))
      end
      if s:sub(i, i) == '"' then
        stub = s:sub(j, i - 1)
        if stub:find('[^"]*,%s*[%]}]') then
          r = "CommaEnd"
          s = mw.text.trim(stub)
          s = mw.ustring.sub(s, 1, JSONutil.more)
          i = false
          j = false
        else
          if j > 1 then
            framework(stub)
          end
          i = i + 1
          j = i
        end
        while j do
          j = s:find(sep1, j)
          if j then
            if s:sub(j, j) == '"' then
              start = s:sub(1, i - 1)
              suffix = s:sub(j)
              if j > i then
                stub =
                  s:sub(i, j - 1):gsub(JSONutil.Encoder.sep, "\\n"):gsub(JSONutil.Encoder.stab, "\\t")
                j = i + stub:len()
                s = string.format("%s%s%s", start, stub, suffix)
              else
                s = start .. suffix
              end
              j = j + 1
              break -- while j
            else
              j = j + 2
            end
          else
            r = "QouteEnd"
            s = mw.text.trim(s:sub(i))
            s = mw.ustring.sub(s, 1, JSONutil.more)
            i = false
          end
        end -- while j
      else
        r = "Qoute"
        s = mw.text.trim(s:sub(i))
        s = mw.ustring.sub(s, 1, JSONutil.more)
        i = false
      end
    elseif not r then
      stub = s:sub(j)
      if stub:find('[^"]*,%s*[%]}]') then
        r = "CommaEnd"
        s = mw.text.trim(stub)
        s = mw.ustring.sub(s, 1, JSONutil.more)
      else
        framework(stub)
      end
    end
  end -- while i
  if not r and (m ~= 0 or n ~= 0) then
    if m ~= 0 then
      s = "}"
      if m > 0 then
        r = "BracketCloseLack"
        j = m
      elseif m < 0 then
        r = "BracketClosePlus"
        j = -m
      end
    else
      s = "]"
      if n > 0 then
        r = "BracketCloseLack"
        j = n
      else
        r = "BracketClosePlus"
        j = -n
      end
    end
    if j > 1 then
      s = string.format("%d %s", j, s)
    end
  elseif not (r or last) then
    stub = suffix or apply or ""
    j = stub:find("/", 1, true)
    if j then
      i, stub = pcall(free, stub, j, free)
    else
      i = true
    end
    stub = mw.text.trim(stub)
    if i then
      if stub:sub(-1) ~= "}" then
        r = "Trailing"
        s = stub:match("%}%s*(%S[^%}]*)$")
        if s then
          s = mw.ustring.sub(s, 1, JSONutil.more)
        else
          s = mw.ustring.sub(stub, -JSONutil.more)
        end
      end
    else
      r = "CommentEnd"
      s = mw.ustring.sub(stub, 1, JSONutil.more)
    end
  end
  if r and s then
    s = s:gsub(JSONutil.Encoder.sep, " ")
    s = mw.text.encode(s):gsub("|", "&#124;")
  end
  return r, s
end -- JSONutil.fair()

JSONutil.fault = function(alert, add, adapt)
  -- Retrieve formatted message.
  -- Parameter:
  --     alert  -- string, with error keyword, or other text
  --     add    -- string|nil|false, with context
  --     adapt  -- function|string|table|nil|false, for I18N
  -- Returns string, with HTML span.
  local e = mw.html.create("span"):addClass("error")
  local s = alert
  if type(s) == "string" then
    s = mw.text.trim(s)
    if s == "" then
      s = "EMPTY JSONutil.fault key"
    end
    if not s:find(" ", 1, true) then
      local storage = string.format("I18n/Module:%s.tab", JSONutil.suite)
      local lucky, t = pcall(mw.ext.data.get, storage, "_")
      if type(t) == "table" then
        t = t.data
        if type(t) == "table" then
          local e
          s = "err_" .. s
          for i = 1, #t do
            e = t[i]
            if type(e) == "table" then
              if e[1] == s then
                e = e[2]
                if type(e) == "table" then
                  local q = type(adapt)
                  if q == "function" then
                    s = adapt(e, s)
                    t = false
                  elseif q == "string" then
                    t = mw.text.split(adapt, "%s+")
                  elseif q == "table" then
                    t = adapt
                  else
                    t = {}
                  end
                  if t then
                    table.insert(t, Fallback())
                    table.insert(t, "en")
                    for k = 1, #t do
                      q = e[t[k]]
                      if type(q) == "string" then
                        s = q
                        break -- for k
                      end
                    end -- for k
                  end
                else
                  s = "JSONutil.fault I18N bad #" .. tostring(i)
                end
                break -- for i
              end
            else
              break -- for i
            end
          end -- for i
        else
          s = "INVALID JSONutil.fault I18N corrupted"
        end
      else
        s = "INVALID JSONutil.fault commons:Data: " .. type(t)
      end
    end
  else
    s = "INVALID JSONutil.fault key: " .. tostring(s)
  end
  if type(add) == "string" then
    s = string.format("%s &#8211; %s", s, add)
  end
  e:wikitext(s)
  return tostring(e)
end -- JSONutil.fault()

JSONutil.fetch = function(apply, always, adapt)
  -- Retrieve JSON data or error message.
  -- Parameter:
  --     apply   -- string, with presumable JSON text
  --     always  -- true, if apply is expected to need preprocessing
  --     adapt   -- function|string|table|nil|false, for I18N
  -- Returns table with data or string, with error as HTML span.
  local lucky, r
  if not always then
    lucky, r = pcall(mw.text.jsonDecode, apply)
  end
  if not lucky then
    lucky, r = JSONutil.fair(apply)
    if lucky then
      r = JSONutil.fault(lucky, r, adapt)
    else
      lucky, r = pcall(mw.text.jsonDecode, r)
      if not lucky then
        r = JSONutil.fault(r, false, adapt)
      end
    end
  end
  return r
end -- JSONutil.fetch()

Failsafe.failsafe = function(atleast)
  -- Retrieve versioning and check for compliance.
  -- Precondition:
  --     atleast  -- string, with required version
  --                         or "wikidata" or "~" or "@" or false
  -- Postcondition:
  --     Returns  string  -- with queried version/item, also if problem
  --              false   -- if appropriate
  -- 2020-08-17
  local since = atleast
  local last = (since == "~")
  local linked = (since == "@")
  local link = (since == "item")
  local r
  if last or link or linked or since == "wikidata" then
    local item = Failsafe.item
    since = false
    if type(item) == "number" and item > 0 then
      local suited = string.format("Q%d", item)
      if link then
        r = suited
      else
        local entity = mw.wikibase.getEntity(suited)
        if type(entity) == "table" then
          local seek = Failsafe.serialProperty or "P348"
          local vsn = entity:formatPropertyValues(seek)
          if type(vsn) == "table" and type(vsn.value) == "string" and vsn.value ~= "" then
            if last and vsn.value == Failsafe.serial then
              r = false
            elseif linked then
              if mw.title.getCurrentTitle().prefixedText == mw.wikibase.getSitelink(suited) then
                r = false
              else
                r = suited
              end
            else
              r = vsn.value
            end
          end
        end
      end
    end
  end
  if type(r) == "nil" then
    if not since or since <= Failsafe.serial then
      r = Failsafe.serial
    else
      r = false
    end
  end
  return r
end -- Failsafe.failsafe()

-- Export.
local p = {}

p.failsafe = function(frame)
  -- Versioning interface.
  local s = type(frame)
  local since
  if s == "table" then
    since = frame.args[1]
  elseif s == "string" then
    since = frame
  end
  if since then
    since = mw.text.trim(since)
    if since == "" then
      since = false
    end
  end
  return Failsafe.failsafe(since) or ""
end -- p.failsafe

p.encodeArray = function(frame)
  return JSONutil.Encoder.Array(frame:getParent().args, frame.args.type, frame.args.error == "1")
end -- p.encodeArray

p.encodeComponent = function(frame)
  return JSONutil.Encoder.Component(
    frame.args.sign,
    frame.args.value,
    frame.args.type,
    flip(frame),
    frame.args.error == "1"
  )
end -- p.encodeComponent

p.encodeHash = function(frame)
  return JSONutil.Encoder.Hash(frame:getParent().args, frame.args)
end -- p.encodeHash

p.encodeI18N = function(frame)
  return JSONutil.Encoder.I18N(frame:getParent().args, flip(frame))
end -- p.encodeI18N

p.encodeObject = function(frame)
  return JSONutil.Encoder.object(flat(frame.args[1]), flip(frame))
end -- p.encodeObject

p.encodePolyglott = function(frame)
  return JSONutil.Encoder.Polyglott(flat(frame.args[1]), flip(frame))
end -- p.encodePolyglott

p.JSONutil = function()
  -- Module interface.
  return JSONutil
end

return p