MediaWiki:Gadget-TsadraEditionReview.js
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
- Opera: Press Ctrl-F5.
/**
* TsadraEditionReview
*
* Tools for the [[Tsadra Edition Review]] pages: one row per translation-memory segment
* whose Tibetan (or Wylie) differs from the 2026 Tsadra edition (Karma Phuntsho).
*
* Approve write the proposed Tibetan and Wylie to the segment
* Edit correct the proposed Tibetan inline; saving writes it (Wylie regenerated)
* Keep as is leave the segment untouched
* Mark as done record the row as handled without writing (e.g. fixed by hand)
* Undo reopen the row (a written segment is NOT reverted; see its history)
*
* Row state is an append-only log on <review page>/Log, one line per action, the last line
* per segment winning. The review page itself is never edited, so it can be regenerated
* without losing marks, and a mark never re-saves a large page.
*
* Nothing is written to a segment that changed after the review page was built: the row
* shows a conflict with a link to the diff instead.
*
* Row values are base64 (UTF-8) in data-* attributes: MediaWiki turns | back into a
* pipe before parsing tables, so escaped wikitext does not survive in a cell.
*
* The Wylie conversion, diff and field editing live in TsadraEditionReviewCore (terCore),
* the same code the offline pipeline ran.
*/
( function () {
'use strict';
var api, core, queue = $.Deferred().resolve().promise();
function getApi() {
if ( !api ) { api = new mw.Api(); }
return api;
}
// run write operations one at a time
function enqueue( fn ) {
var d = $.Deferred();
queue = queue.then( function () {
return fn().then( d.resolve, d.reject );
}, function () {
return fn().then( d.resolve, d.reject );
} );
return d.promise();
}
function unb64( s ) {
var bin = window.atob( s || '' ), bytes = new Uint8Array( bin.length ), i;
for ( i = 0; i < bin.length; i++ ) { bytes[ i ] = bin.charCodeAt( i ); }
return new TextDecoder( 'utf-8' ).decode( bytes );
}
/* ---------------------------------------------------------------
* The log: "* <seg> · <state> · r<revid> · <user> · <time>"
* --------------------------------------------------------------- */
var LINE = /^\*\s*(\d+)\s*·\s*(open|approved|edited|kept|done)\b(?:\s*·\s*r(\d+))?/;
function readLog( title ) {
return getApi().get( {
action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main',
titles: title, formatversion: 2
} ).then( function ( res ) {
var p = res.query.pages[ 0 ], states = {};
if ( p.missing || !p.revisions ) { return states; }
p.revisions[ 0 ].slots.main.content.split( '\n' ).forEach( function ( l ) {
var m = LINE.exec( l );
if ( m ) { states[ m[ 1 ] ] = { state: m[ 2 ], rev: m[ 3 ] || null }; }
} );
return states;
} );
}
function appendLog( title, seg, state, rev, reviewPage ) {
var line = '* ' + seg + ' · ' + state + ( rev ? ' · r' + rev : '' ) + ' · ' +
mw.config.get( 'wgUserName' ) + ' · ' + new Date().toISOString().slice( 0, 16 ) + 'Z';
return getApi().postWithToken( 'csrf', {
action: 'edit', title: title, appendtext: '\n' + line,
summary: 'segment ' + seg + ': ' + state + ' ([[' + reviewPage + ']])'
} );
}
/* ---------------------------------------------------------------
* Hub page: handled counts per chapter
* --------------------------------------------------------------- */
function initHub() {
$( '.ter-hub-count' ).each( function () {
var $s = $( this ), total = +$s.attr( 'data-total' );
readLog( $s.attr( 'data-log' ) ).then( function ( st ) {
var n = 0;
Object.keys( st ).forEach( function ( k ) { if ( st[ k ].state !== 'open' ) { n++; } } );
$s.text( '— ' + n + ' of ' + total + ' handled' ).toggleClass( 'ter-hub-complete', n >= total && total > 0 );
} );
} );
}
/* ---------------------------------------------------------------
* Review table
* --------------------------------------------------------------- */
function initTable( $table ) {
var T = $table.attr( 'data-text' ), field = $table.attr( 'data-field' ),
wfield = $table.attr( 'data-wfield' ), logTitle = $table.attr( 'data-log' ),
reviewPage = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' ),
$rows = $table.find( 'tr.ter-row' ), states = {};
function data( $tr ) {
if ( !$tr.data( 'ter' ) ) {
var $d = $tr.find( '.ter-data' );
$tr.data( 'ter', {
seg: $tr.attr( 'data-seg' ),
title: unb64( $d.attr( 'data-title' ) ),
revid: +$d.attr( 'data-revid' ),
old: unb64( $d.attr( 'data-old' ) ),
neu: unb64( $d.attr( 'data-new' ) ),
oldw: unb64( $d.attr( 'data-oldw' ) ),
neww: unb64( $d.attr( 'data-neww' ) ),
wylieOnly: $d.attr( 'data-wylieonly' ) === '1'
} );
}
return $tr.data( 'ter' );
}
function setStatus( $tr, cls, html ) {
$tr.find( '.ter-status' ).removeClass( 'ter-ok ter-err ter-busy' ).addClass( cls || '' ).html( html || '' );
}
function stateOf( $tr ) { return ( states[ $tr.attr( 'data-seg' ) ] || {} ).state || 'open'; }
function isHandled( $tr ) { return stateOf( $tr ) !== 'open'; }
var LABEL = { approved: 'approved', edited: 'edited & applied', kept: 'kept as is', done: 'marked done' };
// Which action buttons are live. While a row is being edited, Approve (which writes the
// ORIGINAL proposal) and Edit are blanked, so the only way forward is Save & apply.
function syncButtons( $tr ) {
var handled = isHandled( $tr ), editing = $tr.hasClass( 'ter-editing' );
$tr.find( '.ter-act' ).prop( 'disabled', handled );
$tr.find( '.ter-approve, .ter-edit' ).toggleClass( 'ter-btn--blanked', editing && !handled );
if ( editing ) { $tr.find( '.ter-approve, .ter-edit' ).prop( 'disabled', true ); }
}
function paint( $tr ) {
var st = stateOf( $tr ), handled = st !== 'open', rec = states[ $tr.attr( 'data-seg' ) ] || {};
$tr.removeClass( 'ter-state-open ter-state-approved ter-state-edited ter-state-kept ter-state-done' )
.addClass( 'ter-state-' + st ).toggleClass( 'ter-handled', handled );
syncButtons( $tr );
$tr.find( '.ter-undo' ).toggle( handled );
var badge = handled ? LABEL[ st ] + ( rec.rev ? ' — <a href="' + mw.util.getUrl( 'Special:Diff/' + rec.rev ) + '">r' + rec.rev + '</a>' : '' ) : '';
$tr.find( '.ter-badge' ).html( badge );
}
function countAll() {
var c = { approved: 0, edited: 0, kept: 0, done: 0 }, n = 0;
$rows.each( function () { var s = stateOf( $( this ) ); if ( c[ s ] !== undefined ) { c[ s ]++; n++; } } );
$( '.ter-count' ).text( n + ' of ' + $rows.length + ' handled' +
( n ? ' (' + Object.keys( c ).filter( function ( k ) { return c[ k ]; } ).map( function ( k ) { return c[ k ] + ' ' + LABEL[ k ]; } ).join( ', ' ) + ')' : '' ) );
}
function mark( $tr, st, rev ) {
var seg = $tr.attr( 'data-seg' ), prev = states[ seg ];
states[ seg ] = { state: st, rev: rev || null };
paint( $tr ); countAll();
return enqueue( function () { return appendLog( logTitle, seg, st, rev, reviewPage ); } ).fail( function ( code, res ) {
states[ seg ] = prev; paint( $tr ); countAll();
setStatus( $tr, 'ter-err', 'could not save the mark: ' + mw.html.escape( ( res && res.error && res.error.info ) || code ) );
} );
}
/* -- rendering -- */
function renderRow( $tr ) {
var d = data( $tr ), tibNew = d.wylieOnly ? d.old : d.neu;
var $old = $tr.find( '.ter-old' ), $new = $tr.find( '.ter-new' );
$old.html( core.diffHtml( d.old, tibNew, 'old' ) );
$new.html( core.diffHtml( d.old, tibNew, 'new' ) );
$old.after( wylieBlock( d.oldw, d.neww, 'old', d.wylieOnly ) );
$new.after( wylieBlock( d.oldw, d.neww, 'new', d.wylieOnly ), notesBlock( d.old, tibNew ) );
}
function wylieBlock( a, b, side, open ) {
var $w = $( '<details>' ).addClass( 'ter-wylie' ).prop( 'open', !!open );
$w.append( $( '<summary>' ).text( 'Wylie' + ( a === b ? ' (unchanged)' : '' ) ),
$( '<div>' ).addClass( 'ter-wylie-text' ).html( core.textDiffHtml( a, b, side ).replace( /\n/g, '<br>' ) ) );
return $w;
}
function notesBlock( a, b ) {
var ch = core.noteChanges( a, b );
if ( !ch.length ) { return $( [] ); }
var $ul = $( '<ul>' );
ch.forEach( function ( c ) {
var $li = $( '<li>' );
if ( c.oldBody === null ) {
$li.html( '<b>new note ' + mw.html.escape( c.num ) + ':</b> ' + '<mark class="ter-ins">' + mw.html.escape( c.newBody ) + '</mark>' );
} else if ( c.newBody === null ) {
$li.html( '<b>note ' + mw.html.escape( c.oldNum ) + ' removed:</b> ' + '<mark class="ter-del">' + mw.html.escape( c.oldBody ) + '</mark>' );
} else {
$li.html( '<b>note ' + mw.html.escape( c.num ) + ( c.oldNum !== c.num ? ' (was ' + mw.html.escape( c.oldNum ) + ')' : '' ) + ':</b> ' +
core.textDiffHtml( c.oldBody, c.newBody, 'old' ) + ' <span class="ter-arrow">→</span> ' + core.textDiffHtml( c.oldBody, c.newBody, 'new' ) );
}
$ul.append( $li );
} );
return $( '<details>' ).addClass( 'ter-notes' ).prop( 'open', true ).append( $( '<summary>' ).text( ch.length + ' note change' + ( ch.length > 1 ? 's' : '' ) ), $ul );
}
/* -- writing to the segment -- */
function apply( $tr, tib, wy, st ) {
var d = data( $tr );
if ( isHandled( $tr ) ) { return $.Deferred().reject().promise(); }
$tr.find( '.ter-act' ).prop( 'disabled', true );
setStatus( $tr, 'ter-busy', 'checking the segment…' );
return enqueue( function () {
return getApi().get( {
action: 'query', prop: 'revisions', rvprop: 'ids|content|user|timestamp', rvslots: 'main',
titles: d.title, formatversion: 2
} ).then( function ( res ) {
var p = res.query.pages[ 0 ];
if ( p.missing ) { return $.Deferred().reject( 'the segment page does not exist' ); }
var rev = p.revisions[ 0 ], text = rev.slots.main.content;
if ( !core.wellFormed( text ) ) { return $.Deferred().reject( 'malformed' ); }
if ( rev.revid !== d.revid || ( core.getField( text, field ) || '' ) !== d.old ) {
return $.Deferred().reject( 'conflict', rev );
}
var nt = d.wylieOnly ? text : core.setField( text, field, tib );
nt = core.setField( nt, wfield, wy );
if ( nt === text ) { return { revid: null }; }
setStatus( $tr, 'ter-busy', 'saving…' );
return getApi().postWithToken( 'csrf', {
action: 'edit', title: d.title, text: nt, basetimestamp: rev.timestamp, nocreate: 1,
summary: 'Tsadra 2026 edition (ed. Karma Phuntsho): ' +
( d.wylieOnly ? 'fix ' + wfield : 'update ' + field + ' and ' + wfield ) +
( st === 'edited' ? ', corrected by reviewer' : '' ) + ' — [[' + reviewPage + ']]',
formatversion: 2
} ).then( function ( r ) {
// read back and compare, rather than trusting the save
return getApi().get( {
action: 'query', prop: 'revisions', rvprop: 'ids|content', rvslots: 'main',
titles: d.title, formatversion: 2
} ).then( function ( res2 ) {
var got = res2.query.pages[ 0 ].revisions[ 0 ];
if ( ( !d.wylieOnly && core.getField( got.slots.main.content, field ) !== tib ) ||
core.getField( got.slots.main.content, wfield ) !== wy ) {
return $.Deferred().reject( 'saved, but the segment does not read back as expected — check r' + got.revid );
}
return { revid: r.edit.newrevid || got.revid };
} );
} );
} );
} ).then( function ( r ) {
setStatus( $tr, 'ter-ok', r.revid ? 'written' : 'already identical' );
// the row is now bound to the new revision
if ( r.revid ) { d.revid = r.revid; d.old = d.wylieOnly ? d.old : tib; d.oldw = wy; }
return mark( $tr, st, r.revid );
}, function ( why, rev ) {
syncButtons( $tr );
if ( why === 'conflict' ) {
setStatus( $tr, 'ter-err', 'Not written: the segment was edited after this page was built' +
( rev ? ' (by ' + mw.html.escape( rev.user ) + ', ' + mw.html.escape( rev.timestamp.slice( 0, 10 ) ) + ') — ' +
'<a href="' + mw.util.getUrl( 'Special:Diff/' + d.revid + '/' + rev.revid ) + '">see the change</a>' : '' ) +
'. Review it on the segment page, then use “Mark as done”.' );
$tr.addClass( 'ter-conflict' );
} else if ( why === 'malformed' ) {
setStatus( $tr, 'ter-err', 'Not written: this segment’s wikitext has unbalanced <code>{{ }}</code>, so its fields ' +
'cannot be located safely. Fix it on the <a href="' + mw.util.getUrl( d.title, { action: 'edit' } ) + '">segment page</a>, ' +
'then reload this page and approve, or apply the text by hand and use “Mark as done”.' );
$tr.addClass( 'ter-conflict' );
} else {
var info = ( rev && rev.error && rev.error.info ) || ( why && why.message ) || why;
setStatus( $tr, 'ter-err', mw.html.escape( String( info ) ) );
}
// stay rejected, so an open editor keeps the curator's text instead of closing
return $.Deferred().reject( why ).promise();
} );
}
function startEdit( $tr ) {
var d = data( $tr );
if ( isHandled( $tr ) || $tr.hasClass( 'ter-editing' ) ) { return; }
$tr.addClass( 'ter-editing' );
syncButtons( $tr );
var $cell = $tr.find( '.ter-new' ).parent(),
$ta = $( '<textarea>' ).addClass( 'ter-textarea' ).attr( { lang: 'bo', rows: Math.min( 18, d.neu.split( '\n' ).length + 3 ) } ).val( d.neu ),
$prev = $( '<div>' ).addClass( 'ter-preview' ),
$wy = $( '<div>' ).addClass( 'ter-wylie-text ter-preview-wylie' ),
proposal = null, timer = null;
function refresh() {
var v = $ta.val();
proposal = core.proposeWylie( d.old, d.oldw, v, T );
$prev.html( core.diffHtml( d.old, v, 'new' ) );
$wy.html( mw.html.escape( proposal.w ).replace( /\n/g, '<br>' ) );
}
var $box = $( '<div>' ).addClass( 'ter-editbox' ).append(
$ta, $( '<div>' ).addClass( 'ter-label' ).text( 'Preview against the current text' ), $prev,
$( '<div>' ).addClass( 'ter-label' ).text( 'Wylie that will be written' ), $wy,
$( '<div>' ).addClass( 'ter-editbtns' ).append(
$( '<button>' ).addClass( 'ter-btn ter-btn--primary' ).text( 'Save & apply' ).on( 'click', function () {
refresh();
var v = $ta.val();
if ( !v.trim() ) { setStatus( $tr, 'ter-err', 'empty text — nothing written' ); return; }
apply( $tr, v, proposal.w, v === d.neu ? 'approved' : 'edited' ).then( function () {
d.neu = v; d.neww = proposal.w; closeEdit();
$tr.find( '.ter-new' ).html( core.diffHtml( v, v, 'new' ) );
} );
} ),
$( '<button>' ).addClass( 'ter-btn' ).text( 'Cancel' ).on( 'click', closeEdit )
)
);
function closeEdit() { $box.remove(); $cell.children().show(); $tr.removeClass( 'ter-editing' ); syncButtons( $tr ); }
$cell.children().hide();
$cell.append( $box );
$ta.on( 'input', function () { clearTimeout( timer ); timer = setTimeout( refresh, 250 ); } );
refresh();
$ta.trigger( 'focus' );
}
/* -- controls per row -- */
$rows.each( function () {
var $tr = $( this ), $cell = $tr.find( '.ter-data' ).parent();
try { renderRow( $tr ); } catch ( e ) {
$tr.find( '.ter-new' ).text( 'could not render this row: ' + e.message );
}
function btn( cls, label, title, fn ) {
return $( '<button>' ).addClass( 'ter-btn ' + cls ).text( label ).attr( 'title', title ).on( 'click', function () {
// a handled row never acts, whatever the button state says
if ( cls.indexOf( 'ter-undo' ) === -1 && isHandled( $tr ) ) { return; }
fn();
} );
}
var d = data( $tr );
$cell.append(
btn( 'ter-act ter-approve ter-btn--primary', 'Approve', 'Write the proposed text to the segment', function () {
// while editing, only Save & apply may write
if ( $tr.hasClass( 'ter-editing' ) ) { return; }
apply( $tr, d.wylieOnly ? d.old : d.neu, d.neww, 'approved' );
} ),
d.wylieOnly ? $( [] ) : btn( 'ter-act ter-edit', 'Edit', 'Correct the proposed text, then apply it', function () { startEdit( $tr ); } ),
btn( 'ter-act', 'Keep as is', 'Leave the segment as it is', function () { mark( $tr, 'kept' ); } ),
btn( 'ter-act', 'Mark as done', 'Record as handled without writing anything', function () { mark( $tr, 'done' ); } ),
btn( 'ter-undo', 'Undo', 'Reopen this row', function () {
var st = stateOf( $tr );
if ( ( st === 'approved' || st === 'edited' ) &&
!window.confirm( 'Reopen this row?\n\nThe text already written to the segment stays; revert it from the segment history if needed.' ) ) { return; }
mark( $tr, 'open' );
} ),
$( '<div>' ).addClass( 'ter-badge' ),
$( '<div>' ).addClass( 'ter-status' )
);
} );
/* -- page controls -- */
var cats = {};
$rows.each( function () {
var m = /ter-cat-(\w+)/.exec( this.className );
if ( m ) { cats[ m[ 1 ] ] = ( cats[ m[ 1 ] ] || 0 ) + 1; }
} );
var $controls = $( '.ter-controls' ).empty();
var $hide = $( '<input>' ).attr( 'type', 'checkbox' ).on( 'change', function () { $table.toggleClass( 'ter-hide-handled', this.checked ); } );
$controls.append( $( '<label>' ).append( $hide, ' Hide handled rows' ), ' ', $( '<span>' ).addClass( 'ter-count' ) );
var $filters = $( '<span>' ).addClass( 'ter-filters' ).text( 'Show: ' );
Object.keys( cats ).forEach( function ( c ) {
var label = $rows.filter( '.ter-cat-' + c ).first().find( '.ter-cat' ).text();
$filters.append( $( '<label>' ).append(
$( '<input>' ).attr( { type: 'checkbox', checked: 'checked' } ).prop( 'checked', true ).on( 'change', function () {
$table.toggleClass( 'ter-hide-cat-' + c, !this.checked );
} ), ' ' + label + ' (' + cats[ c ] + ')' ) );
} );
$controls.append( $filters );
readLog( logTitle ).then( function ( st ) {
states = st;
$rows.each( function () { paint( $( this ) ); } );
countAll();
}, function () {
$( '.ter-count' ).text( 'could not read the log — marks are not shown' );
} );
}
$( function () {
var $tables = $( '.ter-table' ), $hub = $( '.ter-hub-count' );
if ( !$tables.length && !$hub.length ) { return; }
mw.loader.using( [ 'mediawiki.api', 'mediawiki.util', 'ext.gadget.TsadraEditionReviewCore' ] ).then( function () {
core = window.terCore;
if ( $hub.length ) { initHub(); }
$tables.each( function () { initTable( $( this ) ); } );
} );
} );
}() );