Module:SOM.meta.CompositeAttribute
From SunshinePPS Wiki
Documentation for this module may be created at Module:SOM.meta.CompositeAttribute/doc
require("Module:No globals")
local util = require("Module:SOM.util")
local base = require("Module:SOM.meta.AttributeBase")
--local base = {}
--function base.make_value_property(root)
-- return "SOM:" .. root
--end
local ErrorCapture = require("Module:SOM.util.ErrorCapture")
local StringBuffer = require("Module:SOM.util.StringBuffer")
local function note_missing_composite(class_name)
-- assert(type(field_name) == "string" and field_name ~= "",
-- "Bad field_name: " .. mw.dumpObject(field_name))
if not (type(class_name) == "string" and class_name ~= "") then
error("Bad class_name: " .. mw.dumpObject(class_name), 2)
end
util.assert_smw_set({
[util.MISSING_ATTRIBUTES_PROPERTY] = class_name,
})
end
local p = {}
-- class_name - root name of the class/module
-- docstring - (optional) string documenting the class/module
-- title_key - (optional/string) name of element to use in subobject titles
-- attach_as_simple - (optional/boolean) if true, then index in SMW using a
-- simple property attached to page, instead of subobject; only
-- allowed for class with a single element
-- elements.
-- .field.
-- .label - string: label for the field in entry form (required)
-- .input_type - string/optional: form field input type
-- .extra_args - table: additional arguments to {{{field|...}}}
-- .map - table or function (optional) which maps field/template
-- strings to internal instance values
--
-- .multivalued - bool/optional: can this element comprise multiple values
-- .value_delimiter - string/optional: if multivalued, the character
-- which separates the values
--
-- .fixed_value - string: a fixed value for the element
--
-- Only one of .field or .fixed_value may be non-nil.
--
-- If both are nil, then element is /static/ and value must be supplied
-- by Lua when attach() is called (via default_values).
--
-- .namespace_prefix - string/optional: NS prepended to stored value
--
-- .smw.
-- .property - root name of SMW property (string/required)
-- .ignore_missing - boolean/optional: if true, do not flag nil/empty
-- value as a missing field
--
-- If smw is nil, then element is not indexed to SMW.
--
-- .render_formatter - function/optional: convert value -> string
--
function p.generate_composite(args)
local class_name = args.class_name
local elements = args.elements
local docstring = args.docstring
local title_key = args.title_key
local form_layout = args.form_layout or {}
-- TODO maddog IS THIS STILL USED? XXXXXXX
local attach_as_simple = args.attach_as_simple
local category_name = class_name
local module_name = class_name
local instance_template = module_name .. "/Instance"
if attach_as_simple then
util.assertf(util.count_keys(elements) == 1,
"Class %s has too many elements for attach_as_simple: %d",
class_name, util.count_keys(elements))
util.assertf(title_key == nil,
"Class %s should not have both title_key and attach_as_simple",
class_name)
end
local class = { docs = {} }
-- class.module_name = class_name
function class.docs.docs()
local buffer = StringBuffer.new()
buffer:add_uformat("This module defines the %scomposite attribute class <code>%s</code>.",
util.string_if(attach_as_simple, "simple-valued, "),
class_name):nl()
if docstring then
buffer:nl():add(mw.text.trim(docstring)):nl()
end
if attach_as_simple then
local _, element = next(elements)
if element.smw then
local single_value_property = base.make_value_property(element.smw.property)
buffer:nl():add_uformat([=[
The value will be attached as the SMW property [[Property:%s|%s]].]=],
single_value_property, single_value_property):nl()
end
else
-- TODO maddog Elements with smw will be attached as subobject.
-- Other elements won't.
end
return {
module_name = class_name,
docstring = buffer:output(),
}
end
function class.is_composite()
return true
end
-- .form_field{ field_name=..., label=... }
function class.form_field(args)
local field_name = args.field_name
local label = args.label -- nil ok
local result = StringBuffer.new()
:add_uformat("{{{field|%s|holds template", field_name)
if label then
result:add_uformat("|label=%s", label)
end
result:add("}}}")
return result:output()
end
local function add_form_field_row(buffer, keys, hidden_key)
buffer:add_uformat([[<div class="row">]]):nl()
for _, key in ipairs(keys) do
local element = elements[key]
local field = element.field
-- Skip elements without fields, or hidden elements.
if field and (key ~= hidden_key) then
-- buffer:add_uformat("%s: {{{field|%s|label=%s|input type=%s",
buffer:add_uformat("%s {{{field|%s|input type=%s",
field.label, key, field.input_type)
for k, v in pairs(field.extra_args or {}) do
if type(k) == "number" then
buffer:add_uformat("|%s", v)
else
buffer:add_uformat("|%s=%s", k, v)
end
end
buffer:add("}}}"):nl()
end
end
buffer:add_uformat([[</div>]]):nl()
end
-- returns string containing form embedded-template definition
function class.form_entry(args)
local embed_in = args.embed_in
-- TODO maddog For now, just accept a single key to hide.
local hidden_key = args.hide
local add_button_text = args.add_button_text
local buffer = StringBuffer.new()
buffer
:add_uformat("{{{for template|%s", instance_template):nl()
:add("|multiple"):nl()
:add_uformat("|embed in field=%s", embed_in):nl()
if add_button_text then
buffer:add_uformat("|add button text=Add %s", add_button_text):nl()
end
buffer
:add("}}}"):nl()
:add('<div class="container">'):nl()
local remaining_elements = {}
for key, _ in pairs(elements) do
remaining_elements[key] = true
end
for _, row in ipairs(form_layout) do
add_form_field_row(buffer, row, hidden_key)
for _, key in ipairs(row) do
remaining_elements[key] = nil
end
end
for key, _ in pairs(remaining_elements) do
--XXXXXXX for key, element in pairs(elements) do
local element = elements[key]
add_form_field_row(buffer, { key }, hidden_key)
-- local field = element.field
-- -- Skip elements without fields, or hidden elements.
-- if field and (key ~= hidden_key) then
-- buffer:add_uformat([[
--<div class="row">
--%s: {{{field|%s|label=%s|input type=%s]],
-- field.label, key, field.label, field.input_type)
-- for k, v in pairs(field.extra_args or {}) do
-- if type(k) == "number" then
-- buffer:add_uformat("|%s", v)
-- else
-- buffer:add_uformat("|%s=%s", k, v)
-- end
-- end
-- buffer:add([[
--}}}
--</div>
--]])
-- end
end
buffer:add([[
</div>
{{{end template}}}
]]):nl()
return buffer:output()
end
function class.docs.form_entry()
return {
desc = "Generate a Page Forms form entry template",
args = {
{ "embed in", "the TEMPLATE[FIELD] in which this entry should be embedded" },
{ "hide", "(optional) key of a field to be omitted from the form" },
{ "add button", "text label for the button which adds a new entry" },
},
}
end
local describe_instance_template
local encode_instance
-- returns string:
-- - called by Template:CLASS itself: returns description wikitext
-- - called by a Page: returns JSON-encoded instance
function class.instance_template(frame)
local parent = frame:getParent()
local parent_title = frame:getParent():getTitle()
local current_title = mw.title.getCurrentTitle().fullText
if parent_title == current_title then
return describe_instance_template()
else
return encode_instance(parent)
end
end
function describe_instance_template()
local buffer = StringBuffer.new()
buffer
:add("This template is used to create instances of the ")
:add_uformat("[[Module:%s|<code>%s</code>]]",
module_name, module_name)
:add(" class on a page.")
:nl():nl()
:add("This template has been automatically generated by the class.")
:nl():nl()
--LATER :add("It takes these parameters:"):nl()
-- for _, argdesc in ipairs(class.docs.attach().args) do
-- local arg, desc = unpack(argdesc)
-- buffer:add_uformat("* <code>%s</code> - %s", arg, desc):nl()
-- end
return buffer
end
local JSON_BLOCK_PREFIX = "👉👉👉"
local JSON_BLOCK_SUFFIX = "️👈️👈️👈"
local JSON_BLOCK_PREFIX2 = "👇👇👇"
local JSON_BLOCK_SUFFIX2 = "👆👆👆"
function encode_instance(frame)
local explicit_args = {}
-- NB: We copy every arg in the frame (rather than looking specifically
-- for names corresponding to element keys) because it seems a bit more
-- robust. pairs() is a safe way to iterate over the oddball frame.args
-- metatable.
for key, value in pairs(frame.args) do
-- If we know that the element is defined as a composite
-- class (i.e., a nested composite attribute), then it
-- will have already been encoded and packed (by virtue
-- of being represented on the page by its template,
-- which will have already been expanded before we get
-- to see the contents.
--
-- We will hackily obfuscate the packing peanuts, so
-- that they will not interfere with our own.
--
-- TODO(maddog) This is, of course, a hack, which only
-- permits one more level of nesting.
local e = elements[key]
if e ~= nil and e.class and e.class.is_composite() then
value = mw.ustring.gsub(
value,
JSON_BLOCK_PREFIX, JSON_BLOCK_PREFIX2)
value = mw.ustring.gsub(
value,
JSON_BLOCK_SUFFIX, JSON_BLOCK_SUFFIX2)
end
-- Record the argument value.
explicit_args[key] = value
end
local instance = {
-- TODO maddog Get module_name from frame, and make this function
-- completely generic/independent of module definition.
module_name = module_name,
args = explicit_args,
}
return mw.ustring.format(
"%s%s%s",
JSON_BLOCK_PREFIX,
mw.text.jsonEncode(instance),
JSON_BLOCK_SUFFIX)
end
-- encoded: string with sequence of delimited JSON-encoded instance invocations
-- override_values: table with any key->value mappings to add to instances,
-- overriding any values that may be in JSON data
--
-- returns multiple values
-- - table: list of instances (tables)
-- - boolean: has missing data?
-- - ErrorCapture: collection of any errors
function class.decode_and_attach(encoded, override_values)
local instances = {}
local any_missing_data = false
local ec = ErrorCapture.new()
for json in mw.ustring.gmatch(
encoded,
JSON_BLOCK_PREFIX .. "(.-)" .. JSON_BLOCK_SUFFIX) do
local _, decoded = ec:pcall(mw.text.jsonDecode, json)
if decoded then
assert(decoded.module_name == module_name)
-- Fix up the hacked encoding of any nested composites.
for k, e in pairs(elements) do
if e.class and e.class.is_composite()
and decoded.args[k] ~= nil then
local value = decoded.args[k]
value = mw.ustring.gsub(
value,
JSON_BLOCK_PREFIX2, JSON_BLOCK_PREFIX)
value = mw.ustring.gsub(
value,
JSON_BLOCK_SUFFIX2, JSON_BLOCK_SUFFIX)
decoded.args[k] = value
end
end
local _, instance, missing_fields, sub_ec = ec:pcall(class.attach, decoded.args, override_values)
if instance then
table.insert(instances, instance)
any_missing_data = any_missing_data or (#missing_fields > 0)
ec:add_ec(sub_ec)
end
end
end
return instances, any_missing_data, ec
end
-- Compose an instance from supplied data.
-- Attach an instance (as SMW subobject) to the page.
-- Attach missing-fields properties for any missing fields.
--
-- override_values - key/value pairs which override values in the instance data
-- (but does not override a "fixed_value" for an element)
--
-- return two values
-- - table: instance data (nil if no instance)
-- - table: list of missing fields
-- - ErrorCapture: collection of any sub/recursive errors
-- issues an error upon errors
function class.attach(args, override_values)
local instance = {}
local missing_fields = {}
local ec = ErrorCapture.new()
local ds = {
-- ["@category"] = category_name,
}
for key, element in pairs(elements) do
-- Construct value.
-- local value =
-- util.empty_to_nil(args[key]) or default_values[key]
--XXXXX local value = util.empty_to_nil(args[key])
local value
if element.class ~= nil and element.class.is_composite() then
local instances, missing, sub_ec =
element.class.decode_and_attach(
args[key] or "", {})
value = instances
--value = { AAA = "XXX" .. mw.dumpObject(instances) .. "YYY" .. sub_ec:output() .. "ZZZ" }
--value = { "XXX" .. sub_ec:output() .. "YYY" }
--value = { "XXX" .. (args[key] or "nil") .. "YYY" }
-- TODO(maddog) Do something with missing?
ec:add_ec(sub_ec)
elseif element.fixed_value then
value = element.fixed_value
value = ((type(value) == "table") and value) or {value}
elseif override_values[key] then --XXXXX value == nil then
value = override_values[key]
value = ((type(value) == "table") and value) or {value}
else
value = util.empty_to_nil(args[key])
if element.multivalued and (value ~= nil) then
value = mw.text.split(
mw.text.trim(value),
"%s*" .. (element.value_delimiter or ",") .. "%s*")
else
value = {value}
end
-- TODO maddog What if value is nil at this point? (... since
-- field.map can never have a mapping for nil...)
if element.field.map then
for i, v in ipairs(value) do
util.assertf(element.field.map[v], "Unmapped field value %s", v)
value[i] = element.field.map[v]
end
end
end
--if value and element.namespace_prefix then
if element.namespace_prefix then
for i, v in ipairs(value) do
value[i] = mw.ustring.format("%s:%s",
element.namespace_prefix, v)
end
end
-- Put value into instance.
------instance[key] = value
instance[key] = (element.multivalued and value) or value[1]
-- Index value into SMW (maybe).
if element.smw then
local property = base.make_value_property(element.smw.property)
if next(value) ~= nil then -- "not empty"
-----if value ~= nil then
ds[property] = value -- table -> multiple property instances
elseif not element.smw.ignore_missing then
table.insert(missing_fields, property)
end
end
end
if title_key then
util.assertf(not attach_as_simple, "title_key with attach_as_simple")
-- TODO maddog Do something more principled than "???"
ds["Display title of"] =
category_name .. "=" .. (instance[title_key] or "???")
end
if #missing_fields > 0 then
ds[util.MISSING_FIELDS_PROPERTY] = missing_fields
end
if attach_as_simple then
util.assert_smw_set(ds)
else
ds["@category"] = category_name
util.assert_smw_subobject(ds)
end
return instance, missing_fields, ec
end
-- function class.docs.attach()
-- local arguments = base.document_datebound_args()
-- for key, element in pairs(elements) do
-- if element.arg then
-- table.insert(arguments, { element.arg, element.input_type })
-- end
-- end
-- return {
-- desc = "Attach a new instance to the page",
-- args = arguments,
-- }
-- end
local do_fetch
-- returns a table bearing list of results
-- - each element is a table with k/v pairs being attributes of instance
function class.fetch_all(args)
local results = do_fetch(category_name, elements, args.page_as)
return results or {}
end
function do_fetch(category_name, elements, match_page_to)
local query = {}
local page_clause = ""
if match_page_to then
assert(elements[match_page_to],
"match_page_to of " .. match_page_to .. " is not an valid key.")
page_clause = mw.ustring.format("[[%s::%s]]",
base.make_value_property(elements[match_page_to].property_root),
mw.title.getCurrentTitle().fullText)
end
-- TODO(maddog) For composite relations that may be attached to multiple
-- pages, we really want to link against a field, e.g. org
-- or person, not via [[-Has subobject::...]]
table.insert(query,
-- "[[-Has subobject::" .. mw.title.getCurrentTitle().fullText .. "]]" ..
page_clause ..
"[[Category:" .. category_name .. "]]")
table.insert(query, "mainlabel=-")
for key, element in pairs(elements) do
table.insert(query, mw.ustring.format(
-- The "# -" selects "plain" format (no links, etc).
"?%s # - =%s",
base.make_value_property(element.property_root), key))
end
local result = mw.smw.ask(query)
return result
end
-- target_page - optional(str) name of page to match on
-- if nil, no page-match logic
--
-- page_match_element - optional(str) element to match to target_page
-- if nil, match to "-Has subobject::" instead
--
-- extra_query - optional(str) extra query string
function class.ask(target_page, page_match_element, extra_query)
local query = {}
local page_clause = ""
if target_page then
if page_match_element then
assert(elements[page_match_element],
"page_match_element of " .. page_match_element .. " is not an valid key.")
page_clause = mw.ustring.format("[[%s::%s]]",
base.make_value_property(elements[page_match_element].smw.property),
target_page)
-- mw.title.getCurrentTitle().fullText)
else
page_clause = mw.ustring.format("[[-Has subobject::%s]]",
target_page)
end
end
table.insert(query, page_clause ..
"[[Category:" .. category_name .. "]]" ..
(extra_query or ""))
table.insert(query, "mainlabel=-")
for key, element in pairs(elements) do
if element.smw then
table.insert(query, mw.ustring.format(
-- The "# -" selects "plain" format (no links, etc).
"?%s # - =%s",
base.make_value_property(element.smw.property), key))
end
end
local result = mw.smw.ask(query)
return result or {}
end
function class.render_table(instances, frame, args)
local columns = args.columns
local sorter = args.sort
-- if (instances == nil) or (#instances == 0) then
-- return "''No entries''"
-- end
-- TODO maddog SORT instances using sorter
if sorter then
sorter(instances)
end
local html = mw.html.create("table"):addClass("wikitable")
-- TODO maddog Generalize heading labels - allow specifying along
-- with keys?
local header = html:tag("tr")
for _, column in ipairs(columns) do
---- header:tag("th"):wikitext(elements[key].field.label)
header:tag("th"):wikitext(column.label)
end
if (instances == nil) or (#instances == 0) then
html:tag("tr"):tag("td"):wikitext("''No entries''")
end
for _, instance in ipairs(instances) do
local row = html:tag("tr")
for _, column in ipairs(columns) do
if column.key then
local value = instance[column.key]
local formatter = elements[column.key].render_formatter
if formatter then
value = formatter(value)
end
row:tag("td")
:wikitext(util.show_nil_as_missing(value))
else
util.assertf(column.render, "column.render is required")
row:tag("td")
:wikitext(column.render(instance, frame))
end
end
end
return html
end
return class
end
return p