Module:Studio sessions: Difference between revisions

From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Jump to navigationJump to search
Magent (talk | contribs)
Store subobjects through the preprocessor: without Semantic Scribunto, a #subobject called from Lua renders and is then forgotten (via update-page on MediaWiki MCP Server)
Magent (talk | contribs)
Strip leftover strip markers and SMW span markup from query results, or the record name is not a name (via update-page on MediaWiki MCP Server)
Line 160: Line 160:
record, "?Has license", "link=none", "default=",
record, "?Has license", "link=none", "default=",
} )
} )
licence = mw.text.unstrip( licence or "" ):gsub( "<[^>]+>", "" )
licence = mw.text.unstrip( licence or "" )
licence = licence:gsub( "\127[^\127]*\127", "" ):gsub( "<[^>]+>", "" )
local lower = licence:lower()
local lower = licence:lower()
local free = lower:find( "cc " ) or lower:find( "creative commons" )
local free = lower:find( "cc " ) or lower:find( "creative commons" )
Line 192: Line 193:
"limit=200",
"limit=200",
} )
} )
-- Two cleanups, both learned the hard way. unstrip() resolves the strip
-- marker the parser hands back, but markers can survive nested inside it,
-- and SMW wraps values in span markup even with link=none. Left in, the
-- record's name is not a name: "[[" .. that .. "]]" rendered as a warning
-- about a property label containing "[", followed by a stray "]]".
raw = mw.text.unstrip( raw or "" )
raw = mw.text.unstrip( raw or "" )
raw = raw:gsub( "\127[^\127]*\127", "" ):gsub( "<[^>]+>", "" )
if trim( raw ) == "" then
if trim( raw ) == "" then
return ""
return ""

Revision as of 21:39, 17 September 2026

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

-- Studio sessions: who played what, on which track, on which record.
--
-- The data comes from arthel, where it exists because the Rabbithole player
-- needs it to follow a picker from one track to the next. Here it answers the
-- other direction: given a musician, which records are they on.
--
-- Each track records one subobject per player, because a subobject holds a set
-- of values and not a set of pairs -- "Harry Clark" and "mandolin" have to sit
-- in the same subobject to stay attached to each other. One per player per
-- track is what keeps that true.
--
-- Note for future editors: do not wrap this header in a --[==[ ]==] block
-- comment containing wiki link syntax. Lua reads the inner brackets as nested
-- long brackets and refuses to load the module.

local p = {}

-- Parameters of {{Studio track}} that describe the session rather than name a
-- player. Anything else is a musician.
local NOT_A_PLAYER = {
	track = true,
	number = true,
	studio = true,
	recorded = true,
	engineer = true,
	record = true,
}

local function trim( s )
	return ( s or "" ):gsub( "^%s*(.-)%s*$", "%1" )
end

local function split( raw )
	local out = {}
	for part in ( ( raw or "" ) .. "," ):gmatch( "%s*(.-)%s*," ) do
		if part ~= "" then
			table.insert( out, part )
		end
	end
	return out
end

-- The arguments of the template that invoked us.
--
-- #invoke gives the module only the arguments written in the invoke itself,
-- which for a one-line wrapper like {{Studio track}} is none at all. The
-- template's own parameters are on the parent frame. Reading frame.args
-- directly is why the first version of this answered "no track name given" to
-- a call that plainly gave one.
local function callArgs( frame )
	local parent = frame:getParent()
	if parent then
		-- pairs(), not next(). Scribunto's args table is lazy: values arrive
		-- through a metatable and the table itself is empty, so next() reports
		-- no arguments on a call that has plenty. That cost a second round of
		-- "no track name given".
		for _ in pairs( parent.args ) do
			return parent.args
		end
	end
	return frame.args
end

-- The players of a track.
--
-- Lua hands named arguments back in an arbitrary order, so they are sorted by
-- name: an order nobody chose is better than one that changes between parses.
local function players( args )
	local named = {}
	for name, value in pairs( args ) do
		if type( name ) == "string" and not NOT_A_PLAYER[ name ] then
			table.insert( named, { name = trim( name ), instruments = trim( value ) } )
		end
	end
	table.sort( named, function( a, b ) return a.name < b.name end )
	return named
