跳转到内容

模組: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, '&#126;', '~')
			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