MediaWiki:Gadget-QuoteSourceRedirects.js: Difference between revisions

From Bodhicitta
MediaWiki:Gadget-QuoteSourceRedirects.js
(VSCode edit)
(Four actions: rewrite, redirect, create, delete)
 
(5 intermediate revisions by the same user not shown)
Line 1: Line 1:
/**
/**
  * Approve buttons for [[QuoteSource redirects to create]].
  * QuoteSourceRedirects
  *
  *
  * Each suggested row carries a .qs-approve span with data-from (the unresolved
  * Tools for [[QuoteSource Unresolved Values]].
* QuoteSource value) and data-to (the target page). Clicking creates a redirect
* from one to the other through the API — it does not touch any segment.
  *
  *
  * Deliberately createonly: if a page already exists at that title the edit is
  * QuoteSource is a page property naming the work a quote segment cites. Where a
  * refused rather than overwriting it, so an approval can never clobber real
  * value does not resolve to a text page, one of four things fixes it:
* content. The row reports what happened either way.
  *
  *
  * Only loaded on that page, and only for users who can edit — the buttons are
  *   rewrite  change the value on every segment that uses it
  * not rendered at all otherwise.
*  redirect  point the value's page name at a text page
*  create    create the text page here, for works DRL already holds
*  delete    remove the value from every segment that uses it
*
* Controls are built here rather than written into the page: raw <button> and
  * <input> do not survive the wikitext sanitizer.
  */
  */
