跳转到内容

User:SunAfterRain/js/WikidataDesc.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google ChromeFirefoxMicrosoft EdgeSafari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
/**
 * WikidataDesc
 * 在條目頂端顯示維基資料描述
 * 
 * 基於 https://zh.wikipedia.org/w/index.php?title=MediaWiki:Gadget-WikidataDesc.js&oldid=83140265 (原作者:User:Alexander Misel/改進:User:逆襲的天邪鬼)
 * 
 * @author SunAfterRain
 * 
 * 註:因為使用了 assert=user,沒有維基數據帳號也無法正常自動建立帳號的人可能會無法正常使用。
 *     可以指定 `window.wgWikidataDescForceAnonymous = true;` 改為發出匿名請求,
 *     代價是會失去未來可能實現的編輯的功能。
 * 
 * TODO:實現編輯
 */
$.when(
	$.ready,
	mw.loader.using(['mediawiki.ForeignApi', 'ext.gadget.HanAssist'])
).then(async (_$, require) => {
	const HanAssist = require('ext.gadget.HanAssist');

	const url = new URL(window.location.href);
	if (
		mw.config.get('wgNamespaceNumber') < 0 ||
		(mw.config.get('wgNamespaceNumber') !== 0 && !url.searchParams.has('loadwddesc')) ||
		mw.config.get('wgCurRevisionId') !== mw.config.get('wgRevisionId') ||
		!!mw.config.get('wgDiffNewId')
	) {
		return;
	}

	const messages = HanAssist.batchConv({
		descriptionPlaceholder: {
			hans: '维基数据描述',
			hant: '維基數據描述'
		},
		descriptionEmpty: {
			hans: '(没有描述)',
			hant: '(沒有描述)'
		},
		descriptionEmptyAbbrTitle: {
			hans: '没有描述',
			hant: '沒有描述'
		},

		loadFail: {
			hans: '无法载入描述:',
			hant: '無法載入描述:'
		},

		isLanguage: {
			hans: '($1)',
			hant: '($1)',
		},
		convertedFromLanguage: {
			hans: '(转换拼写自$1)',
			hant: '(轉換拼寫自$1)',
		},

		buttonExpand: {
			hans: '展开所有变体',
			hant: '展開所有變體'
		},
		buttonCollapse: {
			hans: '合并',
			hant: '合併'
		},
		buttonEdit: {
			hans: '编辑',
			hant: '編輯'
		},
		buttonSave: {
			hans: '保存',
			hant: '儲存'
		},
		buttonCancel: {
			hans: '取消',
			hant: '取消'
		},

		saving: {
			hans: '正在保存描述……',
			hant: '正在儲存描述……'
		},
		saved: {
			hans: '描述保存成功。',
			hant: '描述儲存成功。'
		},
		saveFail: {
			hans: '无法编辑描述:$1',
			hant: '無法編輯描述:$1'
		},
		saveErrorEmptyEntity: {
			hans: '错误:你不能尝试建立一个只有空白描述的维基数据项目。',
			hant: '錯誤:你不能嘗試建立一個只有空白描述的維基數據項目。'
		}
	});
	const chineseVariants = [
		'zh',
		'zh-hans', 'zh-cn', 'zh-my', 'zh-sg',
		'zh-hant', 'zh-tw', 'zh-hk', 'zh-mo',
	];
	const wgUserLanguage = mw.config.get('wgUserLanguage', 'zh');
	const wgUserVariant = mw.config.get('wgUserVariant', wgUserLanguage);
	const getLanguageName = (() => {
		const languageNames = {
			'zh': '中文',
			'zh-hans': '中文(简体)',
			'zh-cn': '中文(大陆)',
			'zh-my': '中文(大马)',
			'zh-sg': '中文(新加坡)',
			'zh-hant': '中文(繁體)',
			'zh-tw': '中文(臺灣)',
			'zh-hk': '中文(香港)',
			'zh-mo': '中文(澳門)'
		};
		let displayNames;
		return function getLanguageName(langCode) {
			if (chineseVariants.includes(langCode)) {
				return languageNames[langCode];
			}
			
			if (!displayNames) {
				displayNames = new Intl.DisplayNames(wgUserLanguage, { type: 'language' });
			}
			return displayNames.of(langCode);
		};
	})();
	const useVariant = chineseVariants.includes(wgUserVariant) ? wgUserVariant : 'zh';

	const allowPostEdit = mw.user.isNamed() && !window.wgWikidataDescForceAnonymous;

	const pageName = mw.config.get('wgPageName');
	const wikidataApi = new mw.ForeignApi(
		'//www.wikidata.org/w/api.php',
		allowPostEdit
			? {
				parameters: {
					assert: 'user'
				}
			}
			: {
				anonymous: true
			}
	);
	
	function deepClone(object) {
		if (!object || typeof object !== 'object') {
			return object;
		} else if (Array.isArray(object)) {
			return object.map(deepClone);
		}
		
		const objectType = Object.prototype.toString.call(object);
		if (objectType !== '[object Object]') {
			throw new Error(`Fail to clone ${objectType}: Not implemented.`);
		}
	
		const clonedObj = Object.create(Object.getPrototypeOf(object));
		for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(object))) {
			if (descriptor.get || descriptor.set) {
				throw new Error(`Fail to clone object: Unexpected get/set accessor "${key}".`);
			}
			descriptor.value = deepClone(descriptor.value);
			Object.defineProperty(clonedObj, key, descriptor);
		}
		return clonedObj;
	}

	let entityData = null;
	async function loadData() {
		try {
			const data = await wikidataApi.get({
				action: 'wbgetentities',
				props: ['labels', 'descriptions'],
				sites: 'zhwiki',
				titles: pageName,
				languages: chineseVariants,
				languagefallback: true,
				formatversion: '2',
			});

			const [entityId, entity] = Object.entries(data.entities ?? {})[0] ?? [];

			if (!data.success || !entity) {
				throw Object.assgin(
					new Error('Unable to parse the data returned by the api.'),
					{ data }
				);
			}

			if (entityId !== '-1') {
				entityData = entity;
			}
		} catch (error) {
			mw.notify(
				mw.format(messages.loadFail, String(error)),
				{
					title: 'WikidataDesc',
					autoHide: false,
					type: 'error'
				}
			);
			console.error(error);
			return;
		}
		displayDescription();
	}

	function createElement(tagName, attrs = {}, innerText = null) {
		const element = document.createElement(tagName);
		for (const [attrName, attrValue] of Object.entries(attrs)) {
			if (attrValue !== undefined && attrValue !== null) {
				element.setAttribute(attrName, attrValue);
			}
		}
		if (innerText) {
			element.innerText = innerText;
		}
		return element;
	}

	function makeEmptyPlaceholder() {
		return createElement(
			'abbr',
			{
				class: 'text',
				title: messages.descriptionEmptyAbbrTitle
			},
			messages.descriptionEmpty
		);
	}

	function setDisplay(element, value) {
		element.style.display = value;
	}

	const NOTIFY_TITLE = 'WikidataDesc.js';

	function makeEditableDescArea(variant, desc) {
		const wrapper = createElement('div');

		const nameField = document.createTextNode(`${getLanguageName(variant)}:`);
		const textField = createElement(
			'span',
			{
				class: 'text'
			}
		);
		textField.append(
			desc
				? document.createTextNode(desc)
				: makeEmptyPlaceholder()
		);
		if (!window.wgWikidataDescForceAnonymous) {
			const editButton = createElement(
				'a',
				{
					href: '#',
					class: 'option'
				},
				`[${messages.buttonEdit}]`
			);
	
			let editInput;
			const editArea = createElement('div');
			const saveButton = createElement(
				'a',
				{
					href: '#',
					class: 'option'
				},
				`[${messages.buttonSave}]`
			);
			const cancelButton = createElement(
				'a',
				{
					href: '#',
					class: 'option'
				},
				`[${messages.buttonCancel}]`
			);
	
			wrapper.append(nameField, textField, editButton, editArea, saveButton, cancelButton);
	
			setDisplay(editArea, 'none');
			setDisplay(saveButton, 'none');
			setDisplay(cancelButton, 'none');

			// eslint-disable-next-line no-inner-declarations
			function enableEdit() {
				setDisplay(textField, 'none');
				setDisplay(editButton, 'none');
				setDisplay(editArea, 'inline-block');
				setDisplay(saveButton, 'inline');
				setDisplay(cancelButton, 'inline');
			}
	
			// eslint-disable-next-line no-inner-declarations
			function disableEdit() {
				setDisplay(textField, 'inline');
				setDisplay(editButton, 'inline');
				setDisplay(editArea, 'none');
				setDisplay(saveButton, 'none');
				setDisplay(cancelButton, 'none');
			}
	
			$(editButton).on('click', (ev) => {
				ev.preventDefault();
				enableEdit();
	
				if (!editInput) {
					editInput = new OO.ui.TextInputWidget({
						placeholder: messages.descriptionPlaceholder,
					});
					$(editArea).append(editInput.$element);
				}
				editInput.setValue(desc);
			});
	
			let saving = false;
			$(saveButton).on('click', async (ev) => {
				ev.preventDefault();
				if (saving) {
					return;
				}
				const newDesc = editInput.getValue()?.trim();
				if (desc === newDesc || (!desc && !newDesc)) { // 都一樣或是都是空白
					// treat as cancel
					disableEdit();
					return;
				}
				mw.notify(messages.saving, { title: NOTIFY_TITLE, tag: variant, type: 'info' });
				try {
					saving = new AbortController();
					const signal = saving.signal;
					if (entityData) {
						await wikidataApi.postWithEditToken({
							action: 'wbsetdescription',
							id: entityData.id,
							language: variant,
							value: newDesc,
						}, { signal });
						desc = newDesc;
						if (!entityData.labels.zh) {
							await wikidataApi.postWithEditToken({
								action: 'wbsetlabel',
								id: entityData.id,
								language: 'zh',
								value: pageName,
							}, { signal });
							entityData.labels.zh = pageName;
						}
					} else {
						if (!newDesc) {
							alert(messages.saveErrorEmptyEntity);
							return;
						}
						await wikidataApi.postWithEditToken({
							action: 'wbeditentity',
							new: 'item',
							data: ({
								labels: { zh: { language: 'zh', value: pageName } },
								descriptions: { zh: { language: variant, value: newDesc } },
								sitelinks: { zhwiki: { site: 'zhwiki', title: pageName } },
							})
						}, { signal });
						desc = newDesc;
					}
					mw.notify(messages.saved, { title: NOTIFY_TITLE, tag: variant, type: 'success' });
					textField.replaceChildren(
						desc
							? document.createTextNode(desc)
							: makeEmptyPlaceholder()
					);
					disableEdit();
				} catch (error) {
					console.error(error);
					mw.notify(mw.format(messages.saveFail, String(error)), { title: NOTIFY_TITLE, tag: variant, type: 'error' });
				} finally {
					saving = false;
				}
			});
	
			$(cancelButton).on('click', (ev) => {
				ev.preventDefault();
				if (saving) {
					saving.abort('user aborted.');
					saving = false;
				}
				disableEdit();
			});
		} else {
			wrapper.append(nameField, textField);
		}

		return wrapper;
	}

	let desc = null;
	let descBox = null;
	function displayDescription() {
		if (!desc) {
			mw.util.addCSS('#wikidatadesc .text { color: var(--color-subtle,#54595d); font-size: medium; } #wikidatadesc .collapsed { display: none; } #wikidatadesc .option { font-size: smaller; }');
			desc = createElement('div', {
				id: 'wikidatadesc',
				class: 'noprint'
			});
			descBox = createElement('div', {
				id: 'wikidatadesc-descbox'
			});
			desc.append(descBox);
			$("#siteSub").show().get(0).replaceChildren(desc);
		}

		const fragment = document.createDocumentFragment();
		const singleBox = createElement('div');
		const allVariantsBox = createElement('div', {
			class: 'collapsed'
		});
		fragment.append(singleBox, allVariantsBox);

		let descriptions = entityData ? deepClone(entityData.descriptions) : {};
		const userLangDescription = deepClone(descriptions[useVariant]);
		if (descriptions.zh?.language === 'zh') {
			// 有中文資料,清理回退鏈
			for (const [key, description] of Object.entries(descriptions)) {
				if ('source-language' in description) {
					delete descriptions[key];
				}
			}
		} else {
			// 沒有中文資料
			descriptions = {};
		}
		if (userLangDescription) {
			singleBox.append(
				createElement(
					'span',
					{
						class: 'text'
					},
					userLangDescription.value
				)
			);
			if (userLangDescription.language !== useVariant) {
				singleBox.append(
					document.createTextNode(' '),
					createElement(
						'span',
						{},
						mw.format(messages.isLanguage, getLanguageName(userLangDescription.language))
					)
				);
			} else if ('source-language' in userLangDescription) {
				singleBox.append(
					document.createTextNode(' '),
					createElement(
						'span',
						{},
						mw.format(messages.convertedFromLanguage, getLanguageName(userLangDescription['source-language']))
					)
				);
			}
		} else {
			singleBox.append(makeEmptyPlaceholder());
		}
		for (const variant of chineseVariants) {
			const description = descriptions[variant];
			const value = description?.['source-language'] ? undefined : description?.value;
			allVariantsBox.append(makeEditableDescArea(variant, value));
		}

		const expandButton = createElement(
			'a',
			{
				href: '#',
				class: 'option'
			},
			`[${messages.buttonExpand}]`
		);
		$(expandButton).on('click', (ev) => {
			ev.preventDefault();
			singleBox.classList.add('collapsed');
			allVariantsBox.classList.remove('collapsed');
		});
		singleBox.append(
			document.createTextNode(' '),
			expandButton
		);

		const collapseButton = createElement(
			'a',
			{
				href: '#',
				class: 'option'
			},
			`[${messages.buttonCollapse}]`
		);
		$(collapseButton).on('click', (ev) => {
			ev.preventDefault();
			singleBox.classList.remove('collapsed');
			allVariantsBox.classList.add('collapsed');
		});
		allVariantsBox.append(collapseButton, createElement('hr'));

		desc.replaceChildren(fragment);
	}

	loadData();
});