end

-- A pipe or an equals sign in a value would end the parameter early, so they
-- are written as the templates that stand for them.
local function escape( value )
	return ( value:gsub( "|", "{{!}}" ):gsub( "=", "{{=}}" ) )
end

-- Record one player on one track, as data a query can reach.
--
-- Written as wikitext and run back through the preprocessor rather than called
-- with frame:callParserFunction. This wiki has no Semantic Scribunto, and a
-- #subobject invoked that way renders and is then forgotten: the page looked
-- right and [[Has performer::+]] matched nothing at all. Preprocessing it is
-- what actually stores it.
local function annotate( frame, track, number, player )
	local parts = {
		"Has track name=" .. escape( track ),
		"Has performer=" .. escape( player.name ),
	}
	if number ~= "" then
		table.insert( parts, "Has track number=" .. escape( number ) )
	end
	for _, instrument in ipairs( split( player.instruments ) ) do
		table.insert( parts, "Has instrument=" .. escape( instrument ) )
	end
	frame:preprocess( "{{#subobject:|" .. table.concat( parts, "|" ) .. "}}" )
end

-- One track of a record: its personnel, recorded and shown.
function p.track( frame )
	local args = callArgs( frame )
	local track = trim( args.track )
	if track == "" then
		return '<span class="error">Studio track: no track name given</span>'
	end
	local number = trim( args.number )

	local named = players( args )
	local shown = {}
	for _, player in ipairs( named ) do
		annotate( frame, track, number, player )

		-- The icon comes from the instrument played here, not from whatever the
		-- musician's page calls their main one: a guitarist who played dobro on
		-- one track played dobro on that track.
		local first = split( player.instruments )[1] or ""
		local link = frame:expandTemplate{ title = "m", args = { player.name, first } }
		if player.instruments ~= "" then
			link = link .. ' <span style="color:#6b7566;">' .. player.instruments .. "</span>"
		end
		table.insert( shown, link )
	end

	local label = number ~= "" and ( number .. ". " .. track ) or track
	local session = {}
	for _, field in ipairs( { "recorded", "studio", "engineer" } ) do
		local value = trim( args[ field ] )
		if value ~= "" then
			table.insert( session, field == "engineer" and ( "engineer " .. value ) or value )
		end
	end

	local out = { '<div style="padding:.5em .2em; border-bottom:1px solid #d4dacd;">' }
	table.insert( out, '<div style="font-weight:600;">' .. label .. "</div>" )
	if #shown > 0 then
		table.insert( out, '<div style="font-size:.95em; line-height:1.9;">'
			.. table.concat( shown, " &middot; " ) .. "</div>" )
	end
	if #session > 0 then
		table.insert( out, '<div style="font-size:.8em; color:#6b7566;">'
			.. table.concat( session, " &middot; " ) .. "</div>" )
	end
	table.insert( out, "</div>" )
	return table.concat( out, "\n" )
end

-- Whether a record is under a licence that lets people share it.
--
-- Asked of the record's own page, so the answer lives where the record is
-- described rather than being repeated on every track.
local function freelyLicensed( frame, record )
	local licence = frame:callParserFunction( "#show", {
		record, "?Has license", "link=none", "default=",
	} )
	licence = mw.text.unstrip( licence or "" )
	licence = licence:gsub( "\127[^\127]*\127", "" ):gsub( "<[^>]+>", "" )
	local lower = licence:lower()
	local free = lower:find( "cc " ) or lower:find( "creative commons" )
		or lower:find( "cc0" ) or lower:find( "public domain" )
	return ( free ~= nil ), trim( licence )
end

