模組:TopicList
外观
--- Based on [[Module:沙盒/PexEric/TopicList]]
local p = {}
local title_obj = mw.title.getCurrentTitle()
-- ============================================================================
-- 配置部分
-- ============================================================================
local CONFIG = {
colors = {
['1h'] = '#efe',
['1d'] = '#eef',
['old'] = '#ddd',
['very_old'] = '#bbb',
['archived'] = '#ddd',
['warn'] = '#fcc',
['hot'] = '#ffe'
},
DATEPATTERN = '(%d-)年(%d-)月(%d-)日 %([一二三四五六日]-%) (%d-):(%d-) %(UTC%)',
USERPREFIX = {
contributions = {
'special:用户贡献/', 'special:用戶貢獻/', 'special:使用者貢獻/',
'特殊:用户贡献/', '特殊:用戶貢獻/', '特殊:使用者貢獻/',
'special:contributions/', '特殊:contributions/',
'special:contribs/', '特殊:contribs/'
},
user = {
'user:', '用戶:', '用户:', '使用者:', 'u:'
},
talk = {
'user talk:', 'user_talk:',
'用戶討論:', '用户讨论:', '使用者討論:',
'ut:', 'u_talk:', 'ut:'
}
},
TEMPLATES = {
moved = {
['移動至'] = true, ['movedto'] = true, ['moveto'] = true
},
archive_top = {
['archive top'] = true, ['archive top'] = true, ['archiveh'] = true, ['存档顶'] = true,
['discussion top'] = true
},
archive_bottom = {
['archive bottom'] = true, ['archive bottom'] = true, ['closed rfc bottom'] = true,
['archivef'] = true, ['存档底'] = true, ['rm bottom'] = true, ['discussion bottom'] = true
}
}
}
-- ============================================================================
-- 核心工具函数
-- ============================================================================
local function clean_title_text(text)
if not text then return "" end
text = string.gsub(text, "%[%[[^|%]]-|([^%]]-)%]%]", "%1")
text = string.gsub(text, "%[%[:([^%]]-)%]%]", "%1")
text = string.gsub(text, "%[%[([^%]]-)%]%]", "%1")
text = string.gsub(text, "[%[%]]", "")
return text
end
local function rfind(s, pattern, init)
local x, y
local i = init or #s
local len = #s
x, y = string.find(string.reverse(s), string.reverse(pattern), len-i+1, true)
if x then return len-y+1, len-x+1 end
end
local function extract_username(text, prefix_end)
local i = prefix_end + 1
while i <= #text do
local char = string.sub(text, i, i)
if char == '&' then
local entity_end = string.find(text, ';', i + 1, true)
if entity_end then
i = entity_end + 1
else
i = i + 1
end
elseif char == '|' or char == ']' or char == '/' or char == '#' then
return string.sub(text, prefix_end + 1, i - 1)
else
i = i + 1
end
end
return nil
end
local function date_to_timestamp(date_str)
local Y, M, D, h, m = string.match(date_str, CONFIG.DATEPATTERN)
if Y and M and D and h and m then
return os.time({
year = tonumber(Y), month = tonumber(M), day = tonumber(D),
hour = tonumber(h), min = tonumber(m), sec = 0
})
end
return 0
end
local function format_user_link(name)
if not name or name == "Unknown" then return "Unknown" end
if string.match(name, '^~') or string.match(name, '^%d+%.%d+%.%d+%.%d+$') or string.match(name, '^[%x:]+$') then
return string.format('[[Special:Contributions/%s|%s]]', name, name)
else
return string.format('[[User:%s|%s]]', name, name)
end
end
local function check_template_at_start(text, template_set)
local t_name = string.match(text, "^%s*{{%s*([^{}|]+)")
if t_name then
t_name = mw.text.trim(string.lower(t_name))
t_name = string.gsub(t_name, "_", " ")
if template_set[t_name] then return true end
end
return false
end
local function check_template_at_end(text, template_set)
local rev_text = string.reverse(text)
local rev_match = string.match(rev_text, "^%s*}}%s*([^}|{]+)%s*{{")
if rev_match then
local t_name = string.reverse(rev_match)
t_name = mw.text.trim(string.lower(t_name))
t_name = string.gsub(t_name, "_", " ")
if template_set[t_name] then return true end
end
return false
end
-- ============================================================================
-- 章节解析逻辑
-- ============================================================================
local function normalize_heading_text(text)
if not text then return "" end
text = mw.text.trim(text)
return string.gsub(text, "%s+", " ")
end
local function parse_headings(content)
local headings = {}
local pos = 1
while true do
local s, e, line = string.find(content, "\n([^\n]*)", pos)
if not s then break end
local opening, title, closing = string.match(
line,
"^(=+)%s*(.-)%s*(=+)%s*$"
)
if opening and #opening == #closing then
table.insert(headings, {
start_idx = s,
end_idx = e + 1,
level = #opening,
title = mw.text.trim(title)
})
end
pos = e + 1
end
return headings
end
local function make_default_sections(content, headings)
local sections = {}
local level_two = {}
for _, heading in ipairs(headings) do
if heading.level == 2 then
table.insert(level_two, heading)
end
end
for i, heading in ipairs(level_two) do
local text_start = heading.end_idx + 1
local next_heading = level_two[i + 1]
local text_end = next_heading and (next_heading.start_idx - 1)
or #content
table.insert(sections, {
header = heading.title,
text = string.sub(content, text_start, text_end)
})
end
return sections
end
local function find_section_end(content, headings, heading_index)
local heading = headings[heading_index]
for i = heading_index + 1, #headings do
if headings[i].level <= heading.level then
return headings[i].start_idx - 1
end
end
return #content
end
local function make_subheading_sections(content, headings, target_title)
local normalized_target = normalize_heading_text(target_title)
local target_index = nil
for i, heading in ipairs(headings) do
if normalize_heading_text(heading.title) == normalized_target then
target_index = i
break
end
end
if not target_index then
return nil, string.format(
"未找到指定标题:%s。",
target_title
)
end
local target = headings[target_index]
local target_end = find_section_end(content, headings, target_index)
local child_level = target.level + 1
local child_indexes = {}
for i = target_index + 1, #headings do
local heading = headings[i]
if heading.start_idx > target_end then
break
end
if heading.level == child_level then
table.insert(child_indexes, i)
end
end
local sections = {}
for _, child_index in ipairs(child_indexes) do
local child = headings[child_index]
local text_start = child.end_idx + 1
local text_end = target_end
for i = child_index + 1, #headings do
local next_heading = headings[i]
if next_heading.start_idx > target_end then
break
end
if next_heading.level <= child.level then
text_end = next_heading.start_idx - 1
break
end
end
table.insert(sections, {
header = child.title,
text = string.sub(content, text_start, text_end)
})
end
if #sections == 0 then
return nil, "指定标题下未发现下一级标题讨论话题。"
end
return sections, nil
end
local function analyze_section_text(text)
local stats = {
reply_count = 0,
participants = {},
participant_count = 0,
last_time = 0,
last_user = nil,
status = 'normal'
}
if check_template_at_start(text, CONFIG.TEMPLATES.moved) then
stats.status = 'moved'
elseif check_template_at_start(text, CONFIG.TEMPLATES.archive_top) and
check_template_at_end(text, CONFIG.TEMPLATES.archive_bottom) then
stats.status = 'archived'
end
local lowertext = string.lower(text)
local x, y = 1, 1
while true do
x, y = string.find(text, CONFIG.DATEPATTERN, y + 1)
if not x then break end
local t1, _ = rfind(text, '\n', x)
t1 = t1 or 1
local curtalk_segment = string.sub(lowertext, t1, x - 1)
local found_user = nil
local selected_priority = 0
local selected_position = 0
local original_segment = string.sub(text, t1, x - 1)
local user_groups = {
{ priority = 3, prefixes = CONFIG.USERPREFIX.contributions },
{ priority = 2, prefixes = CONFIG.USERPREFIX.user },
{ priority = 1, prefixes = CONFIG.USERPREFIX.talk }
}
for _, group in ipairs(user_groups) do
for _, prefix in ipairs(group.prefixes) do
local _, prefix_end = rfind(curtalk_segment, '[[' .. prefix)
if prefix_end then
local candidate = extract_username(original_segment, prefix_end)
if candidate and candidate ~= '' then
if group.priority > selected_priority or (group.priority == selected_priority and prefix_end > selected_position) then
found_user = candidate
selected_priority = group.priority
selected_position = prefix_end
end
end
end
end
end
if found_user then
found_user = string.gsub(found_user, '&#[xX]7[eE];', '~')
found_user = string.gsub(found_user, '~', '~')
found_user = string.gsub(found_user, '&[aA][mM][pP];', '&')
local first = string.byte(found_user, 1, 1)
if first and first >= 97 and first <= 122 then
found_user = string.upper(string.char(first)) .. string.sub(found_user, 2)
end
local date_str = string.sub(text, x, y)
local ts = date_to_timestamp(date_str)
stats.reply_count = stats.reply_count + 1
if not stats.participants[found_user] then
stats.participants[found_user] = true
stats.participant_count = stats.participant_count + 1
end
if ts > stats.last_time then
stats.last_time = ts
stats.last_user = found_user
end
end
end
return stats
end
-- ============================================================================
-- 主程序
-- ============================================================================
local function render(frame, target_heading)
local args = frame.args
local page_name = args.page or args[1]
local content
if page_name then
local t = mw.title.new(page_name)
if t then content = t:getContent() end
else
content = title_obj:getContent()
end
if not content then return "无法读取页面内容。" end
content = "\n" .. content .. "\n"
-- 解析章节
local headings = parse_headings(content)
local sections_data
local parse_error
if target_heading and mw.text.trim(target_heading) ~= "" then
sections_data, parse_error = make_subheading_sections(
content,
headings,
target_heading
)
else
sections_data = make_default_sections(content, headings)
end
if parse_error then return parse_error end
if #sections_data == 0 then
return "未发现二级标题讨论话题。"
end
-- 生成HTML
local output = mw.html.create('div')
output:addClass('topic-list')
local heading = output:tag('div')
heading
:addClass('topic-list__heading')
:wikitext('讨论主题')
local collapsible = output:tag('div')
collapsible:addClass('topic-list__collapsible mw-collapsible')
local note = collapsible:tag('div')
note
:addClass('topic-list__note collapsible-title')
:wikitext('将-{zh-hans:光标;zh-hant:游標;}-指向-{zh-hans:图标;zh-hant:圖示;}-以显示实际意义。')
:tag('span')
:addClass('mw-collapsible-toggle-placeholder')
local data = collapsible:tag('div')
data
:addClass('topic-list__data mw-collapsible-content')
:attr('role', 'region')
:attr('aria-label', '讨论主题表格,可左右滚动')
:attr('tabindex', '0')
local root = data:tag('table')
root:addClass('wikitable sortable mw-collapsible')
-- 表头
local header = root:tag('tr')
-- #
header:tag('th')
:attr('data-sort-type', 'number')
:css('font-weight', 'normal')
:wikitext('<small>#</small>')
-- 话题
header:tag('th'):wikitext('💭 話題')
-- 回复数
header:tag('th')
:wikitext('<span title="發言數/發言人次 (實際上為計算簽名數)">💬</span>')
-- 参与人数
header:tag('th')
:wikitext('<span title="參與討論人數/發言人數">👥</span>')
-- 最新发言 (用户)
header:tag('th'):wikitext('🙋 最新發言')
-- 最后更新 (时间)
header:tag('th')
:attr('data-sort-type', 'isoDate')
:wikitext('<span title="最後更新">🕒 <small>(UTC+8)</small></span>')
:css('white-space', 'nowrap')
local index_counter = 0
local now = os.time()
for _, sec in ipairs(sections_data) do
index_counter = index_counter + 1
local stats = analyze_section_text(sec.text)
local time_diff = now - stats.last_time
local is_closed = (stats.status == 'archived' or stats.status == 'moved')
local is_single_reply = (stats.reply_count == 1 and stats.participant_count == 1)
-- 背景色逻辑
local bg_color
if is_closed then
bg_color = CONFIG.colors['archived']
elseif time_diff < 3600 then
bg_color = CONFIG.colors['1h']
elseif time_diff < 86400 then
bg_color = CONFIG.colors['1d']
elseif time_diff >= 604800 and time_diff <= 2592000 then
bg_color = CONFIG.colors['old']
elseif time_diff > 2592000 then
bg_color = CONFIG.colors['very_old']
end
local row = root:tag('tr')
if is_closed then
row:css('text-decoration', 'line-through')
end
-- 1. 序号
row:tag('td'):css('text-align', 'right'):wikitext(index_counter)
-- 2. 话题
local clean_display = clean_title_text(sec.header)
local link_text = string.format("[[#%s|%s]]", clean_display, clean_display)
local title_cell = row:tag('td'):css('max-width', '24em')
-- 字数超过20变小
if mw.ustring.len(clean_display) > 20 then
title_cell:wikitext('<small>' .. link_text .. '</small>')
else
title_cell:wikitext(link_text)
end
-- 3. 回复数
local reply_cell = row:tag('td')
:css('text-align', 'right')
:wikitext(stats.reply_count)
if stats.reply_count >= 10 then
reply_cell:css('background-color', CONFIG.colors['hot'])
elseif is_single_reply then
reply_cell:css('background-color', CONFIG.colors['warn'])
end
reply_cell:css('color', 'inherit')
-- 4. 参与人数
local part_cell = row:tag('td')
:css('text-align', 'right')
:wikitext(stats.participant_count)
if is_single_reply then
part_cell:css('background-color', CONFIG.colors['warn'])
end
part_cell:css('color', 'inherit')
-- 5. 最新发言人
row:tag('td')
:css('background-color', bg_color)
:css('color', 'inherit')
:wikitext(format_user_link(stats.last_user))
-- 6. 最后更新时间
local time_cell = row:tag('td')
:css('background-color', bg_color)
:css('color', 'inherit')
:attr('data-sort-type', 'isoDate')
if stats.last_time > 0 then
local iso_val = os.date("!%Y-%m-%dT%H:%M:%S.000Z", stats.last_time)
time_cell:attr('data-sort-value', iso_val)
local tz_offset = 8 * 3600
local local_ts = stats.last_time + tz_offset
local date_part = os.date("!%Y-%m-%d", local_ts)
local time_part = os.date("!%H:%M", local_ts)
time_cell:wikitext(string.format('%s <span style="color: blue;">%s</span>', date_part, time_part))
else
time_cell:wikitext('-')
end
end
-- ========================================================================
-- 图例生成
-- ========================================================================
if not args.no_time_legend then
local legend = output:tag('div')
legend
:addClass('topic-list__legend')
:tag('div')
:addClass('topic-list__legend-title')
:wikitext('最后发言:')
local keys = legend:tag('ul'):addClass('topic-list__keys')
-- 辅助函数
local function add_legend_key(key, text)
local keycell = keys:tag('li')
keycell:addClass('topic-list__key')
if key then keycell:addClass('topic-list__key--' .. key) end
keycell:wikitext(text)
end
-- 添加各时间段
add_legend_key('hour', '1小时内')
add_legend_key('day', '1小时至1日')
add_legend_key('week', '1日至1週')
add_legend_key('month', '1週至1个月')
add_legend_key('old', '逾1个月')
add_legend_key('archived', '已关闭 / 已移动')
-- 签名状态
legend:tag('div')
:addClass('topic-list__note')
:wikitext('统计:<span class="topic-list__key--hot">黄色为签名数≥10</span>,<span class="topic-list__key--warn">红色为签名数或参与人数<2</span>。')
end
local templatestyles = frame:extensionTag{ name = 'templatestyles', args = { src = 'Template:Topic list/styles.css' } }
return templatestyles .. tostring(output)
end
function p.main(frame)
return render(frame, frame.args.heading)
end
function p.heading(frame)
local target_heading = frame.args.heading or frame.args[2]
if not target_heading or mw.text.trim(target_heading) == "" then
return "请通过 |heading= 指定标题。"
end
return render(frame, target_heading)
end
return p