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( '|', '|' )
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