Module:Src

From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Jump to navigationJump to search

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

--[[
Src — inline citations that are also queryable data.

Every {{Src}} does two jobs at once. It renders a marker a reader can follow,
and it emits a Semantic MediaWiki subobject describing what the citation
actually points at. The subobject is the reason this module exists: rendering a
footnote is easy, but asking "every claim on this wiki attested by somebody who
was actually on the record" needs structure sitting behind the marker.

On not using the Cite extension: a named <ref> is byte-identical everywhere it
is reused, so it cannot express "this video at 14:32" and "the same video at
21:07". Here resource-plus-offset is the ordinary case rather than an edge one,
which rules Cite out on mechanics, not on taste.

Each renderer takes the argument table and returns either:
  a table  { link = <wikitext>, props = <ordered pairs> }  on success
  a string                                                  an error message

props is an ordered list of {property, value} pairs rather than a keyed table,
so the emitted wikitext comes out in a stable order. That keeps page diffs
readable when somebody edits an unrelated line.
]]

local p = {}

local ETHERSCAN = 'https://etherscan.io'

--------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------

--- Parse a timecode into whole seconds.
--
-- Accepts "14:32", "1:02:03", or a bare count of seconds.
--
-- @param raw string|nil Raw parameter value.
-- @return number|nil Seconds, or nil when unparseable. Unparseable degrades to
--   a citation without a timecode rather than an error blot in the middle of
--   an article — a typo should not cost the reader the whole sentence.
local function parseTimecode( raw )
	if not raw or raw == '' then
		return nil
	end
	local total = 0
	local parts = mw.text.split( mw.text.trim( raw ), ':', true )
	for _, part in ipairs( parts ) do
		local n = tonumber( part )
		if not n or n < 0 then
			return nil
		end
		total = total * 60 + n
	end
	return math.floor( total )
end

--- Render seconds back as a timecode: 872 becomes "14:32", 3723 "1:02:03".
--
-- @param seconds number
-- @return string
local function formatTimecode( seconds )
	local h = math.floor( seconds / 3600 )
	local m = math.floor( ( seconds % 3600 ) / 60 )
	local s = seconds % 60
	if h > 0 then
		return string.format( '%d:%02d:%02d', h, m, s )
	end
	return string.format( '%d:%02d', m, s )
end

--- Shorten a long opaque identifier for display, keeping both ends.
--
-- Hashes are recognised by their head and tail, so an ellipsis in the middle
-- stays useful where a plain truncation does not.
--
-- @param value string
-- @param keep number How many leading characters to show.
-- @return string
local function abbreviate( value, keep )
	if #value <= keep + 5 then
		return value
	end
	return value:sub( 1, keep ) .. '…' .. value:sub( -4 )
end

--- Collect template and invoke arguments into one trimmed table.
--
-- Values arriving from a template wrapper carry whitespace from the wikitext
-- that called it; everything downstream assumes trimmed input.
--
-- @param frame table
-- @return table
local function getArgs( frame )
	local args = {}
	local parent = frame:getParent()
	if parent and parent.args then
		for k, v in pairs( parent.args ) do
			args[k] = v
		end
	end
	for k, v in pairs( frame.args ) do
		args[k] = v
	end
	for k, v in pairs( args ) do
		if type( v ) == 'string' then
			args[k] = mw.text.trim( v )
		end
	end
	return args
end

--- Non-empty string, or nil.
local function present( value )
	if value and value ~= '' then
		return value
	end
	return nil
end

--------------------------------------------------------------------------
-- Renderers, one per source type
--------------------------------------------------------------------------

local renderers = {}

--- A moment in an archived video — the workhorse.
--
-- Cites the exact second at which someone asserts the thing. The #t= fragment
-- is read by the HLS player gadget in MediaWiki:Common.js, which seeks the
-- Release page's player to that second on arrival, so following the citation
-- lands the reader on the assertion itself rather than at 0:00.
renderers.video = function( args )
	local cid = present( args.cid ) or present( args[2] )
	if not cid then
		return 'video citation needs cid='
	end

	local props = {
		{ 'Has source type', 'video' },
		{ 'Cites release', 'Release:' .. cid },
	}

	local seconds = parseTimecode( args.t )
	if not seconds then
		return {
			link = '[[Release:' .. cid .. '|▶ video]]',
			props = props,
		}
	end

	table.insert( props, { 'Has timecode', tostring( seconds ) } )
	return {
		link = '[[Release:' .. cid .. '#t=' .. seconds .. '|▶ '
			.. formatTimecode( seconds ) .. ']]',
		props = props,
	}
end

