MediaWiki:Gadget-ArticleReview.js
Appearance
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)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
( function () {
'use strict';
var rootId = 'shalombux-article-review';
var editorId = 'mayapedia-writing-review-editor';
var guideUrl = 'https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing';
var viewCard, editorCard, activeTarget, sourceInput, activeDocument;
var editorGeneration = 0, requestSerial = 0;
var maxFindings = 80;
function element( name, className, text ) {
var node = document.createElement( name );
if ( className ) {
node.className = className;
}
if ( text !== undefined ) {
node.textContent = String( text );
}
return node;
}
function safeLink( label, address, newWindow ) {
var url;
try {
url = new URL( address, window.location.href );
} catch ( error ) {
return null;
}
if ( url.protocol !== 'https:' && url.protocol !== 'http:' ) {
return null;
}
var link = element( 'a', '', label );
link.href = url.href;
if ( newWindow ) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
}
return link;
}
function paragraphLink( parent, label, address ) {
var link = safeLink( label, address, true );
if ( link ) {
parent.appendChild( link );
}
}
function makeCard( id, label, storageKey, editor ) {
var root = element( 'div', 'sb-article-review' + ( editor ? ' sb-article-review--editor' : '' ) );
root.id = id;
var badge = element( 'button', 'sb-article-review__badge', label );
badge.type = 'button';
badge.setAttribute( 'aria-controls', id + '-panel' );
badge.setAttribute( 'aria-expanded', 'false' );
badge.title = editor ? 'Check the current unsaved writing' : 'Show the article review';
var panel = element( 'section', 'sb-article-review__panel' );
panel.id = id + '-panel';
panel.hidden = true;
panel.setAttribute( 'aria-labelledby', id + '-heading' );
var header = element( 'div', 'sb-article-review__header' );
var heading = element( 'h2', 'sb-article-review__heading', editor ? 'Writing review' : 'Article review' );
heading.id = id + '-heading';
var close = element( 'button', 'sb-article-review__close', '\u00d7' );
close.type = 'button';
close.setAttribute( 'aria-label', 'Close ' + heading.textContent.toLowerCase() );
close.title = 'Close review (Escape)';
header.appendChild( heading );
header.appendChild( close );
panel.appendChild( header );
var body = element( 'div', 'sb-article-review__body' );
panel.appendChild( body );
root.appendChild( badge );
root.appendChild( panel );
var dismissed = false, hoverBlocked = false, hoverAfter = 0, suppressFocus = false;
try {
dismissed = storageKey && window.localStorage.getItem( storageKey ) === 'dismissed';
} catch ( error ) {
// The card also works when browser storage is unavailable.
}
function remember( value ) {
if ( !storageKey ) {
return;
}
try {
if ( value ) {
window.localStorage.setItem( storageKey, 'dismissed' );
} else {
window.localStorage.removeItem( storageKey );
}
} catch ( error ) {
// Dismissal still works until this page is closed.
}
}
function open( rememberOpen ) {
if ( root.hidden ) {
return;
}
panel.hidden = false;
badge.setAttribute( 'aria-expanded', 'true' );
if ( rememberOpen ) {
remember( false );
}
}
function closePanel( returnFocus ) {
panel.hidden = true;
badge.setAttribute( 'aria-expanded', 'false' );
hoverBlocked = true;
hoverAfter = Date.now() + 800;
remember( true );
if ( returnFocus ) {
suppressFocus = true;
badge.focus( { preventScroll: true } );
suppressFocus = false;
}
}
close.addEventListener( 'click', function () { closePanel( true ); } );
badge.addEventListener( 'click', function () { open( true ); } );
if ( !editor ) {
badge.addEventListener( 'focus', function () {
if ( !suppressFocus ) {
open( false );
}
} );
badge.addEventListener( 'mouseenter', function () {
if ( !hoverBlocked && Date.now() >= hoverAfter && window.matchMedia( '(hover: hover)' ).matches ) {
open( false );
}
} );
}
root.addEventListener( 'mouseleave', function () { hoverBlocked = false; } );
document.addEventListener( 'keydown', function ( event ) {
if ( event.key === 'Escape' && !root.hidden && !panel.hidden ) {
var focusInside = root.contains( document.activeElement );
closePanel( focusInside );
if ( focusInside ) {
event.preventDefault();
}
}
} );
// The controls are outside article content and editor serialization.
document.body.appendChild( root );
return { root: root, body: body, badge: badge, panel: panel, open: open, dismissed: dismissed };
}
function renderManual( body, review, data, viewedRevision ) {
if ( !review || !Number( review.reviewedRevision ) ) {
return;
}
var revision = Number( review.reviewedRevision );
body.appendChild( element( 'h3', 'sb-article-review__section', 'Recorded article review' ) );
body.appendChild( element( 'p', 'sb-article-review__date', 'Reviewed ' + String( data.reviewDate ) + ' \u00b7 revision ' + revision ) );
if ( viewedRevision !== revision || Number( mw.config.get( 'wgCurRevisionId' ) ) > revision ) {
body.appendChild( element( 'p', 'sb-article-review__stale', viewedRevision !== revision ?
'These recorded findings cover another revision and may no longer apply. Writing checks below cover the revision identified in that section.' :
'A newer revision exists. These recorded findings cover the older revision shown here.' ) );
}
var revisionParagraph = element( 'p', 'sb-article-review__revision' );
paragraphLink( revisionParagraph, 'View reviewed revision ' + revision,
mw.util.getUrl( review.title || mw.config.get( 'wgPageName' ), { oldid: revision } ) );
body.appendChild( revisionParagraph );
var issues = Array.isArray( review.issues ) ? review.issues : [];
if ( issues.length ) {
var list = element( 'ul', 'sb-article-review__issues' );
issues.slice( 0, maxFindings ).forEach( function ( issue ) {
if ( !issue || typeof issue !== 'object' ) {
return;
}
var kind = [ 'error', 'review', 'note' ].indexOf( issue.kind ) !== -1 ? issue.kind : 'note';
var item = element( 'li', 'sb-article-review__issue sb-article-review__issue--' + kind );
item.appendChild( element( 'span', 'sb-article-review__kind', { error: 'Verified error', review: 'Review needed', note: 'Note' }[ kind ] ) );
item.appendChild( element( 'strong', 'sb-article-review__issue-title', issue.title || 'Review note' ) );
item.appendChild( element( 'p', 'sb-article-review__detail', issue.detail || '' ) );
if ( Array.isArray( issue.links ) ) {
var links = element( 'div', 'sb-article-review__links' );
issue.links.forEach( function ( source ) {
if ( source && typeof source.url === 'string' ) {
paragraphLink( links, source.label || 'Source', source.url );
}
} );
item.appendChild( links );
}
list.appendChild( item );
} );
body.appendChild( list );
} else {
body.appendChild( element( 'p', '', 'No article-specific issue was recorded in this limited review.' ) );
}
var scope = element( 'details', 'sb-article-review__scope' );
scope.appendChild( element( 'summary', '', 'Scope of the recorded review' ) );
scope.appendChild( element( 'p', 'sb-article-review__coverage', 'The following notes apply only to reviewed revision ' + revision + '.' ) );
if ( review.unicodeNote ) {
scope.appendChild( element( 'p', 'sb-article-review__unicode', review.unicodeNote ) );
}
scope.appendChild( element( 'p', 'sb-article-review__coverage', review.sourceReview === 'spot-checked' ?
'Selected citations were spot-checked; other sources remain unverified.' : 'Citation destinations were not checked for this article.' ) );
scope.appendChild( element( 'p', 'sb-article-review__coverage', 'Policy links provide review guidance; this was not a complete policy assessment.' ) );
body.appendChild( scope );
}
function sourceContents() {
if ( !sourceInput ) {
return null;
}
var input = window.jQuery( sourceInput );
return typeof input.textSelection === 'function' ? input.textSelection( 'getContents' ) : sourceInput.value;
}
function selectFinding( finding, checkedText ) {
if ( activeTarget || !sourceInput || sourceContents() !== checkedText ) {
return;
}
var encoded = new TextEncoder().encode( checkedText );
var startByte = Math.max( 0, Math.min( encoded.length, Number( finding.offset ) || 0 ) );
var endByte = Math.max( startByte, Math.min( encoded.length, startByte + ( Number( finding.length ) || 0 ) ) );
var decoder = new TextDecoder();
var start = decoder.decode( encoded.slice( 0, startByte ) ).length;
var end = decoder.decode( encoded.slice( 0, endByte ) ).length;
var input = window.jQuery( sourceInput );
if ( typeof input.textSelection === 'function' ) {
input.textSelection( 'setSelection', { start: start, end: end } );
input.textSelection( 'scrollToCaretPosition' );
} else {
sourceInput.setSelectionRange( start, end );
}
sourceInput.focus();
}
function renderWriting( body, result, options ) {
var findings = Array.isArray( result.findings ) ? result.findings : [];
body.appendChild( element( 'h3', 'sb-article-review__section', 'Writing checks' ) );
body.appendChild( element( 'p', 'sb-article-review__date', options.editor ?
'Checked an unsaved snapshot. Nothing has been saved or changed.' :
'Checked revision ' + Number( options.revision ) ) );
if ( options.editor ) {
var stale = element( 'p', 'sb-article-review__stale sb-writing-review__stale',
'The draft changed after this check. Check writing again to review the current text.' );
stale.hidden = !options.stale;
body.appendChild( stale );
}
body.appendChild( element( 'p', 'sb-article-review__limits',
'These are prompts for human review, not proof of AI authorship or policy violations. Saving is never blocked.' ) );
if ( !findings.length ) {
body.appendChild( element( 'p', 'sb-writing-review__empty', result.coverage && result.coverage.status === 'scanned' ?
'No matching signs were found by these checks. This does not establish who wrote the text or whether it is accurate.' :
'No findings are available from this check. See the coverage notes below.' ) );
} else {
var list = element( 'ul', 'sb-article-review__issues sb-writing-review__findings' );
findings.slice( 0, maxFindings ).forEach( function ( finding ) {
if ( !finding || typeof finding !== 'object' ) {
return;
}
var kind = finding.severity === 'review' ? 'review' : 'note';
var item = element( 'li', 'sb-article-review__issue sb-article-review__issue--' + kind );
item.appendChild( element( 'span', 'sb-article-review__kind', kind === 'review' ? 'Review this passage' : 'Context check' ) );
item.appendChild( element( 'strong', 'sb-article-review__issue-title', finding.title || 'Writing sign' ) );
item.appendChild( element( 'p', 'sb-article-review__detail', finding.explanation || '' ) );
if ( typeof finding.evidence === 'string' && finding.evidence ) {
item.appendChild( element( 'blockquote', 'sb-writing-review__evidence', finding.evidence.slice( 0, 1200 ) ) );
}
var links = element( 'div', 'sb-article-review__links' );
if ( Number( finding.line ) > 0 ) {
if ( options.editor && sourceInput && !activeTarget ) {
var select = element( 'button', 'sb-writing-review__source', 'Select source line ' + Number( finding.line ) );
select.type = 'button';
select.disabled = !!options.stale;
select.addEventListener( 'click', function () { selectFinding( finding, options.checkedText ); } );
links.appendChild( select );
} else if ( options.editor ) {
links.appendChild( element( 'span', '', 'Draft source line ' + Number( finding.line ) ) );
} else {
paragraphLink( links, 'Source line ' + Number( finding.line ), mw.util.getUrl(
options.title || mw.config.get( 'wgPageName' ), { action: 'edit', oldid: Number( options.revision ) } ) );
}
}
paragraphLink( links, 'Why this is flagged', typeof finding.guide === 'string' ? finding.guide : guideUrl );
item.appendChild( links );
list.appendChild( item );
} );
body.appendChild( list );
}
if ( result.truncated || findings.length > maxFindings ) {
body.appendChild( element( 'p', 'sb-article-review__stale', 'This report is limited in size. Additional passages may need review.' ) );
}
var scope = element( 'details', 'sb-article-review__scope' );
scope.appendChild( element( 'summary', '', 'What the checks cover' ) );
if ( result.coverage && result.coverage.reason ) {
scope.appendChild( element( 'p', '', result.coverage.reason ) );
}
var limitations = result.coverage && result.coverage.limitations;
if ( Array.isArray( limitations ) ) {
limitations.slice( 0, 12 ).forEach( function ( note ) {
if ( typeof note === 'string' ) {
scope.appendChild( element( 'p', '', note ) );
}
} );
} else if ( typeof limitations === 'string' ) {
scope.appendChild( element( 'p', '', limitations ) );
}
scope.appendChild( element( 'p', '', 'Checks use this wiki\u2019s text. They do not establish plagiarism, verify every source, or identify the writer.' ) );
paragraphLink( scope, 'Wikipedia: Signs of AI writing', guideUrl );
body.appendChild( scope );
}
function initializeView() {
var data = window.shalombuxArticleReviewData || {};
var scan = mw.config.get( 'wgMayapediaWritingReview' );
var pageId = Number( mw.config.get( 'wgArticleId' ) );
var revision = Number( mw.config.get( 'wgRevisionId' ) );
var query = new URLSearchParams( window.location.search );
if ( document.getElementById( rootId ) || pageId <= 0 || revision <= 0 ||
mw.config.get( 'wgAction' ) !== 'view' || query.has( 'diff' ) ||
mw.config.get( 'wgDiffOldId' ) || mw.config.get( 'wgDiffNewId' ) ) {
return;
}
var review = data.pages && data.pages[ String( pageId ) ];
// Never present another page/revision's automated findings as current.
if ( scan && ( Number( scan.revision ) !== revision || Number( scan.pageId ) !== pageId ) ) {
scan = null;
}
if ( !scan && !review ) {
return;
}
var count = scan && Array.isArray( scan.findings ) ? scan.findings.length : 0;
var manualCount = review && Array.isArray( review.issues ) ? review.issues.length : 0;
var storageKey = 'shalombux-article-review:writing:' + String( data.version || '' ) + ':' +
String( scan && scan.version || '' ) + ':' + pageId + ':' + revision + ':' + String( review && review.reviewedRevision || '' );
viewCard = makeCard( rootId, count + manualCount ? 'Article review (' + ( count + manualCount ) + ')' : 'Article review', storageKey, false );
viewCard.revision = revision;
viewCard.currentRevision = Number( mw.config.get( 'wgCurRevisionId' ) ) || revision;
renderManual( viewCard.body, review, data, revision );
if ( scan ) {
renderWriting( viewCard.body, scan, { revision: revision, title: scan.title } );
} else {
viewCard.body.appendChild( element( 'p', 'sb-article-review__coverage', 'Automated writing checks are unavailable for this view.' ) );
}
if ( ( count || manualCount ) && !viewCard.dismissed ) {
viewCard.open( false );
}
}
function markDraftChanged() {
editorGeneration++;
if ( editorCard ) {
var stale = editorCard.body.querySelector( '.sb-writing-review__stale' );
if ( stale ) {
stale.hidden = false;
}
editorCard.body.querySelectorAll( '.sb-writing-review__source' ).forEach( function ( button ) { button.disabled = true; } );
}
}
function currentDraft() {
if ( activeTarget ) {
var doc = activeTarget.getDocToSave();
if ( typeof doc === 'string' ) {
return Promise.resolve( doc );
}
return Promise.resolve( activeTarget.serialize( doc ) ).then( function ( data ) {
if ( !data || typeof data.content !== 'string' ) {
throw new Error( 'serialization' );
}
return data.content;
} );
}
var text = sourceContents();
return typeof text === 'string' ? Promise.resolve( text ) : Promise.reject( new Error( 'editor' ) );
}
function checkDraft() {
if ( !editorCard || editorCard.badge.disabled ) {
return;
}
var serial = ++requestSerial, generation = editorGeneration, checkedText;
editorCard.badge.disabled = true;
editorCard.badge.textContent = 'Checking writing\u2026';
editorCard.body.textContent = '';
editorCard.body.appendChild( element( 'p', 'sb-writing-review__status', 'Checking the current unsaved text on Mayapedia\u2026' ) );
editorCard.body.setAttribute( 'aria-busy', 'true' );
editorCard.open( false );
currentDraft().then( function ( text ) {
checkedText = text;
return new mw.Api().postWithToken( 'csrf', { action: 'writingreview', text: text, format: 'json', formatversion: 2 } );
} ).then( function ( response ) {
if ( serial !== requestSerial || !editorCard ) {
return;
}
if ( !response || !response.writingreview ) {
throw new Error( 'response' );
}
editorCard.body.textContent = '';
renderWriting( editorCard.body, response.writingreview, { editor: true, checkedText: checkedText,
stale: generation !== editorGeneration || ( !activeTarget && sourceContents() !== checkedText ) } );
} ).catch( function () {
if ( serial === requestSerial && editorCard ) {
editorCard.body.textContent = '';
editorCard.body.appendChild( element( 'p', 'sb-article-review__stale',
'The writing check could not finish. Your draft is unchanged and you can still save. Try Check writing again.' ) );
}
} ).then( function () {
if ( serial === requestSerial && editorCard ) {
editorCard.badge.disabled = false;
editorCard.badge.textContent = 'Check writing';
editorCard.body.removeAttribute( 'aria-busy' );
}
} );
}
function ensureEditor() {
if ( !editorCard ) {
editorCard = makeCard( editorId, 'Check writing', null, true );
editorCard.badge.addEventListener( 'click', checkDraft );
}
editorCard.root.hidden = false;
}
function activateVisualEditor() {
var target = window.ve && ve.init && ve.init.target;
if ( !target || typeof target.getSurface !== 'function' || typeof target.getDocToSave !== 'function' || typeof target.serialize !== 'function' ) {
return;
}
var surface = target.getSurface();
if ( !surface || !surface.getModel || !surface.getModel().getDocument ) {
return;
}
var doc = surface.getModel().getDocument();
activeTarget = target;
if ( activeDocument !== doc ) {
if ( activeDocument && typeof activeDocument.off === 'function' ) {
activeDocument.off( 'transact', markDraftChanged );
}
activeDocument = doc;
activeDocument.on( 'transact', markDraftChanged );
markDraftChanged();
}
ensureEditor();
if ( viewCard ) {
viewCard.root.hidden = true;
}
}
function initialize() {
if ( [ 0, 118 ].indexOf( Number( mw.config.get( 'wgNamespaceNumber' ) ) ) === -1 ) {
return;
}
initializeView();
sourceInput = document.getElementById( 'wpTextbox1' );
if ( sourceInput && !sourceInput.readOnly && !sourceInput.disabled ) {
ensureEditor();
sourceInput.addEventListener( 'input', markDraftChanged );
window.jQuery( sourceInput ).on( 'change', markDraftChanged );
}
mw.hook( 'ext.CodeMirror.input' ).add( markDraftChanged );
mw.hook( 've.activationStart' ).add( function () {
if ( viewCard ) {
viewCard.root.hidden = true;
}
if ( editorCard ) {
editorCard.root.hidden = true;
}
} );
mw.hook( 've.activationComplete' ).add( activateVisualEditor );
mw.hook( 've.deactivationComplete' ).add( function () {
activeTarget = null;
if ( activeDocument && typeof activeDocument.off === 'function' ) {
activeDocument.off( 'transact', markDraftChanged );
}
activeDocument = null;
requestSerial++;
if ( editorCard ) {
editorCard.root.hidden = true;
editorCard.badge.disabled = false;
editorCard.badge.textContent = 'Check writing';
}
if ( viewCard ) {
var revision = Number( mw.config.get( 'wgRevisionId' ) );
var currentRevision = Number( mw.config.get( 'wgCurRevisionId' ) ) || revision;
if ( revision !== viewCard.revision || currentRevision !== viewCard.currentRevision ) {
viewCard.body.textContent = '';
viewCard.body.appendChild( element( 'p', 'sb-article-review__stale',
'The saved page changed in the editor. Reload this page to check the new saved revision.' ) );
paragraphLink( viewCard.body, 'Refresh saved-page checks', mw.util.getUrl( mw.config.get( 'wgPageName' ) ) );
viewCard.badge.textContent = 'Refresh article review';
}
viewCard.root.hidden = false;
}
} );
if ( document.documentElement.classList.contains( 've-activated' ) || document.body.classList.contains( 've-activated' ) ) {
activateVisualEditor();
}
}
mw.loader.using( [ 'mediawiki.util', 'mediawiki.api', 'jquery.textSelection' ] ).then( function () {
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', initialize, { once: true } );
} else {
initialize();
}
} );
}() );