Module:etymon

From Wiktionary, the free dictionary
Jump to navigation Jump to search

This module provides the backend for {{etymon}}.


local export = {}

local concat = table.concat
local find = string.find
local findTemplates = require("Module:template parser").findTemplates
local get_current_title = mw.title.getCurrentTitle
local get_lang = require("Module:languages").getByCode
local insert = table.insert
local ipairs = ipairs
local max = math.max
local new_title = mw.title.new
local split = require("Module:string utilities").split
local sub = string.sub
local type = type
local unpack = unpack
local full_link = require("Module:links").full_link

--Given a full code and entry name, return the Wiktionary page title.
local function pageName(lang, entry)
	--TODO: is there a better way?
	if sub(entry, 1, 1) == "*" then
		entry = lang:makeEntryName(entry)
		return "Reconstruction:" .. lang:getCanonicalName() .. "/" .. sub(entry, 2)
	else
		return lang:makeEntryName(entry)
	end
end

--Given an etymon param, return its parts.
local function getParts(lang, etymonParam, normalized)
	local parts, etymonLang, etymonPage, etymonId = split(etymonParam, ">", true, true)
	if #parts == 2 then
		--Assume language is the same as the template call if none is provided.
		etymonLang, etymonPage, etymonId = lang, unpack(parts)
	else
		etymonLang, etymonPage, etymonId = unpack(parts)
		etymonLang = get_lang(etymonLang, true, true)
	end

	if normalized then
		etymonLang = etymonLang:getFull()
		etymonPage = pageName(etymonLang, etymonPage)
	end

	return etymonLang, etymonPage, etymonId
end

local paramsOf = {}
--Given an etymon, scrape the page and get its parameters.
--This function returns either: a table of the params, "missing", "redlink", or "nolink"
local function getParams(lang, etymonParam)
	--Get normalized parts of the etymon parameter.
	local etymonLang, etymonPage, etymonId = getParts(lang, etymonParam, true)
	--"?" is a special value that unlinks the page.
	if etymonId == "?" then
		return "nolink"
	end

	--Find the parameters by scraping etymonPage.
	--Store data in the paramsOf[] table to save time in case the same etymon is accessed again.
	--The key is a normalized version of etymonParam.
	local key = etymonLang:getFullCode() .. ">" .. etymonPage .. ">" .. etymonId
	if paramsOf[key] == nil then
		local pageContent = new_title(etymonPage):getContent()

		if pageContent == nil then
			paramsOf[key] = "redlink"
		else
			--Find the template on the page.
			for name, templateArgs in findTemplates(pageContent) do
				if name == "etymon" then
					--Might as well store whatever we find along the way to potentially save time later.
					paramsOf[templateArgs[1] .. ">" .. etymonPage .. ">" .. templateArgs["id"]] = templateArgs
					if templateArgs[1] == etymonLang and templateArgs["id"] == etymonId then
						--This "break" saves time only if etymonPage is only ever accessed once.
						--Since this is usually true, it is probably beneficial overall.
						break
					end
				end
			end
			--If scraping did not produce anything, the template must be missing from the page.
			if paramsOf[key] == nil then
				paramsOf[key] = "missing"
			end
		end
	end

	return paramsOf[key]
end

