MediaWiki:Gadget-TsadraEditionReview.js

From Bodhicitta

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 &#124; 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 ) ); } );
		} );
	} );

}() );