-- Every track a musician appears on, grouped by record.
--
-- Records anybody may share come first and in full; the rest are listed after,
-- more quietly. That ordering is a claim about what this wiki is for: a
-- recording you can pass to somebody is worth more here than one you can only
-- point at.
function p.appearances( frame )
	local who = trim( callArgs( frame )[1] or frame.args[1] )
	if who == "" then
		who = mw.title.getCurrentTitle().text
	end

	local raw = frame:callParserFunction( "#ask", {
		"[[Has performer::" .. who .. "]]",
		"?Has track name=track",
		"?Has track number=number",
		"?Has instrument=instrument",
		"mainlabel=record",
		"format=plainlist",
		"link=none",
		"sep=@@",
		"template=Studio appearance row",
		"searchlabel=",
		"limit=200",
	} )
	-- Two cleanups, both learned the hard way. unstrip() resolves the strip
	-- marker the parser hands back, but markers can survive nested inside it,
	-- and SMW wraps values in span markup even with link=none. Left in, the
	-- record's name is not a name: "[[" .. that .. "]]" rendered as a warning
	-- about a property label containing "[", followed by a stray "]]".
	raw = mw.text.unstrip( raw or "" )
	raw = raw:gsub( "\127[^\127]*\127", "" ):gsub( "<[^>]+>", "" )
	if trim( raw ) == "" then
		return ""
	end

	-- Rows arrive as "record||track||number||instrument" from
	-- Template:Studio appearance row. Grouping happens here rather than in the
	-- query because a record with six tracks should read as one line, not six.
	local order, byRecord = {}, {}
	for _, row in ipairs( mw.text.split( raw, "@@", true ) ) do
		local record, track, number, instrument = row:match( "^(.-)||(.-)||(.-)||(.*)$" )
		if record and trim( record ) ~= "" then
			-- A subobject's subject prints as "4masks#_abc123". The record is
			-- the part before the fragment; the fragment is SMW's own id.
			record = trim( record:match( "^([^#]*)" ) or record )
			if not byRecord[ record ] then
				byRecord[ record ] = { tracks = {}, instruments = {}, seen = {} }
				table.insert( order, record )
			end
			local entry = byRecord[ record ]
			if trim( track ) ~= "" and not entry.seen[ track ] then
				entry.seen[ track ] = true
				table.insert( entry.tracks, { title = trim( track ), number = tonumber( number ) } )
			end
			for _, one in ipairs( split( instrument ) ) do
				if not entry.instruments[ one ] then
					entry.instruments[ one ] = true
					table.insert( entry.instruments, one )
				end
			end
		end
	end

	local free, paid = {}, {}
	for _, record in ipairs( order ) do
		local entry = byRecord[ record ]
		table.sort( entry.tracks, function( a, b )
			if a.number and b.number then return a.number < b.number end
			return a.title < b.title
		end )

		local titles = {}
		for _, track in ipairs( entry.tracks ) do
			table.insert( titles, track.title )
		end

		local isFree, licence = freelyLicensed( frame, record )
		local instruments = table.concat( entry.instruments, ", " )
		local line = {
			'<div style="padding:.45em .2em; border-bottom:1px solid #d4dacd;">',
			'<div style="font-weight:600;">[[' .. record .. "]]",
		}
		if isFree then
			table.insert( line, ' <span style="font-size:.72em; letter-spacing:.08em;'
				.. ' text-transform:uppercase; color:#3d6b3d; border:1px solid #9bb89b;'
				.. ' border-radius:3px; padding:0 .35em;">' .. ( licence ~= "" and licence or "free to share" )
				.. "</span>" )
		end
		table.insert( line, "</div>" )
		table.insert( line, '<div style="font-size:.9em; color:#3d4a3e;">'
			.. ( instruments ~= "" and instruments .. " &middot; " or "" )
			.. ( #titles == 1 and titles[1] or ( #titles .. " tracks: " .. table.concat( titles, ", " ) ) )
			.. "</div>" )
		table.insert( line, "</div>" )

		table.insert( isFree and free or paid, table.concat( line, "" ) )
	end

	local out = { "== In the studio ==" }
	for _, block in ipairs( free ) do
		table.insert( out, block )
	end
	if #paid > 0 then
		if #free > 0 then
			table.insert( out, '<div style="font-size:.72em; letter-spacing:.14em;'
				.. ' text-transform:uppercase; color:#6b7566; margin:.8em 0 .2em;">'
				.. "Also on</div>" )
		end
		for _, block in ipairs( paid ) do
			table.insert( out, block )
		end
	end
	return table.concat( out, "\n" )
end

return p