-- [tag]: {abbreviation, label glossary anchor, start text, start text plus, middle text}
-- Note: the keywords `afeq`, `conf`, and `unc` are also recognized, but do not use this dictionary.
local keywordDict = {
	["from"] = {false, false, "From", "From", "from"},
	["inh"] = {false, false, "From", "[[Appendix:Glossary#inherited|Inherited]] from", "from"},
	["af"] = {false, false, "From", "From", "from"},
	["bor"] = {"bor.", "loanword", "Borrowed from", "[[Appendix:Glossary#loanword|Borrowed]] from", "borrowed from"},
	["lbor"] = {"lbor.", "learned_borrowing", "Learned borrowing from", "[[Appendix:Glossary#learned_borrowing|Learned borrowing]] from", "borrowed from"},
	["der"] = {"der.", "derived_terms", "Derived from", "[[Appendix:Glossary#derived_terms|Derived]] from", "from"},
	["calque"] = {"calq.", "calque", "Calque of", "[[Appendix:Glossary#calque|Calque]] of", "a calque of"},
	["influence"] = {"influ.", "contamination", "", "", ""}
}
--Keep track of what's already been added to the tree and the depth reached.
--Use the normalized etymon parameters as the key.
local alreadySeen = {}
--This function takes an etymon and recursively builds a tree to display in an entry.
local function etyTree(currTitle, lang, args, isTopLevel, isUncertain, label)
	local maxDepth = 0
	local subtree, subtreeDepth, etymonLang, etymonPage, etymonParams
	local subtrees = {}
	local normLang = lang:getFullCode()
	local normTitle = pageName(lang, currTitle)
	local key = normLang .. ">" .. normTitle .. ">" .. args["id"]
	local derType, confidence, ignoreEtymons = "from", "conf", false

	--If the entry has already been included in the tree, then don't process it again.
	if alreadySeen[key] == nil then
		--Loop over each parameter in the current template.
		for i, param in ipairs(args) do
			if i > 1 and find(param, ">") and not ignoreEtymons then
				--We want to display the unnormalized language and page title.
				--We can't use args[1] here because that would give the normalized language code.
				etymonLang, etymonPage = getParts(lang, param, false)

				--Scrape the page and get the parameters.
				etymonParams = getParams(lang, param)

				--Check if the function returned an error code.
				if type(etymonParams) == "table" then
					--Recurse into the etymon and append its tree to the list of subtrees.
					subtree, subtreeDepth = etyTree(etymonPage, etymonLang, etymonParams, false, confidence == "unc", derType)
					insert(subtrees, subtree)
					maxDepth = max(maxDepth, subtreeDepth)
				end
			elseif i > 1 then
				--Reached a keyword.
				if param == "conf" or param == "unc" then
					confidence = param
				elseif keywordDict[param] ~= nil then
					ignoreEtymons = false
					confidence = "conf"
					derType = param
				elseif param == "afeq" then
					ignoreEtymons = true
				else
					error("Received unknown keyword: " .. param)
				end
			end
		end

		--Add the page to alreadySeen.
		alreadySeen[key] = true
	end

	--Create term block.
	local link
	if isTopLevel then
		link = lang:getCanonicalName() .. " " .. full_link({lang=lang, alt="'''" .. currTitle .. "'''", tr=lang:transliterate(currTitle)}, "term")
	else
		link = lang:getCanonicalName() .. " " .. full_link({lang=lang, term=currTitle, id=args["id"]}, "term")
	end

	--Create tree.
	local tree = "<div style=\"display:inline;z-index:1;vertical-align:bottom;position:relative;margin-left:4px;margin-right:4px\"><p style=\"margin-top:8px;margin-bottom:8px;display:inline-block;position:relative;padding:5px 10px;background-color:#fffbf2;border:1px solid #ccc;border-radius:4px\">" .. link

	--Add a short top connector if multiple subtrees exist.
    if #subtrees >= 2 then
		tree = tree .. "<span style=\"position:absolute;z-index:-1;top:-10px;bottom:0px;right:50%;border-right:2px solid #9e9e9e\"></span>"
    end

	tree = tree .. "</p>"

	--Add derivation and uncertainty labels.
	if (label ~= "" and keywordDict[label][1] ~= false) or isUncertain then
		tree = tree .. "<span style=\"position:absolute;z-index:2;left:50%;transform:translate(-50%);top:calc(100% + 8.5px);border-radius:2px;background-color:rgba(234,255,255,0.85);font-size:12px;height:10px;line-height:10px\">"
		if label ~= "" and keywordDict[label][1] ~= false then
			tree = tree .. "<span>''[[Appendix:Glossary#" .. keywordDict[label][2] .. "|<span style=\"color:black\">" .. keywordDict[label][1] .. "</span>]]''</span>"
			if isUncertain then
				--Add uncertainty label next to the derivation label.
				tree = tree .. "<span style=\"position:absolute;left:calc(100% + 2px);top:50%;transform:translate(0,-48%);font-size:10px;border-radius:2px;background-color:rgba(255,224,240,0.85);padding:1px 2px\"><span class=\"desc-arr\" title=\"uncertain\"><b>?</b></span></span>"
			end
		elseif isUncertain then
			--Add uncertainty label in the middle.
			tree = tree .. "<span style=\"position:absolute;left:50%;top:50%;transform:translate(calc(-50% - 1px),-48%);font-size:10px;border-radius:2px;background-color:rgba(255,224,240,0.85);padding:1px 2px\"><span class=\"desc-arr\" title=\"uncertain\"><b>?</b></span></span>"
		end
		tree = tree .. "</span>"
	end

	tree = tree .. "</div>"

	--Add line break if parents exist.
	if #subtrees >= 1 then
		tree = "<br>" .. tree
	end

	--Append subtrees. For cleaner HTML, only add text-align:center div when necessary.
	local subtreeString = ""
	if #subtrees == 1 then
        --Add a long bottom connector to the subtree.
        --Use sub() to insert it right before the </div>, which is a little hacky.
        subtreeString = sub(subtrees[1], 1, -7) .. "<span style=\"position:absolute;z-index:-1;right:50%;top:0;height:50px;border-right:2px solid #9e9e9e\"></span></div>"
	elseif #subtrees >= 2 then
		for i,v in ipairs(subtrees) do
			if i == 1 then
				--Add left connector.
				v = v .. "<span style=\"position:absolute;z-index:0;left:calc(50% - 2px);right:-20px;top:calc(100% - 9px);bottom:0;border-bottom:2px solid #9e9e9e;border-left:2px solid #9e9e9e;border-bottom-left-radius:4px\"></span>"
			elseif i == #subtrees then
				--Add right connector.
				v = v .. "<span style=\"position:absolute;z-index:0;left:-20px;right:50%;top:calc(100% - 9px);bottom:0;border-bottom:2px solid #9e9e9e;border-right:2px solid #9e9e9e;border-bottom-right-radius:4px\"></span>"
			else
				--Add middle connector.
				v = v .. "<span style=\"position:absolute;z-index:0;left:-20px;right:-20px;top:calc(100% - 9px);bottom:0;border-bottom:2px solid #9e9e9e\"></span>"
			end
			subtreeString = subtreeString .. "<div style=\"position:relative;display:inline-block;text-align:center\">" .. v .. "</div>"
		end
	end
    tree = subtreeString .. tree

	--Add outer divs.
	if isTopLevel then
		tree = "<div style=\"flex-grow:1;overflow:auto\"><div style=\"all:initial;font-family:sans-serif;font-size:14px;color:black;white-space:nowrap;line-height:1.1;display:inline-block;text-align:center\">" .. tree .. "</div></div>"
		tree = "<div class=\"NavFrame\"><div class=\"NavHead\">Etymology tree</div><div class=\"NavContent\" style=\"text-align:left;padding:0.5em\">" .. tree .. "</div></div>"
	end

	return tree, maxDepth + 1
