User:PhiLiP/cgroup-finder.js

注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google ChromeFirefoxMicrosoft EdgeSafari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。

/**
 * CGroupFinder
 *
 * This script uses MediaWiki APIs to help find articles that lack of certain
 * CGroups but may need them actually.
 * Also see [[Wikipedia:字詞轉換處理/公共轉換組]].
 * 
 * @author: [[User:PhiLiP]]
 */

var MWAPI = new mw.Api();

/**
 * Core function to get sub-category through API
 * 
 * @param {string} title The full category title (include the namespace)
 * @param {integer} limit
 * @param {string} _continue The correspond param of API's "cmcontinue",
 *  For internal usage.
 * @return {jQuery#Promise object}
 * 
 * The returning promise passes these parameters when resolved:
 * @param {array[string]} titles
 * @param {function|null} next()
 * @param {string|null} currentContinue
 * @param {string|null} nextContinue
 * 
 * The next() function:
 * @return {jQuery#Promise object} The same to getSubCategory
 */
function getSubCategory( category, limit, _continue ) {
	return MWAPI
	.post( {
		action: 'query',
		list: 'categorymembers',
		cmtitle: category,
		cmtype: 'subcat',
		cmcontinue: _continue,
		cmlimit: limit
	} )
	.then( function( data ) {
		var members = 'query' in data ? data.query.categorymembers : [],
			nextCont = 'continue' in data ? data['continue'].cmcontinue : null;
		return $.Deferred().resolve(
			/* titles = */$.map( members, function( page ) {
				return page.title;
			} ),
			/* next = */nextCont && function() {
				return getSubCategory(
					category, limit, nextCont );
			},
			/* currentContinue = */_continue,
			/* nextContinue = */nextCont ).promise();
	} );
}

/**
 * Core function to find articles in a category which do not transclude giving
 * template. It is useful to find any article which should use but doesn't
 * use certain CGroup(s).
 * 
 * @param {string} category Full category title
 * @param {string} template Full template title
 * @param {integer} limit Not guaranteed
 * @param {string} _continue Internal (API) continue string
 * @return {jQuery#Promise object} The same to getSubCategory
 * 
 * Except the next() function passed when resolved:
 * @param {integer} limit Default to the previous limit
 */
function getNontransArticlesInCategory( category, template, limit, _continue ) {
	var scopedLimit = limit;
	// I didn't use generator here since JSON messes up the sort order when
	// the result pages is in the form of Object (dictionary).
	return MWAPI
	.post( {
		action: 'query',
		list: 'categorymembers',
		cmtitle: category,
		cmtype: 'page',
		cmcontinue: _continue,
		cmlimit: limit
	} )
	.then( function( data ) {
		var members = 'query' in data ? data.query.categorymembers : [],
			pageids = $.map( members, function( m ) { return m.pageid; } ),
			pageLength = pageids.length;
		return ( pageLength ? MWAPI
		.post( {
			action: 'query',
			prop: 'templates',
			pageids: pageids.join( '|' ),
			tltemplates: template,
			tllimit: pageLength
		} ) : $.Deferred().resolve( null ).promise() )
		.then( function( d2 ) {
			var titles,
			nextCont =
			'continue' in data ? data['continue'].cmcontinue : null;
			if ( pageLength && 'query' in d2 ) {
				titles = $.map( pageids, function( pageid ) {
					var page = d2.query.pages[pageid];
					if ( !( 'templates' in page ) ) {
						return page.title;
					}
					else return null;
				} );
			} else {
				titles = [];
			}
			return $.Deferred().resolve( titles, nextCont ).promise();
		} );
	} )
	.then( function( titles, nextCont ) {
		return $.Deferred().resolve(
			titles,
			nextCont && function( limit ) {
				return getNontransArticlesInCategory(
					category, template, limit || scopedLimit, nextCont );
			},
			_continue, nextCont ).promise();
	} );
}

/**
 * Core function to find category member articles lack of certain
 * template which will also flatten the result for further UI rendering.
 *
 * @param {string} category Full category title
 * @param {string} template Full template title
 * @param {object} startAt Combination to resume from last position
 * @param {object|null} categoryPath Internal object to store the search path
 * @return {jQuery#Promise object} Gives title results and startAt combination
 *
 * The returning promise passes these parameters when resolved:
 * @param {array[[string, string]]} titles_and_categories
 * @param {object} startAt Same combination to flattenNontransArticles
 * @param {boolean} end
 *
 */
