MediaWiki:Gadget-QuoteSourceRedirects.js: Difference between revisions
From Bodhicitta
MediaWiki:Gadget-QuoteSourceRedirects.js
(VSCode edit) |
(VSCode edit) |
||
| Line 73: | Line 73: | ||
// localStorage. It is a working note, not data: losing it costs a | // localStorage. It is a working note, not data: losing it costs a | ||
// second look, not information. | // second look, not information. | ||
var | // Treated rows are recorded on a wiki subpage, not in localStorage: this | ||
// is a shared worklist, so a mark has to survive a different browser and | |||
// be visible to whoever picks the list up next. One value per line. | |||
var STORE = 'QuoteSource redirects to create/Treated'; | |||
var treated = []; | |||
var treatedRev = null; | |||
function | function loadTreated( then ) { | ||
api.get( { action: 'query', prop: 'revisions', rvprop: 'content|ids', | |||
rvslots: 'main', titles: STORE, format: 'json', formatversion: 2 } | |||
} | ).done( function ( res ) { | ||
var page = ( ( res.query && res.query.pages ) || [] )[ 0 ] || {}; | |||
var text = page.revisions && page.revisions[ 0 ].slots.main.content; | |||
treatedRev = page.revisions && page.revisions[ 0 ].revid; | |||
treated = text ? text.split( '\n' ) | |||
.map( function ( l ) { return $.trim( l.replace( /^\*\s*/, '' ) ); } ) | |||
.filter( Boolean ) : []; | |||
then(); | |||
} ).fail( function () { treated = []; then(); } ); | |||
} | } | ||
function | function saveTreated( $status ) { | ||
var text = treated.map( function ( v ) { return '* ' + v; } ).join( '\n' ); | |||
return api.postWithToken( 'csrf', { | |||
} | action: 'edit', title: STORE, | ||
text: '<!-- QuoteSource values marked treated. One per line. -->\n' + text, | |||
summary: 'Update treated QuoteSource values (' + treated.length + ')' | |||
} ).fail( function ( code ) { | |||
if ( $status ) { $status.text( 'could not save: ' + code ); } | |||
} ); | |||
} | } | ||
| Line 99: | Line 115: | ||
$row.find( 'button' ).prop( 'disabled', false ); | $row.find( 'button' ).prop( 'disabled', false ); | ||
$row.find( '.qs-value-edit, .qs-target-edit' ).removeClass( 'qs-disabled' ); | $row.find( '.qs-value-edit, .qs-target-edit' ).removeClass( 'qs-disabled' ); | ||
} | } | ||
| Line 196: | Line 120: | ||
var $tr = $( this ).closest( 'tr' ); | var $tr = $( this ).closest( 'tr' ); | ||
var v = $.trim( $tr.find( '.qs-value' ).text() ); | var v = $.trim( $tr.find( '.qs-value' ).text() ); | ||
var | if ( !v ) { return; } | ||
var at = | var $status = $( '.qs-resolved-count' ); | ||
var at = treated.indexOf( v ); | |||
if ( at === -1 ) { | if ( at === -1 ) { | ||
treated.push( v ); | |||
setTreated( $tr, ' | setTreated( $tr, 'treated' ); | ||
} else { | } else { | ||
treated.splice( at, 1 ); | |||
clearTreated( $tr ); | clearTreated( $tr ); | ||
} | } | ||
$status.text( 'saving…' ); | |||
saveTreated( $status ).done( function () { | |||
$status.text( treated.length + ' marked treated' ); | |||
} ); | |||
} ); | } ); | ||
loadTreated( function () { | |||
if ( !treated.length ) { return; } | |||
$( '.qs-table tr' ).each( function () { | |||
if ( | var $tr = $( this ); | ||
var v = $.trim( $tr.find( '.qs-value' ).text() ); | |||
if ( v && treated.indexOf( v ) !== -1 ) { setTreated( $tr, 'treated' ); } | |||
} ); | |||
$( '.qs-resolved-count' ).text( treated.length + ' marked treated' ); | |||
} ); | |||
} | |||
$( '.qs-hide-treated' ).on( 'change', function () { | $( '.qs-hide-treated' ).on( 'change', function () { | ||
Revision as of 12:47, 9 September 2026
/**
* Approve buttons for [[QuoteSource redirects to create]].
*
* Each suggested row carries a .qs-approve span with data-from (the unresolved
* 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
* refused rather than overwriting it, so an approval can never clobber real
* content. The row reports what happened either way.
*
* Only loaded on that page, and only for users who can edit — the buttons are
* not rendered at all otherwise.
*/
( function () {
'use strict';
if ( mw.config.get( 'wgPageName' ) !== 'QuoteSource_redirects_to_create' ) {
return;
}
$( function () {
var $rows = $( '.qs-approve' );
if ( !$rows.length ) {
return;
}
var api = new mw.Api();
// Replaces the button with a result. The button is gone by then, so the
// 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 ) {
var from = $cell.data( 'from' );
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.
//
// Two kinds of "done" and they behave differently:
//
// - 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.
// Treated rows are recorded on a wiki subpage, not in localStorage: this
// is a shared worklist, so a mark has to survive a different browser and
// be visible to whoever picks the list up next. One value per line.
var STORE = 'QuoteSource redirects to create/Treated';
var treated = [];
var treatedRev = null;
function loadTreated( then ) {
api.get( { action: 'query', prop: 'revisions', rvprop: 'content|ids',
rvslots: 'main', titles: STORE, format: 'json', formatversion: 2 }
).done( function ( res ) {
var page = ( ( res.query && res.query.pages ) || [] )[ 0 ] || {};
var text = page.revisions && page.revisions[ 0 ].slots.main.content;
treatedRev = page.revisions && page.revisions[ 0 ].revid;
treated = text ? text.split( '\n' )
.map( function ( l ) { return $.trim( l.replace( /^\*\s*/, '' ) ); } )
.filter( Boolean ) : [];
then();
} ).fail( function () { treated = []; then(); } );
}
function saveTreated( $status ) {
var text = treated.map( function ( v ) { return '* ' + v; } ).join( '\n' );
return api.postWithToken( 'csrf', {
action: 'edit', title: STORE,
text: '<!-- QuoteSource values marked treated. One per line. -->\n' + text,
summary: 'Update treated QuoteSource values (' + treated.length + ')'
} ).fail( function ( code ) {
if ( $status ) { $status.text( 'could not save: ' + code ); }
} );
}
function setTreated( $row, why ) {
$row.addClass( 'qs-treated' ).attr( 'data-treated', why );
$row.find( 'button' ).prop( 'disabled', true );
$row.find( '.qs-value-edit, .qs-target-edit' ).addClass( 'qs-disabled' );
}
function clearTreated( $row ) {
$row.removeClass( 'qs-treated' ).removeAttr( 'data-treated' );
$row.find( 'button' ).prop( 'disabled', false );
$row.find( '.qs-value-edit, .qs-target-edit' ).removeClass( 'qs-disabled' );
}
$( '.qs-table' ).on( 'click', '.qs-dismiss', function () {
var $tr = $( this ).closest( 'tr' );
var v = $.trim( $tr.find( '.qs-value' ).text() );
if ( !v ) { return; }
var $status = $( '.qs-resolved-count' );
var at = treated.indexOf( v );
if ( at === -1 ) {
treated.push( v );
setTreated( $tr, 'treated' );
} else {
treated.splice( at, 1 );
clearTreated( $tr );
}
$status.text( 'saving…' );
saveTreated( $status ).done( function () {
$status.text( treated.length + ' marked treated' );
} );
} );
loadTreated( function () {
if ( !treated.length ) { return; }
$( '.qs-table tr' ).each( function () {
var $tr = $( this );
var v = $.trim( $tr.find( '.qs-value' ).text() );
if ( v && treated.indexOf( v ) !== -1 ) { setTreated( $tr, 'treated' ); }
} );
$( '.qs-resolved-count' ).text( treated.length + ' marked treated' );
} );
$( '.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;
}
if ( !window.confirm(
'Create ' + $todo.length + ' redirects?\n\n' +
'Each points a QuoteSource value at its suggested target. ' +
'Existing pages are never overwritten.'
) ) {
return;
}
$btn.prop( 'disabled', true );
var i = 0;
function next() {
if ( i >= $todo.length ) {
$status.text( '✓ done (' + $todo.length + ')' );
$btn.prop( 'disabled', false );
return;
}
var $cell = $todo.eq( i );
$status.text( 'creating ' + ( i + 1 ) + ' of ' + $todo.length + '…' );
$cell.find( '.qs-approve-btn' ).prop( 'disabled', true );
api.postWithToken( 'csrf', {
action: 'edit',
title: $cell.data( 'from' ),
text: '#REDIRECT [[' + $cell.data( 'to' ) + ']]',
summary: 'Redirect for a QuoteSource value used in the translation memory',
createonly: 1
} ).done( function () {
setState( $cell, 'qs-done', '✓ created' );
i++; next();
} ).fail( function ( code ) {
setState( $cell, 'qs-failed', '✗ ' + ( code === 'articleexists' ? 'exists' : code ) );
// Keep going: one existing page should not stop the batch.
i++; next();
} );
}
next();
} );
$rows.each( function () {
var $cell = $( this );
$( '<button>' )
.addClass( 'qs-approve-btn' )
.text( 'Create redirect' )
.on( 'click', function () {
$( this ).prop( 'disabled', true );
approve( $cell );
} )
.appendTo( $cell );
} );
// Rewriting the stored value across every segment that uses it.
//
// 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
// keep. It edits every segment carrying that value, so it asks for
// confirmation with the count and reports progress as it goes.
//
// 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(
'Rewrite this QuoteSource value on every segment that uses it.\n\n' +
'This edits the segments themselves, not just a redirect.\n\n' +
'Current value:',
from
);
if ( to === null ) {
return;
}
to = $.trim( to );
if ( !to || to === from ) {
return;
}
var $status = $cell.find( '.qs-rewrite-status' );
$status.text( 'finding segments…' );
api.get( {
action: 'ask',
query: '[[QuoteSource::' + from.replace( /[[\]|]/g, '' ) + ']]|limit=500',
format: 'json'
} ).done( function ( res ) {
var results = ( res.query && res.query.results ) || {};
var pages = Object.keys( results );
if ( !pages.length ) {
$status.text( 'no segments matched' );
return;
}
if ( !window.confirm(
'Rewrite on ' + pages.length + ' segment' + ( pages.length === 1 ? '' : 's' ) + '?\n\n' +
'"' + from + '"\n -> "' + to + '"'
) ) {
$status.text( '' );
return;
}
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.
function editNext( pages, i, from, to, $status ) {
if ( i >= pages.length ) {
$status.text( '✓ rewrote ' + pages.length );
return;
}
$status.text( 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );
api.get( { action: 'query', prop: 'revisions', rvprop: 'content',
rvslots: 'main', titles: pages[ i ], format: 'json', formatversion: 2 }
).done( function ( res ) {
var page = ( ( res.query && res.query.pages ) || [] )[ 0 ];
var text = page && page.revisions && page.revisions[ 0 ].slots.main.content;
if ( !text ) {
$status.text( '✗ could not read ' + pages[ i ] );
return;
}
// Exact value replacement, leaving any other sources on the line.
if ( text.indexOf( from ) === -1 ) {
// Already changed, or stored differently — skip rather than guess.
editNext( pages, i + 1, from, to, $status );
return;
}
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 () {
rewriteValue( $( this ).closest( 'tr' ).find( '.qs-approve' ) );
} );
// Editing a target in place: the suggestion is a guess, and the row is
// useless if the only way to correct it is to edit 67KB of wikitext.
$( '.qs-table' ).on( 'click', '.qs-target-edit', function () {
var $link = $( this );
var $cell = $link.closest( 'td' );
var $approve = $cell.closest( 'tr' ).find( '.qs-approve' );
var current = $approve.data( 'to' ) || '';
var value = window.prompt(
'Redirect target for:\n\n' + ( $approve.data( 'from' ) || '' ),
current
);
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 );
} );
} );
}() );