end

--This function takes an etymon and generates some text to display in an entry.
--Currently, it is only able to handle simple combinations of parameters.
local function etyText(title, lang, args, usePlusTemplates, maxDepth)
	local text = ""
	local depth = 1
	local alreadyWritten = {}
	local key, currLang, group, groupType, groupConfidence, confidence, derType, foundGroup, complexParams, ignoreEtymons, etymonLang, etymonTitle, etymonId
	local params = args

	--Loop and continuously expand the sentence until we reach the end of the chain.
	while not maxDepth or depth <= maxDepth do
		group, groupType, groupConfidence, confidence, derType, foundGroup, complexParams, ignoreEtymons, currLang = {}, "from", "conf", "conf", "from", false, false, false, lang
		key = lang:getFullCode() .. ">" .. pageName(lang, title) .. ">" .. args["id"]
		--Stop if we encounter an already-seen term.
		if alreadyWritten[key] ~= nil then
			break
		end
		alreadyWritten[key] = true
		
		for i, param in ipairs(params) do
			if i > 1 and find(param, ">") and not ignoreEtymons then
				--The text should only continue if `args` is either (not including `influence` or `afeq` etymons):
				--A single etymon, or single `af` group. Otherwise the parameters are too "complex" and are rejected.
				--TODO: add smarter handling for complex parameters.
				if foundGroup or (#group == 1 and derType ~= "af") then
					complexParams = true
					break
				end
				groupType = derType
				if confidence == "unc" then
					groupConfidence = "unc"
				end
				insert(group, param)
			elseif i > 1 then
				--Reached a keyword.
				--Check if af was only given one parameter, which doesn't make sense.
				if #group == 2 and derType == "af" then
					complexParams = true
					break
				end
				if param == "unc" then
					confidence = param
				elseif param == "afeq" or param == "influence" then
					ignoreEtymons = true
					if #group == 1 then
						foundGroup = true
					end
				elseif keywordDict[param] ~= nil then
					ignoreEtymons = false
					confidence = "conf"
					derType = param
					if #group == 1 then
						foundGroup = true
					end
				else
					error("Received unknown keyword: " .. param)
				end
			end
		end
		if complexParams or #group == 0 then
			break
		end
		if #group == 1 then
			params = getParams(lang, group[1])
		end
		if text == "" then
			--Start the sentence.
			if groupConfidence == "conf" and not usePlusTemplates then
				text = keywordDict[groupType][3]
			elseif groupConfidence == "conf" and usePlusTemplates then
				text = keywordDict[groupType][4]
			else
				text = "Possibly " .. keywordDict[groupType][5]
			end
		else
			--Add a phrase onto the sentence.
			if groupConfidence == "conf" then
				text = text .. ", " .. keywordDict[groupType][5]
			else
				text = text .. ", possibly " .. keywordDict[groupType][5]
			end
		end
		--Add the links.
		for i = 1,#group do
			etymonLang, etymonTitle, etymonId = getParts(lang, group[i], false)
			if etymonLang ~= currLang then
				group[i] = etymonLang:makeWikipediaLink() .. " " .. full_link({lang=etymonLang, term=etymonTitle, id=etymonId}, "term")
				currLang = etymonLang
			else
				group[i] = full_link({lang=etymonLang, term=etymonTitle, id=etymonId}, "term")
			end
		end
		text = text .. " " .. concat(group, " + ")
		depth = depth + 1
		if #group == 2 then
			break
		end
		lang = etymonLang
		title = etymonTitle
		if type(params) ~= "table" then
			break
		end
	end
	--Add period at the end of the sentence.
	if text ~= "" then
		text = text .. "."
	end
	return text
end

function export.main(frame)
	local args = frame:getParent().args
	if args["id"] == nil then
		error("You need to provide an `id` parameter!")
	end
	local lang = get_lang(args[1], true, true)

	--The `title` parameter is used for overriding the page title.
	local title = args["title"]
	if args["title"] == nil then
		title = get_current_title().prefixedText
	end

	--Add anchor to output.
	local output = {"<ul id=\"" .. lang:getCanonicalName() .. ":_" .. args["id"] .. "\"></ul>"}

	local currNode = args[1] .. ">" .. title .. ">" .. args["id"]
	paramsOf[currNode] = args

	--Insert tree.
	if args["tree"] ~= nil then
        local tree, _ = etyTree(title, lang, args, true, false, "")
		insert(output, tree)
	end

	--Insert text.
	if args["text"] == "++" then
		insert(output, etyText(title, lang, args, true, false))
	elseif args["text"] == "+" then
		insert(output, etyText(title, lang, args, true, 1))
	elseif args["text"] == "-" then
		insert(output, etyText(title, lang, args, false, 1))
	elseif args["text"] ~= nil then
		insert(output, etyText(title, lang, args, false, false))
	end

	return concat(output)
end

return export