( function () {
( function () {
'use strict';
'use strict';


if ( mw.config.get( 'wgPageName' ) !== 'QuoteSource_redirects_to_create' ) {
var api;
return;
 
function getApi() {
if ( !api ) { api = new mw.Api(); }
return api;
}
 
/* Mirrors the page generator's attr(): the wikitext stores each value
* HTML-escaped in data-value, so a lookup has to escape it the same way. */
function attrEscape( s ) {
return String( s )
.replace( /&/g, '&amp;' )
.replace( /"/g, '&quot;' )
.replace( /</g, '&lt;' )
.replace( />/g, '&gt;' )
.replace( /\r\n?|\n/g, '&#10;' )
.replace( /\|/g, '&#124;' );
}
}


$( function () {
$( function () {
var $rows = $( '.qs-approve' );
var $tables = $( '.qs-table' );
if ( !$rows.length ) {
if ( !$tables.length ) { return; }
return;
 
/* 27 values contain a newline, which would end the table cell. They are
* rendered with a pilcrow and carry the real value in data-value, so
* every lookup reads the attribute rather than the cell text. */
function valueOf( $tr ) {
return $tr.find( '.qs-actions' ).attr( 'data-value' );
}
}


var api = new mw.Api();
function setStatus( $tr, cls, text ) {
 
$tr.find( '.qs-status' ).removeClass( 'qs-ok qs-err qs-busy' )
// Replaces the button with a result. The button is gone by then, so the
.addClass( cls ).text( text );
// row cannot be double-submitted.
function setState( $cell, cls, text ) {
$cell.removeClass( 'qs-pending qs-done qs-failed' ).addClass( cls ).text( text );
}
}


function approve( $cell ) {
function isTreated( $tr ) {
var from = $cell.data( 'from' );
return $tr.hasClass( 'qs-treated' );
var to = $cell.data( 'to' );
if ( !from || !to ) {
return;
}
 
setState( $cell, 'qs-pending', 'creating…' );
 
api.postWithToken( 'csrf', {
action: 'edit',
title: from,
text: '#REDIRECT [[' + to + ']]',
summary: 'Redirect for a QuoteSource value used in the translation memory',
createonly: 1
} ).done( function () {
setState( $cell, 'qs-done', '✓ created' );
} ).fail( function ( code, res ) {
var why = code;
if ( code === 'articleexists' ) {
why = 'page already exists';
} else if ( res && res.error && res.error.info ) {
why = res.error.info;
}
setState( $cell, 'qs-failed', '✗ ' + why );
} );
}
}


// Marking a row treated.
function setTreated( $tr ) {
//
$tr.addClass( 'qs-treated' );
// Two kinds of "done" and they behave differently:
$tr.find( 'button' ).prop( 'disabled', true );
//
$tr.find( '.qs-dismiss' ).text( 'undo' ).prop( 'disabled', false );
//  - RESOLVED: the value now points at a page. That is a fact about the
//    wiki, so it is re-checked on load and needs no storage — a redirect
//    created in a previous session shows as done next time.
//  - DISMISSED: a judgement that a row needs nothing, or nothing we can
//    do. Nothing on the wiki records that, so it is kept per-user in
//    localStorage. It is a working note, not data: losing it costs a
//    second look, not information.
var DISMISS_KEY = 'qs-dismissed';
 
function loadDismissed() {
try {
return JSON.parse( mw.storage.get( DISMISS_KEY ) || '[]' );
} catch ( e ) {
return [];
}
}
}


function saveDismissed( list ) {
function clearTreated( $tr ) {
try {
$tr.removeClass( 'qs-treated' );
mw.storage.set( DISMISS_KEY, JSON.stringify( list ) );
$tr.find( 'button' ).prop( 'disabled', false );
} catch ( e ) {}
$tr.find( '.qs-dismiss' ).text( 'done' );
}
}


function setTreated( $row, why ) {
function countTreated() {
$row.addClass( 'qs-treated' ).attr( 'data-treated', why );
var n = $( 'tr.qs-treated' ).length;
$row.find( 'button' ).prop( 'disabled', true );
var total = $( '.qs-table tr' ).has( 'td' ).length;
$row.find( '.qs-value-edit, .qs-target-edit' ).addClass( 'qs-disabled' );
$( '.qs-treated-count' ).text( n ? n + ' of ' + total + ' handled' : '' );
}
}


function clearTreated( $row ) {
/* ---------------------------------------------------------------
$row.removeClass( 'qs-treated' ).removeAttr( 'data-treated' );
* Marking a row handled, persisted into this page's wikitext.
$row.find( 'button' ).prop( 'disabled', false );
* The row's `|-` becomes `|- class="qs-treated"`, found by the
$row.find( '.qs-value-edit, .qs-target-edit' ).removeClass( 'qs-disabled' );
* data-value attribute, which is unique per row.
* --------------------------------------------------------------- */
function markInWikitext( value, add ) {
var page = mw.config.get( 'wgPageName' );
return getApi().get( {
action: 'query', prop: 'revisions', rvprop: 'content',
rvslots: 'main', titles: page, formatversion: 2
} ).then( function ( res ) {
var text = res.query.pages[ 0 ].revisions[ 0 ].slots.main.content;
var at = text.indexOf( 'data-value="' + attrEscape( value ) + '"' );
if ( at === -1 ) {
return $.Deferred().reject( 'row not found in wikitext' );
}
var rowStart = text.lastIndexOf( '|-', at );
var lineEnd = text.indexOf( '\n', rowStart );
var line = text.slice( rowStart, lineEnd );
var updated = add
? ( line.indexOf( 'qs-treated' ) === -1 ? '|- class="qs-treated"' : line )
: '|-';
return getApi().postWithToken( 'csrf', {
action: 'edit', title: page,
text: text.slice( 0, rowStart ) + updated + text.slice( lineEnd ),
summary: ( add ? 'mark' : 'unmark' ) + ' QuoteSource row handled: ' + value
} );
} );
}
}


// Ask the wiki which values now resolve, in batches of 40.
/* ---------------------------------------------------------------
function refreshResolved() {
* Finding the segments that use a value
var $cells = $( '.qs-approve, .qs-row' );
* --------------------------------------------------------------- */
var values = [];
function segmentsUsing( value ) {
$( '.qs-table tr' ).each( function () {
return getApi().get( {
var v = $( this ).find( '.qs-value' ).text();
action: 'ask',
if ( v ) { values.push( $.trim( v ) ); }
query: '[[QuoteSource::' + value + ']]|limit=500',
formatversion: 2
} ).then( function ( res ) {
return Object.keys( ( res.query && res.query.results ) || {} );
} );
} );
if ( !values.length ) { return; }
}


var resolved = {};
/* QuoteSource can hold several sources separated by ';', so a value is
function batch( i ) {
* changed by exact match on one part, never by replacing the field. */
if ( i >= values.length ) {
function editField( text, from, to ) {
$( '.qs-table tr' ).each( function () {
return text.replace( /(\|\s*QuoteSource\s*=)([^\n]*)/g, function ( all, head, val ) {
var $tr = $( this );
var parts = val.split( ';' );
var v = $.trim( $tr.find( '.qs-value' ).text() );
var kept = [];
if ( v && resolved[ v ] ) { setTreated( $tr, 'resolved' ); }
for ( var i = 0; i < parts.length; i++ ) {
} );
if ( $.trim( parts[ i ] ) === $.trim( from ) ) {
$( '.qs-resolved-count' ).text(
if ( to !== null ) { kept.push( to ); }
Object.keys( resolved ).length + ' of ' + values.length + ' now resolve'
} else {
);
kept.push( parts[ i ] );
return;
}
}
}
var slice = values.slice( i, i + 40 );
return head + kept.join( ';' );
api.get( { action: 'query', titles: slice.join( '|' ), redirects: 1,
} );
format: 'json', formatversion: 2 }
).done( function ( res ) {
var q = res.query || {};
( q.redirects || [] ).forEach( function ( r ) { resolved[ r.from ] = true; } );
( q.pages || [] ).forEach( function ( p ) { if ( !p.missing ) { resolved[ p.title ] = true; } } );
( q.normalized || [] ).forEach( function ( n ) {
if ( resolved[ n.to ] ) { resolved[ n.from ] = true; }
} );
batch( i + 40 );
} ).fail( function () { batch( i + 40 ); } );
}
batch( 0 );
}
}


$( '.qs-table' ).on( 'click', '.qs-dismiss', function () {
function editSegments( pages, i, from, to, $tr, done ) {
var $tr = $( this ).closest( 'tr' );
if ( i >= pages.length ) {
var v = $.trim( $tr.find( '.qs-value' ).text() );
setStatus( $tr, 'qs-ok', ( to === null ? 'removed from ' : 'rewrote ' ) +
var list = loadDismissed();
pages.length + ' segments' );
var at = list.indexOf( v );
done();
if ( at === -1 ) {
list.push( v );
setTreated( $tr, 'dismissed' );
} else {
list.splice( at, 1 );
clearTreated( $tr );
}
saveDismissed( list );
} );
 
// Apply stored dismissals, then ask the wiki about the rest.
( function () {
var dismissed = loadDismissed();
if ( dismissed.length ) {
$( '.qs-table tr' ).each( function () {
var $tr = $( this );
var v = $.trim( $tr.find( '.qs-value' ).text() );
if ( v && dismissed.indexOf( v ) !== -1 ) { setTreated( $tr, 'dismissed' ); }
} );
}
refreshResolved();
}() );
 
$( '.qs-hide-treated' ).on( 'change', function () {
$( '.qs-table' ).toggleClass( 'qs-hiding', this.checked );
} );
 
// Create every redirect in a table at once.
//
// Sequential, not parallel: a hundred simultaneous edits would trip rate
// limits, and stopping cleanly on the first failure is more useful than
// discovering afterwards which of them landed. Rows already created are
// skipped, so it is safe to run again after a failure.
$( '.qs-bulk-btn' ).on( 'click', function () {
var $btn = $( this );
var $status = $btn.siblings( '.qs-bulk-status' );
var $table = $btn.closest( '.qs-bulk' ).next( '.qs-table' );
var $todo = $table.find( '.qs-approve' ).filter( function () {
return !$( this ).hasClass( 'qs-done' );
} );
if ( !$todo.length ) {
$status.text( 'nothing left to create' );
return;
return;
}
}
if ( !window.confirm(
setStatus( $tr, 'qs-busy', 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );
'Create ' + $todo.length + ' redirects?\n\n' +
getApi().get( {
'Each points a QuoteSource value at its suggested target. ' +
action: 'query', prop: 'revisions', rvprop: 'content',
'Existing pages are never overwritten.'
rvslots: 'main', titles: pages[ i ], formatversion: 2
) ) {
} ).done( function ( res ) {
return;
var pg = res.query.pages[ 0 ];
}
if ( !pg || !pg.revisions ) {
$btn.prop( 'disabled', true );
editSegments( pages, i + 1, from, to, $tr, done ); return;
 
}
var i = 0;
var text = pg.revisions[ 0 ].slots.main.content;
function next() {
var updated = editField( text, from, to );
if ( i >= $todo.length ) {
if ( updated === text ) {
$status.text( '✓ done (' + $todo.length + ')' );
editSegments( pages, i + 1, from, to, $tr, done ); return;
$btn.prop( 'disabled', false );
return;
}
}
var $cell = $todo.eq( i );
getApi().postWithToken( 'csrf', {
$status.text( 'creating ' + ( i + 1 ) + ' of ' + $todo.length + '…' );
action: 'edit', title: pages[ i ], text: updated,
$cell.find( '.qs-approve-btn' ).prop( 'disabled', true );
summary: to === null
api.postWithToken( 'csrf', {
? ( 'QuoteSource: remove ' + from )
action: 'edit',
: ( 'QuoteSource: ' + from + ' → ' + to )
title: $cell.data( 'from' ),
text: '#REDIRECT [[' + $cell.data( 'to' ) + ']]',
summary: 'Redirect for a QuoteSource value used in the translation memory',
createonly: 1
} ).done( function () {
} ).done( function () {
setState( $cell, 'qs-done', '✓ created' );
editSegments( pages, i + 1, from, to, $tr, done );
i++; next();
} ).fail( function ( code ) {
} ).fail( function ( code ) {
setState( $cell, 'qs-failed', '✗ ' + ( code === 'articleexists' ? 'exists' : code ) );
setStatus( $tr, 'qs-err', code );
// Keep going: one existing page should not stop the batch.
i++; next();
} );
} );
}
} ).fail( function ( code ) {
next();
setStatus( $tr, 'qs-err', code );
} );
} );
}


$rows.each( function () {
function runOnSegments( $tr, from, to ) {
var $cell = $( this );
setStatus( $tr, 'qs-busy', 'finding segments…' );
$( '<button>' )
segmentsUsing( from ).done( function ( pages ) {
.addClass( 'qs-approve-btn' )
if ( !pages.length ) {
.text( 'Create redirect' )
setStatus( $tr, 'qs-err', 'no segments found' ); return;
.on( 'click', function () {
}
$( this ).prop( 'disabled', true );
editSegments( pages, 0, from, to, $tr, function () {
approve( $cell );
setTreated( $tr );
} )
countTreated();
.appendTo( $cell );
markInWikitext( from, true );
} );
} );
} ).fail( function ( code ) {
setStatus( $tr, 'qs-err', code );
} );
}


// Rewriting the stored value across every segment that uses it.
/* ---------------------------------------------------------------
//
* The four actions
// A redirect fixes the link without touching the data; this is for when
* --------------------------------------------------------------- */
// the value itself is wrong — a misspelling, or a form we do not want to
function doRewrite( $tr ) {
// keep. It edits every segment carrying that value, so it asks for
var from = valueOf( $tr );
// confirmation with the count and reports progress as it goes.
var suggest = $tr.find( '.qs-actions' ).attr( 'data-suggest' ) || from;
//
// Replacement is on the exact value within the parameter, not the whole
// line: 119 segments name several sources separated by semicolons, and
// rewriting the line would destroy the others.
function rewriteValue( $cell ) {
var from = $cell.data( 'from' );
var to = window.prompt(
var to = window.prompt(
'Rewrite this QuoteSource value on every segment that uses it.\n\n' +
'Rewrite this QuoteSource value on every segment that uses it:', suggest );
'This edits the segments themselves, not just a redirect.\n\n' +
if ( to === null || !$.trim( to ) || $.trim( to ) === from ) { return; }
'Current value:',
runOnSegments( $tr, from, $.trim( to ) );
from
}
);
if ( to === null ) {
return;
}
to = $.trim( to );
if ( !to || to === from ) {
return;
}


var $status = $cell.find( '.qs-rewrite-status' );
function doDelete( $tr ) {
$status.text( 'finding segments…' );
var value = valueOf( $tr );
if ( !window.confirm(
'Remove "' + value + '" from every segment that uses it?\n\n' +
'Other sources on the same segment are kept.' ) ) { return; }
runOnSegments( $tr, value, null );
}


api.get( {
function doRedirect( $tr ) {
action: 'ask',
var from = valueOf( $tr );
query: '[[QuoteSource::' + from.replace( /[[\]|]/g, '' ) + ']]|limit=500',
var suggest = $tr.find( '.qs-actions' ).attr( 'data-suggest' ) || '';
format: 'json'
var to = window.prompt( 'Redirect "' + from + '" to which page?', suggest );
} ).done( function ( res ) {
if ( to === null || !$.trim( to ) ) { return; }
var results = ( res.query && res.query.results ) || {};
to = $.trim( to );
var pages = Object.keys( results );
setStatus( $tr, 'qs-busy', 'creating redirect…' );
if ( !pages.length ) {
getApi().postWithToken( 'csrf', {
$status.text( 'no segments matched' );
action: 'edit', title: from,
return;
text: '#REDIRECT [[' + to + ']]',
}
summary: 'QuoteSource redirect to [[' + to + ']]',
if ( !window.confirm(
createonly: 1
'Rewrite on ' + pages.length + ' segment' + ( pages.length === 1 ? '' : 's' ) + '?\n\n' +
} ).done( function () {
'"' + from + '"\n  ->  "' + to + '"'
setStatus( $tr, 'qs-ok', 'redirect created' );
) ) {
setTreated( $tr ); countTreated(); markInWikitext( from, true );
$status.text( '' );
} ).fail( function ( code, res ) {
return;
if ( code === 'articleexists' ) {
setStatus( $tr, 'qs-ok', 'already exists' );
} else {
setStatus( $tr, 'qs-err',
( res && res.error && res.error.info ) || code );
}
}
editNext( pages, 0, from, to, $status );
} ).fail( function ( code ) {
$status.text( 'lookup failed: ' + code );
} );
} );
}
}


// One at a time, so a failure stops rather than half-finishing a batch.
/* The page is created with {{LibraryItem}} and the DRL page name, which
function editNext( pages, i, from, to, $status ) {
* is what pulls the work's data across. Nothing else is guessed here. */
if ( i >= pages.length ) {
function doCreate( $tr ) {
$status.text( '✓ rewrote ' + pages.length );
var value = valueOf( $tr );
return;
var drl = $tr.find( '.qs-actions' ).attr( 'data-drl' );
}
if ( !drl ) { setStatus( $tr, 'qs-err', 'no DRL page recorded' ); return; }
$status.text( 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );
var title = window.prompt(
 
'Create the text page here, with {{LibraryItem}}.\n\n' +
api.get( { action: 'query', prop: 'revisions', rvprop: 'content',
'DRL holds this work as: ' + drl + '\n\nPage name:',
rvslots: 'main', titles: pages[ i ], format: 'json', formatversion: 2 }
'Texts/' + drl );
).done( function ( res ) {
if ( title === null || !$.trim( title ) ) { return; }
var page = ( ( res.query && res.query.pages ) || [] )[ 0 ];
title = $.trim( title );
var text = page && page.revisions && page.revisions[ 0 ].slots.main.content;
setStatus( $tr, 'qs-busy', 'creating page…' );
if ( !text ) {
getApi().postWithToken( 'csrf', {
$status.text( '✗ could not read ' + pages[ i ] );
action: 'edit', title: title,
text: '{{LibraryItem}}',
summary: 'Create text page for QuoteSource value "' + value + '"',
createonly: 1
} ).done( function () {
// The value still has to reach the new page: if the value is not
// the page name itself, it needs a redirect as well.
if ( value === title ) {
setStatus( $tr, 'qs-ok', 'page created' );
setTreated( $tr ); countTreated(); markInWikitext( value, true );
return;
return;
}
}
// Exact value replacement, leaving any other sources on the line.
getApi().postWithToken( 'csrf', {
if ( text.indexOf( from ) === -1 ) {
action: 'edit', title: value,
// Already changed, or stored differently — skip rather than guess.
text: '#REDIRECT [[' + title + ']]',
editNext( pages, i + 1, from, to, $status );
summary: 'QuoteSource redirect to [[' + title + ']]',
return;
createonly: 1
} ).always( function () {
setStatus( $tr, 'qs-ok', 'page created, value redirected' );
setTreated( $tr ); countTreated(); markInWikitext( value, true );
} );
} ).fail( function ( code, res ) {
if ( code === 'articleexists' ) {
setStatus( $tr, 'qs-err', 'that page already exists' );
} else {
setStatus( $tr, 'qs-err',
( res && res.error && res.error.info ) || code );
}
}
var updated = text.split( from ).join( to );
api.postWithToken( 'csrf', {
action: 'edit', title: pages[ i ], text: updated,
summary: 'QuoteSource: ' + from + ' → ' + to
} ).done( function () {
editNext( pages, i + 1, from, to, $status );
} ).fail( function ( code ) {
$status.text( '✗ ' + pages[ i ] + ': ' + code );
} );
} ).fail( function ( code ) {
$status.text( '✗ ' + code );
} );
} );
}
}


$( '.qs-table' ).on( 'click', '.qs-value-edit', function () {
var ACTIONS = [
rewriteValue( $( this ).closest( 'tr' ).find( '.qs-approve' ) );
{ key: 'rewrite',  label: 'rewrite',  run: doRewrite },
{ key: 'redirect', label: 'redirect', run: doRedirect },
{ key: 'create',  label: 'create',  run: doCreate },
{ key: 'delete',  label: 'delete',  run: doDelete }
];
 
/* ---------------------------------------------------------------
* Build the controls
* --------------------------------------------------------------- */
$( '.qs-actions' ).each( function () {
var $cell = $( this );
var $tr = $cell.closest( 'tr' );
var recommended = $cell.attr( 'data-recommended' );
var hasDrl = !!$cell.attr( 'data-drl' );
 
ACTIONS.forEach( function ( a ) {
// "create" needs a confirmed DRL page; without one there is
// nothing to base the new page on.
if ( a.key === 'create' && !hasDrl ) { return; }
var $b = $( '<button>' )
.addClass( 'qs-btn qs-btn--' + a.key )
.text( a.label )
.on( 'click', function () {
if ( isTreated( $tr ) ) { return; }
a.run( $tr );
} );
if ( a.key === recommended ) { $b.addClass( 'qs-btn--suggested' ); }
$cell.append( $b );
} );
 
$cell.append(
$( '<button>' ).addClass( 'qs-dismiss' ).text( 'done' )
.attr( 'title', 'Mark this row handled' )
.on( 'click', function () {
var value = valueOf( $tr );
var add = !isTreated( $tr );
if ( add ) { setTreated( $tr ); } else { clearTreated( $tr ); }
countTreated();
markInWikitext( value, add ).fail( function ( why ) {
if ( add ) { clearTreated( $tr ); } else { setTreated( $tr ); }
countTreated();
setStatus( $tr, 'qs-err', 'could not save: ' + why );
} );
} ),
$( '<span>' ).addClass( 'qs-status' )
);
} );
} );


// Editing a target in place: the suggestion is a guess, and the row is
$( '.qs-controls' ).each( function () {
// useless if the only way to correct it is to edit 67KB of wikitext.
var $box = $( this );
$( '.qs-table' ).on( 'click', '.qs-target-edit', function () {
var $table = $box.nextAll( '.qs-table' ).first();
var $link = $( this );
if ( !$table.length ) { $table = $( '.qs-table' ).first(); }
var $cell = $link.closest( 'td' );
var $cb = $( '<input>' ).attr( 'type', 'checkbox' )
var $approve = $cell.closest( 'tr' ).find( '.qs-approve' );
.on( 'change', function () {
var current = $approve.data( 'to' ) || '';
$table.toggleClass( 'qs-hiding', this.checked );
var value = window.prompt(
} );
'Redirect target for:\n\n' + ( $approve.data( 'from' ) || '' ),
$box.append(
current
$( '<label>' ).addClass( 'qs-hide-label' ).append( $cb, ' Hide handled rows' ),
$( '<span>' ).addClass( 'qs-treated-count' )
);
);
if ( value === null ) {
return;
}
value = $.trim( value );
if ( !value ) {
return;
}
$approve.data( 'to', value );
$cell.find( '.qs-target-label' ).text( value );
// Re-arm the row: a changed target means the previous result, whether
// it succeeded or failed, no longer describes what the button does.
$approve.removeClass( 'qs-pending qs-done qs-failed' ).empty();
$( '<button>' )
.addClass( 'qs-approve-btn' )
.text( 'Create redirect' )
.on( 'click', function () {
$( this ).prop( 'disabled', true );
approve( $approve );
} )
.appendTo( $approve );
} );
} );
$( 'tr.qs-treated' ).each( function () { setTreated( $( this ) ); } );
countTreated();
} );
} );
}() );
}() );

