跳转到内容

Module:曲目页面:修订间差异

来自Bleap Wiki
Admin​(留言 | 贡献)
JSON 流水线更新曲目页面模块
 
Admin​(留言 | 贡献)
输出加 __NOCONTENTCONVERT__/__NOTITLECONVERT__:署名与曲名保持 UTF-8 原字,不做繁简变体转换(无需给名字加包裹,无 DOM 开销)
 
(未显示2个用户的18个中间版本)
第3行: 第3行:
-- 数据页: 曲目列表 (曲绘/曲师/曲包/定数 的单一来源)
-- 数据页: 曲目列表 (曲绘/曲师/曲包/定数 的单一来源)
local DATA_TITLE = '模板:曲目列表.json'
local DATA_TITLE = '模板:曲目列表.json'
local validator = require('模块:数据页校验')
local parse = require('模块:署名解析')
local ARTIST_MAP = '模板:曲师映射.json'
local CHARTER_MAP = '模板:谱师映射.json'


local function esc(s)
local function esc(s)
第28行: 第32行:
end
end


local function loadData()
local function loadJson(title)
local t = mw.title.new(DATA_TITLE)
local t = mw.title.new(title)
if not t or not t.exists then
if not t or not t.exists then return nil end
return nil, '数据页不存在: [[' .. DATA_TITLE .. ']]'
local ok, data = pcall(mw.text.jsonDecode, t:getContent())
if not ok then return nil end
return data
end
 
-- 列表页里实际有小节的署名集合
local function knownCredits(data, lk, field, includeSpecial)
local known = {}
local function eat(list)
for _, s in ipairs(list or {}) do
local raw = s[field]
if type(raw) == 'table' then
for _, v in pairs(raw) do
for _, pt in ipairs(parse.attribute(lk, v) or parse.parts(v)) do known[pt.base] = true end
end
else
for _, pt in ipairs(parse.attribute(lk, raw) or parse.parts(raw)) do known[pt.base] = true end
end
end
end
eat(data.songs)
if includeSpecial then eat(data.special) end
return known
end
 