function flattenNontransArticles(
category, template, fetch, startAt, categoryPath ) {
	var result = [],
		_continue = startAt ? startAt['continue'] : null,
		startPage = startAt ? startAt.startPage : null,
		processedPath = startAt ? startAt.processedPath : {},
		inProcessedPath = false,
		deferred = $.Deferred();

	function resolveEnd() {
		deferred.resolve( result, /* startAt = */null, /* end = */true );
	}

	function processTitlesAndNext( titles, next, currentCont, nextCont ) {
		var nextContinue,
			stopPage = null;
		$.each( titles, function( _, title ) {
			if ( startPage ) {
				if ( startPage === title ) {
					startPage = null;
				}
				else return; // continue
			}
			if ( ( fetch -- ) > 0 ) {
				result.push( [ title, category ] );
			}
			else { // fetch == -1
				stopPage = title;
				return false; // break
			}
		} );
		if ( fetch > 0 && next instanceof Function ) {
			return next( fetch ).then( processTitlesAndNext );
		}
		else if ( fetch === 0 ) {
			// start a new cmcontinue of current category
			nextContinue = nextCont;
		}
		else { // fetch === -1
			// still in current cmcontinue
			nextContinue = currentCont;
		}
		
		return $.Deferred().resolve( stopPage, nextContinue );
	}

	function processSubCategory( titles, next, parentCurCont, parentNextCont ) {
		if ( titles.length === 0 ) {
			if ( next instanceof Function ) {
				// fetch more subcats in same level
				categoryPath[category] = parentNextCont;
				return next().then( processSubCategory );
			}
			// else, it's the end of current category
			resolveEnd();
			return deferred.promise();
		}
		var subCat = titles.shift(),
			subCatPath = $.extend( {}, categoryPath );
		// search pages in subcat from first page
		subCatPath[subCat] = null;
		// clean dangling data
		if ( Object.keys( processedPath ).length === 0 ) {
			_continue = startPage = null;
		}
		// start to search a new category
		return flattenNontransArticles(
			subCat, template, fetch, {
			'continue': _continue,
			startPage: startPage,
			processedPath: processedPath
			}, subCatPath )
		.then( function( subResult, startAt ) {
			// merge subcat result
			fetch -= subResult.length;
			$.merge( result, subResult );
			if ( fetch === 0 ) {
				// call resolved now
				deferred.resolve( result, startAt, /* end = */false );
				return deferred.promise();
			}
			else {
				// not done yet. process remaining fetched sub-categories
				return processSubCategory(
					titles, next, parentCurCont, parentNextCont );
			}
		} );
	}

	function callProcessSubCategory() {
		return getSubCategory(
			category,
			/* limit =*/10,
			/* _continue = */categoryPath[category] )
		.then( processSubCategory );
	}

	if ( !categoryPath ) {
		// initial top
		categoryPath = {};
		categoryPath[category] = null;
	}

	if ( category in processedPath ) {
		inProcessedPath = true;
		// fast forward to stored continue
		categoryPath[category] = processedPath[category];
		delete processedPath[category];
	}

	if ( Object.keys( processedPath ).length === 0 ) {
		// it's in the bottom of processedPath or it's just a new category
		// never known. Either of them should fetch articles
		getNontransArticlesInCategory(
			category, template, fetch, _continue )
		.then( processTitlesAndNext )
		.then( function( nextStartPage, nextContinue ) {
			_continue = startPage = null;
			if ( fetch < 1 ) {
				// we got all result we interested now.
				// it's time to call resolved.
				deferred.resolve( result, {
					startPage: nextStartPage,
					processedPath: categoryPath,
					'continue': nextContinue
				}, /* end = */false );
			}
			else {
				// it's not enough in current category, search subcat now
				return callProcessSubCategory();
			}
		} );
	}
	else if ( fetch > 0 && inProcessedPath ) {
		// may have unprocessed sub-category
		return callProcessSubCategory();
	}
	else {
		// processed previously
		resolveEnd();
	}
	return deferred.promise();
}

function normalizeCategoryTitle( category ) {
	category = category.replace( /^(分类|分類|category):/i, 'Category:' );
	if ( !( /^Category:/.test( category ) ) ) {
		category = 'Category:' + category;
	}
	return category;
}

/**
 * UI function to build main search form of CGroupFinder
 * 
 * Requires these modules for correct style:
 * 
 * - ext.inputBox.styles
 * - mediawiki.ui.input
 * - mediawiki.ui.button
 * 
 * @param {string} pname Template to be searched around
 */
