Module:STARDIT sources

From STARDIT
Jump to navigation Jump to search

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

--[[
Module:STARDIT sources

Renders sources for STARDIT reports. Two functions:

  cite   - a small pointer shown after an entry, e.g. [S1] or [source]
           {{#invoke:STARDIT sources|cite|src=...|page=...|quote=...}}
           src   = one or more source keys (S1, S2...) and/or web addresses,
                   separated by semicolons
           page  = optional page number(s)
           quote = optional exact words from the source that support the entry

  source - one entry in a report's Sources list
           {{#invoke:STARDIT sources|source|src_key=...|src_url=...|...}}

Design rules:
  * Fails open: anything not understood is shown as plain text, never hidden,
    and the page is added to a tracking category for review.
  * Shows what was entered as entered: titles, dates and quotes are not
    reformatted or filled in.
  * Field names map to Wikidata reference properties
    (see Template:STARDIT source for the table).
]]

local p = {}

local TRACKING_CAT = '[[Category:STARDIT pages with unrecognised source pointers]]'

-- Quotation marks people often paste around a quote (double marks only,
-- so a real trailing apostrophe such as "patients'" is never removed).
local WRAP_MARKS = { '"', '\226\128\156', '\226\128\157', '\194\171', '\194\187' }

local function clean(s)
	if s == nil then return nil end
	s = mw.text.trim(tostring(s))
	if s == '' then return nil end
	return s
end

local function isUrl(s)
	return s:match('^[Hh][Tt][Tt][Pp][Ss]?://') ~= nil
end

-- Source keys are S followed by a number (S1, S2, S10). "s01" becomes "S1".
local function normKey(s)
	local n = s:match('^[Ss](%d+)$')
	if n then return 'S' .. tostring(tonumber(n)) end
	return nil
end

local function isContentPage()
	local t = mw.title.getCurrentTitle()
	return t ~= nil and t.namespace == 0
end

-- Make a web address safe inside [url label] link syntax.
local function sanitiseUrl(u)
	u = u:gsub(' ', '%%20')
	u = u:gsub('%[', '%%5B')
	u = u:gsub('%]', '%%5D')
	return u
end

-- Percent-encode everything except letters, digits and . ~ _
-- (dash, comma and ampersand MUST be encoded inside a text fragment).
local function pctEncode(s)
	return (s:gsub('[^%w%.~_]', function(c)
		return string.format('%%%02X', string.byte(c))
	end))
end

local function stripWrapMarks(q)
	local changed = true
	while changed and q ~= '' do
		changed = false
		for _, m in ipairs(WRAP_MARKS) do
			if q:sub(1, #m) == m then
				q = mw.text.trim(q:sub(#m + 1))
				changed = true
			end
			if #q >= #m and q:sub(-#m) == m then
				q = mw.text.trim(q:sub(1, -#m - 1))
				changed = true
			end
		end
	end
	return q
end

local function cleanQuote(q)
	q = clean(q)
	if not q then return nil end
	q = q:gsub('%s+', ' ')
	return clean(stripWrapMarks(q))
end

-- Short quotes are matched whole; longer ones by their first and last four
-- words, which survives line breaks and formatting in between.
local function textFragment(q)
	local words = mw.text.split(q, ' ', true)
	if #words <= 8 then
		return 'text=' .. pctEncode(q)
	end
	local first = table.concat(words, ' ', 1, 4)
	local last = table.concat(words, ' ', #words - 3, #words)
	return 'text=' .. pctEncode(first) .. ',' .. pctEncode(last)
end

local function isPdf(u)
	local l = u:lower()
	local path = l:gsub('[#?].*$', '')
	return path:match('%.pdf$') ~= nil or l:find('/pdf/', 1, true) ~= nil
end

-- Build the link for a web-address pointer: jump to the page of a PDF,
-- or highlight the quoted passage on a web page.
local function deepLink(u, page, quote)
	u = sanitiseUrl(u)
	if isPdf(u) then
		local n = page and page:match('%d+')
		if n and not u:find('#', 1, true) then
			return u .. '#page=' .. n
		end
		return u
	end
	if quote then
		if u:find('#', 1, true) then
			return u .. ':~:' .. textFragment(quote)
		end
		return u .. '#:~:' .. textFragment(quote)
	end
	return u
end

local function sup(inner)
	return '<sup class="stardit-src" style="white-space:nowrap;">' .. inner .. '</sup>'
end

local function pageText(page)
	if page:match('^%d') then
		return 'p.&nbsp;' .. mw.text.nowiki(page)
	end
	return mw.text.nowiki(page)
end

function p.cite(frame)
	local args = frame.args
	local src = clean(args.src)
	local page = clean(args.page)
	local quote = cleanQuote(args.quote)

	if not src and not page and not quote then
		return ''
	end

	local out = {}
	local flag = false

	if src then
		for _, token in ipairs(mw.text.split(src, ';', true)) do
			token = clean(token)
			if token then
				local key = normKey(token)
				if key then
					table.insert(out, sup('[[#src-' .. key .. '|&#91;' .. key .. '&#93;]]'))
				elseif isUrl(token) then
					table.insert(out, sup('[' .. deepLink(token, page, quote) .. ' &#91;source&#93;]'))
				else
					table.insert(out, sup('&#91;' .. mw.text.nowiki(token) .. '&#93;'))
					flag = true
				end
			end
		end
	else
		-- a page number or quote with nothing to point at
		flag = true
	end

	local details = {}
	if page then table.insert(details, pageText(page)) end
	if quote then table.insert(details, '\226\128\156' .. mw.text.nowiki(quote) .. '\226\128\157') end

	local result = table.concat(out, '')
	if #details > 0 then
		result = result .. ' <span class="stardit-src-detail" style="font-size:85%;opacity:0.85;">('
			.. table.concat(details, ': ') .. ')</span>'
	end
	if flag and isContentPage() then
		result = result .. TRACKING_CAT
	end
	return result
end

function p.source(frame)
	local a = frame.args
	local key = clean(a.src_key)
	local url = clean(a.src_url)
	local title = clean(a.src_title)
	local publisher = clean(a.src_publisher)
	local date = clean(a.src_date)
	local accessed = clean(a.src_accessed)
	local arcUrl = clean(a.src_archive_url)
	local arcDate = clean(a.src_archive_date)
	local doi = clean(a.src_doi)
	local stype = clean(a.src_type)

	if not (key or url or title or doi or publisher) then
		return ''
	end

	local flag = false
	local nkey = key and normKey(key)
	local pieces = {}

	if nkey then
		table.insert(pieces, "'''&#91;" .. nkey .. "&#93;'''")
	elseif key then
		table.insert(pieces, "'''&#91;" .. mw.text.nowiki(key) .. "&#93;'''")
		flag = true
	end

	local titleText = title and mw.text.nowiki(title)
	if url and isUrl(url) then
		table.insert(pieces, '[' .. sanitiseUrl(url) .. ' ' .. (titleText or mw.text.nowiki(url)) .. '].')
	else
		if titleText then table.insert(pieces, "''" .. titleText .. "''.") end
		if url then
			table.insert(pieces, mw.text.nowiki(url) .. '.')
			flag = true
		end
	end

	local pubBits = {}
	if publisher then table.insert(pubBits, mw.text.nowiki(publisher)) end
	if date then table.insert(pubBits, mw.text.nowiki(date)) end
	if #pubBits > 0 then table.insert(pieces, table.concat(pubBits, ', ') .. '.') end

	if doi then
		local d = doi:gsub('^[Hh][Tt][Tt][Pp][Ss]?://[Dd]?[Xx]?%.?[Dd][Oo][Ii]%.[Oo][Rr][Gg]/', '')
		d = d:gsub('^[Dd][Oo][Ii]:%s*', '')
		table.insert(pieces, 'doi:[https://doi.org/' .. sanitiseUrl(d) .. ' ' .. mw.text.nowiki(d) .. '].')
	end

	if stype then table.insert(pieces, mw.text.nowiki(stype) .. '.') end
	if accessed then table.insert(pieces, 'Accessed ' .. mw.text.nowiki(accessed) .. '.') end

	if arcUrl and isUrl(arcUrl) then
		local s = '[' .. sanitiseUrl(arcUrl) .. ' Archived copy]'
		if arcDate then s = s .. ' (' .. mw.text.nowiki(arcDate) .. ')' end
		table.insert(pieces, s .. '.')
	elseif arcUrl then
		table.insert(pieces, 'Archive: ' .. mw.text.nowiki(arcUrl) .. '.')
		flag = true
	elseif arcDate then
		table.insert(pieces, 'Archived ' .. mw.text.nowiki(arcDate) .. '.')
	end

	local idAttr = nkey and (' id="src-' .. nkey .. '"') or ''
	local result = '<div class="stardit-source"' .. idAttr .. ' style="margin:0.3em 0;">'
		.. table.concat(pieces, ' ') .. '</div>'
	if flag and isContentPage() then
		result = result .. TRACKING_CAT
	end
	return result
end

return p