Latest revision as of 16:47, 17 September 2026

/**
 * QuoteSourceRedirects
 *
 * Tools for [[QuoteSource Unresolved Values]].
 *
 * QuoteSource is a page property naming the work a quote segment cites. Where a
 * value does not resolve to a text page, one of four things fixes it:
 *
 *   rewrite   change the value on every segment that uses it
 *   redirect  point the value's page name at a text page
 *   create    create the text page here, for works DRL already holds
 *   delete    remove the value from every segment that uses it
 *
 * Controls are built here rather than written into the page: raw <button> and
 * <input> do not survive the wikitext sanitizer.
 */
( function () {
	'use strict';

	var api;

	function getApi() {
		if ( !api ) { api = new mw.Api(); }
		return api;
	}

	/* Mirrors the page generator's attr(): the wikitext stores each value
	 * HTML-escaped in data-value, so a lookup has to escape it the same way. */
	function attrEscape( s ) {
		return String( s )
			.replace( /&/g, '&amp;' )
			.replace( /"/g, '&quot;' )
			.replace( /</g, '&lt;' )
			.replace( />/g, '&gt;' )
			.replace( /\r\n?|\n/g, '&#10;' )
			.replace( /\|/g, '&#124;' );
	}

	$( function () {
		var $tables = $( '.qs-table' );
		if ( !$tables.length ) { return; }

		/* 27 values contain a newline, which would end the table cell. They are
		 * rendered with a pilcrow and carry the real value in data-value, so
		 * every lookup reads the attribute rather than the cell text. */
		function valueOf( $tr ) {
			return $tr.find( '.qs-actions' ).attr( 'data-value' );
		}

		function setStatus( $tr, cls, text ) {
			$tr.find( '.qs-status' ).removeClass( 'qs-ok qs-err qs-busy' )
				.addClass( cls ).text( text );
		}

		function isTreated( $tr ) {
			return $tr.hasClass( 'qs-treated' );
		}

		function setTreated( $tr ) {
			$tr.addClass( 'qs-treated' );
			$tr.find( 'button' ).prop( 'disabled', true );
			$tr.find( '.qs-dismiss' ).text( 'undo' ).prop( 'disabled', false );
		}

		function clearTreated( $tr ) {
			$tr.removeClass( 'qs-treated' );
			$tr.find( 'button' ).prop( 'disabled', false );
			$tr.find( '.qs-dismiss' ).text( 'done' );
		}

		function countTreated() {
			var n = $( 'tr.qs-treated' ).length;
			var total = $( '.qs-table tr' ).has( 'td' ).length;
			$( '.qs-treated-count' ).text( n ? n + ' of ' + total + ' handled' : '' );
		}

		/* ---------------------------------------------------------------
		 * Marking a row handled, persisted into this page's wikitext.
		 * The row's `|-` becomes `|- class="qs-treated"`, found by the
		 * data-value attribute, which is unique per row.
		 * --------------------------------------------------------------- */
		function markInWikitext( value, add ) {
			var page = mw.config.get( 'wgPageName' );
			return getApi().get( {
				action: 'query', prop: 'revisions', rvprop: 'content',
				rvslots: 'main', titles: page, formatversion: 2
			} ).then( function ( res ) {
				var text = res.query.pages[ 0 ].revisions[ 0 ].slots.main.content;
				var at = text.indexOf( 'data-value="' + attrEscape( value ) + '"' );
				if ( at === -1 ) {
					return $.Deferred().reject( 'row not found in wikitext' );
				}
				var rowStart = text.lastIndexOf( '|-', at );
				var lineEnd = text.indexOf( '\n', rowStart );
				var line = text.slice( rowStart, lineEnd );
				var updated = add
					? ( line.indexOf( 'qs-treated' ) === -1 ? '|- class="qs-treated"' : line )
					: '|-';
				return getApi().postWithToken( 'csrf', {
					action: 'edit', title: page,
					text: text.slice( 0, rowStart ) + updated + text.slice( lineEnd ),
					summary: ( add ? 'mark' : 'unmark' ) + ' QuoteSource row handled: ' + value
				} );
			} );
		}

		/* ---------------------------------------------------------------
		 * Finding the segments that use a value
		 * --------------------------------------------------------------- */
		function segmentsUsing( value ) {
			return getApi().get( {
				action: 'ask',
				query: '[[QuoteSource::' + value + ']]|limit=500',
				formatversion: 2
			} ).then( function ( res ) {
				return Object.keys( ( res.query && res.query.results ) || {} );
			} );
		}

		/* QuoteSource can hold several sources separated by ';', so a value is
		 * changed by exact match on one part, never by replacing the field. */
		function editField( text, from, to ) {
			return text.replace( /(\|\s*QuoteSource\s*=)([^\n]*)/g, function ( all, head, val ) {
				var parts = val.split( ';' );
				var kept = [];
				for ( var i = 0; i < parts.length; i++ ) {
					if ( $.trim( parts[ i ] ) === $.trim( from ) ) {
						if ( to !== null ) { kept.push( to ); }
					} else {
						kept.push( parts[ i ] );
					}
				}
				return head + kept.join( ';' );
			} );
		}

		function editSegments( pages, i, from, to, $tr, done ) {
			if ( i >= pages.length ) {
				setStatus( $tr, 'qs-ok', ( to === null ? 'removed from ' : 'rewrote ' ) +
					pages.length + ' segments' );
				done();
				return;
			}
			setStatus( $tr, 'qs-busy', 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );
			getApi().get( {
				action: 'query', prop: 'revisions', rvprop: 'content',
				rvslots: 'main', titles: pages[ i ], formatversion: 2
			} ).done( function ( res ) {
				var pg = res.query.pages[ 0 ];
				if ( !pg || !pg.revisions ) {
					editSegments( pages, i + 1, from, to, $tr, done ); return;
				}
				var text = pg.revisions[ 0 ].slots.main.content;
				var updated = editField( text, from, to );
				if ( updated === text ) {
					editSegments( pages, i + 1, from, to, $tr, done ); return;
				}
				getApi().postWithToken( 'csrf', {
					action: 'edit', title: pages[ i ], text: updated,
					summary: to === null
						? ( 'QuoteSource: remove ' + from )
						: ( 'QuoteSource: ' + from + ' → ' + to )
				} ).done( function () {
					editSegments( pages, i + 1, from, to, $tr, done );
				} ).fail( function ( code ) {
					setStatus( $tr, 'qs-err', code );
				} );
			} ).fail( function ( code ) {
				setStatus( $tr, 'qs-err', code );
			} );
		}

		function runOnSegments( $tr, from, to ) {
			setStatus( $tr, 'qs-busy', 'finding segments…' );
			segmentsUsing( from ).done( function ( pages ) {
				if ( !pages.length ) {
					setStatus( $tr, 'qs-err', 'no segments found' ); return;
				}
				editSegments( pages, 0, from, to, $tr, function () {
					setTreated( $tr );
					countTreated();
					markInWikitext( from, true );
				} );
			} ).fail( function ( code ) {
				setStatus( $tr, 'qs-err', code );
			} );
		}

		/* ---------------------------------------------------------------
		 * The four actions
		 * --------------------------------------------------------------- */
		function doRewrite( $tr ) {
			var from = valueOf( $tr );
			var suggest = $tr.find( '.qs-actions' ).attr( 'data-suggest' ) || from;
			var to = window.prompt(
				'Rewrite this QuoteSource value on every segment that uses it:', suggest );
			if ( to === null || !$.trim( to ) || $.trim( to ) === from ) { return; }
			runOnSegments( $tr, from, $.trim( to ) );
		}

		function doDelete( $tr ) {
			var value = valueOf( $tr );
			if ( !window.confirm(
				'Remove "' + value + '" from every segment that uses it?\n\n' +
				'Other sources on the same segment are kept.' ) ) { return; }
			runOnSegments( $tr, value, null );
		}

		function doRedirect( $tr ) {
			var from = valueOf( $tr );
			var suggest = $tr.find( '.qs-actions' ).attr( 'data-suggest' ) || '';
			var to = window.prompt( 'Redirect "' + from + '" to which page?', suggest );
			if ( to === null || !$.trim( to ) ) { return; }
			to = $.trim( to );
			setStatus( $tr, 'qs-busy', 'creating redirect…' );
			getApi().postWithToken( 'csrf', {
				action: 'edit', title: from,
				text: '#REDIRECT [[' + to + ']]',
				summary: 'QuoteSource redirect to [[' + to + ']]',
				createonly: 1
			} ).done( function () {
				setStatus( $tr, 'qs-ok', 'redirect created' );
				setTreated( $tr ); countTreated(); markInWikitext( from, true );
			} ).fail( function ( code, res ) {
				if ( code === 'articleexists' ) {
					setStatus( $tr, 'qs-ok', 'already exists' );
				} else {
					setStatus( $tr, 'qs-err',
						( res && res.error && res.error.info ) || code );
				}
			} );
		}

		/* The page is created with {{LibraryItem}} and the DRL page name, which
		 * is what pulls the work's data across. Nothing else is guessed here. */
		function doCreate( $tr ) {
			var value = valueOf( $tr );
			var drl = $tr.find( '.qs-actions' ).attr( 'data-drl' );
			if ( !drl ) { setStatus( $tr, 'qs-err', 'no DRL page recorded' ); return; }
			var title = window.prompt(
				'Create the text page here, with {{LibraryItem}}.\n\n' +
				'DRL holds this work as: ' + drl + '\n\nPage name:',
				'Texts/' + drl );
			if ( title === null || !$.trim( title ) ) { return; }
			title = $.trim( title );
			setStatus( $tr, 'qs-busy', 'creating page…' );
			getApi().postWithToken( 'csrf', {
				action: 'edit', title: title,
				text: '{{LibraryItem}}',
				summary: 'Create text page for QuoteSource value "' + value + '"',
				createonly: 1
			} ).done( function () {
				// The value still has to reach the new page: if the value is not
				// the page name itself, it needs a redirect as well.
				if ( value === title ) {
					setStatus( $tr, 'qs-ok', 'page created' );
					setTreated( $tr ); countTreated(); markInWikitext( value, true );
					return;
				}
				getApi().postWithToken( 'csrf', {
					action: 'edit', title: value,
					text: '#REDIRECT [[' + title + ']]',
					summary: 'QuoteSource redirect to [[' + title + ']]',
					createonly: 1
				} ).always( function () {
					setStatus( $tr, 'qs-ok', 'page created, value redirected' );
					setTreated( $tr ); countTreated(); markInWikitext( value, true );
				} );
			} ).fail( function ( code, res ) {
				if ( code === 'articleexists' ) {
					setStatus( $tr, 'qs-err', 'that page already exists' );
				} else {
					setStatus( $tr, 'qs-err',
						( res && res.error && res.error.info ) || code );
				}
			} );
		}

		var ACTIONS = [
			{ key: 'rewrite',  label: 'rewrite',  run: doRewrite },
			{ key: 'redirect', label: 'redirect', run: doRedirect },
			{ key: 'create',   label: 'create',   run: doCreate },
			{ key: 'delete',   label: 'delete',   run: doDelete }
		];

		/* ---------------------------------------------------------------
		 * Build the controls
		 * --------------------------------------------------------------- */
		$( '.qs-actions' ).each( function () {
			var $cell = $( this );
			var $tr = $cell.closest( 'tr' );
			var recommended = $cell.attr( 'data-recommended' );
			var hasDrl = !!$cell.attr( 'data-drl' );

			ACTIONS.forEach( function ( a ) {
				// "create" needs a confirmed DRL page; without one there is
				// nothing to base the new page on.
				if ( a.key === 'create' && !hasDrl ) { return; }
				var $b = $( '<button>' )
					.addClass( 'qs-btn qs-btn--' + a.key )
					.text( a.label )
					.on( 'click', function () {
						if ( isTreated( $tr ) ) { return; }
						a.run( $tr );
					} );
				if ( a.key === recommended ) { $b.addClass( 'qs-btn--suggested' ); }
				$cell.append( $b );
			} );

			$cell.append(
				$( '<button>' ).addClass( 'qs-dismiss' ).text( 'done' )
					.attr( 'title', 'Mark this row handled' )
					.on( 'click', function () {
						var value = valueOf( $tr );
						var add = !isTreated( $tr );
						if ( add ) { setTreated( $tr ); } else { clearTreated( $tr ); }
						countTreated();
						markInWikitext( value, add ).fail( function ( why ) {
							if ( add ) { clearTreated( $tr ); } else { setTreated( $tr ); }
							countTreated();
							setStatus( $tr, 'qs-err', 'could not save: ' + why );
						} );
					} ),
				$( '<span>' ).addClass( 'qs-status' )
			);
		} );

		$( '.qs-controls' ).each( function () {
			var $box = $( this );
			var $table = $box.nextAll( '.qs-table' ).first();
			if ( !$table.length ) { $table = $( '.qs-table' ).first(); }
			var $cb = $( '<input>' ).attr( 'type', 'checkbox' )
				.on( 'change', function () {
					$table.toggleClass( 'qs-hiding', this.checked );
				} );
			$box.append(
				$( '<label>' ).addClass( 'qs-hide-label' ).append( $cb, ' Hide handled rows' ),
				$( '<span>' ).addClass( 'qs-treated-count' )
			);
		} );

		$( 'tr.qs-treated' ).each( function () { setTreated( $( this ) ); } );
		countTreated();
	} );

}() );