-- 署名 → 链到 [[<列表页>#主名义]](马甲/合作名义自动归并;无小节则纯文本)
local function linkedCredits(raw, lk, page, known)
if not raw or raw == '' then return '' end
local linked = parse.linkify(raw, lk, { page = page, known = known })
if linked then return linked end
local parts = parse.attribute(lk, raw) or parse.parts(raw)
local out = {}
for _, pt in ipairs(parts) do
local text = pt.rawPart or pt.base
if known and not known[pt.base] then
out[#out + 1] = esc(text)
else
out[#out + 1] = '[[' .. page .. '#' .. esc(pt.base) .. '|' .. esc(text) .. ']]'
end
end
-- 回退分支:
--  ① 解析出多个主名义(标签式合作名义,如 rank.x / Kevin.B / ↑=192.333KB/s)
--    → 「合作名义<br>主名义A链接 / 主名义B链接」
--  ② 单个主名义 → 直接显示(原名与主名义不同时补「(名义:X)」)
local joined = table.concat(out, ' / ')
if #parts > 1 then
return esc(raw) .. '<br>' .. joined
end
end
local ok, data = pcall(mw.text.jsonDecode, t:getContent())
if parse.creditNote(raw, lk) and parse.norm(raw) ~= parse.norm(parts[1].rawPart or parts[1].base) then
if not ok or type(data) ~= 'table' then
return joined .. ' <span style="font-size:85%;color:#666">(名义:' .. esc(raw) .. ')</span>'
return nil, '数据页 JSON 解析失败: [[' .. DATA_TITLE .. ']]'
end
end
return data
return joined
end
 
local function loadData()
return validator.load(DATA_TITLE)
end
end


local function findSong(data, name)
local function findSong(data, name, specialOnly)
for _, s in ipairs(data.songs or {}) do
if not specialOnly then
if s['曲名'] == name then
for _, s in ipairs(data.songs or {}) do
return s, false
if s['曲名'] == name then
return s, false
end
end
end
end
end
第52行: 第107行:
end
end
return nil, false
return nil, false
end
local function langWrapName(song)
local lang = song['lang']
if lang == 'ja' then
return '{{lang|ja|' .. esc(song['曲名']) .. '}}'
elseif lang == 'zh-Hant' then
return '{{lang|zh-Hant|' .. esc(song['曲名']) .. '}}'
end
return esc(song['曲名'])
end
end


第61行: 第126行:
return norm(art)
return norm(art)
end
end
-- 自动生成解锁方式: 按曲包类型
local function autoUnlock(pack)
if pack == '' or pack == nil then
return '(待补充)'
end
if pack == '教程' then
return '游戏内教学关卡,无需解锁'
elseif pack == '剧情模式' then
return '剧情模式内出现'
elseif pack == '免费单曲' then
return '无需解锁'
elseif pack == '免费单曲(商店)' then
return '前往[[商店#单曲|商店]]使用[[货币#瓶盖|瓶盖]]购买单曲'
elseif pack == 'CORE基础卡' then
return '前往[[商店#闪音卡|商店]]购买基础卡解锁'
else
return '前往[[商店#曲包|商店]]购买[[曲包列表#' .. esc(pack) .. '|' .. esc(pack) .. ']]解锁'
end
end


function p.render(frame)
function p.render(frame)
第73行: 第159行:
end
end


local song, isSpecial = findSong(data, name)
local specialOnly = arg(frame, '特殊', '') == '石墩子'
local song, isSpecial = findSong(data, name, specialOnly)
if not song then
if not song then
return '<span class="error">数据页中找不到曲目: ' .. esc(name)
return '<span class="error">数据页中找不到曲目: ' .. esc(name)
第79行: 第166行:
end
end


local title = arg(frame, '标题', song['曲名'])
local defaultTitle = isSpecial
and (langWrapName(song) .. '(石墩子模式)')
or song['曲名']
local title = arg(frame, '标题', defaultTitle)
-- 标题先单独展开 ({{lang}} 嵌套在信息框参数内不会被最终 preprocess 二次展开)
title = frame:preprocess(title)
local art = songArt(song, isSpecial)
local art = songArt(song, isSpecial)
local consts = song['定数'] or {}
local consts = song['定数'] or {}
第90行: 第182行:
end
end


-- 信息框: 自动填 标题/图片/曲师/曲包/定数, 谱师/连击/时长/BPM 手工传参
local artistLk = parse.buildLookup(loadJson(ARTIST_MAP))
local charterLk = parse.buildLookup(loadJson(CHARTER_MAP))
local knownArtists = knownCredits(data, artistLk, '曲师', true)
local knownCharters = knownCredits(data, charterLk, '谱师', false)
 
-- 信息框: 自动填 标题/图片/曲师/曲包/定数/谱师/时长/BPM, 手工传参可覆盖
table.insert(out, '{{曲目信息框')
table.insert(out, '{{曲目信息框')
table.insert(out, '|标题= ' .. esc(title))
table.insert(out, '|标题= ' .. esc(title))
table.insert(out, '|图片= [[文件:曲绘-' .. esc(art) .. '.png|256px]]')
local imgOverride = arg(frame, '图片', '')
table.insert(out, '|曲师= ' .. esc(song['曲师'] or ''))
if imgOverride ~= '' then
table.insert(out, '|曲包= ' .. esc(song['曲包'] or ''))
table.insert(out, '|图片= ' .. imgOverride)
table.insert(out, '|时长= ' .. esc(arg(frame, '时长', '')))
else
table.insert(out, '|BPM= ' .. esc(arg(frame, 'BPM', '')))
table.insert(out, '|图片= [[文件:曲绘-' .. esc(art) .. '.png|256px]]')
end
table.insert(out, '|曲师= ' .. arg(frame, '曲师', linkedCredits(song['曲师'], artistLk, '曲师列表', knownArtists)))
-- 曲包: 手工传参优先; 否则自动生成指向 曲包列表#<曲包名> 的链接
local packArg = frame.args['曲包']
local packText = song['曲包'] or ''
if packArg ~= nil and packArg ~= '' then
packText = packArg
elseif packText ~= '' then
packText = '[[' .. '曲包列表#' .. esc(packText) .. '|' .. esc(packText) .. ']]'
end
table.insert(out, '|曲包= ' .. packText)
table.insert(out, '|时长= ' .. esc(arg(frame, '时长', song['时长'] or '')))
table.insert(out, '|BPM= ' .. esc(arg(frame, 'BPM', song['BPM'] or '')))
local charters = song['谱师'] or {}
for _, d in ipairs({ 'CASUAL', 'TOUGH', 'INSANE', 'PRO' }) do
for _, d in ipairs({ 'CASUAL', 'TOUGH', 'INSANE', 'PRO' }) do
table.insert(out, '|' .. d .. '定数= ' .. fmt(consts[d]))
table.insert(out, '|' .. d .. '定数= ' .. fmt(consts[d]))
table.insert(out, '|' .. d .. '谱师= ' .. esc(arg(frame, d .. '谱师', '')))
table.insert(out, '|' .. d .. '谱师= ' .. arg(frame, d .. '谱师', linkedCredits(charters[d], charterLk, '谱师列表', knownCharters)))
table.insert(out, '|' .. d .. '连击= ' .. esc(arg(frame, d .. '连击', '')))
table.insert(out, '|' .. d .. '连击= ' .. esc(arg(frame, d .. '连击', '')))
end
end
第106行: 第217行:


-- 标准段落
-- 标准段落
table.insert(out, '\n== 解锁方式 ==\n' .. arg(frame, '解锁方式', '(待补充)'))
-- 解锁方式: 手工传参优先, 否则按曲包自动生成
local unlock = arg(frame, '解锁方式', '')
if unlock == '' then
unlock = autoUnlock(song['曲包'] or '')
end
table.insert(out, '\n== 解锁方式 ==\n' .. unlock)
table.insert(out, '\n== 游戏相关 ==\n=== 定数调整历史 ===\n'
table.insert(out, '\n== 游戏相关 ==\n=== 定数调整历史 ===\n'
.. arg(frame, '定数调整历史', '(待补充)'))
.. arg(frame, '定数调整历史', '(待补充)'))
local extra = arg(frame, '游戏相关', '')
-- 其他相关子章节: 新参数优先, 兼容旧参数 |游戏相关=
if extra ~= '' then
local otherRelated = arg(frame, '其他相关', '')
table.insert(out, '\n' .. extra)
local legacy = arg(frame, '游戏相关', '')
if otherRelated == '' then
otherRelated = legacy
end
if otherRelated == '' then
otherRelated = '(待补充)'
end
end
table.insert(out, '\n== 歌词 ==\n' .. arg(frame, '歌词', '(待补充)'))
table.insert(out, '\n=== 其他相关 ===\n' .. otherRelated)
table.insert(out, '\n== 歌词 ==\n' .. arg(frame, '歌词', '纯音乐,请欣赏'))
local other = arg(frame, '其他', '')
local other = arg(frame, '其他', '')
if other ~= '' then
if other ~= '' then
第122行: 第244行:


-- 展开 {{曲目信息框}} 等模板
-- 展开 {{曲目信息框}} 等模板
return frame:preprocess(table.concat(out, '\n'))
return frame:preprocess('__NOCONTENTCONVERT__\n__NOTITLECONVERT__\n' .. table.concat(out, '\n'))
end
end


return p
return p

2026年10月3日 (六) 21:57的最新版本

此模块的文档可以在Module:曲目页面/doc创建

local p = {}

-- 数据页: 曲目列表 (曲绘/曲师/曲包/定数 的单一来源)
local DATA_TITLE = '模板:曲目列表.json'
local validator = require('模块:数据页校验')
local parse = require('模块:署名解析')
local ARTIST_MAP = '模板:曲师映射.json'
local CHARTER_MAP = '模板:谱师映射.json'

local function esc(s)
	return (s:gsub('|', '&#124;'))
end

local function norm(s)
	return (s:gsub(' ', '_'))
end

-- Lua 中空字符串为真值, 必须显式判空
local function arg(frame, k, default)
	local v = frame.args[k]
	if v == nil or v == '' then
		return default
	end
	return v
end

local function fmt(v)
	if type(v) == 'number' then
		return string.format('%.1f', v)
	end
	return v or ''
end

local function loadJson(title)
	local t = mw.title.new(title)
	if not t or not t.exists then return nil end
	local ok, data = pcall(mw.text.jsonDecode, t:getContent())
	if not ok then return nil end
	return data
end

-- 列表页里实际有小节的署名集合
local function knownCredits(data, lk, field, includeSpecial)
	local known = {}
	local function eat(list)
		for _, s in ipairs(list or {}) do
			local raw = s[field]
			if type(raw) == 'table' then
				for _, v in pairs(raw) do
					for _, pt in ipairs(parse.attribute(lk, v) or parse.parts(v)) do known[pt.base] = true end
				end
			else
				for _, pt in ipairs(parse.attribute(lk, raw) or parse.parts(raw)) do known[pt.base] = true end
			end
		end
	end
	eat(data.songs)
	if includeSpecial then eat(data.special) end
	return known
end

-- 署名 → 链到 [[<列表页>#主名义]](马甲/合作名义自动归并;无小节则纯文本)
local function linkedCredits(raw, lk, page, known)
	if not raw or raw == '' then return '' end
	local linked = parse.linkify(raw, lk, { page = page, known = known })
	if linked then return linked end
	local parts = parse.attribute(lk, raw) or parse.parts(raw)
	local out = {}
	for _, pt in ipairs(parts) do
		local text = pt.rawPart or pt.base
		if known and not known[pt.base] then
			out[#out + 1] = esc(text)
		else
			out[#out + 1] = '[[' .. page .. '#' .. esc(pt.base) .. '|' .. esc(text) .. ']]'
		end
	end
	-- 回退分支:
	--  ① 解析出多个主名义(标签式合作名义,如 rank.x / Kevin.B / ↑=192.333KB/s)
	--     → 「合作名义<br>主名义A链接 / 主名义B链接」
	--  ② 单个主名义 → 直接显示(原名与主名义不同时补「(名义:X)」)
	local joined = table.concat(out, ' / ')
	if #parts > 1 then
		return esc(raw) .. '<br>' .. joined
	end
	if parse.creditNote(raw, lk) and parse.norm(raw) ~= parse.norm(parts[1].rawPart or parts[1].base) then
		return joined .. ' <span style="font-size:85%;color:#666">(名义:' .. esc(raw) .. ')</span>'
	end
	return joined
end

local function loadData()
	return validator.load(DATA_TITLE)
end

local function findSong(data, name, specialOnly)
	if not specialOnly then
		for _, s in ipairs(data.songs or {}) do
			if s['曲名'] == name then
				return s, false
			end
		end
	end
	for _, s in ipairs(data.special or {}) do
		if s['曲名'] == name then
			return s, true
		end
	end
	return nil, false
end

local function langWrapName(song)
	local lang = song['lang']
	if lang == 'ja' then
		return '{{lang|ja|' .. esc(song['曲名']) .. '}}'
	elseif lang == 'zh-Hant' then
		return '{{lang|zh-Hant|' .. esc(song['曲名']) .. '}}'
	end
	return esc(song['曲名'])
end

local function songArt(song, isSpecial)
	local art = song['曲绘'] or song['曲名'] or ''
	if isSpecial then
		art = art:gsub('(石墩子模式)$', '') .. '_石墩子模式'
	end
	return norm(art)
end

-- 自动生成解锁方式: 按曲包类型
local function autoUnlock(pack)
	if pack == '' or pack == nil then
		return '(待补充)'
	end
	if pack == '教程' then
		return '游戏内教学关卡,无需解锁'
	elseif pack == '剧情模式' then
		return '剧情模式内出现'
	elseif pack == '免费单曲' then
		return '无需解锁'
	elseif pack == '免费单曲(商店)' then
		return '前往[[商店#单曲|商店]]使用[[货币#瓶盖|瓶盖]]购买单曲'
	elseif pack == 'CORE基础卡' then
		return '前往[[商店#闪音卡|商店]]购买基础卡解锁'
	else
		return '前往[[商店#曲包|商店]]购买[[曲包列表#' .. esc(pack) .. '|' .. esc(pack) .. ']]解锁'
	end
end


function p.render(frame)
	local name = arg(frame, '曲名', '')
	if name == '' then
		return '<span class="error">未指定 |曲名= 参数</span>'
	end

	local data, err = loadData()
	if not data then
		return '<span class="error">' .. tostring(err) .. '</span>'
	end

	local specialOnly = arg(frame, '特殊', '') == '石墩子'
	local song, isSpecial = findSong(data, name, specialOnly)
	if not song then
		return '<span class="error">数据页中找不到曲目: ' .. esc(name)
			.. '(请检查 [[模板:曲目列表.json]])</span>'
	end

	local defaultTitle = isSpecial
		and (langWrapName(song) .. '(石墩子模式)')
		or song['曲名']
	local title = arg(frame, '标题', defaultTitle)
	-- 标题先单独展开 ({{lang}} 嵌套在信息框参数内不会被最终 preprocess 二次展开)
	title = frame:preprocess(title)
	local art = songArt(song, isSpecial)
	local consts = song['定数'] or {}

	local out = {}

	local tagline = arg(frame, '标语', '')
	if tagline ~= '' then
		table.insert(out, '<div class="song-tagline">' .. tagline .. '</div>')
	end

	local artistLk = parse.buildLookup(loadJson(ARTIST_MAP))
	local charterLk = parse.buildLookup(loadJson(CHARTER_MAP))
	local knownArtists = knownCredits(data, artistLk, '曲师', true)
	local knownCharters = knownCredits(data, charterLk, '谱师', false)

	-- 信息框: 自动填 标题/图片/曲师/曲包/定数/谱师/时长/BPM, 手工传参可覆盖
	table.insert(out, '{{曲目信息框')
	table.insert(out, '|标题= ' .. esc(title))
	local imgOverride = arg(frame, '图片', '')
	if imgOverride ~= '' then
		table.insert(out, '|图片= ' .. imgOverride)
	else
		table.insert(out, '|图片= [[文件:曲绘-' .. esc(art) .. '.png|256px]]')
	end
	table.insert(out, '|曲师= ' .. arg(frame, '曲师', linkedCredits(song['曲师'], artistLk, '曲师列表', knownArtists)))
	-- 曲包: 手工传参优先; 否则自动生成指向 曲包列表#<曲包名> 的链接
	local packArg = frame.args['曲包']
	local packText = song['曲包'] or ''
	if packArg ~= nil and packArg ~= '' then
		packText = packArg
	elseif packText ~= '' then
		packText = '[[' .. '曲包列表#' .. esc(packText) .. '|' .. esc(packText) .. ']]'
	end
	table.insert(out, '|曲包= ' .. packText)
	table.insert(out, '|时长= ' .. esc(arg(frame, '时长', song['时长'] or '')))
	table.insert(out, '|BPM= ' .. esc(arg(frame, 'BPM', song['BPM'] or '')))
	local charters = song['谱师'] or {}
	for _, d in ipairs({ 'CASUAL', 'TOUGH', 'INSANE', 'PRO' }) do
		table.insert(out, '|' .. d .. '定数= ' .. fmt(consts[d]))
		table.insert(out, '|' .. d .. '谱师= ' .. arg(frame, d .. '谱师', linkedCredits(charters[d], charterLk, '谱师列表', knownCharters)))
		table.insert(out, '|' .. d .. '连击= ' .. esc(arg(frame, d .. '连击', '')))
	end
	table.insert(out, '}}')

	-- 标准段落
	-- 解锁方式: 手工传参优先, 否则按曲包自动生成
	local unlock = arg(frame, '解锁方式', '')
	if unlock == '' then
		unlock = autoUnlock(song['曲包'] or '')
	end
	table.insert(out, '\n== 解锁方式 ==\n' .. unlock)
	table.insert(out, '\n== 游戏相关 ==\n=== 定数调整历史 ===\n'
		.. arg(frame, '定数调整历史', '(待补充)'))
	-- 其他相关子章节: 新参数优先, 兼容旧参数 |游戏相关=
	local otherRelated = arg(frame, '其他相关', '')
	local legacy = arg(frame, '游戏相关', '')
	if otherRelated == '' then
		otherRelated = legacy
	end
	if otherRelated == '' then
		otherRelated = '(待补充)'
	end
	table.insert(out, '\n=== 其他相关 ===\n' .. otherRelated)
	table.insert(out, '\n== 歌词 ==\n' .. arg(frame, '歌词', '纯音乐,请欣赏'))
	local other = arg(frame, '其他', '')
	if other ~= '' then
		table.insert(out, '\n== 其他 ==\n' .. other)
	end

	table.insert(out, '\n[[Category:曲目]]')

	-- 展开 {{曲目信息框}} 等模板
	return frame:preprocess('__NOCONTENTCONVERT__\n__NOTITLECONVERT__\n' .. table.concat(out, '\n'))
end

return p