--- A particular edit, by somebody in a position to know.
--
-- Distinct from testimony: this points at a dated, immutable revision rather
-- than at a person's standing assertion. The revision cannot be edited out
-- from under the citation.
renderers.edit = function( args )
	local oldid = present( args.oldid )
	if not oldid or not tonumber( oldid ) then
		return 'edit citation needs a numeric oldid='
	end

	local props = {
		{ 'Has source type', 'edit' },
		{ 'Cites revision', oldid },
	}

	local by = present( args.by )
	local label = 'edit'
	if by then
		table.insert( props, { 'Asserted by', 'User:' .. by } )
		label = 'edit by ' .. by
	end

	return {
		link = '[[Special:PermanentLink/' .. oldid .. '|✎ ' .. label .. ']]',
		props = props,
	}
end

--- An on-chain fact.
--
-- Alone among these types, a chain citation carries its own timestamp: it can
-- show that something was asserted *before* a given block, which no amount of
-- wiki history can establish on its own.
renderers.chain = function( args )
	local tx = present( args.tx )
	local block = present( args.block )
	if not tx and not block then
		return 'chain citation needs tx= or block='
	end

	local props = { { 'Has source type', 'chain' } }

	if tx then
		table.insert( props, { 'Has transaction', tx } )
		return {
			link = '[' .. ETHERSCAN .. '/tx/' .. tx .. ' ⛓ ' .. abbreviate( tx, 8 ) .. ']',
			props = props,
		}
	end

	table.insert( props, { 'At blockheight', block } )
	return {
		link = '[' .. ETHERSCAN .. '/block/' .. block .. ' ⛓ block ' .. block .. ']',
		props = props,
	}
end

--- First-party assertion. "This is my band, and I know we play Bob's Tuesday."
--
-- Deliberately a first-class type rather than something to be laundered into
-- looking like a published source. Its weight comes from who said it, which is
-- exactly what the trust graph is for.
renderers.testimony = function( args )
	local by = present( args.by )
	if not by then
		return 'testimony citation needs by='
	end

	local rel = present( args.rel ) or 'witness'
	return {
		link = '[[User:' .. by .. '|' .. by .. ', ' .. rel .. ']]',
		props = {
			{ 'Has source type', 'testimony' },
			{ 'Asserted by', 'User:' .. by },
			{ 'Has relationship', rel },
		},
	}
end

--- A recording or release as a whole, with no particular moment singled out.
renderers.recording = function( args )
	local page = present( args.page ) or present( args[2] )
	if not page then
		return 'recording citation needs page='
	end
	return {
		link = '[[' .. page .. '|♫ ' .. ( present( args.label ) or 'recording' ) .. ']]',
		props = {
			{ 'Has source type', 'recording' },
			{ 'Cites release', page },
		},
	}
end

--- An ordinary published source somewhere off-wiki.
renderers.published = function( args )
	local url = present( args.url )
	if not url then
		return 'published citation needs url='
	end
	return {
		link = '[' .. url .. ' ' .. ( present( args.title ) or 'source' ) .. ']',
		props = {
			{ 'Has source type', 'published' },
			{ 'Cites url', url },
		},
	}
end

--------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------

--- Render an error in place, visibly but without killing the page.
local function errorSpan( message )
	return '<span class="error pp-src-error">Src: ' .. message .. '</span>'
end

--- Build the subobject wikitext for a citation.
--
-- Anonymous subobjects are fine here: nothing needs to address an individual
-- citation by name, and auto-generated ids keep authors from having to invent
-- unique keys for every footnote on a page.
local function subobject( frame, kind, props )
	local parts = {}
	for _, pair in ipairs( props ) do
		-- Only the first '=' separates name from value in a parser function
		-- argument, so values containing '=' (query strings, mostly) survive.
		-- A literal '|' would not, hence the escape.
		local value = tostring( pair[2] ):gsub( '|', '&#124;' )
		table.insert( parts, '|' .. pair[1] .. '=' .. value )
	end
	table.insert( parts, '|@category=Citations' )
	table.insert( parts, '|@sortkey=' .. kind )
	return frame:preprocess( '{{#subobject:' .. table.concat( parts ) .. '}}' )
end

--- {{Src|<type>|...}}
--
-- @param frame table
-- @return string Marker wikitext, with the subobject attached invisibly.
function p.src( frame )
	local args = getArgs( frame )
	local kind = present( args[1] )
	if not kind then
		return errorSpan( 'no source type given' )
	end

	kind = kind:lower()
	local renderer = renderers[kind]
	if not renderer then
		return errorSpan( 'unknown source type "' .. kind .. '"' )
	end

	local result = renderer( args )
	if type( result ) == 'string' then
		return errorSpan( result )
	end

	-- No literal brackets around the link. Wiki links already arrive wrapped
	-- in [[...]], and a second pair produced [[[...]]], which the parser gives
	-- up on and prints raw. Brackets are a styling concern anyway — .pp-src
	-- can draw them in CSS without touching the link syntax.
	return '<sup class="pp-src pp-src-' .. kind .. '">' .. result.link .. '</sup>'
		.. subobject( frame, kind, result.props )
end

return p