|
|
| (3 intermediate revisions by the same user not shown) |
| Line 1: |
Line 1: |
| -- Studio cuts: a particular recording of a composition, and where it turns up. | | -- Studio cuts: a particular recording of a composition, and where it turns up. |
| -- | | -- |
| -- Three things, kept apart because they really are three things: | | -- The module itself lives in the repository, at |
| | -- extensions/PickiPediaContent/lua/pickipedia/studio.lua, and is loaded from |
| | -- there through Scribunto's external library paths. Six hundred lines |
| | -- deciding what every song, record and musician page says is code, and code |
| | -- belongs somewhere that reviews it, remembers why it changed, and can put it |
| | -- back. On a wiki page it had none of those. |
| -- | | -- |
| -- the composition the page itself, in Song: (PickiPedia:Compositions) | | -- So change it there, not here. An edit to this page would be overridden by |
| -- a cut one recording of it, by one lineup, on one day | | -- nothing and reviewed by nobody -- it would simply take effect, across |
| -- an appearance that cut turning up on a record | | -- thirty-six pages, which is the problem. |
| -- | | -- |
| -- A cut has zero or more appearances. Zero is ordinary: plenty of things are | | -- The model it implements is documented at PickiPedia:Compositions: a |
| -- cut in a studio without ever landing on an album. More than one happens the | | -- composition is a page in Song:, a cut is one recording of it by one lineup |
| -- moment a compilation exists, and the point of giving a cut its own identity | | -- on one day, and an appearance is that cut turning up on a record. Templates: |
| -- is that the two appearances can then say they are the same performance | | -- Studio cut, Cut appears on, Record tracks, In the studio. |
| -- rather than each claiming its own.
| |
| -- | | -- |
| -- A cut is identified by block height and studio, which is always | | -- Tests run without a wiki, in the extension's tests/check-studio.lua. |
| -- disambiguating and does not depend on anybody agreeing what to call a
| |
| -- session.
| |
| --
| |
| -- 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 cut}} that describe the session rather than name a
| |
| -- player. Anything else is a musician.
| |
| local NOT_A_PLAYER = {
| |
| cut = true,
| |
| blockheight = true,
| |
| studio = true,
| |
| recorded = true,
| |
| engineer = true,
| |
| record = true,
| |
| number = 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
| |
| | |
| local function callArgs( frame )
| |
| local parent = frame:getParent()
| |
| if parent then
| |
| -- pairs(), not next(): Scribunto's args table is lazy, so next()
| |
| -- reports no arguments on a call that has plenty.
| |
| for _ in pairs( parent.args ) do
| |
| return parent.args
| |
| end
| |
| end
| |
| return frame.args
| |
| end
| |
| | |
| -- Everything the parser wraps around a value that is not part of the value:
| |
| -- the strip marker, SMW's span markup, and SMW's own [[SMW::off]] switch.
| |
| local function plain( text )
| |
| text = mw.text.unstrip( text or "" )
| |
| text = text:gsub( "\127[^\127]*\127", "" ):gsub( "<[^>]+>", "" )
| |
| return ( text:gsub( "%[%[SMW::o[nf]f?%]%]", "" ) )
| |
| end
| |
| | |
| local function escape( value )
| |
| return ( value:gsub( "|", "{{!}}" ):gsub( "=", "{{=}}" ) )
| |
| end
| |
| | |
| local function bare( title )
| |
| return ( title:gsub( "^[^:]+:", "" ) )
| |
| end
| |
| | |
| local function subobject( frame, parts )
| |
| frame:preprocess( "{{#subobject:|" .. table.concat( parts, "|" ) .. "}}" )
| |
| end
| |
| | |
| -- What identifies this cut.
| |
| --
| |
| -- Block height and studio, per the convention: a block height is a time nobody
| |
| -- has to agree on the name of, and two sessions in the same studio at the same
| |
| -- height do not happen.
| |
| --
| |
| -- Failing that, an explicit label, and failing that the record it was cut for
| |
| -- — which is a placeholder, not an identity. Two records naming the same cut
| |
| -- that way cannot be told they are the same performance; that is exactly what
| |
| -- recording the block height fixes.
| |
| local function cutId( args )
| |
| local given = trim( args.cut )
| |
| if given ~= "" then
| |
| return given
| |
| end
| |
| local height, studio = trim( args.blockheight ), trim( args.studio )
| |
| if height ~= "" and studio ~= "" then
| |
| return height .. " " .. studio
| |
| end
| |
| if height ~= "" then
| |
| return height
| |
| end
| |
| return trim( args.record )
| |
| end
| |
| | |
| 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
| |
| | |
| -- One recording of this composition.
| |
| function p.cut( frame )
| |
| local args = callArgs( frame )
| |
| local id = cutId( args )
| |
| if id == "" then
| |
| return '<span class="error">Studio cut: give it a block height and '
| |
| .. 'studio, or a record it was cut for</span>'
| |
| end
| |
| | |
| local record, number = trim( args.record ), trim( args.number )
| |
| | |
| local shown = {}
| |
| for _, player in ipairs( players( args ) ) do
| |
| local parts = {
| |
| "Has cut=" .. escape( id ),
| |
| "Has performer=" .. escape( player.name ),
| |
| }
| |
| for _, instrument in ipairs( split( player.instruments ) ) do
| |
| table.insert( parts, "Has instrument=" .. escape( instrument ) )
| |
| end
| |
| subobject( frame, parts )
| |
| | |
| -- The icon is the instrument played on this cut, not whatever the
| |
| -- musician's page calls their main one.
| |
| 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
| |
| | |
| -- The common case: a cut made for one record says so here, rather than
| |
| -- needing a second call to state the obvious.
| |
| if record ~= "" then
| |
| local parts = { "Has cut=" .. escape( id ), "Has record=" .. escape( record ) }
| |
| if number ~= "" then
| |
| table.insert( parts, "Has track number=" .. escape( number ) )
| |
| end
| |
| subobject( frame, parts )
| |
| end
| |
| | |
| 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
| |
| if trim( args.blockheight ) ~= "" then
| |
| table.insert( session, "block " .. trim( args.blockheight ) )
| |
| end
| |
| | |
| local heading
| |
| if record ~= "" then
| |
| heading = "[[" .. record .. "]]"
| |
| .. ( number ~= "" and ( ' <span style="font-weight:400; color:#6b7566;">track '
| |
| .. number .. "</span>" ) or "" )
| |
| else
| |
| -- A cut with nowhere to appear is not a lesser thing; say what it is.
| |
| heading = '<span style="color:#5a6858;">studio cut</span>'
| |
| end
| |
| | |
| local out = { '<div style="padding:.5em .2em; border-bottom:1px solid #d4dacd;">' }
| |
| table.insert( out, '<div style="font-weight:600;">' .. heading .. "</div>" )
| |
| if #shown > 0 then
| |
| table.insert( out, '<div style="font-size:.95em; line-height:1.9;">'
| |
| .. table.concat( shown, " · " ) .. "</div>" )
| |
| end
| |
| if #session > 0 then
| |
| table.insert( out, '<div style="font-size:.8em; color:#6b7566;">'
| |
| .. table.concat( session, " · " ) .. "</div>" )
| |
| end
| |
| table.insert( out, "</div>" )
| |
| return table.concat( out, "\n" )
| |
| end
| |
| | |
| -- This cut turning up somewhere else: a compilation, a reissue, a single.
| |
| function p.appearance( frame )
| |
| local args = callArgs( frame )
| |
| local id, record = trim( args.cut ), trim( args.record )
| |
| if id == "" or record == "" then
| |
| return '<span class="error">Cut appears on: needs both a cut and a record</span>'
| |
| end
| |
| local number = trim( args.number )
| |
| | |
| local parts = { "Has cut=" .. escape( id ), "Has record=" .. escape( record ) }
| |
| if number ~= "" then
| |
| table.insert( parts, "Has track number=" .. escape( number ) )
| |
| end
| |
| subobject( frame, parts )
| |
| | |
| return '<div style="padding:.3em .2em; font-size:.9em; color:#3d4a3e;">also on [['
| |
| .. record .. "]]"
| |
| .. ( number ~= "" and ( ", track " .. number ) or "" ) .. "</div>"
| |
| end
| |
| | |
| local function property( frame, page, name )
| |
| return trim( plain( frame:callParserFunction( "#show", {
| |
| page, "?" .. name, "link=none", "default=",
| |
| } ) ) )
| |
| end
| |
| | |
| local function freelyLicensed( frame, record )
| |
| local licence = property( frame, record, "Has license" )
| |
| 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 ), licence
| |
| end
| |
| | |
| -- Rows of "subject||b||c||d", from Template:Studio appearance row.
| |
| local function rows( frame, query, printouts, mainlabel )
| |
| local args = { query }
| |
| for _, printout in ipairs( printouts ) do
| |
| table.insert( args, printout )
| |
| end
| |
| for _, option in ipairs( {
| |
| "mainlabel=" .. mainlabel, "format=plainlist", "link=none", "sep=@@",
| |
| "template=Studio appearance row", "searchlabel=", "limit=500",
| |
| } ) do
| |
| table.insert( args, option )
| |
| end
| |
| | |
| local raw = plain( frame:callParserFunction( "#ask", args ) )
| |
| local out = {}
| |
| if trim( raw ) == "" then
| |
| return out
| |
| end
| |
| for _, row in ipairs( mw.text.split( raw, "@@", true ) ) do
| |
| local a, b, c, d = row:match( "^(.-)||(.-)||(.-)||(.*)$" )
| |
| if a and trim( a ) ~= "" then
| |
| table.insert( out, {
| |
| subject = trim( ( a:match( "^([^#]*)" ) or a ) ),
| |
| b = trim( b ), c = trim( c ), d = trim( d ),
| |
| } )
| |
| end
| |
| end
| |
| return out
| |
| end
| |
| | |
| -- Where a set of cuts appear, asked in one query rather than one per cut.
| |
| --
| |
| -- A record with a dozen tracks would otherwise mean a dozen queries; SMW takes
| |
| -- alternatives with ||, so it is one.
| |
| local function appearancesOf( frame, ids )
| |
| local wanted = {}
| |
| for id in pairs( ids ) do
| |
| table.insert( wanted, id )
| |
| end
| |
| if #wanted == 0 then
| |
| return {}
| |
| end
| |
| table.sort( wanted )
| |
| | |
| local found = rows( frame, "[[Has cut::" .. table.concat( wanted, "||" ) .. "]]", {
| |
| "?Has cut=cut",
| |
| "?Has record=record",
| |
| "?Has track number=number",
| |
| }, "song" )
| |
| | |
| local byCut = {}
| |
| for _, row in ipairs( found ) do
| |
| if row.c ~= "" then
| |
| byCut[ row.b ] = byCut[ row.b ] or {}
| |
| table.insert( byCut[ row.b ], {
| |
| song = row.subject, record = row.c, number = tonumber( row.d ),
| |
| } )
| |
| end
| |
| end
| |
| return byCut
| |
| end
| |
| | |
| local function subjectOf( frame )
| |
| local given = trim( callArgs( frame )[1] or frame.args[1] )
| |
| if given ~= "" then
| |
| return given
| |
| end
| |
| return mw.title.getCurrentTitle().fullText
| |
| end
| |
| | |
| -- Every cut a musician played on, grouped by the record it appears on.
| |
| --
| |
| -- @param wanted "free" for records anybody may share, "rest" for the others,
| |
| -- "none" for cuts that appear on no record at all.
| |
| local function appearances( frame, wanted, heading )
| |
| local who = subjectOf( frame )
| |
| local mine = rows( frame, "[[Has performer::" .. who .. "]]", {
| |
| "?Has cut=cut",
| |
| "?Has instrument=instrument",
| |
| "?Has performer=performer",
| |
| }, "song" )
| |
| | |
| local ids, instruments, songOf = {}, {}, {}
| |
| for _, row in ipairs( mine ) do
| |
| if row.b ~= "" then
| |
| ids[ row.b ] = true
| |
| songOf[ row.b ] = row.subject
| |
| for _, one in ipairs( split( row.c ) ) do
| |
| instruments[ row.b ] = instruments[ row.b ] or {}
| |
| table.insert( instruments[ row.b ], one )
| |
| end
| |
| end
| |
| end
| |
| | |
| local where = appearancesOf( frame, ids )
| |
| | |
| -- Grouped by record, so a picker's page reads as a list of records rather
| |
| -- than a list of sessions.
| |
| local order, byRecord = {}, {}
| |
| local loose = {}
| |
| for id in pairs( ids ) do
| |
| local places = where[ id ]
| |
| if places and #places > 0 then
| |
| for _, place in ipairs( places ) do
| |
| if not byRecord[ place.record ] then
| |
| byRecord[ place.record ] = { songs = {}, instruments = {}, seen = {} }
| |
| table.insert( order, place.record )
| |
| end
| |
| local entry = byRecord[ place.record ]
| |
| if not entry.seen[ place.song ] then
| |
| entry.seen[ place.song ] = true
| |
| table.insert( entry.songs, { title = place.song, number = place.number } )
| |
| end
| |
| for _, one in ipairs( instruments[ id ] or {} ) do
| |
| if not entry.instruments[ one ] then
| |
| entry.instruments[ one ] = true
| |
| table.insert( entry.instruments, one )
| |
| end
| |
| end
| |
| end
| |
| else
| |
| table.insert( loose, { song = songOf[ id ], id = id,
| |
| instruments = instruments[ id ] or {} } )
| |
| end
| |
| end
| |
| | |
| local blocks = {}
| |
| | |
| if wanted == "none" then
| |
| table.sort( loose, function( a, b ) return a.song < b.song end )
| |
| for _, cut in ipairs( loose ) do
| |
| table.insert( blocks,
| |
| '<div style="padding:.45em .2em; border-bottom:1px solid #d4dacd;">'
| |
| .. '<div style="font-weight:600;">[[' .. cut.song .. "|"
| |
| .. bare( cut.song ) .. "]]</div>"
| |
| .. '<div style="font-size:.9em; color:#3d4a3e;">'
| |
| .. ( #cut.instruments > 0
| |
| and ( table.concat( cut.instruments, ", " ) .. " · " ) or "" )
| |
| .. cut.id .. "</div></div>" )
| |
| end
| |
| else
| |
| table.sort( order )
| |
| for _, record in ipairs( order ) do
| |
| local entry = byRecord[ record ]
| |
| local isFree, licence = freelyLicensed( frame, record )
| |
| if ( wanted == "free" ) == isFree then
| |
| table.sort( entry.songs, 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 _, song in ipairs( entry.songs ) do
| |
| table.insert( titles,
| |
| "[[" .. song.title .. "|" .. bare( song.title ) .. "]]" )
| |
| end
| |
| | |
| local artist = property( frame, record, "Has record artist" )
| |
| local line = {
| |
| '<div style="padding:.45em .2em; border-bottom:1px solid #d4dacd;">',
| |
| '<div style="font-weight:600;">[[' .. record .. "]]",
| |
| }
| |
| if artist ~= "" then
| |
| table.insert( line, ' <span style="font-weight:400; color:#5a6858;">by [['
| |
| .. artist .. "]]</span>" )
| |
| end
| |
| if isFree and licence ~= "" 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 .. "</span>" )
| |
| end
| |
| table.insert( line, "</div>" )
| |
| table.insert( line, '<div style="font-size:.9em; color:#3d4a3e;">'
| |
| .. ( #entry.instruments > 0
| |
| and ( table.concat( entry.instruments, ", " ) .. " · " ) or "" )
| |
| .. table.concat( titles, ", " ) .. "</div>" )
| |
| table.insert( line, "</div>" )
| |
| table.insert( blocks, table.concat( line, "" ) )
| |
| end
| |
| end
| |
| end
| |
| | |
| if #blocks == 0 then
| |
| return ""
| |
| end
| |
| table.insert( blocks, 1, "== " .. heading .. " ==" )
| |
| return table.concat( blocks, "\n" )
| |
| end
| |
| | |
| function p.appearances( frame )
| |
| return appearances( frame, "free",
| |
| "[[PickiPedia:Creative Commons|Creative Commons]] Studio Work" )
| |
| end
| |
| | |
| function p.other( frame )
| |
| return appearances( frame, "rest", "Other studio work" )
| |
| end
| |
| | |
| -- Cuts that are on no record at all — a session for its own sake, a video, a
| |
| -- thing that was never finished. Worth saying: it happened, and they played on
| |
| -- it.
| |
| function p.unreleased( frame )
| |
| return appearances( frame, "none", "Studio cuts not on a record" )
| |
| end
| |
| | |
| -- The track listing of a record, assembled from the cuts that appear on it.
| |
| function p.tracks( frame )
| |
| local record = subjectOf( frame )
| |
| local places = rows( frame, "[[Has record::" .. record .. "]]", {
| |
| "?Has cut=cut",
| |
| "?Has track number=number",
| |
| "?Has record=record",
| |
| }, "song" )
| |
| | |
| local ids, order, bySong = {}, {}, {}
| |
| for _, row in ipairs( places ) do
| |
| if row.b ~= "" then
| |
| ids[ row.b ] = true
| |
| if not bySong[ row.subject ] then
| |
| bySong[ row.subject ] = { number = tonumber( row.c ), cuts = {} }
| |
| table.insert( order, row.subject )
| |
| end
| |
| local entry = bySong[ row.subject ]
| |
| entry.number = entry.number or tonumber( row.c )
| |
| table.insert( entry.cuts, row.b )
| |
| end
| |
| end
| |
| | |
| if #order == 0 then
| |
| return ""
| |
| end
| |
| | |
| -- Who played, for every cut on this record, in one more query.
| |
| local wanted = {}
| |
| for id in pairs( ids ) do
| |
| table.insert( wanted, id )
| |
| end
| |
| table.sort( wanted )
| |
| local personnel = rows( frame,
| |
| "[[Has cut::" .. table.concat( wanted, "||" ) .. "]][[Has performer::+]]", {
| |
| "?Has cut=cut",
| |
| "?Has performer=performer",
| |
| "?Has instrument=instrument",
| |
| }, "song" )
| |
| | |
| local byCut = {}
| |
| for _, row in ipairs( personnel ) do
| |
| if row.b ~= "" and row.c ~= "" then
| |
| byCut[ row.b ] = byCut[ row.b ] or {}
| |
| table.insert( byCut[ row.b ], { name = row.c, instrument = split( row.d )[1] or "" } )
| |
| end
| |
| end
| |
| | |
| table.sort( order, function( a, b )
| |
| local na, nb = bySong[ a ].number, bySong[ b ].number
| |
| if na and nb then return na < nb end
| |
| if na then return true end
| |
| if nb then return false end
| |
| return a < b
| |
| end )
| |
| | |
| local out = {}
| |
| for _, song in ipairs( order ) do
| |
| local entry = bySong[ song ]
| |
| | |
| local seen, shown = {}, {}
| |
| for _, id in ipairs( entry.cuts ) do
| |
| for _, player in ipairs( byCut[ id ] or {} ) do
| |
| if not seen[ player.name ] then
| |
| seen[ player.name ] = true
| |
| table.insert( shown, player )
| |
| end
| |
| end
| |
| end
| |
| table.sort( shown, function( a, b ) return a.name < b.name end )
| |
| | |
| local links = {}
| |
| for _, player in ipairs( shown ) do
| |
| table.insert( links, frame:expandTemplate{
| |
| title = "m", args = { player.name, player.instrument } } )
| |
| end
| |
| | |
| local line = { '<div style="padding:.45em .2em; border-bottom:1px solid #d4dacd;">' }
| |
| table.insert( line, '<div style="font-weight:600;">'
| |
| .. ( entry.number and ( entry.number .. ". " ) or "" )
| |
| .. "[[" .. song .. "|" .. bare( song ) .. "]]</div>" )
| |
| if #links > 0 then
| |
| table.insert( line, '<div style="font-size:.9em; line-height:1.9; color:#3d4a3e;">'
| |
| .. table.concat( links, " · " ) .. "</div>" )
| |
| end
| |
| table.insert( line, "</div>" )
| |
| table.insert( out, table.concat( line, "" ) )
| |
| end
| |
| return table.concat( out, "\n" )
| |
| end
| |
|
| |
|
| return p | | return require( 'pickipedia.studio' ) |