Module:columns: Difference between revisions

From Linguifex
Jump to navigation Jump to search
No edit summary
No edit summary
 
Line 1: Line 1:
local export = {}
local export = {}


local m_links = require("Module:links")
local collation_module = "Module:collation"
local m_languages = require("Module:languages")
local headword_data_module = "Module:headword/data"
local m_table = require("Module:table")
local JSON_module = "Module:JSON"
local languages_module = "Module:languages"
local links_module = "Module:links"
local pages_module = "Module:pages"
local parameter_utilities_module = "Module:parameter utilities"
local parameters_module = "Module:parameters"
local parse_utilities_module = "Module:parse utilities"
local pron_qualifier_module = "Module:pron qualifier"
local qualifier_module = "Module:qualifier"
local string_utilities_module = "Module:string utilities"
local table_module = "Module:table"
local utilities_module = "Module:utilities"
local yesno_module = "Module:yesno"


local m_str_utils = require(string_utilities_module)


local function format_list_items(items, lang, sc)
local concat = table.concat
local result = {}
local html = mw.html.create
local is_substing = mw.isSubsting
local insert = table.insert
local rmatch = m_str_utils.match
local remove = table.remove
local sub = string.sub
local trim = m_str_utils.trim
local u = m_str_utils.char
local dump = mw.dumpObject


local function term_already_linked(term)
local function deepEquals(...)
-- FIXME: "<span" is an ugly hack to prevent double-linking of terms already run through {{l|...}}:
    deepEquals = require(table_module).deepEquals
-- [[Thread:User talk:CodeCat/MewBot adding lang to column templates]]
    return deepEquals(...)
return term:find("<span")
end
 
local function term_already_linked(term)
return term == "?" or -- signals an unknown term
-- optimization to avoid unnecessarily loading [[Module:parse utilities]]
(term:find("[<{]") and require(parse_utilities_module).term_already_linked(term))
end
 
local function convert_delimiter_to_separator(item, itemind, args)
if itemind == 1 then
item.separator = nil
elseif item.delimiter == " " then
item.separator = args.space_delim
elseif item.delimiter == "~" then
item.separator = args.tilde_delim
else
item.separator = args.comma_delim
end
end
 
local function get_horizontal_separator(args_horiz, embedded_comma)
return args_horiz == "bullet" and " · " or embedded_comma and "; " or ", "
end
 
 
-- Suppress false positives in categories like [[Category:English links with redundant wikilinks]] so people won't
-- be tempted to "correct" them; terms like embedded ~ like [[Micros~1]] or embedded comma not followed by a space
-- such as [[1,6-Cleves acid]] need to have a link around them to avoid the tilde or comma being interpreted as a
-- delimiter.
local function suppress_redundant_wikilink_cat(term, alt)
return term:find("~") or term:find(",%S")
end
 
local function full_link_and_track_self_links(item, face)
if item.term then
local pagename = mw.loadData(headword_data_module).pagename
local term_is_pagename = item.term == pagename
local term_contains_pagename = item.term:find("%[%[" .. m_str_utils.pattern_escape(pagename) .. "[|%]]")
if term_is_pagename or term_contains_pagename then
local current_L2 = require(pages_module).get_current_L2()
if current_L2 then
local current_L2_lang = require(languages_module).getByCanonicalName(current_L2)
if current_L2_lang and current_L2_lang:getCode() == item.lang:getCode() then
if term_is_pagename then
track("term-is-pagename")
else
track("term-contains-pagename")
end
end
end
end
end
end
for _, item in ipairs(items) do
 
if type(item) == "table" then
item.suppress_redundant_wikilink_cat = suppress_redundant_wikilink_cat
local link = term_already_linked(item.term.term) and item.term.term or m_links.full_link(item.term)
return require(links_module).full_link(item, face)
if item.q then
end
link = require("Module:qualifier").format_qualifier(item.q) .. " " .. link
 
local function format_subitem(subitem, lang, face, compute_embedded_comma)
local embedded_comma = false
local text
if subitem.term and term_already_linked(subitem.term) then
text = subitem.term
if compute_embedded_comma then
embedded_comma = not not require(utilities_module).get_plaintext(text):find(",")
end
else
text = full_link_and_track_self_links(subitem, face)
if compute_embedded_comma then
-- We don't check qualifier, label or reference text for commas as it's inside parens or displayed
-- elsewhere.
local subitem_plaintext = subitem.alt or subitem.term
if subitem_plaintext then
embedded_comma = not not subitem_plaintext:find(",")
end
end
if item.qq then
end
link = link .. " " .. require("Module:qualifier").format_qualifier(item.qq)
end
-- We could use the "show qualifiers" flag to full_link() but not when term_already_linked().
if subitem.q and subitem.q[1] or subitem.qq and subitem.qq[1] or subitem.l and subitem.l[1] or
subitem.ll and subitem.ll[1] or subitem.refs and subitem.refs[1] then
text = require(pron_qualifier_module).format_qualifiers {
lang = subitem.lang or args.lang,
text = text,
q = subitem.q,
qq = subitem.qq,
l = subitem.l,
ll = subitem.ll,
refs = subitem.refs,
}
end
return text, embedded_comma
end
 
function export.format_item(item, args, face)
local compute_embedded_comma = args.horiz == "comma"
local embedded_comma = false
if type(item) == "table" then
if item.terms then
local parts = {}
local is_first = true
for _, subitem in ipairs(item.terms) do
if subitem == false then
-- omitted subitem; do nothing
else
local separator = subitem.separator or not is_first and (args.subitem_separator or ", ")
if separator then
if compute_embedded_comma then
embedded_comma = embedded_comma or not not separator:find(",")
end
insert(parts, separator)
end
local formatted, this_embedded_comma = format_subitem(subitem, args.lang, face,
compute_embedded_comma)
embedded_comma = embedded_comma or this_embedded_comma
insert(parts, formatted)
is_first = false
end
end
return concat(parts), embedded_comma
else
return format_subitem(item, args.lang, face, compute_embedded_comma)
end
else
if compute_embedded_comma then
embedded_comma = not not require(utilities_module).get_plaintext(item):find(",")
end
if args.lang and not term_already_linked(item) then
return full_link_and_track_self_links({lang = args.lang, term = item, sc = args.sc}, face), embedded_comma
else
return item, embedded_comma
end
end
end
 
function export.construct_old_style_header(header, horiz)
local old_style_header
local function ib_colon()
return tostring(html("span"):addClass("ib-colon"):addClass("ib-content"):wikitext(":"))
end
if horiz then
old_style_header = require(qualifier_module).format_qualifiers {
qualifiers = header,
open = false,
close = false,
} .. ib_colon() ..  " "
else
old_style_header = require(qualifier_module).format_qualifiers {
qualifiers = header
} .. ib_colon()
old_style_header = tostring(html("div"):wikitext(old_style_header))
end
return old_style_header
end
 
-- Construct the sort base of a single term. As a hack, sort appendices after mainspace items.
local function term_sortbase(val)
if not val then
-- This should not normally happen.
return u(0x10FFFF)
elseif val:find("^%[*Appendix:") then
return u(0x10FFFE) .. val
else
return val
end
end
 
-- Construct the sort base of a single item, using the display form preferentially, otherwise the term itself.
-- As a hack, sort appendices after mainspace items.
local function item_sortbase(item)
return term_sortbase(item.alt or item.term)
end
 
local function make_sortbase(item)
if item == false then
return "*" -- doesn't matter, will be omitted in create_list()
elseif type(item) == "table" then
if item.terms then
-- Optimize for the common case of only a single term
if item.terms[2] then
local parts = {}
-- multiple terms
local first = true
for _, subitem in ipairs(item.terms) do
if subitem ~= false then
if not first then
insert(parts, ", ")
end
insert(parts, item_sortbase(subitem))
first = false
end
end
if parts[1] then
return concat(parts)
end
else
local subitem = item.terms[1]
if subitem ~= false then
return item_sortbase(subitem)
end
end
end
item = link
return "*" -- doesn't matter, entire group will be omitted in create_list()
elseif lang and not term_already_linked(item) then
else
item = m_links.full_link {lang = lang, term = item, sc = sc}
return item_sortbase(item)
end
end
else
return item
end
end


table.insert(result, '\n* ' .. item)
local function make_node_sortbase(node)
return make_sortbase(node.item)
end
 