function uiSearchForm( tpl ) {
	var shortName = tpl.replace( /^(Template|Module|模块):CGroup\//, '' );
	var $button, $form = $( (
		'<form class="mw-inputbox-centered cgroupfinder-form">$1&nbsp;' +
		'<input name="category" type="text" placeholder="$2"' +
		'class="mw-ui-input mw-ui-input-inline" size=30>' +
		'&nbsp;<button type="submit" class="mw-ui-button mw-ui-progressive"' +
		' disabled>$3</button></form>'
	)
	.replace(
		'$1', mw.message( 'cgroupfinder-search-label', shortName ).escaped() )
	.replace( '$2', mw.message( 'cgroupfinder-input-placeholder' ).escaped() )
	.replace( '$3', mw.message( 'cgroupfinder-search' ).escaped() ) )
	.data( 'cgroup-template', tpl );
	$button = $form.find( 'button[type="submit"]' );

	return $form.find( 'input[type="text"]' )
	.on( 'keydown paste change', function( evt ) {
		var self = this;
		if ( $form.data( 'cgroupfinder-loading' ) ) {
			evt.preventDefault();
			return;
		}
		setTimeout( function() {
			if ( !$form.data( 'cgroupfinder-loading' ) ) {
				$button.prop( 'disabled', self.value.length === 0 );
			}
		} );
	} ).end();
}

function uiSearchResultGroup( topCat, curCat ) {
	var $group = $(
		'<div class="cgroupfinder-search-result-group">' );
	if ( topCat === curCat ) {
		$group.append(
			'<h3><a href="$1">$2</a></h3>'
			.replace( '$1', mw.util.getUrl( curCat ) )
			.replace( '$2', curCat.replace( /^Category:/, '' ) )
		);
	}
	else {
		$group.append(
			'<h3>&gt;&gt; <a href="$1">$2</a></h3>'
			.replace( '$1', mw.util.getUrl( curCat ) )
			.replace( '$2', curCat.replace( /^Category:/, '' ) )
		);
	}
	$group.append( '<ul class="cgroupfinder-search-result-list">' );
	return $group;
}

function uiSearchResult( titles, topCat, curStartAt, nextStartAt, isEnd ) {
	var $curGroup, lastCat, $curList,
		$container = $( '<div class="cgroupfinder-search-result-container">' ),
		$groups = $( '<div class="cgroupfinder-search-result-groups">' ),
		$pagination = uiPagination( curStartAt, nextStartAt, isEnd );

	$container
	.append(
		'<p class="cgroupfinder-search-result-description">$1</p>'
		.replace(
			'$1',
			mw.message( 'cgroupfinder-search-result-description' ).escaped() )
	)
	.append( $pagination )
	.append( $groups );

	$.each( titles, function( _, title ) {
		var $item,
			category = title[1];
		title = title[0];
		if ( category !== lastCat ) {
			$curGroup = uiSearchResultGroup( topCat, category )
			.appendTo( $groups );
			$curList = $curGroup.find( 'ul' );
			lastCat = category;
		}
		
		$item = $( (
			'<li class="cgroupfinder-search-result-item">' +
			'<a href="$1">$2</a>' +
			'<span class="cgroupfinder-search-result-item-options">' +
			'</span></li>'
		)
		.replace( '$1', mw.util.getUrl( title ) )
		.replace( '$2', title ) )
		.appendTo( $curList );
	} );
	return $container;
}

var _paginations = [];

function uiPagination( curStartAt, nextStartAt, isEnd ) {
	var prevStartAt = false;
	if ( curStartAt !== null ) {
		// page > 1
		if ( curStartAt === _paginations[_paginations.length - 2] ) {
			// clicked prev
			_paginations.pop(); // pop orig current
			if ( _paginations.length > 1 ) {
				// still have prev
				prevStartAt = _paginations[_paginations.length - 2];
			}
			// else prevStartAt should be false
		}
		else {
			// clicked next, already have _paginations
			prevStartAt = _paginations[_paginations.length - 1];
			_paginations.push( curStartAt );
		}
	}
	else {
		// page = 1
		_paginations = [ null ];
	}
	var $pagination = $( '<div class="cgroupfinder-pagination">' ),
		$prev = $(
			( prevStartAt !== false ? '<a href="#">$1</a>' : '<span>$1</span>' )
			.replace(
				'$1', mw.message( 'cgroupfinder-pagination-prev' ).escaped() )
		),
		$next = $(
			( nextStartAt ? '<a href="#">$1</a>' : '<span>$1</span>' )
			.replace(
				'$1', mw.message( 'cgroupfinder-pagination-next' ).escaped() )
		);
	$pagination
	.append( ' (' ).append( $prev ).append( ' | ' )
	.append( $next ).append( ')' );
	if ( prevStartAt !== false ) {
		$prev.on( 'click', function( evt ) {
			evt.preventDefault();
			doSearch( 50, prevStartAt );
		} );
	}
	if ( nextStartAt ) {
		$next.on( 'click', function( evt ) {
			evt.preventDefault();
			doSearch( 50, nextStartAt );
		} );
	}
	return $pagination;
}

function doSearch( fetch, startAt ) {
	var curStartAt = null,
		$form = $( 'form.cgroupfinder-form' ),
		$resultContainer = $( '.cgroupfinder-search-result-container' ),
		$input = $form.find( 'input[name="category"]' ),
		$submit = $form.find( 'button[type="submit"]' ),
		topCat = $input.val(),
		tpl = $form.data( 'cgroup-template' );

	topCat = normalizeCategoryTitle( topCat );
	$input.val( topCat );
	if ( startAt ) {
		// do deepcopy
		curStartAt = $.extend( /* deep =*/true, {}, startAt );
	}

	$form.data( 'cgroupfinder-loading', true );
	$submit
	.prop( 'disabled', true )
	.text( mw.message( 'cgroupfinder-searching' ).text() )
	.prepend( '<span class="mw-ajax-loader" style="top: 0;">' );
	$resultContainer.find( '.cgroupfinder-pagination' ).remove();
	return flattenNontransArticles( topCat, tpl, fetch, curStartAt )
	.then( function( titles, nextStartAt, isEnd ) {
		$resultContainer
		.replaceWith( 
			uiSearchResult( titles, topCat, startAt, nextStartAt, isEnd )
		);
		$form.data( 'cgroupfinder-loading', false );
		$submit
		.prop( 'disabled', false )
		.text( mw.message( 'cgroupfinder-search' ) );
	} );
}

function uiContainer( tpl ) {
	var $container = $( '<div class="cgroupfinder">' ),
		$form = uiSearchForm( tpl ).appendTo( $container ),
		$resultContainer = $(
			'<div class="cgroupfinder-search-result-container">' )
		.appendTo( $container );

	$container.append( '<hr>' );

	$form.submit( function( evt ) {
		evt.preventDefault();
		if ( !$form.data( 'cgroupfinder-loading' ) ) {
			doSearch( 50, null, null );
		}
	} );
	return $container;
}

function initFinder() {
	var tpl = mw.config.get( 'wgPageName' ),
		$container = uiContainer( tpl ).prependTo( '#mw-content-text' );
}

function autoInit() {
	var init = true;
	init = init && mw.config.get( 'wgAction' ) === 'view';
	init = init && /^(Template|Module|模块):CGroup\//.test(
		mw.config.get( 'wgPageName' ) );
	init = init && $( '[data-cgroup-module-exists]' ).length === 0;
	if ( init ) {
		mw.loader.using( [
			'ext.inputBox.styles',
			'mediawiki.ui.input',
			'mediawiki.ui.button'
		], initFinder );
	}
}

$.each(
	wgULS( /* zh-hans =*/{
		'cgroupfinder-search-label': '查找缺少$1组转换的页面:',
		'cgroupfinder-input-placeholder': '输入分类名称',
		'cgroupfinder-search': '搜索',
		'cgroupfinder-searching': '搜索中',
		'cgroupfinder-search-result-description':
		'以下列出了所输入分类下(包含子分类)所有缺少当前组转换的页面。',
		'cgroupfinder-pagination-prev': '上一页',
		'cgroupfinder-pagination-next': '下一页'
	}, /* zh-hant =*/{
		'cgroupfinder-search-label': '查詢缺少$1組轉換的頁面:',
		'cgroupfinder-input-placeholder': '鍵入分類名稱',
		'cgroupfinder-search': '搜尋',
		'cgroupfinder-searching': '搜尋中',
		'cgroupfinder-search-result-description':
		'以下列出了所鍵入分類下(包含子分類)所有缺少當前組轉換的頁面。',
		'cgroupfinder-pagination-prev': '上一頁',
		'cgroupfinder-pagination-next': '下一頁'
	} ),
	function( selection, value ) {
		mw.messages.set( selection, value );
	}
);

autoInit();