Jump to content

User:ChasingAir/js/QuickArchive.js

From testwiki, a Wikimedia project for testing

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * QuickArchive - 在每个小节旁显示 [编辑源代码 | 删除 | 存档]
 * - 删除:清空该段(按章节 section 编辑)
 * - 存档:追加该段源码到“本页面/{{archiveto|1=...}}”子页面,然后清空该段
 */
// <nowiki>
(function (mw, $) {
	'use strict';

	if (!mw || !$) return;

	// 仅在阅读界面运行
	if (mw.config.get('wgAction') !== 'view') return;
	if (!mw.config.get('wgPageName')) return;

	var api = null;
	var archivetoConfigCache = null; // Promise<{archiveTitle:string|null, showDelete:boolean, allowedHeadingTags:Set<string>|null}>

	function getParseTextHtml(parseData) {
		if (!parseData || !parseData.parse) return '';
		// formatversion=2: parse.text is a string
		if (typeof parseData.parse.text === 'string') return parseData.parse.text;
		// formatversion=1: parse.text['*']
		if (parseData.parse.text && typeof parseData.parse.text['*'] === 'string') return parseData.parse.text['*'];
		return '';
	}

	function extractTemplateInvocation(wikitext, templateName) {
		if (!wikitext) return null;
		var lower = wikitext.toLowerCase();
		var needle = '{{' + templateName.toLowerCase();
		var start = lower.indexOf(needle);
		if (start < 0) return null;

		var depth = 0;
		for (var i = start; i < wikitext.length - 1; i++) {
			var two = wikitext.substr(i, 2);
			if (two === '{{') {
				depth++;
				i++;
				continue;
			}
			if (two === '}}') {
				depth--;
				i++;
				if (depth === 0) {
					return wikitext.slice(start, i + 1);
				}
				continue;
			}
		}
		return null;
	}

	function splitTopLevel(text, sepChar) {
		var parts = [];
		var buf = '';
		var curly = 0;
		var square = 0;

		for (var i = 0; i < text.length; i++) {
			var two = text.substr(i, 2);
			if (two === '{{') {
				curly++;
				buf += two;
				i++;
				continue;
			}
			if (two === '}}') {
				if (curly > 0) curly--;
				buf += two;
				i++;
				continue;
			}
			if (two === '[[') {
				square++;
				buf += two;
				i++;
				continue;
			}
			if (two === ']]') {
				if (square > 0) square--;
				buf += two;
				i++;
				continue;
			}

			var ch = text.charAt(i);
			if (ch === sepChar && curly === 0 && square === 0) {
				parts.push(buf);
				buf = '';
				continue;
			}
			buf += ch;
		}

		parts.push(buf);
		return parts;
	}

	function splitKeyValueTopLevel(paramText) {
		var curly = 0;
		var square = 0;
		for (var i = 0; i < paramText.length; i++) {
			var two = paramText.substr(i, 2);
			if (two === '{{') {
				curly++;
				i++;
				continue;
			}
			if (two === '}}') {
				if (curly > 0) curly--;
				i++;
				continue;
			}
			if (two === '[[') {
				square++;
				i++;
				continue;
			}
			if (two === ']]') {
				if (square > 0) square--;
				i++;
				continue;
			}
			if (paramText.charAt(i) === '=' && curly === 0 && square === 0) {
				return {
					key: paramText.slice(0, i).trim(),
					value: paramText.slice(i + 1).trim()
				};
			}
		}
		return null;
	}

	function truthyParam(value) {
		var v = (value || '').toString().trim().toLowerCase();
		return v === '1' || v === 'yes' || v === 'true' || v === 'y';
	}

	function falsyParam(value) {
		var v = (value || '').toString().trim().toLowerCase();
		return v === '0' || v === 'no' || v === 'false' || v === 'n';
	}

	function computeArchiveTitle(pageName, resolvedTarget) {
		if (!resolvedTarget) return null;
		// 兼容两种写法:
		// 1) 仅给出子页面名(如“2026年”)=> [[本页面/2026年]]
		// 2) 给出完整标题(如“Wikivoyage:.../存档/2026年”)=> 直接使用
		if (resolvedTarget.indexOf(':') !== -1 || resolvedTarget.indexOf('/') !== -1) {
			return resolvedTarget;
		}
		return pageName + '/' + resolvedTarget;
	}

	async function getArchivetoConfig() {
		if (archivetoConfigCache) return archivetoConfigCache;

		archivetoConfigCache = (async function () {
			var pageName = mw.config.get('wgPageName');
			var data = await api.get({
				action: 'query',
				prop: 'revisions',
				rvprop: 'content',
				rvslots: 'main',
				titles: pageName,
				formatversion: 2
			});

			var page = data && data.query && data.query.pages && data.query.pages[0];
			if (!page || !page.revisions || !page.revisions[0]) return null;

			var rev = page.revisions[0];
			var wikitext = '';
			if (rev.slots && rev.slots.main && typeof rev.slots.main.content === 'string') {
				wikitext = rev.slots.main.content;
			} else if (typeof rev.content === 'string') {
				wikitext = rev.content;
			}
			if (!wikitext) return null;

			var invocation = extractTemplateInvocation(wikitext, 'archiveto');
			if (!invocation) {
				return { archiveTitle: null, showDelete: true, allowedHeadingTags: null };
			}

			var inner = invocation.slice(2, -2);
			inner = inner.replace(/^\s*archiveto\s*/i, '');
			if (inner.charAt(0) === '|') inner = inner.slice(1);

			var rawParams = splitTopLevel(inner, '|');
			var positional = [];
			var named = Object.create(null);
			for (var i = 0; i < rawParams.length; i++) {
				var p = (rawParams[i] || '').trim();
				if (!p) continue;
				var kv = splitKeyValueTopLevel(p);
				if (kv) {
					named[(kv.key || '').trim().toLowerCase()] = kv.value;
				} else {
					positional.push(p);
				}
			}

			var param1 = null;
			if (Object.prototype.hasOwnProperty.call(named, '1')) param1 = named['1'];
			else if (positional.length) param1 = positional[0];
			if (!param1) {
				return { archiveTitle: null, showDelete: true, allowedHeadingTags: null };
			}

			var showDelete = true;
			if (Object.prototype.hasOwnProperty.call(named, 'delete-link')) {
				showDelete = !falsyParam(named['delete-link']);
			}

			var allowedHeadingTags = null;
			var tagKeys = ['h2', 'h3', 'h4', 'h5', 'h6'];
			for (var k = 0; k < tagKeys.length; k++) {
				if (Object.prototype.hasOwnProperty.call(named, tagKeys[k]) && truthyParam(named[tagKeys[k]])) {
					if (!allowedHeadingTags) allowedHeadingTags = new Set();
					allowedHeadingTags.add(tagKeys[k]);
				}
			}

			// 展开 magic words / 模板(例如 {{CURRENTYEAR}})
			var parsed = await api.get({
				action: 'parse',
				title: pageName,
				text: param1,
				prop: 'text',
				disableeditsection: 1,
				disablelimitreport: 1,
				formatversion: 2
			});
			var html = getParseTextHtml(parsed);
			var resolved = $('<div>').html(html).text();
			resolved = (resolved || '').replace(/\s+/g, ' ').trim();

			var archiveTitle = computeArchiveTitle(pageName, resolved || null);
			return { archiveTitle: archiveTitle, showDelete: showDelete, allowedHeadingTags: allowedHeadingTags };
		})();

		return archivetoConfigCache;
	}

	function getSectionIdFromHeading(headingNode) {
		if (!headingNode) return null;
		var editNode = headingNode.querySelector('.mw-editsection') || (headingNode.parentNode && headingNode.parentNode.querySelector('.mw-editsection'));
		if (!editNode) return null;
		var editLink = editNode.querySelector('a[href*="section="]');
		if (!editLink || !editLink.href) return null;
		var m = editLink.href.match(/section=(\d+)/);
		return m ? m[1] : null;
	}

	function findEditSectionNode(headingNode) {
		if (!headingNode) return null;
		return headingNode.querySelector('.mw-editsection') || (headingNode.parentNode && headingNode.parentNode.querySelector('.mw-editsection'));
	}

	function setLinkDisabled($link, disabled) {
		if (disabled) {
			$link.addClass('quickarchive-disabled');
			$link.css({ 'pointer-events': 'none', 'opacity': 0.6 });
		} else {
			$link.removeClass('quickarchive-disabled');
			$link.css({ 'pointer-events': '', 'opacity': '' });
		}
	}

	async function fetchSectionContent(pageName, sectionId) {
		var sectionData = await api.get({
			action: 'query',
			prop: 'revisions',
			rvprop: ['content', 'timestamp'],
			rvslots: 'main',
			titles: pageName,
			rvsection: sectionId,
			formatversion: 2
		});
		var page = sectionData && sectionData.query && sectionData.query.pages && sectionData.query.pages[0];
		if (!page || !page.revisions || !page.revisions[0]) throw new Error('无法读取章节内容');

		var rev = page.revisions[0];
		var content = '';
		if (rev.slots && rev.slots.main && typeof rev.slots.main.content === 'string') content = rev.slots.main.content;
		else if (typeof rev.content === 'string') content = rev.content;

		return { content: content || '', timestamp: rev.timestamp };
	}

	async function deleteSection(pageName, sectionId) {
		var sec = await fetchSectionContent(pageName, sectionId);
		await api.postWithToken('csrf', {
			action: 'edit',
			title: pageName,
			section: sectionId,
			text: '',
			basetimestamp: sec.timestamp,
			summary: '删除段落',
			format: 'json'
		});
	}

	async function archiveSection(pageName, sectionId) {
		var cfg = await getArchivetoConfig();
		if (!cfg || !cfg.archiveTitle) throw new Error('未找到 {{archiveto|1=}} 或参数1为空');

		var archiveTitle = cfg.archiveTitle;
		var sec = await fetchSectionContent(pageName, sectionId);
		var sectionText = (sec.content || '').replace(/\s+$/g, '');
		if (!sectionText.trim()) throw new Error('该段内容为空,无法存档');

		// 1) 追加到存档页
		await api.postWithToken('csrf', {
			action: 'edit',
			title: archiveTitle,
			appendtext: '\n\n' + sectionText,
			summary: '存档自[[' + pageName + ']]',
			format: 'json'
		});

		// 2) 清空本页该段
		await api.postWithToken('csrf', {
			action: 'edit',
			title: pageName,
			section: sectionId,
			text: '',
			basetimestamp: sec.timestamp,
			summary: '存档到[[' + archiveTitle + ']]',
			format: 'json'
		});
	}

	function insertActionLinks(headingNode, cfg) {
		var pageName = mw.config.get('wgPageName');
		var tag = (headingNode && headingNode.tagName ? headingNode.tagName.toLowerCase() : '');
		if (cfg && cfg.allowedHeadingTags && !cfg.allowedHeadingTags.has(tag)) return;

		var sectionId = getSectionIdFromHeading(headingNode);
		if (!sectionId) return;

		var editNode = findEditSectionNode(headingNode);
		if (!editNode) return;
		if (editNode.getAttribute('data-quickarchive') === '1') return;
		editNode.setAttribute('data-quickarchive', '1');

		var closeBracket = editNode.querySelectorAll('.mw-editsection-bracket');
		closeBracket = closeBracket && closeBracket.length ? closeBracket[closeBracket.length - 1] : null;

		function insertBeforeClose(node) {
			if (closeBracket && closeBracket.parentNode === editNode) {
				editNode.insertBefore(node, closeBracket);
			} else {
				editNode.appendChild(node);
			}
		}

		var links = [];

		if (!cfg || cfg.showDelete !== false) {
			var delLink = document.createElement('a');
			delLink.href = '#';
			delLink.textContent = '删除';
			delLink.className = 'quickarchive-delete';

			$(delLink).on('click', async function (e) {
				e.preventDefault();
				var $self = $(this);
				if ($self.hasClass('quickarchive-disabled')) return false;

				setLinkDisabled($self, true);
				try {
					await deleteSection(pageName, sectionId);
					mw.notify('删除成功:已清空该段。', { type: 'success', autoHide: true });
					setTimeout(function () { window.location.reload(); }, 800);
				} catch (err) {
					console.error(err);
					mw.notify('删除失败:' + (err && err.message ? err.message : err), { type: 'error' });
					setLinkDisabled($self, false);
				}
				return false;
			});

			links.push(delLink);
		}

		var archLink = document.createElement('a');
		archLink.href = '#';
		archLink.textContent = '存档';
		archLink.className = 'quickarchive-archive';

		$(archLink).on('click', async function (e) {
			e.preventDefault();
			var $self = $(this);
			if ($self.hasClass('quickarchive-disabled')) return false;

			setLinkDisabled($self, true);
			try {
				var c = await getArchivetoConfig();
				await archiveSection(pageName, sectionId);
				mw.notify('存档成功:已存档到[[' + ((c && c.archiveTitle) ? c.archiveTitle : (pageName + '/…')) + ']]。', { type: 'success', autoHide: true });
				setTimeout(function () { window.location.reload(); }, 800);
			} catch (err) {
				console.error(err);
				mw.notify('存档失败:' + (err && err.message ? err.message : err), { type: 'error' });
				setLinkDisabled($self, false);
			}
			return false;
		});

		links.push(archLink);

		if (!links.length) return;

		// 统一插入:" | ..."(置于右括号前)
		insertBeforeClose(document.createTextNode(' | '));
		for (var li = 0; li < links.length; li++) {
			if (li > 0) insertBeforeClose(document.createTextNode(' | '));
			insertBeforeClose(links[li]);
		}
	}

	function init($content, cfg) {
		// 只给章节标题加(h2-h6),避免引入额外“段落”定义
		var $headings = ($content || $(document)).find('.mw-parser-output').find('h2, h3, h4, h5, h6');
		$headings.each(function () {
			insertActionLinks(this, cfg);
		});
	}

	function main() {
		mw.loader.using(['mediawiki.api']).then(function () {
			api = new mw.Api();
			getArchivetoConfig().then(function (cfg) {
				init($(document), cfg);
			});
		});

		// 兼容部分皮肤/动态刷新
		mw.hook('wikipage.content').add(function ($content) {
			if (!api) return;
			getArchivetoConfig().then(function (cfg) {
				init($content, cfg);
			});
		});
	}

	$(main);

}(mediaWiki, jQuery));
// </nowiki>