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