-- Sort a sublist of `list` in place, keeping the first `keepfirst` and last `keeplast` items fixed.
-- `lang` is the language of the items and `make_sortbase` creates the appropriate sort base.
local function sort_sublist(list, lang, make_sortbase, keepfirst, keeplast)
if keepfirst == 0 and keeplast == 0 then
require(collation_module).sort(list, lang, make_sortbase)
else
local sublist = {}
for i = keepfirst + 1, #list - keeplast do
sublist[i - keepfirst] = list[i]
end
require(collation_module).sort(sublist, lang, make_sortbase)
for i = keepfirst + 1, #list - keeplast do
list[i] = sublist[i - keepfirst]
end
end
end
end
-- URL-encode only the characters that serve as template delimiters (left and right brace, vertical bar, equal sign
-- and percent sign since it's the escape character).
local function bot_url_encode(txt)
return (txt:gsub("[%%|{}=&]",
{["%"] = "%25", ["|"] =  "%7C", ["{"] = "%7B", ["}"] = "%7D", ["="] = "%3D", ["&"] = "%26"}))
end
-- Reverse the action of bot_url_encode().
local function bot_url_decode(txt)
return (txt:gsub("%%7([BCD])", {B = "{", C = "|", D = "}"}):gsub("%%3D", "="):gsub("%%26", "&"):gsub("%%25", "%%"))
end


return table.concat(result)
--[==[
Bot-callable function to generate a number of sortkeys simultaneously. {{para|1}} contains the langcode, and remaining
numeric parameters contain "bot-URL-encoded" strings whose sort keys will be computed and returned as a JSON array.
Here, "bot-URL-encoded" means that the six characters `{ | } = & %` should be converted to
their URL-encoded representation (respectively <code>%7B %7C %7D %3D %26 %25</code>), and will be decoded appropriately
before computing the sortkey.
]==]
function export.make_sortkey(frame)
local iparams = {
[1] = {type = "language"},
[2] = {list = true},
}
local iargs = require(parameters_module).process(frame.args, iparams)
local make_sortkey = require(collation_module).make_lang_sortkey_function(iargs[1], term_sortbase)
local retval = {}
for _, arg in ipairs(iargs[2]) do
arg = bot_url_decode(arg)
insert(retval, make_sortkey(arg))
end
return require(JSON_module).toJSON(retval)
end
end


local collapse_header =
[[<div class="list-switcher" data-toggle-category="{{{toggle_category}}}">]]
local column_header = [[<div class="{{{class}}} term-list ul-column-count" ]]
.. [[data-column-count="{{{column_count}}}" ]]
.. [[style="background-color: {{{background_color}}};">]]
local button = [[<div class="list-switcher-element" ]]
.. [[data-showtext="&nbsp;show more ▼&nbsp;" ]]
.. [[data-hidetext="&nbsp;show less ▲&nbsp;" style="display: none;">&nbsp;</div>]]


local large_text_scripts = {
["Arab"] = true,
["Beng"] = true,
["Deva"] = true,
["Gujr"] = true,
["Guru"] = true,
["Hebr"] = true,
["Khmr"] = true,
["Knda"] = true,
["Laoo"] = true,
["Mlym"] = true,
["Mong"] = true,
["Mymr"] = true,
["Orya"] = true,
["Sinh"] = true,
["Syrc"] = true,
["Taml"] = true,
["Telu"] = true,
["Tfng"] = true,
["Thai"] = true,
["Tibt"] = true,
}
--[==[
Format a list of items using HTML. `args` is an object specifying the items to add and related properties, with the
following fields:
* `content`: A list of the items to format. See below for the format of the items.
* `lang`: The language object of the items to format, if the items in `content` are strings.
* `sc`: The script object of the items to format, if the items in `content` are strings.
* `raw`: If true, return the list raw, without any collapsing or columns.
* `class`: The CSS class of the surrounding <div>.
* `column_count`: Number of columns to format the list into.
* `alphabetize`: If true, sort the items in the table.
* `collapse`: If true, make the table partially collapsed by default, with a "Show more" button at the bottom.
* `toggle_category`: Value of `data-toggle-category` property grouping collapsible elements.
* `header`: If specified, Wikicode to prepend to the output.
* `title_new_style`: If true, the header is treated as a title and displayed in a new style. This is ignored if `horiz`
is non-nil.
* `subitem_separator`: Separator used between subitems when multiple subitems occur on a line, if not specified in the
                      subitem itself (using the `separator` field). Defaults to {", "}.
* `keepfirst`: If > 0, keep this many rows unsorted at the beginning of the top level.
* `keeplast`: If > 0, keep this many rows unsorted at the end of the top level.
* `horiz`: If non-nil, format the items horizontally. If the value is "bullet", put a center dot/bullet (·) between
  items. If the value is "comma", put a comma between items (but if there is an embedded comma in any item,
  put a semicolon between all items).
Each item in `content` is in one of the following formats:
* A string. This is for compatibility and should not be used by new callers.
* An object describing an item to format, in the format expected by full_link() in [[Module:links]] but can also
  have left or right qualifiers, left or right labels, or references.
* An object describing a list of subitems to format, displayed side-by-side, separated by a comma or other separator.
  This format is identified by the presence of a key `terms` specifying the list of subitems. Each subitem is in
  the same format as for a single top-level item, except that it should also have a `separator` field specifying the
  separator to display before each item (which will typically be a blank string before the first item).
]==]
function export.create_list(args)
function export.create_list(args)
-- Fields in args that are used:
-- args.column_count, args.content, args.alphabetize, args.background_color,
-- args.collapse, args.toggle_category, args.class, args.lang
-- Check for required fields?
if type(args) ~= "table" then
if type(args) ~= "table" then
error("expected table, got " .. type(args))
error("expected table, got " .. type(args))
end
end


args.class = args.class or "derivedterms"
local column_count = args.column_count or 1
args.column_count = args.column_count or 1
local toggle_category = args.toggle_category or "derived terms"
args.toggle_category = args.toggle_category or "derived terms"
local keepfirst = args.keepfirst or 0
local keeplast = args.keeplast or 0
if keepfirst > 0 then
track("keepfirst")
end
if keeplast > 0 then
track("keeplast")
end


local output = {}
-- maybe construct old-style header
local old_style_header = nil
if args.header and (args.horiz or not args.title_new_style) then
old_style_header = export.construct_old_style_header(args.header, args.horiz)
end
if args.horiz then
old_style_header = "* " .. (old_style_header or "")
end


if args.header then
local list
if args.format_header then
 
args.header = '<div class="term-list-header">' .. args.header .. "</div>"
local any_extra_indented_item = false
for _, item in ipairs(args.content) do
if item == false then
-- do nothing
elseif type(item) == "table" and item.extra_indent and item.extra_indent > 0 then
any_extra_indented_item = true
break
end
end
table.insert(output, args.header)
end
end
if args.collapse then
 
table.insert(output, (collapse_header:gsub('{{{(.-)}}}', args)))
-- If any extra indented item, convert the items to a nested structure, which is necessary both for sorting and
-- for converting to HTML.
if any_extra_indented_item then
local function make_node(item)
return {
item = item
}
end
local root_node = make_node(nil)
local node_stack = {root_node}
local last_indent = 0
local function append_subnode(node, subnode)
if not node.subnodes then
node.subnodes = {}
end
insert(node.subnodes, subnode)
end
for i, item in ipairs(args.content) do
if item == false then
-- do nothing
else
local this_indent
if type(item) ~= "table" then
this_indent = 1
else
this_indent = (item.extra_indent or 0) + 1
end
local node = make_node(item)
if this_indent == last_indent then
append_subnode(node_stack[#node_stack], node)
elseif this_indent > last_indent + 1 then
error(("Element #%s (%s) has indent %s, which is more than one greater than the previous item with indent %s"):format(
i, make_sortbase(item), this_indent, last_indent))
elseif this_indent > last_indent then
-- Start a new sublist attached to the last item of the sublist one level up; but we need special
-- handling for the root node (last_indent == 0).
if last_indent > 0 then
local subnodes = node_stack[#node_stack].subnodes
if not subnodes then
error(("Internal error: Not first item and no subnodes at preceding level %s: %s"):format(
#node_stack, dump(node_stack)))
end
insert(node_stack, subnodes[#subnodes])
end
append_subnode(node_stack[#node_stack], node)
last_indent = this_indent
else
while last_indent > this_indent do
local finished_node = table.remove(node_stack)
if args.alphabetize then
require(collation_module).sort(finished_node.subnodes, args.lang, make_node_sortbase)
end
last_indent = last_indent - 1
end
append_subnode(node_stack[#node_stack], node)
end
end
end
if args.alphabetize then
while node_stack[1] do
local finished_node = table.remove(node_stack)
if node_stack[1] then
-- We're sorting something other than the root node.
require(collation_module).sort(finished_node.subnodes, args.lang, make_node_sortbase)
else
-- We're sorting the root node; honor `keepfirst` and `keeplast`.
sort_sublist(finished_node.subnodes, args.lang, make_node_sortbase, keepfirst, keeplast)
end
end
end
 
local function format_node(node, depth)
local sublist
local embedded_comma = false
if node.subnodes then
if args.horiz then
sublist = {}
else
sublist = html("ul")
end
local prevnode = nil
for _, subnode in ipairs(node.subnodes) do
local thisnode, this_embedded_comma = format_node(subnode, depth + 1)
embedded_comma = embedded_comma or this_embedded_comma
if not prevnode or not args.alphabetize or not deepEquals(prevnode, thisnode) then
if args.horiz then
table.insert(sublist, thisnode)
else
sublist = sublist:node(thisnode)
end
prevnode = thisnode
end
end
if args.horiz then
sublist = table.concat(sublist, get_horizontal_separator(args.horiz, embedded_comma))
end
end
if not node.item then
-- At the root.
return sublist, embedded_comma
end
local formatted, listitem
-- Ignore embedded commas in subitems inside of parens or square brackets.
formatted, embedded_comma = export.format_item(node.item, args)
if args.horiz then
listitem = formatted
if sublist then
-- Use parens for the first, third, fifth, etc. sublists and square brackets for the remainder.
if depth % 2 == 1 then
listitem = ("%s (%s)"):format(listitem, sublist)
else
listitem = ("%s [%s]"):format(listitem, sublist)
end
end
else
listitem = html("li"):wikitext(formatted)
if sublist then
listitem = listitem:node(sublist)
end
end
return listitem, embedded_comma
end
 
list = format_node(root_node, 0)
else
if args.alphabetize then
sort_sublist(args.content, args.lang, make_sortbase, keepfirst, keeplast)
end
if args.horiz then
list = {}
else
list = html("ul")
end
local previtem = nil
local embedded_comma = false
for _, item in ipairs(args.content) do
if item == false then
-- omitted item; do nothing
else
local thisitem, this_embedded_comma = export.format_item(item, args)
embedded_comma = embedded_comma or this_embedded_comma
if not previtem or not args.alphabetize or previtem ~= thisitem then
if args.horiz then
table.insert(list, thisitem)
else
list = list:node(html("li"):wikitext(thisitem))
end
previtem = thisitem
end
end
end
if args.horiz then
list = table.concat(list, get_horizontal_separator(args.horiz, embedded_comma))
end
end
end
table.insert(output, (column_header:gsub('{{{(.-)}}}', args)))


    if args.alphabetize then
local output
    local function keyfunc(item)
if args.horiz then
    if type(item) == "table" then
output = list
    item = item.term.term
else
    end
output = html("div"):addClass("term-list"):node(list)
    return item
 
    end
if args.class then
require("Module:collation").sort(args.content, args.lang, keyfunc)
output:addClass(args.class)
end
end
table.insert(output, format_list_items(args.content, args.lang, args.sc))
 
if not args.raw then
output:addClass("ul-column-count")
:attr("data-column-count", column_count)
 
if args.collapse then
output = html("div")
:node(output)
:addClass("list-switcher")
:attr("data-toggle-category", toggle_category)
 
-- identify commonly used scripts that use large text and
-- provide a special CSS class to make the template bigger
local sc = args.sc
if sc == nil then
local scripts = args.lang:getScripts()
if #scripts > 0 then
sc = scripts[1]
end
end
if sc ~= nil then
local scriptcode = sc:getParentCode()
if scriptcode == "top" then
scriptcode = sc:getCode()
end
if large_text_scripts[scriptcode] then
output:addClass("list-switcher-large-text")
end
end
end
end
 
if args.collapse or args.title_new_style then
-- wrap in wrapper to prevent interference from floating elements
local list_switcher_wrapper = html("div")
:addClass("list-switcher-wrapper")
if args.title_new_style then
list_switcher_wrapper
:node(
html("div")
:addClass("list-switcher-header")
:wikitext(args.header)
)
end
list_switcher_wrapper:node(output)
output = list_switcher_wrapper
end


table.insert(output, '</div>')
output = tostring(output)
if args.collapse then
table.insert(output, button .. '</div>')
end
end


return table.concat(output)
return (old_style_header or "") .. output
end
end


Line 95: Line 617:
-- n_columns, content, alphabetize, bg, collapse, class, title, column_width, line_start, lang
-- n_columns, content, alphabetize, bg, collapse, class, title, column_width, line_start, lang
local args = {}
local args = {}
args.column_count, args.content, args.alphabetize, args.background_color,
args.column_count, args.content, args.alphabetize,
args.collapse, args.class, args.header, args.column_width,
args.collapse, args.class, args.header, args.column_width,
args.line_start, args.lang = ...
args.line_start, args.lang = ...
args.format_header = true


return export.create_list(args)
return export.create_list(args)
Line 105: Line 625:




local param_mods = {"t", "alt", "tr", "ts", "pos", "lit", "id", "sc", "g", "q", "qq"}
function export.display_from(frame_args, parent_args, frame)
local param_mod_set = m_table.listToSet(param_mods)
local boolean = {type = "boolean"}
 
function export.display_from(column_args, list_args)
local iparams = {
local iparams = {
["class"] = {},
["class"] = true,
-- Default for auto-collapse. Overridable by template |collapse= param.
-- Default for auto-collapse. Overridable by template |collapse= param.
["collapse"] = {type = "boolean"},
["collapse"] = boolean,
-- If specified, this specifies the number of columns, and no columns
-- If specified, this specifies the number of columns, and no columns parameter is available on the template.
-- parameter is available on the template. Otherwise, the columns
-- Otherwise, the columns parameter is named |n=.
-- parameter is the first available numbered param after the language-code
-- parameter.
["columns"] = {type = "number"},
["columns"] = {type = "number"},
-- If specified, this specifies the language code, and no language-code
-- If specified, this specifies the default language code, which can be overridden using |lang= in the template.
-- parameter is available on the template. Otherwise, the language-code
-- Otherwise, the language-code parameter is required and normally found in |1=, but for compatibility can be
-- parameter can be specified as either |lang= or |1=.
-- specified as |lang= (which leads to deprecation handling).
["lang"] = {},
["lang"] = {type = "language"},
-- Default for auto-sort. Overridable by template |sort= param.
-- Default for auto-sort. Overridable by template |sort= param.
["sort"] = {type = "boolean"},
["sort"] = boolean,
-- The following is accepted but currently ignored, per an extended discussion in
["toggle_category"] = true,
-- [[Wiktionary:Beer parlour/2018/November#Titles of morphological relations templates]].
-- Minimum number of rows required to format into a multicolumn list. If below this, the list is displayed "raw"
["title"] = {default = ""},
-- (no columns, no collapsbility).
["toggle_category"] = {},
["minrows"] = {type = "number", default = 5},
}
}


local frame_args = require("Module:parameters").process(column_args, iparams, nil, "columns", "display_from")
local iargs = require(parameters_module).process(frame_args, iparams)


local compat = frame_args["lang"] or list_args["lang"]
local langcode_in_lang = iargs.lang or parent_args.lang
local lang_param = compat and "lang" or 1
local lang_param = langcode_in_lang and "lang" or 1
local columns_param = compat and 1 or 2
local deprecated = not iargs.lang and langcode_in_lang
local first_content_param = columns_param + (frame_args["columns"] and 0 or 1)
 
local ret = export.handle_display_from_or_topic_list(iargs, parent_args, nil)
 
return deprecated and frame:expandTemplate{title = "check deprecated lang param usage",
args = {ret, lang = args[lang_param]}} or ret
end
 
 
--[==[
Implement `display_from()` [the internal entry point for {{tl|col}} and variants, which enter originally through
`display()`] as well as regular (column-oriented) topic lists, invoked through [[Module:topic list]].
`iargs` are the invocation args of {{tl|col}}, and `raw_item_args` are the arguments specifying the values of
each row as well as other properties, corresponding to the user-specified template arguments of {{tl|col}}. Note that
`show()` in [[Module:topic list]] is normally invoked directly by a topic list template, whose invocation
arguments are passed in using `raw_item_args` and are similar to the template arguments of {{tl|col}}. `iargs` for
topic-list invocations is hard-coded, and template arguments to a topic-list template are processed in
[[Module:topic list]] itself. Note that the handling of topic lists is currently implemented almost entirely
through callbacks in `topic_list_data` (which is nil if we're processing {{tl|col}} rather than a topic list) in an
attempt to reduce the coupling and keep the topic-list-specific code in [[Module:topic list]], but IMO the coupling
is still too tight. Probably the control structure should be reversed and the following function split up into
subfunctions, which are invoked as needed by {{tl|col}} and/or [[Module:topic list]].
]==]
function export.handle_display_from_or_topic_list(iargs, raw_item_args, topic_list_data)
local boolean = {type = "boolean"}
local langcode_in_lang = iargs.lang or raw_item_args.lang
local lang_param = langcode_in_lang and "lang" or 1
local first_content_param = langcode_in_lang and 1 or 2


local params = {
local params = {
[lang_param] = not frame_args["lang"] and {required = true, default = "und"} or nil,
[lang_param] = {required = not iargs.lang, type = "language",
[columns_param] = not frame_args["columns"] and {required = true, default = 2} or nil,
template_default = not iargs.lang and "und" or nil},
[first_content_param] = {list = true},
["n"] = not iargs.columns and {type = "number"} or nil,
[first_content_param] = {list = true, allow_holes = true},


["title"] = {},
["title"] = {},
["collapse"] = {type = "boolean"},
["collapse"] = boolean,
["sort"] = {type = "boolean"},
["sort"] = boolean,
["sc"] = {},
["sc"] = {type = "script"},
-- used when calling from [[Module:saurus]] so the page displaying the synonyms/antonyms doesn't occur in the
-- list
["omit"] = {list = true},
["keepfirst"] = {type = "number", default = 0},
["keeplast"] = {type = "number", default = 0},
["horiz"] = {},
["notr"] = boolean,
["allow_space_delim"] = boolean,
["tilde_delim"] = {},
["space_delim"] = {},
["comma_delim"] = {},
}
}


local args = require("Module:parameters").process(list_args, params, nil, "columns", "display_from")
if topic_list_data then
topic_list_data.add_topic_list_params(params)
end


local lang = frame_args["lang"] or args[lang_param]
local m_param_utils = require(parameter_utilities_module)
lang = m_languages.getByCode(lang, lang_param)


local sc = args["sc"] and require("Module:scripts").getByCode(sc, "sc") or nil
local param_mods = m_param_utils.construct_param_mods {
{default = true, require_index = true},
{group = "link"}, -- sc has separate_no_index = true; that's the only one
-- It makes no sense to have overall l=, ll=, q= or qq= params for columnar display.
{group = {"ref", "l", "q"}, require_index = true},
}


local sort = frame_args["sort"]
m_param_utils.augment_params_with_modifiers(params, param_mods)
if args["sort"] ~= nil then
local processed_args = require(parameters_module).process(raw_item_args, params)
sort = args["sort"]
local horiz = processed_args.horiz
if horiz and horiz ~= "comma" and horiz ~= "bullet" then
horiz = require(yesno_module)(horiz)
if horiz == nil then
error(("Unrecognized value |horiz=%s; should be 'comma', 'bullet' or a recognized Boolean value such " ..
"as 'yes' or '1' (same as 'bullet') or 'no' or '0'"):format(processed_args.horiz))
end
if horiz == true then
horiz = "bullet"
end
processed_args.horiz = horiz
end
end
local collapse = frame_args["collapse"]
if args["collapse"] ~= nil then
-- If default argument values specified, set them after parsing the caller-specified arguments in `raw_item_args`.
collapse = args["collapse"]
if topic_list_data then
topic_list_data.set_default_arguments(processed_args)
end
end


local put
-- Now set defaults for the various delimiters, depending in some cases on whether horiz was set.
for i, item in ipairs(args[first_content_param]) do
-- We can't set these defaults (even regardless of their dependency on horiz=) in `local params` above
-- Parse off an initial language code (e.g. 'la:minūtia' or 'grc:[[σκῶρ|σκατός]]'). Don't parse if there's a spac
-- because we want any defaults specified in `default_props` to override these.
-- after the colon (happens e.g. if the user uses {{desc|...}} inside of {{col}}, grrr ...).
if not processed_args.tilde_delim then
local termlang, actual_term = item:match("^([A-Za-z._-]+):([^ ].*)$")
local tilde_with_abbr = '<abbr title="near equivalent">~</abbr>'
-- Make sure that only real language codes are handled as language links, so as to not catch interwiki
processed_args.tilde_delim = processed_args.horiz and tilde_with_abbr or " " .. tilde_with_abbr .. " "
-- or namespaces links.
end
if termlang and (
if not processed_args.space_delim then
mw.loadData("Module:languages/code to canonical name")[termlang] or
processed_args.space_delim = "&nbsp;"
mw.loadData("Module:etymology languages/code to canonical name")[termlang]
end
) then
if not processed_args.comma_delim then
-- -1 since i is one-based
processed_args.comma_delim = processed_args.horiz and "/" or ", "
termlang = m_languages.getByCode(termlang, first_content_param + i - 1, "allow etym")
end
item = actual_term
 
else
-- Check for extra term indent. Do this before calling parse_list_with_inline_modifiers_and_separate_params()
termlang = lang
-- because sometimes space is a delimiter and the space in the indent will confuse things and get interpreted as a
-- delimiter.
local extra_indent_by_termno = {}
local termargs = processed_args[first_content_param]
for i = 1, termargs.maxindex do
local term = termargs[i]
if term then
local extra_indent, actual_term = rmatch(term, "^(%*+)%s+(.-)$")
if extra_indent then
termargs[i] = actual_term
extra_indent_by_termno[i] = #extra_indent
end
end
end
local termobj = {term = {lang = termlang, sc = sc}}
end
 
local groups, args = m_param_utils.parse_list_with_inline_modifiers_and_separate_params {
param_mods = param_mods,
processed_args = processed_args,
termarg = first_content_param,
parse_lang_prefix = true,
allow_multiple_lang_prefixes = true,
disallow_custom_separators = true,
track_module = "columns",
lang = iargs.lang or lang_param,
sc = "sc.default",
splitchar = processed_args.allow_space_delim and "[,~ ]" or "[,~]",
}
 
local lang = iargs.lang or args[lang_param]
local langcode = lang:getCode()
local fulllangcode = lang:getFullCode()
local sc = args.sc.default


-- Check for inline modifier, e.g. מרים<tr:Miryem>. But exclude HTML entry with <span ...>, <i ...>, <br/> or
local sort = iargs.sort
-- similar in it, caused by wrapping an argument in {{l|...}}, {{af|...}} or similar. Basically, all tags of
if args.sort ~= nil then
-- the sort we parse here should consist of a less-than sign, plus letters, plus a colon, e.g. <tr:...>, so if
if not args.sort then
-- we see a tag on the outer level that isn't in this format, we don't try to parse it. The restriction to the
track("nosort")
-- outer level is to allow generated HTML inside of e.g. qualifier tags, such as foo<q:similar to {{m|fr|bar}}>.
end
if item:find("<") and not item:find("^[^<]*<[a-z]*[^a-z:]") then
sort = args.sort
if not put then
else
put = require("Module:parse utilities")
-- HACK! For Japanese-script languages (Japanese, Okinawan, Miyako, etc.), sorting doesn't yet work properly, so
-- disable it.
for _, langsc in ipairs(lang:getScriptCodes()) do
if langsc == "Jpan" then
sort = false
break
end
end
local run = put.parse_balanced_segment_run(item, "<", ">")
end
local orig_param = first_content_param + i - 1
end
local function parse_err(msg)
 
error(msg .. ": " .. orig_param .. "= " .. table.concat(run))
local collapse = iargs.collapse
if args.collapse ~= nil then
if not args.collapse then
track("nocollapse")
end
collapse = args.collapse
end
 
local title = args.title
local formatted_cats
if topic_list_data then
title, formatted_cats = topic_list_data.get_title_and_formatted_cats(args, lang, sc, topic_list_data)
end
 
local number_of_groups = 0
for i, group in ipairs(groups) do
local number_of_items = 0
group.extra_indent = extra_indent_by_termno[group.orig_index]
for j, item in ipairs(group.terms) do
convert_delimiter_to_separator(item, j, args)
 
if args.notr then
item.tr = "-"
end
end
termobj.term.term = run[1]


for j = 2, #run - 1, 2 do
-- If a separate language code was given for the term, display the language name as a right qualifier.
if run[j + 1] ~= "" then
-- (Briefly we made them labels but this leads to non-obvious behavior e.g. "French" becoming "France" under
parse_err("Extraneous text '" .. run[j + 1] .. "' after modifier")
-- some circumstances.) Otherwise it may not be obvious that the term is in a separate language (e.g. if the
-- main language is 'zh' and the term language is a Chinese lect such as Min Nan). But don't do this for
-- Translingual terms, which are often added to the list of English and other-language terms.
if item.termlangs then
local qqs = {}
for _, termlang in ipairs(item.termlangs) do
local termlangcode = termlang:getCode()
if termlangcode ~= langcode and termlangcode ~= "mul" then
insert(qqs, termlang:getCanonicalName())
end
end
end
local modtext = run[j]:match("^<(.*)>$")
if item.qq then
if not modtext then
for _, qq in ipairs(item.qq) do
parse_err("Internal error: Modifier '" .. modtext .. "' isn't surrounded by angle brackets")
insert(qqs, qq)
end
end
end
local prefix, arg = modtext:match("^([a-z]+):(.*)$")
item.qq = qqs
if not prefix then
end
parse_err("Modifier " .. run[j] .. " lacks a prefix, should begin with one of '" ..
local omitted = false
table.concat(param_mods, ":', '") .. ":'")
for _, omitted_item in ipairs(args.omit) do
end
if omitted_item == item.term then
if param_mod_set[prefix] then
omitted = true
local obj_to_set
break
if prefix == "q" or prefix == "qq" then
obj_to_set = termobj
else
obj_to_set = termobj.term
end
if prefix == "t" then
prefix = "gloss"
elseif prefix == "g" then
prefix = "genders"
arg = mw.text.split(arg, ",")
elseif prefix == "sc" then
arg = require("Module:scripts").getByCode(arg, orig_param .. ":sc")
end
if obj_to_set[prefix] then
parse_err("Modifier '" .. prefix .. "' occurs twice, second occurrence " .. run[j])
end
obj_to_set[prefix] = arg
else
parse_err("Unrecognized prefix '" .. prefix .. "' in modifier " .. run[j])
end
end
end
end
if omitted then
-- signal create_list() to omit this item
group.terms[j] = false
else
number_of_items = number_of_items + 1
end
end
if number_of_items == 0 then
-- omit the whole group
groups[i] = false
else
else
termobj.term.term = item
number_of_groups = number_of_groups + 1
end
end
args[first_content_param][i] = termobj
end
end


return export.create_list { column_count = frame_args["columns"] or args[columns_param],
local column_count = iargs.columns or args.n
content = args[first_content_param],
-- FIXME: This needs a total rewrite.
if column_count == nil then
column_count = number_of_groups <= 3 and 1 or
number_of_groups <= 9 and 2 or
number_of_groups <= 27 and 3 or
number_of_groups <= 81 and 4 or
5
end
local raw = number_of_groups < iargs.minrows
 
local horiz_edit_button
if topic_list_data and args.horiz then
-- append edit button to title
horiz_edit_button = topic_list_data.make_horiz_edit_button(topic_list_data.topic_list_template)
end
 
return export.create_list {
column_count = column_count,
raw = raw,
content = groups,
alphabetize = sort,
alphabetize = sort,
header = args["title"], background_color = "#F8F8FF",
header = title,
title_new_style = (title ~= nil and title ~= ''),
collapse = collapse,
collapse = collapse,
toggle_category = frame_args["toggle_category"],
toggle_category = iargs.toggle_category,
class = frame_args["class"], lang = lang, sc = sc, format_header = true }
-- columns-bg (in [[MediaWiki:Gadget-Site.css]]) provides the background color
class = (iargs.class and iargs.class .. " columns-bg" or "columns-bg"),
lang = lang,
sc = sc,
subitem_separator = ", ",
keepfirst = args.keepfirst,
keeplast = args.keeplast,
horiz = args.horiz,
} .. (horiz_edit_button or "") .. (formatted_cats or "")
end
end


function export.display(frame)
function export.display(frame)
return export.display_from(frame.args, frame:getParent().args)
if not is_substing() then
end
return export.display_from(frame.args, frame:getParent().args, frame, false)
end


-- A version of col which substs any automatically generated forms in order to save memory (e.g. for Chinese).
-- If substed, unsubst template with newlines between each term, redundant wikilinks removed, and remove duplicates + sort terms if sort is enabled.
function export.generated_forms(frame)
local m_table = require("Module:table")
local column_args, list_args = frame.args, frame:getParent().args
local m_template_parser = require("Module:template parser")


local iparams = {
local parent = frame:getParent()
["columns"] = {type = "number"},
local elems = m_table.shallowCopy(parent.args)
["name"] = {required = true},
local code = remove(elems, 1)
["toggle_category"] = {},
code = code and trim(code)
}
local lang = require("Module:languages").getByCode(code, 1)


local frame_args = require("Module:parameters").process(column_args, iparams, nil, "columns", "generated_forms")
local i = 1
 
while true do
local first_content_param = 2 + (frame_args["columns"] and 0 or 1)
local elem = elems[i]
 
while elem do
frame_args["name"] = frame_args["columns"] and frame_args["name"] .. frame_args["columns"] or frame_args["name"]
elem = trim(elem, "%s")
 
if elem ~= "" then
local params = {
break
[1] = {required = true, default = "und"},
end
[2] = not frame_args["columns"] and {required = true, default = 2} or nil,
remove(elems, i)
[first_content_param] = {list = true},
elem = elems[i]
 
end
["title"] = {},
if not elem then
["collapse"] = {type = "boolean"},
break
["sort"] = {type = "boolean"},
elseif not ( -- Strip redundant wikilinks.
["sc"] = {},
not elem:match("^()%[%[") or
}
elem:find("[[", 3, true) or
 
elem:find("]]", 3, true) ~= #elem - 1 or
local args = require("Module:parameters").process(list_args, params, nil, "columns", "generated_forms")
elem:find("|", 3, true)
 
) then
local lang = args[1]
elem = sub(elem, 3, -3)
lang = m_languages.getByCode(lang, lang_param)
elem = trim(elem, "%s")
 
end
args["sc"] = args["sc"] or ""
elems[i] = elem .. "\n"
if sc and sc ~= "" then
i = i + 1
sc = require "Module:scripts".getByCode(sc)
or error("|sc= does not contain a valid script code")
end
end


args[2] = args[2] or ""
-- If sort is enabled, remove duplicates then sort elements.
args["title"] = args["title"] or ""
if require("Module:yesno")(frame.args.sort) then
args["collapse"] = args["collapse"] or ""
elems = m_table.removeDuplicates(elems)
args["sort"] = args["sort"] or ""
require("Module:collation").sort(elems, lang)
 
local items = {}
for i, item in ipairs(args[first_content_param]) do
if item:find("<") and not item:find("^[^<]*<[a-z]*[^a-z:]") then
item = item:gsub("^([^<]*)(<[a-z]*[a-z:])", function(m1, m2)
return table.concat(lang:generateForms(m1), "//")
end)
else
item = table.concat(lang:generateForms(item), "//")
end
table.insert(items, item)
end
end


for k, arg in pairs(args) do
-- Readd the langcode.
if type(arg) == "string" then
insert(elems, 1, code .. "\n")
if arg ~= "" then
if type(k) == "string" then
args[k] = k .. "=" .. args[k] .. "|"
else
args[k] = args[k] .. "|"
end
end
elseif type(arg) == "boolean" then
args[k] = k .. "=1|"
end
end


local prefix = "{{" .. frame_args["name"] .. "|" .. args[1] .. args["sc"]
-- TODO: Place non-numbered parameters after 1 and before 2.
prefix = not frame_args["columns"] and prefix .. args[2] or prefix
local template = m_template_parser.getTemplateInvocationName(mw.title.new(parent:getTitle()))


return prefix ..  args["title"] .. args["collapse"] ..  args["sort"] ..  table.concat(items, "|") .. "}}"
return "{{" .. concat(m_template_parser.buildTemplate(template, elems), "|") .. "}}"
end
end


return export
return export

Latest revision as of 08:49, 6 July 2025



local export = {}

local collation_module = "Module:collation"
local headword_data_module = "Module:headword/data"
local JSON_module = "Module:JSON"
local languages_module = "Module:languages"
local links_module = "Module:links"
local pages_module = "Module:pages"
local parameter_utilities_module = "Module:parameter utilities"
local parameters_module = "Module:parameters"
local parse_utilities_module = "Module:parse utilities"
local pron_qualifier_module = "Module:pron qualifier"
local qualifier_module = "Module:qualifier"
local string_utilities_module = "Module:string utilities"
local table_module = "Module:table"
local utilities_module = "Module:utilities"
local yesno_module = "Module:yesno"

local m_str_utils = require(string_utilities_module)

local concat = table.concat
local html = mw.html.create
local is_substing = mw.isSubsting
local insert = table.insert
local rmatch = m_str_utils.match
local remove = table.remove
local sub = string.sub
local trim = m_str_utils.trim
local u = m_str_utils.char
local dump = mw.dumpObject

local function deepEquals(...)
    deepEquals = require(table_module).deepEquals
    return deepEquals(...)
end

local function term_already_linked(term)
	return term == "?" or -- signals an unknown term
		-- optimization to avoid unnecessarily loading [[Module:parse utilities]]
		(term:find("[<{]") and require(parse_utilities_module).term_already_linked(term))
end

local function convert_delimiter_to_separator(item, itemind, args)
	if itemind == 1 then
		item.separator = nil
	elseif item.delimiter == " " then
		item.separator = args.space_delim
	elseif item.delimiter == "~" then
		item.separator = args.tilde_delim
	else
		item.separator = args.comma_delim
	end
end

local function get_horizontal_separator(args_horiz, embedded_comma)
	return args_horiz == "bullet" and " · " or embedded_comma and "; " or ", "
end


-- Suppress false positives in categories like [[Category:English links with redundant wikilinks]] so people won't
-- be tempted to "correct" them; terms like embedded ~ like [[Micros~1]] or embedded comma not followed by a space
-- such as [[1,6-Cleves acid]] need to have a link around them to avoid the tilde or comma being interpreted as a
-- delimiter.
local function suppress_redundant_wikilink_cat(term, alt)
	return term:find("~") or term:find(",%S")
end

local function full_link_and_track_self_links(item, face)
	if item.term then
		local pagename = mw.loadData(headword_data_module).pagename
		local term_is_pagename = item.term == pagename
		local term_contains_pagename = item.term:find("%[%[" .. m_str_utils.pattern_escape(pagename) .. "[|%]]")
		if term_is_pagename or term_contains_pagename then
			local current_L2 = require(pages_module).get_current_L2()
			if current_L2 then
				local current_L2_lang = require(languages_module).getByCanonicalName(current_L2)
				if current_L2_lang and current_L2_lang:getCode() == item.lang:getCode() then
					if term_is_pagename then
						track("term-is-pagename")
					else
						track("term-contains-pagename")
					end
				end
			end
		end
	end

	item.suppress_redundant_wikilink_cat = suppress_redundant_wikilink_cat
	return require(links_module).full_link(item, face)
end

local function format_subitem(subitem, lang, face, compute_embedded_comma)
	local embedded_comma = false
	local text
	if subitem.term and term_already_linked(subitem.term) then
		text = subitem.term
		if compute_embedded_comma then
			embedded_comma = not not require(utilities_module).get_plaintext(text):find(",")
		end
	else
		text = full_link_and_track_self_links(subitem, face)
		if compute_embedded_comma then
			-- We don't check qualifier, label or reference text for commas as it's inside parens or displayed
			-- elsewhere.
			local subitem_plaintext = subitem.alt or subitem.term
			if subitem_plaintext then
				embedded_comma = not not subitem_plaintext:find(",")
			end
		end
	end
	-- We could use the "show qualifiers" flag to full_link() but not when term_already_linked().
	if subitem.q and subitem.q[1] or subitem.qq and subitem.qq[1] or subitem.l and subitem.l[1] or
		subitem.ll and subitem.ll[1] or subitem.refs and subitem.refs[1] then
		text = require(pron_qualifier_module).format_qualifiers {
			lang = subitem.lang or args.lang,
			text = text,
			q = subitem.q,
			qq = subitem.qq,
			l = subitem.l,
			ll = subitem.ll,
			refs = subitem.refs,
		}
	end
	return text, embedded_comma
end

function export.format_item(item, args, face)
	local compute_embedded_comma = args.horiz == "comma"
	local embedded_comma = false
	if type(item) == "table" then
		if item.terms then
			local parts = {}
			local is_first = true
			for _, subitem in ipairs(item.terms) do
				if subitem == false then
					-- omitted subitem; do nothing
				else
					local separator = subitem.separator or not is_first and (args.subitem_separator or ", ")
					if separator then
						if compute_embedded_comma then
							embedded_comma = embedded_comma or not not separator:find(",")
						end
						insert(parts, separator)
					end
					local formatted, this_embedded_comma = format_subitem(subitem, args.lang, face,
						compute_embedded_comma)
					embedded_comma = embedded_comma or this_embedded_comma
					insert(parts, formatted)
					is_first = false
				end
			end
			return concat(parts), embedded_comma
		else
			return format_subitem(item, args.lang, face, compute_embedded_comma)
		end
	else
		if compute_embedded_comma then
			embedded_comma = not not require(utilities_module).get_plaintext(item):find(",")
		end
		if args.lang and not term_already_linked(item) then
			return full_link_and_track_self_links({lang = args.lang, term = item, sc = args.sc}, face), embedded_comma
		else
			return item, embedded_comma
		end
	end
end

function export.construct_old_style_header(header, horiz)
	local old_style_header
	local function ib_colon()
		return tostring(html("span"):addClass("ib-colon"):addClass("ib-content"):wikitext(":"))
	end
	if horiz then
		old_style_header = require(qualifier_module).format_qualifiers {
			qualifiers = header,
			open = false,
			close = false,
		} .. ib_colon() ..  " "
	else
		old_style_header = require(qualifier_module).format_qualifiers {
			qualifiers = header
		} .. ib_colon()
		old_style_header = tostring(html("div"):wikitext(old_style_header))
	end
	return old_style_header
end

-- Construct the sort base of a single term. As a hack, sort appendices after mainspace items.
local function term_sortbase(val)
	if not val then
		-- This should not normally happen.
		return u(0x10FFFF)
	elseif val:find("^%[*Appendix:") then
		return u(0x10FFFE) .. val
	else
		return val
	end
end

-- Construct the sort base of a single item, using the display form preferentially, otherwise the term itself.
-- As a hack, sort appendices after mainspace items.
local function item_sortbase(item)
	return term_sortbase(item.alt or item.term)
end

local function make_sortbase(item)
	if item == false then
		return "*" -- doesn't matter, will be omitted in create_list()
	elseif type(item) == "table" then
		if item.terms then
			-- Optimize for the common case of only a single term
			if item.terms[2] then
				local parts = {}
				-- multiple terms
				local first = true
				for _, subitem in ipairs(item.terms) do
					if subitem ~= false then
						if not first then
							insert(parts, ", ")
						end
						insert(parts, item_sortbase(subitem))
						first = false
					end
				end
				if parts[1] then
					return concat(parts)
				end
			else
				local subitem = item.terms[1]
				if subitem ~= false then
					return item_sortbase(subitem)
				end
			end
			return "*" -- doesn't matter, entire group will be omitted in create_list()
		else
			return item_sortbase(item)
		end
	else
		return item
	end
end

local function make_node_sortbase(node)
	return make_sortbase(node.item)
end

-- Sort a sublist of `list` in place, keeping the first `keepfirst` and last `keeplast` items fixed.
-- `lang` is the language of the items and `make_sortbase` creates the appropriate sort base.
local function sort_sublist(list, lang, make_sortbase, keepfirst, keeplast)
	if keepfirst == 0 and keeplast == 0 then
		require(collation_module).sort(list, lang, make_sortbase)
	else
		local sublist = {}
		for i = keepfirst + 1, #list - keeplast do
			sublist[i - keepfirst] = list[i]
		end
		require(collation_module).sort(sublist, lang, make_sortbase)
		for i = keepfirst + 1, #list - keeplast do
			list[i] = sublist[i - keepfirst]
		end
	end
end

-- URL-encode only the characters that serve as template delimiters (left and right brace, vertical bar, equal sign
-- and percent sign since it's the escape character).
local function bot_url_encode(txt)
	return (txt:gsub("[%%|{}=&]",
		{["%"] = "%25", ["|"] =  "%7C", ["{"] = "%7B", ["}"] = "%7D", ["="] = "%3D", ["&"] = "%26"}))
end

-- Reverse the action of bot_url_encode().
local function bot_url_decode(txt)
	return (txt:gsub("%%7([BCD])", {B = "{", C = "|", D = "}"}):gsub("%%3D", "="):gsub("%%26", "&"):gsub("%%25", "%%"))
end

--[==[
Bot-callable function to generate a number of sortkeys simultaneously. {{para|1}} contains the langcode, and remaining
numeric parameters contain "bot-URL-encoded" strings whose sort keys will be computed and returned as a JSON array.
Here, "bot-URL-encoded" means that the six characters `{ | } = & %` should be converted to
their URL-encoded representation (respectively <code>%7B %7C %7D %3D %26 %25</code>), and will be decoded appropriately
before computing the sortkey.
]==]
function export.make_sortkey(frame)
	local iparams = {
		[1] = {type = "language"},
		[2] = {list = true},
	}
	local iargs = require(parameters_module).process(frame.args, iparams)
	local make_sortkey = require(collation_module).make_lang_sortkey_function(iargs[1], term_sortbase)
	local retval = {}
	for _, arg in ipairs(iargs[2]) do
		arg = bot_url_decode(arg)
		insert(retval, make_sortkey(arg))
	end
	return require(JSON_module).toJSON(retval)
end


local large_text_scripts = {
	["Arab"] = true,
	["Beng"] = true,
	["Deva"] = true,
	["Gujr"] = true,
	["Guru"] = true,
	["Hebr"] = true,
	["Khmr"] = true,
	["Knda"] = true,
	["Laoo"] = true,
	["Mlym"] = true,
	["Mong"] = true,
	["Mymr"] = true,
	["Orya"] = true,
	["Sinh"] = true,
	["Syrc"] = true,
	["Taml"] = true,
	["Telu"] = true,
	["Tfng"] = true,
	["Thai"] = true,
	["Tibt"] = true,
}

--[==[
Format a list of items using HTML. `args` is an object specifying the items to add and related properties, with the
following fields:
* `content`: A list of the items to format. See below for the format of the items.
* `lang`: The language object of the items to format, if the items in `content` are strings.
* `sc`: The script object of the items to format, if the items in `content` are strings.
* `raw`: If true, return the list raw, without any collapsing or columns.
* `class`: The CSS class of the surrounding <div>.
* `column_count`: Number of columns to format the list into.
* `alphabetize`: If true, sort the items in the table.
* `collapse`: If true, make the table partially collapsed by default, with a "Show more" button at the bottom.
* `toggle_category`: Value of `data-toggle-category` property grouping collapsible elements.
* `header`: If specified, Wikicode to prepend to the output.
* `title_new_style`: If true, the header is treated as a title and displayed in a new style. This is ignored if `horiz`
					 is non-nil.
* `subitem_separator`: Separator used between subitems when multiple subitems occur on a line, if not specified in the
                       subitem itself (using the `separator` field). Defaults to {", "}.
* `keepfirst`: If > 0, keep this many rows unsorted at the beginning of the top level.
* `keeplast`: If > 0, keep this many rows unsorted at the end of the top level.
* `horiz`: If non-nil, format the items horizontally. If the value is "bullet", put a center dot/bullet (·) between
		   items. If the value is "comma", put a comma between items (but if there is an embedded comma in any item,
		   put a semicolon between all items).

Each item in `content` is in one of the following formats:
* A string. This is for compatibility and should not be used by new callers.
* An object describing an item to format, in the format expected by full_link() in [[Module:links]] but can also
  have left or right qualifiers, left or right labels, or references.
* An object describing a list of subitems to format, displayed side-by-side, separated by a comma or other separator.
  This format is identified by the presence of a key `terms` specifying the list of subitems. Each subitem is in
  the same format as for a single top-level item, except that it should also have a `separator` field specifying the
  separator to display before each item (which will typically be a blank string before the first item).
]==]
function export.create_list(args)
	if type(args) ~= "table" then
		error("expected table, got " .. type(args))
	end

	local column_count = args.column_count or 1
	local toggle_category = args.toggle_category or "derived terms"
	local keepfirst = args.keepfirst or 0
	local keeplast = args.keeplast or 0
	if keepfirst > 0 then
		track("keepfirst")
	end
	if keeplast > 0 then
		track("keeplast")
	end

	-- maybe construct old-style header
	local old_style_header = nil
	if args.header and (args.horiz or not args.title_new_style) then
		old_style_header = export.construct_old_style_header(args.header, args.horiz)
	end
	if args.horiz then
		old_style_header = "* " .. (old_style_header or "")
	end

	local list

	local any_extra_indented_item = false
	for _, item in ipairs(args.content) do
		if item == false then
			-- do nothing
		elseif type(item) == "table" and item.extra_indent and item.extra_indent > 0 then
			any_extra_indented_item = true
			break
		end
	end

	-- If any extra indented item, convert the items to a nested structure, which is necessary both for sorting and
	-- for converting to HTML.
	if any_extra_indented_item then
		local function make_node(item)
			return {
				item = item
			}
		end
		local root_node = make_node(nil)
		local node_stack = {root_node}
		local last_indent = 0
		local function append_subnode(node, subnode)
			if not node.subnodes then
				node.subnodes = {}
			end
			insert(node.subnodes, subnode)
		end
		for i, item in ipairs(args.content) do
			if item == false then
				-- do nothing
			else
				local this_indent
				if type(item) ~= "table" then
					this_indent = 1
				else
					this_indent = (item.extra_indent or 0) + 1
				end
				local node = make_node(item)
				if this_indent == last_indent then
					append_subnode(node_stack[#node_stack], node)
				elseif this_indent > last_indent + 1 then
					error(("Element #%s (%s) has indent %s, which is more than one greater than the previous item with indent %s"):format(
						i, make_sortbase(item), this_indent, last_indent))
				elseif this_indent > last_indent then
					-- Start a new sublist attached to the last item of the sublist one level up; but we need special
					-- handling for the root node (last_indent == 0).
					if last_indent > 0 then
						local subnodes = node_stack[#node_stack].subnodes
						if not subnodes then
							error(("Internal error: Not first item and no subnodes at preceding level %s: %s"):format(
								#node_stack, dump(node_stack)))
						end
						insert(node_stack, subnodes[#subnodes])
					end
					append_subnode(node_stack[#node_stack], node)
					last_indent = this_indent
				else
					while last_indent > this_indent do
						local finished_node = table.remove(node_stack)
						if args.alphabetize then
							require(collation_module).sort(finished_node.subnodes, args.lang, make_node_sortbase)
						end
						last_indent = last_indent - 1
					end
					append_subnode(node_stack[#node_stack], node)
				end
			end
		end
		if args.alphabetize then
			while node_stack[1] do
				local finished_node = table.remove(node_stack)
				if node_stack[1] then
					-- We're sorting something other than the root node.
					require(collation_module).sort(finished_node.subnodes, args.lang, make_node_sortbase)
				else
					-- We're sorting the root node; honor `keepfirst` and `keeplast`.
					sort_sublist(finished_node.subnodes, args.lang, make_node_sortbase, keepfirst, keeplast)
				end
			end
		end

		local function format_node(node, depth)
			local sublist
			local embedded_comma = false
			if node.subnodes then
				if args.horiz then
					sublist = {}
				else
					sublist = html("ul")
				end
				local prevnode = nil
				for _, subnode in ipairs(node.subnodes) do
					local thisnode, this_embedded_comma = format_node(subnode, depth + 1)
					embedded_comma = embedded_comma or this_embedded_comma
					if not prevnode or not args.alphabetize or not deepEquals(prevnode, thisnode) then
						if args.horiz then
							table.insert(sublist, thisnode)
						else
							sublist = sublist:node(thisnode)
						end
						prevnode = thisnode
					end
				end
				if args.horiz then
					sublist = table.concat(sublist, get_horizontal_separator(args.horiz, embedded_comma))
				end
			end
			if not node.item then
				-- At the root.
				return sublist, embedded_comma
			end
			local formatted, listitem
			-- Ignore embedded commas in subitems inside of parens or square brackets.
			formatted, embedded_comma = export.format_item(node.item, args)
			if args.horiz then
				listitem = formatted
				if sublist then
					-- Use parens for the first, third, fifth, etc. sublists and square brackets for the remainder.
					if depth % 2 == 1 then
						listitem = ("%s (%s)"):format(listitem, sublist)
					else
						listitem = ("%s [%s]"):format(listitem, sublist)
					end
				end
			else
				listitem = html("li"):wikitext(formatted)
				if sublist then
					listitem = listitem:node(sublist)
				end
			end
			return listitem, embedded_comma
		end

		list = format_node(root_node, 0)
	else
		if args.alphabetize then
			sort_sublist(args.content, args.lang, make_sortbase, keepfirst, keeplast)
		end
		if args.horiz then
			list = {}
		else
			list = html("ul")
		end
		local previtem = nil
		local embedded_comma = false
		for _, item in ipairs(args.content) do
			if item == false then
				-- omitted item; do nothing
			else
				local thisitem, this_embedded_comma = export.format_item(item, args)
				embedded_comma = embedded_comma or this_embedded_comma
				if not previtem or not args.alphabetize or previtem ~= thisitem then
					if args.horiz then
						table.insert(list, thisitem)
					else
						list = list:node(html("li"):wikitext(thisitem))
					end
					previtem = thisitem
				end
			end
		end
		if args.horiz then
			list = table.concat(list, get_horizontal_separator(args.horiz, embedded_comma))
		end
	end

	local output
	if args.horiz then
		output = list
	else
		output = html("div"):addClass("term-list"):node(list)

		if args.class then
			output:addClass(args.class)
		end

		if not args.raw then
			output:addClass("ul-column-count")
				:attr("data-column-count", column_count)

			if args.collapse then
				output = html("div")
					:node(output)
					:addClass("list-switcher")
					:attr("data-toggle-category", toggle_category)

				-- identify commonly used scripts that use large text and
				-- provide a special CSS class to make the template bigger
				local sc = args.sc
				if sc == nil then
					local scripts = args.lang:getScripts()
					if #scripts > 0 then
						sc = scripts[1]
					end
				end
				if sc ~= nil then
					local scriptcode = sc:getParentCode()
					if scriptcode == "top" then
						scriptcode = sc:getCode()
					end
					if large_text_scripts[scriptcode] then
						output:addClass("list-switcher-large-text")
					end
				end
			end
		end

		if args.collapse or args.title_new_style then
			-- wrap in wrapper to prevent interference from floating elements
			local list_switcher_wrapper = html("div")
				:addClass("list-switcher-wrapper")
				
			if args.title_new_style then
				list_switcher_wrapper
					:node(
					html("div")
						:addClass("list-switcher-header")
						:wikitext(args.header)
				)
			end
			
			list_switcher_wrapper:node(output)
			output = list_switcher_wrapper
		end

		output = tostring(output)
	end

	return (old_style_header or "") .. output
end


-- This function is for compatibility with earlier version of [[Module:columns]]
-- (now found in [[Module:columns/old]]).
function export.create_table(...)
	-- Earlier arguments to create_table:
	-- n_columns, content, alphabetize, bg, collapse, class, title, column_width, line_start, lang
	local args = {}
	args.column_count, args.content, args.alphabetize,
		args.collapse, args.class, args.header, args.column_width,
		args.line_start, args.lang = ...

	return export.create_list(args)
end


function export.display_from(frame_args, parent_args, frame)
	local boolean = {type = "boolean"}
	local iparams = {
		["class"] = true,
		-- Default for auto-collapse. Overridable by template |collapse= param.
		["collapse"] = boolean,
		-- If specified, this specifies the number of columns, and no columns parameter is available on the template.
		-- Otherwise, the columns parameter is named |n=.
		["columns"] = {type = "number"},
		-- If specified, this specifies the default language code, which can be overridden using |lang= in the template.
		-- Otherwise, the language-code parameter is required and normally found in |1=, but for compatibility can be
		-- specified as |lang= (which leads to deprecation handling).
		["lang"] = {type = "language"},
		-- Default for auto-sort. Overridable by template |sort= param.
		["sort"] = boolean,
		["toggle_category"] = true,
		-- Minimum number of rows required to format into a multicolumn list. If below this, the list is displayed "raw"
		-- (no columns, no collapsbility).
		["minrows"] = {type = "number", default = 5},
	}

	local iargs = require(parameters_module).process(frame_args, iparams)

	local langcode_in_lang = iargs.lang or parent_args.lang
	local lang_param = langcode_in_lang and "lang" or 1
	local deprecated = not iargs.lang and langcode_in_lang

	local ret = export.handle_display_from_or_topic_list(iargs, parent_args, nil)

	return deprecated and frame:expandTemplate{title = "check deprecated lang param usage",
		args = {ret, lang = args[lang_param]}} or ret
end


--[==[
Implement `display_from()` [the internal entry point for {{tl|col}} and variants, which enter originally through
`display()`] as well as regular (column-oriented) topic lists, invoked through [[Module:topic list]].
`iargs` are the invocation args of {{tl|col}}, and `raw_item_args` are the arguments specifying the values of
each row as well as other properties, corresponding to the user-specified template arguments of {{tl|col}}. Note that
`show()` in [[Module:topic list]] is normally invoked directly by a topic list template, whose invocation
arguments are passed in using `raw_item_args` and are similar to the template arguments of {{tl|col}}. `iargs` for
topic-list invocations is hard-coded, and template arguments to a topic-list template are processed in
[[Module:topic list]] itself. Note that the handling of topic lists is currently implemented almost entirely
through callbacks in `topic_list_data` (which is nil if we're processing {{tl|col}} rather than a topic list) in an
attempt to reduce the coupling and keep the topic-list-specific code in [[Module:topic list]], but IMO the coupling
is still too tight. Probably the control structure should be reversed and the following function split up into
subfunctions, which are invoked as needed by {{tl|col}} and/or [[Module:topic list]].
]==]
function export.handle_display_from_or_topic_list(iargs, raw_item_args, topic_list_data)
	local boolean = {type = "boolean"}
	local langcode_in_lang = iargs.lang or raw_item_args.lang
	local lang_param = langcode_in_lang and "lang" or 1
	local first_content_param = langcode_in_lang and 1 or 2

	local params = {
		[lang_param] = {required = not iargs.lang, type = "language",
			template_default = not iargs.lang and "und" or nil},
		["n"] = not iargs.columns and {type = "number"} or nil,
		[first_content_param] = {list = true, allow_holes = true},

		["title"] = {},
		["collapse"] = boolean,
		["sort"] = boolean,
		["sc"] = {type = "script"},
		-- used when calling from [[Module:saurus]] so the page displaying the synonyms/antonyms doesn't occur in the
		-- list
		["omit"] = {list = true},
		["keepfirst"] = {type = "number", default = 0},
		["keeplast"] = {type = "number", default = 0},
		["horiz"] = {},
		["notr"] = boolean,
		["allow_space_delim"] = boolean,
		["tilde_delim"] = {},
		["space_delim"] = {},
		["comma_delim"] = {},
	}

	if topic_list_data then
		topic_list_data.add_topic_list_params(params)
	end

	local m_param_utils = require(parameter_utilities_module)

	local param_mods = m_param_utils.construct_param_mods {
		{default = true, require_index = true},
		{group = "link"}, -- sc has separate_no_index = true; that's the only one
		-- It makes no sense to have overall l=, ll=, q= or qq= params for columnar display.
		{group = {"ref", "l", "q"}, require_index = true},
	}

	m_param_utils.augment_params_with_modifiers(params, param_mods)
	local processed_args = require(parameters_module).process(raw_item_args, params)
	local horiz = processed_args.horiz
	if horiz and horiz ~= "comma" and horiz ~= "bullet" then
		horiz = require(yesno_module)(horiz)
		if horiz == nil then
			error(("Unrecognized value |horiz=%s; should be 'comma', 'bullet' or a recognized Boolean value such " ..
				"as 'yes' or '1' (same as 'bullet') or 'no' or '0'"):format(processed_args.horiz))
		end
		if horiz == true then
			horiz = "bullet"
		end
		processed_args.horiz = horiz
	end
		
	-- If default argument values specified, set them after parsing the caller-specified arguments in `raw_item_args`.
	if topic_list_data then
		topic_list_data.set_default_arguments(processed_args)
	end

	-- Now set defaults for the various delimiters, depending in some cases on whether horiz was set.
	-- We can't set these defaults (even regardless of their dependency on horiz=) in `local params` above
	-- because we want any defaults specified in `default_props` to override these.
	if not processed_args.tilde_delim then
		local tilde_with_abbr = '<abbr title="near equivalent">~</abbr>'
		processed_args.tilde_delim = processed_args.horiz and tilde_with_abbr or " " .. tilde_with_abbr .. " "
	end
	if not processed_args.space_delim then
		processed_args.space_delim = "&nbsp;"
	end
	if not processed_args.comma_delim then
		processed_args.comma_delim = processed_args.horiz and "/" or ", "
	end

	-- Check for extra term indent. Do this before calling parse_list_with_inline_modifiers_and_separate_params()
	-- because sometimes space is a delimiter and the space in the indent will confuse things and get interpreted as a
	-- delimiter.
	local extra_indent_by_termno = {}
	local termargs = processed_args[first_content_param]
	for i = 1, termargs.maxindex do
		local term = termargs[i]
		if term then
			local extra_indent, actual_term = rmatch(term, "^(%*+)%s+(.-)$")
			if extra_indent then
				termargs[i] = actual_term
				extra_indent_by_termno[i] = #extra_indent
			end
		end
	end

	local groups, args = m_param_utils.parse_list_with_inline_modifiers_and_separate_params {
		param_mods = param_mods,
		processed_args = processed_args,
		termarg = first_content_param,
		parse_lang_prefix = true,
		allow_multiple_lang_prefixes = true,
		disallow_custom_separators = true,
		track_module = "columns",
		lang = iargs.lang or lang_param,
		sc = "sc.default",
		splitchar = processed_args.allow_space_delim and "[,~ ]" or "[,~]",
	}

	local lang = iargs.lang or args[lang_param]
	local langcode = lang:getCode()
	local fulllangcode = lang:getFullCode()
	local sc = args.sc.default

	local sort = iargs.sort
	if args.sort ~= nil then
		if not args.sort then
			track("nosort")
		end
		sort = args.sort
	else
		-- HACK! For Japanese-script languages (Japanese, Okinawan, Miyako, etc.), sorting doesn't yet work properly, so
		-- disable it.
		for _, langsc in ipairs(lang:getScriptCodes()) do
			if langsc == "Jpan" then
				sort = false
				break
			end
		end
	end

	local collapse = iargs.collapse
	if args.collapse ~= nil then
		if not args.collapse then
			track("nocollapse")
		end
		collapse = args.collapse
	end

	local title = args.title
	local formatted_cats
	if topic_list_data then
		title, formatted_cats = topic_list_data.get_title_and_formatted_cats(args, lang, sc, topic_list_data)
	end

	local number_of_groups = 0
	for i, group in ipairs(groups) do
		local number_of_items = 0
		group.extra_indent = extra_indent_by_termno[group.orig_index]
		for j, item in ipairs(group.terms) do
			convert_delimiter_to_separator(item, j, args)

			if args.notr then
				item.tr = "-"
			end

			-- If a separate language code was given for the term, display the language name as a right qualifier.
			-- (Briefly we made them labels but this leads to non-obvious behavior e.g. "French" becoming "France" under
			-- some circumstances.) Otherwise it may not be obvious that the term is in a separate language (e.g. if the
			-- main language is 'zh' and the term language is a Chinese lect such as Min Nan). But don't do this for
			-- Translingual terms, which are often added to the list of English and other-language terms.
			if item.termlangs then
				local qqs = {}
				for _, termlang in ipairs(item.termlangs) do
					local termlangcode = termlang:getCode()
					if termlangcode ~= langcode and termlangcode ~= "mul" then
						insert(qqs, termlang:getCanonicalName())
					end
				end
				if item.qq then
					for _, qq in ipairs(item.qq) do
						insert(qqs, qq)
					end
				end
				item.qq = qqs
			end
			local omitted = false
			for _, omitted_item in ipairs(args.omit) do
				if omitted_item == item.term then
					omitted = true
					break
				end
			end
			if omitted then
				-- signal create_list() to omit this item
				group.terms[j] = false
			else
				number_of_items = number_of_items + 1
			end
		end
		if number_of_items == 0 then
			-- omit the whole group
			groups[i] = false
		else
			number_of_groups = number_of_groups + 1
		end
	end

	local column_count = iargs.columns or args.n
	-- FIXME: This needs a total rewrite.
	if column_count == nil then
		column_count = number_of_groups <= 3 and 1 or
			number_of_groups <= 9 and 2 or
			number_of_groups <= 27 and 3 or
			number_of_groups <= 81 and 4 or
			5
	end
	local raw = number_of_groups < iargs.minrows

	local horiz_edit_button
	if topic_list_data and args.horiz then
		-- append edit button to title
		horiz_edit_button = topic_list_data.make_horiz_edit_button(topic_list_data.topic_list_template)
	end

	return export.create_list {
		column_count = column_count,
		raw = raw,
		content = groups,
		alphabetize = sort,
		header = title,
		title_new_style = (title ~= nil and title ~= ''),
		collapse = collapse,
		toggle_category = iargs.toggle_category,
		-- columns-bg (in [[MediaWiki:Gadget-Site.css]]) provides the background color
		class = (iargs.class and iargs.class .. " columns-bg" or "columns-bg"),
		lang = lang,
		sc = sc,
		subitem_separator = ", ",
		keepfirst = args.keepfirst,
		keeplast = args.keeplast,
		horiz = args.horiz,
	} .. (horiz_edit_button or "") .. (formatted_cats or "")
end

function export.display(frame)
	if not is_substing() then
		return export.display_from(frame.args, frame:getParent().args, frame, false)
	end

	-- If substed, unsubst template with newlines between each term, redundant wikilinks removed, and remove duplicates + sort terms if sort is enabled.
	local m_table = require("Module:table")
	local m_template_parser = require("Module:template parser")

	local parent = frame:getParent()
	local elems = m_table.shallowCopy(parent.args)
	local code = remove(elems, 1)
	code = code and trim(code)
	local lang = require("Module:languages").getByCode(code, 1)

	local i = 1
	while true do
		local elem = elems[i]
		while elem do
			elem = trim(elem, "%s")
			if elem ~= "" then
				break
			end
			remove(elems, i)
			elem = elems[i]
		end
		if not elem then
			break
		elseif not ( -- Strip redundant wikilinks.
			not elem:match("^()%[%[") or
			elem:find("[[", 3, true) or
			elem:find("]]", 3, true) ~= #elem - 1 or
			elem:find("|", 3, true)
		) then
			elem = sub(elem, 3, -3)
			elem = trim(elem, "%s")
		end
		elems[i] = elem .. "\n"
		i = i + 1
	end

	-- If sort is enabled, remove duplicates then sort elements.
	if require("Module:yesno")(frame.args.sort) then
		elems = m_table.removeDuplicates(elems)
		require("Module:collation").sort(elems, lang)
	end

	-- Readd the langcode.
	insert(elems, 1, code .. "\n")

	-- TODO: Place non-numbered parameters after 1 and before 2.
	local template = m_template_parser.getTemplateInvocationName(mw.title.new(parent:getTitle()))

	return "{{" .. concat(m_template_parser.buildTemplate(template, elems), "|") .. "}}"
end

return export