Jump to content
Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

MediaWiki:Gadget-AuthorAssistant.js: Difference between revisions

MediaWiki interface page
Machine-seeded from the topic schema; no approved revision
 
Machine-seeded from the topic schema; no approved revision
 
(8 intermediate revisions by the same user not shown)
Line 3: Line 3:
   diff, and saves only when you click, through your own session under your own name. The service
   diff, and saves only when you click, through your own session under your own name. The service
   at /assistant/ holds no credential; it confirms who you are from this session and drafts. */
   at /assistant/ holds no credential; it confirms who you are from this session and drafts. */
( function () {
$( function () {
var ns = mw.config.get( 'wgNamespaceNumber' );
var ns = mw.config.get( 'wgNamespaceNumber' );
var title = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
var title = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
var action = mw.config.get( 'wgAction' );
var action = mw.config.get( 'wgAction' );
if ( ( ns !== 0 && ns !== 6 ) || action !== 'view' || !mw.config.get( 'wgUserName' ) ) {
// On a view page the assistant saves as you; on the source editor it puts the draft into the
// editor so you save with the normal form. In the visual editor, switch to source editing first.
var editing = ( action === 'edit' || action === 'submit' ) && $( '#wpTextbox1' ).length > 0;
if ( ( ns !== 0 && ns !== 6 ) || ( action !== 'view' && !editing ) || !mw.config.get( 'wgUserName' ) ) {
return;
return;
}
}
Line 16: Line 19:
var instruction = $( '<textarea rows="4" placeholder="What should change on this page? For example: add these two sources under the Sources heading, or fill in the population from the source below.">' );
var instruction = $( '<textarea rows="4" placeholder="What should change on this page? For example: add these two sources under the Sources heading, or fill in the population from the source below.">' );
var urls = $( '<input type="text" placeholder="Optional: web addresses to draw on, separated by spaces">' );
var urls = $( '<input type="text" placeholder="Optional: web addresses to draw on, separated by spaces">' );
// Attach a file: it is uploaded through your own session as your contribution, exactly as on the
// Upload page, and the assistant reads it as a source. It reaches the public site only after review.
var files = [];
var fileList = $( '<div class="okc-files">' );
var attachToggle = $( '<button class="okc-link">' ).text( 'Attach a file as a source' );
var attachBox = $( '<div class="okc-attach">' ).hide();
var fileInput = $( '<input type="file" accept=".pdf,.docx,.pptx,.xlsx,.csv,.png,.jpg,.jpeg,.gif,.webp,.svg,.mp3,.ogg,.wav,.m4a,.mp4,.webm">' );
var fileDesc = $( '<input type="text" placeholder="What is this document? For example: KPA annual report 2025">' );
var fileLicence = $( '<select>' );
var uploadBtn = $( '<button class="okc-secondary" disabled>' ).text( 'Upload and use' );
// The button wakes up, and turns blue, once there is both a file and a description.
function uploadReady() {
var ready = !!( fileInput[ 0 ].files && fileInput[ 0 ].files.length ) && !!fileDesc.val().trim();
uploadBtn.prop( 'disabled', !ready ).toggleClass( 'okc-primary', ready ).toggleClass( 'okc-secondary', !ready );
}
fileInput.on( 'change', uploadReady );
fileDesc.on( 'input', uploadReady );
attachBox.append(
$( '<p class="okc-note">' ).text( 'The file is uploaded under your name, like any contribution. Choose the licence it was published under. It appears on the public site only after a reviewer approves it.' ),
fileInput, fileDesc, $( '<label class="okc-note">' ).text( 'Licence ' ).append( fileLicence ), $( '<div>' ).append( uploadBtn ) );
api.get( { action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', titles: 'MediaWiki:Licenses', formatversion: 2 } ).then( function ( data ) {
var page = data.query.pages[ 0 ];
var text = page.revisions ? page.revisions[ 0 ].slots.main.content : '';
text.split( '\n' ).forEach( function ( line ) {
var m = /^\*\*\s*([^|]+)\|(.+)$/.exec( line );
if ( m ) { fileLicence.append( $( '<option>' ).val( m[ 1 ].trim() ).text( m[ 2 ].trim() ) ); }
} );
} );
function renderFiles() {
fileList.empty();
files.forEach( function ( f, i ) {
fileList.append( $( '<span class="okc-file">' ).append(
$( '<a target="_blank">' ).attr( 'href', mw.util.getUrl( f ) ).text( f.replace( /^File:/, '' ) ),
$( '<button class="okc-x" title="Do not use this file">' ).text( '\u00d7' ).on( 'click', function () { files.splice( i, 1 ); renderFiles(); } ) ) );
} );
}
function addFile( name ) {
var t = 'File:' + name.replace( /^(File|Image):/i, '' ).replace( /_/g, ' ' );
if ( files.indexOf( t ) < 0 ) { files.push( t ); }
renderFiles();
}
attachToggle.on( 'click', function () { attachBox.toggle(); } );
uploadBtn.on( 'click', function () {
var f = fileInput[ 0 ].files && fileInput[ 0 ].files[ 0 ];
if ( !f ) { status.text( 'Choose a file first.' ); return; }
var desc = fileDesc.val().trim();
if ( !desc ) { status.text( 'Say in a few words what the document is, so others can find it.' ); return; }
var filename = f.name.replace( /[#<>\[\]|{}:\/]/g, '-' );
uploadBtn.prop( 'disabled', true );
status.text( 'Uploading ' + filename + ' as ' + mw.config.get( 'wgUserName' ) + '\u2026' );
api.upload( f, {
filename: filename,
comment: 'Contributed as a source for [[' + title + ']], with the author\u2019s assistant',
text: '== Summary ==\n' + desc + '\n\nContributed as a source for [[' + title + ']].\n\n== Licensing ==\n{{' + fileLicence.val() + '}}\n'
} ).then( function ( d ) {
addFile( ( d.upload && d.upload.filename ) || filename );
status.text( 'Uploaded. The assistant will read it when you draft the change.' );
fileInput.val( '' ); fileDesc.val( '' ); attachBox.hide();
}, function ( code, d ) {
var w = d && d.upload && d.upload.warnings;
if ( w && w.duplicate ) {
// the same document is already in the Collab Studio: use that copy rather than a second one
addFile( [].concat( w.duplicate )[ 0 ] );
status.text( 'This document is already in the Collab Studio, so the assistant will use that copy.' );
} else if ( w && ( w.exists || w[ 'exists-normalized' ] || w[ 'page-exists' ] ) ) {
status.text( 'A different file named ' + filename + ' already exists. Rename your file and upload again.' );
} else {
status.text( 'The upload failed: ' + ( d && d.error ? d.error.info : code ) );
}
} ).always( uploadReady );
} );
var draftBtn = $( '<button class="okc-primary">' ).text( 'Draft the change' );
var draftBtn = $( '<button class="okc-primary">' ).text( 'Draft the change' );
var scoutBtn = $( '<button class="okc-secondary" style="margin-left:8px">' ).text( 'Suggest sources' );
var scoutList = $( '<div class="okc-sources">' ).hide();
var status = $( '<p>' );
var status = $( '<p>' );
var result = $( '<div>' ).hide();
var result = $( '<div>' ).hide();
Line 22: Line 98:
var warn = $( '<div class="okc-warn">' ).hide();
var warn = $( '<div class="okc-warn">' ).hide();
var diff = $( '<div class="okc-diff">' );
var diff = $( '<div class="okc-diff">' );
var saveBtn = $( '<button class="okc-primary">' ).text( 'Save this as my edit' );
var saveBtn = $( '<button class="okc-primary">' ).text( editing ? 'Put this in the editor' : 'Save this as my edit' );
var discardBtn = $( '<button class="okc-secondary" style="margin-left:8px">' ).text( 'Discard' );
var discardBtn = $( '<button class="okc-secondary" style="margin-left:8px">' ).text( 'Discard' );
var proposal = null;
var proposal = null;
Line 29: Line 105:
$( '<h3>' ).text( 'Author’s assistant' ),
$( '<h3>' ).text( 'Author’s assistant' ),
$( '<p>' ).text( 'Tell it what to change. You will see the diff before anything is saved, and the edit will be yours: your name, your responsibility, still needing a reviewer’s approval.' ),
$( '<p>' ).text( 'Tell it what to change. You will see the diff before anything is saved, and the edit will be yours: your name, your responsibility, still needing a reviewer’s approval.' ),
instruction, urls, draftBtn, status,
instruction, urls, attachToggle, attachBox, fileList, draftBtn, scoutBtn, scoutList, status,
result.append( explanation, warn, diff, saveBtn, discardBtn,
result.append( explanation, warn, diff, saveBtn, discardBtn,
$( '<p class="okc-note">' ).text( 'The edit summary will say the assistant helped and name any sources used.' ) )
$( '<p class="okc-note">' ).text( 'The edit summary will say the assistant helped and name any sources used.' ) )
Line 35: Line 111:
root.append( panel, openBtn );
root.append( panel, openBtn );
$( 'body' ).append( root );
$( 'body' ).append( root );
// In the visual editor the assistant works on the saved page and saves as you, then reopens
// the editor on the new revision. Unsaved visual edits would be lost, so it refuses while there are any.
function veUnsaved() {
try {
return $( 'html' ).hasClass( 've-active' ) && ve.init.target.getSurface().getModel().hasBeenModified();
} catch ( e ) { return false; }
}


openBtn.on( 'click', function () {
openBtn.on( 'click', function () {
root.toggleClass( 'okc-is-open' );
root.toggleClass( 'okc-is-open' );
if ( root.hasClass( 'okc-is-open' ) && $( 'html' ).hasClass( 've-active' ) ) {
status.text( veUnsaved()
? 'You have unsaved changes in the visual editor. Save or discard them first; the assistant works on the saved page.'
: 'In the visual editor the assistant works on the saved page and saves the result as your edit, then reopens the editor.' );
}
if ( root.hasClass( 'okc-is-open' ) ) {
if ( root.hasClass( 'okc-is-open' ) ) {
fetch( '/assistant/whoami', { credentials: 'same-origin' } ).then( function ( r ) {
fetch( '/assistant/whoami', { credentials: 'same-origin' } ).then( function ( r ) {
Line 48: Line 137:
var text = instruction.val().trim();
var text = instruction.val().trim();
if ( !text ) { status.text( 'Say what should change.' ); return; }
if ( !text ) { status.text( 'Say what should change.' ); return; }
if ( veUnsaved() ) { status.text( 'You have unsaved changes in the visual editor. Save or discard them first; the assistant works on the saved page.' ); return; }
status.text( 'Reading the page and drafting…' );
status.text( 'Reading the page and drafting…' );
draftBtn.prop( 'disabled', true );
draftBtn.prop( 'disabled', true );
result.hide();
result.hide();
api.get( { action: 'query', prop: 'revisions', rvprop: 'content|ids', rvslots: 'main', titles: title, formatversion: 2 } ).then( function ( data ) {
var current = editing
var page = data.query.pages[ 0 ];
? $.Deferred().resolve( { wikitext: $( '#wpTextbox1' ).val(), revid: undefined } ).promise()
var rev = page.revisions && page.revisions[ 0 ];
: api.get( { action: 'query', prop: 'revisions', rvprop: 'content|ids', rvslots: 'main', titles: title, formatversion: 2 } ).then( function ( data ) {
var wikitext = rev ? rev.slots.main.content : '';
var page = data.query.pages[ 0 ];
var rev = page.revisions && page.revisions[ 0 ];
return { wikitext: rev ? rev.slots.main.content : '', revid: rev ? rev.revid : undefined };
} );
current.then( function ( cur ) {
var wikitext = cur.wikitext;
var rev = { revid: cur.revid };
return fetch( '/assistant/draft', {
return fetch( '/assistant/draft', {
method: 'POST', credentials: 'same-origin',
method: 'POST', credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json' },
body: JSON.stringify( { title: title, wikitext: wikitext, instruction: text, urls: urls.val().split( /\s+/ ).filter( Boolean ) } )
body: JSON.stringify( {
title: title, wikitext: wikitext, instruction: text,
urls: urls.val().split( /\s+/ ).filter( function ( u ) { return u && !/^(File|Image):/i.test( u ); } ),
files: files.concat( urls.val().split( /\s+/ ).filter( function ( u ) { return /^(File|Image):/i.test( u ); } ) )
} )
} ).then( function ( r ) {
} ).then( function ( r ) {
if ( !r.ok ) { return r.json().then( function ( e ) { throw new Error( e.detail || r.status ); } ); }
if ( !r.ok ) { return r.json().then( function ( e ) { throw new Error( e.detail || r.status ); } ); }
Line 64: Line 164:
} ).then( function ( d ) {
} ).then( function ( d ) {
proposal = { wikitext: d.wikitext, summary: d.summary, baserevid: rev ? rev.revid : undefined };
proposal = { wikitext: d.wikitext, summary: d.summary, baserevid: rev ? rev.revid : undefined };
if ( d.unchanged ) {
// the assistant asked for a source instead of drafting: show its explanation and the scout's candidates
explanation.text( d.explanation );
diff.empty();
warn.hide();
result.show();
saveBtn.hide();
discardBtn.hide();
renderSuggestions( d.suggestions, d.suggestions && d.suggestions.length
? 'It needs a source for that. Tick one or more of these, click Use the ticked sources, then Draft the change again.'
: 'It needs a source for that, and the scout found none. Paste an address you trust and draft again.' );
return;
}
saveBtn.show().prop( 'disabled', false );
discardBtn.show();
explanation.text( d.explanation );
explanation.text( d.explanation );
if ( d.warning ) { warn.text( d.warning ).show(); } else { warn.hide(); }
if ( d.warning ) { warn.text( d.warning ).show(); } else { warn.hide(); }
return api.post( { action: 'compare', fromtitle: title, totext: d.wikitext, toslots: 'main', 'totext-main': d.wikitext, 'tocontentmodel-main': 'wikitext', prop: 'diff', formatversion: 2 } ).then( function ( c ) {
blockedLinks( d.wikitext, wikitext ).then( function ( bad ) {
if ( !bad.length ) { return; }
saveBtn.prop( 'disabled', true );
warn.text( blockedMessage( bad ) ).show();
} );
return api.post( { action: 'compare', fromslots: 'main', 'fromtext-main': wikitext, 'fromcontentmodel-main': 'wikitext', toslots: 'main', 'totext-main': d.wikitext, 'tocontentmodel-main': 'wikitext', prop: 'diff', formatversion: 2 } ).then( function ( c ) {
var body = c.compare.body || c.compare[ '*' ] || '';
var body = c.compare.body || c.compare[ '*' ] || '';
diff.html( body ? '<table class="diff"><colgroup><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content"></colgroup>' + body + '</table>' : '<p>No difference.</p>' );
diff.html( body ? '<table class="diff"><colgroup><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content"></colgroup>' + body + '</table>' : '<p>No difference.</p>' );
Line 76: Line 196:
status.text( 'The assistant could not draft that: ' + ( e && e.message ? e.message : e ) );
status.text( 'The assistant could not draft that: ' + ( e && e.message ? e.message : e ) );
} ).always( function () { draftBtn.prop( 'disabled', false ); } );
} ).always( function () { draftBtn.prop( 'disabled', false ); } );
} );
// Links the wiki will refuse. Link shorteners and some sites are on the spam blacklist; finding
// that out here, in plain words, beats a failed save.
function blockedLinks( newText, oldText ) {
var found = ( newText.match( /https?:\/\/[^\s\]|<>"']+/g ) || [] ).filter( function ( u, i, all ) {
return all.indexOf( u ) === i && oldText.indexOf( u ) < 0;
} );
if ( !found.length ) { return $.Deferred().resolve( [] ).promise(); }
return api.get( { action: 'spamblacklist', url: found.slice( 0, 50 ), formatversion: 2 } ).then( function ( r ) {
var res = r.spamblacklist || {};
return res.result === 'blacklisted' ? ( res.matches || [] ) : [];
}, function () { return []; } );
}
function blockedMessage( matches ) {
return 'This draft links to ' + matches.join( ', ' ) + ', which the wiki does not accept. ' +
'Short links such as share.google or bit.ly hide where they lead, so they are blocked. ' +
'Open the link, copy the full address of the page it leads to, and draft again with that address.';
}
// Suggest sources: the commons first, then the web. The author ticks what to draw on.
function currentWikitext() {
if ( editing ) { return $.Deferred().resolve( $( '#wpTextbox1' ).val() ).promise(); }
return api.get( { action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', titles: title, formatversion: 2 } ).then( function ( data ) {
var page = data.query.pages[ 0 ];
return page.revisions ? page.revisions[ 0 ].slots.main.content : '';
} );
}
function renderSuggestions( suggestions, okMessage ) {
scoutList.empty();
if ( !suggestions || !suggestions.length ) { status.text( 'The scout found nothing suitable. You can still paste an address you trust.' ); return; }
suggestions.forEach( function ( sug ) {
var row = $( '<label class="okc-source">' );
var cb = $( '<input type="checkbox">' ).val( sug.url );
row.append( cb, ' ', $( '<b>' ).text( sug.title ), ' ', $( '<span class="okc-kind">' ).text( sug.kind === 'commons' ? 'in the commons' : 'web' ),
$( '<div class="okc-why">' ).text( sug.why + ( sug.caveat ? ' Caveat: ' + sug.caveat : '' ) ),
$( '<div class="okc-url">' ).append( $( '<a target="_blank" rel="noopener">' ).attr( 'href', sug.url ).text( sug.url ) ) );
scoutList.append( row );
} );
var use = $( '<button class="okc-secondary">' ).text( 'Use the ticked sources' ).on( 'click', function () {
var picked = scoutList.find( 'input:checked' ).map( function () { return this.value; } ).get();
var existing = urls.val().split( /\s+/ ).filter( Boolean );
urls.val( existing.concat( picked.filter( function ( u ) { return existing.indexOf( u ) < 0; } ) ).join( ' ' ) );
status.text( picked.length ? picked.length + ' source' + ( picked.length > 1 ? 's' : '' ) + ' added. Now click Draft the change.' : 'Nothing ticked.' );
} );
scoutList.append( $( '<div style="margin-top:6px">' ).append( use ) ).show();
status.text( okMessage );
}
scoutBtn.on( 'click', function () {
var text = instruction.val().trim();
if ( !text ) { status.text( 'Say what should change, so the scout knows what needs a source.' ); return; }
status.text( 'Looking in the commons and on the web\u2026' );
scoutBtn.prop( 'disabled', true );
currentWikitext().then( function ( wikitext ) {
return fetch( '/assistant/sources', {
method: 'POST', credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify( { title: title, wikitext: wikitext, instruction: text } )
} ).then( function ( r ) {
if ( !r.ok ) { return r.json().then( function ( e ) { throw new Error( e.detail || r.status ); } ); }
return r.json();
} );
} ).then( function ( d ) {
renderSuggestions( d.suggestions, 'Tick the sources to draw on, then draft. Only ticked addresses are used.' );
} ).catch( function ( e ) {
status.text( 'The scout could not search: ' + ( e && e.message ? e.message : e ) );
} ).always( function () { scoutBtn.prop( 'disabled', false ); } );
} );
} );


saveBtn.on( 'click', function () {
saveBtn.on( 'click', function () {
if ( !proposal ) { return; }
if ( !proposal ) { return; }
if ( editing ) {
$( '#wpTextbox1' ).val( proposal.wikitext );
var summary = $( '#wpSummary' );
if ( summary.length && !summary.val() ) { summary.val( proposal.summary ); }
status.text( 'The draft is in the editor. Review it and save with the normal button; the edit will be yours.' );
result.hide();
root.removeClass( 'okc-is-open' );
return;
}
saveBtn.prop( 'disabled', true );
saveBtn.prop( 'disabled', true );
status.text( 'Saving as ' + mw.config.get( 'wgUserName' ) + '…' );
status.text( 'Saving as ' + mw.config.get( 'wgUserName' ) + '…' );
api.postWithToken( 'csrf', { action: 'edit', title: title, text: proposal.wikitext, summary: proposal.summary, baserevid: proposal.baserevid, formatversion: 2 } ).then( function () {
api.postWithToken( 'csrf', { action: 'edit', title: title, text: proposal.wikitext, summary: proposal.summary, baserevid: proposal.baserevid, formatversion: 2 } ).then( function () {
location.reload();
if ( $( 'html' ).hasClass( 've-active' ) ) {
location.href = mw.util.getUrl( title, { veaction: 'edit' } );
} else {
location.reload();
}
}, function ( code, e ) {
}, function ( code, e ) {
status.text( 'Save failed: ' + ( e && e.error ? e.error.info : code ) );
if ( code === 'spamblacklist' ) {
var m = e && e.error && e.error.spamblacklist && e.error.spamblacklist.matches;
status.text( blockedMessage( m || [ 'a blocked site' ] ) );
} else {
status.text( 'Save failed: ' + ( e && e.error ? e.error.info : code ) );
}
saveBtn.prop( 'disabled', false );
saveBtn.prop( 'disabled', false );
} );
} );
Line 91: Line 297:


discardBtn.on( 'click', function () { proposal = null; result.hide(); status.text( 'Discarded. Nothing was saved.' ); } );
discardBtn.on( 'click', function () { proposal = null; result.hide(); status.text( 'Discarded. Nothing was saved.' ); } );
}() );
} );

Latest revision as of 01:34, 16 October 2026

/* The author's assistant (PRD 5.7, ADR D9).
   Drafts a change to the current page from your instruction and the sources you name, shows the
   diff, and saves only when you click, through your own session under your own name. The service
   at /assistant/ holds no credential; it confirms who you are from this session and drafts. */
$( function () {
	var ns = mw.config.get( 'wgNamespaceNumber' );
	var title = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
	var action = mw.config.get( 'wgAction' );
	// On a view page the assistant saves as you; on the source editor it puts the draft into the
	// editor so you save with the normal form. In the visual editor, switch to source editing first.
	var editing = ( action === 'edit' || action === 'submit' ) && $( '#wpTextbox1' ).length > 0;
	if ( ( ns !== 0 && ns !== 6 ) || ( action !== 'view' && !editing ) || !mw.config.get( 'wgUserName' ) ) {
		return;
	}
	var api = new mw.Api();
	var root = $( '<div id="okc-assistant">' );
	var panel = $( '<div class="okc-panel">' );
	var openBtn = $( '<button class="okc-open">' ).text( 'Author’s assistant' );
	var instruction = $( '<textarea rows="4" placeholder="What should change on this page? For example: add these two sources under the Sources heading, or fill in the population from the source below.">' );
	var urls = $( '<input type="text" placeholder="Optional: web addresses to draw on, separated by spaces">' );
	// Attach a file: it is uploaded through your own session as your contribution, exactly as on the
	// Upload page, and the assistant reads it as a source. It reaches the public site only after review.
	var files = [];
	var fileList = $( '<div class="okc-files">' );
	var attachToggle = $( '<button class="okc-link">' ).text( 'Attach a file as a source' );
	var attachBox = $( '<div class="okc-attach">' ).hide();
	var fileInput = $( '<input type="file" accept=".pdf,.docx,.pptx,.xlsx,.csv,.png,.jpg,.jpeg,.gif,.webp,.svg,.mp3,.ogg,.wav,.m4a,.mp4,.webm">' );
	var fileDesc = $( '<input type="text" placeholder="What is this document? For example: KPA annual report 2025">' );
	var fileLicence = $( '<select>' );
	var uploadBtn = $( '<button class="okc-secondary" disabled>' ).text( 'Upload and use' );
	// The button wakes up, and turns blue, once there is both a file and a description.
	function uploadReady() {
		var ready = !!( fileInput[ 0 ].files && fileInput[ 0 ].files.length ) && !!fileDesc.val().trim();
		uploadBtn.prop( 'disabled', !ready ).toggleClass( 'okc-primary', ready ).toggleClass( 'okc-secondary', !ready );
	}
	fileInput.on( 'change', uploadReady );
	fileDesc.on( 'input', uploadReady );
	attachBox.append(
		$( '<p class="okc-note">' ).text( 'The file is uploaded under your name, like any contribution. Choose the licence it was published under. It appears on the public site only after a reviewer approves it.' ),
		fileInput, fileDesc, $( '<label class="okc-note">' ).text( 'Licence ' ).append( fileLicence ), $( '<div>' ).append( uploadBtn ) );
	api.get( { action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', titles: 'MediaWiki:Licenses', formatversion: 2 } ).then( function ( data ) {
		var page = data.query.pages[ 0 ];
		var text = page.revisions ? page.revisions[ 0 ].slots.main.content : '';
		text.split( '\n' ).forEach( function ( line ) {
			var m = /^\*\*\s*([^|]+)\|(.+)$/.exec( line );
			if ( m ) { fileLicence.append( $( '<option>' ).val( m[ 1 ].trim() ).text( m[ 2 ].trim() ) ); }
		} );
	} );
	function renderFiles() {
		fileList.empty();
		files.forEach( function ( f, i ) {
			fileList.append( $( '<span class="okc-file">' ).append(
				$( '<a target="_blank">' ).attr( 'href', mw.util.getUrl( f ) ).text( f.replace( /^File:/, '' ) ),
				$( '<button class="okc-x" title="Do not use this file">' ).text( '\u00d7' ).on( 'click', function () { files.splice( i, 1 ); renderFiles(); } ) ) );
		} );
	}
	function addFile( name ) {
		var t = 'File:' + name.replace( /^(File|Image):/i, '' ).replace( /_/g, ' ' );
		if ( files.indexOf( t ) < 0 ) { files.push( t ); }
		renderFiles();
	}
	attachToggle.on( 'click', function () { attachBox.toggle(); } );
	uploadBtn.on( 'click', function () {
		var f = fileInput[ 0 ].files && fileInput[ 0 ].files[ 0 ];
		if ( !f ) { status.text( 'Choose a file first.' ); return; }
		var desc = fileDesc.val().trim();
		if ( !desc ) { status.text( 'Say in a few words what the document is, so others can find it.' ); return; }
		var filename = f.name.replace( /[#<>\[\]|{}:\/]/g, '-' );
		uploadBtn.prop( 'disabled', true );
		status.text( 'Uploading ' + filename + ' as ' + mw.config.get( 'wgUserName' ) + '\u2026' );
		api.upload( f, {
			filename: filename,
			comment: 'Contributed as a source for [[' + title + ']], with the author\u2019s assistant',
			text: '== Summary ==\n' + desc + '\n\nContributed as a source for [[' + title + ']].\n\n== Licensing ==\n{{' + fileLicence.val() + '}}\n'
		} ).then( function ( d ) {
			addFile( ( d.upload && d.upload.filename ) || filename );
			status.text( 'Uploaded. The assistant will read it when you draft the change.' );
			fileInput.val( '' ); fileDesc.val( '' ); attachBox.hide();
		}, function ( code, d ) {
			var w = d && d.upload && d.upload.warnings;
			if ( w && w.duplicate ) {
				// the same document is already in the Collab Studio: use that copy rather than a second one
				addFile( [].concat( w.duplicate )[ 0 ] );
				status.text( 'This document is already in the Collab Studio, so the assistant will use that copy.' );
			} else if ( w && ( w.exists || w[ 'exists-normalized' ] || w[ 'page-exists' ] ) ) {
				status.text( 'A different file named ' + filename + ' already exists. Rename your file and upload again.' );
			} else {
				status.text( 'The upload failed: ' + ( d && d.error ? d.error.info : code ) );
			}
		} ).always( uploadReady );
	} );
	var draftBtn = $( '<button class="okc-primary">' ).text( 'Draft the change' );
	var scoutBtn = $( '<button class="okc-secondary" style="margin-left:8px">' ).text( 'Suggest sources' );
	var scoutList = $( '<div class="okc-sources">' ).hide();
	var status = $( '<p>' );
	var result = $( '<div>' ).hide();
	var explanation = $( '<p>' );
	var warn = $( '<div class="okc-warn">' ).hide();
	var diff = $( '<div class="okc-diff">' );
	var saveBtn = $( '<button class="okc-primary">' ).text( editing ? 'Put this in the editor' : 'Save this as my edit' );
	var discardBtn = $( '<button class="okc-secondary" style="margin-left:8px">' ).text( 'Discard' );
	var proposal = null;

	panel.append(
		$( '<h3>' ).text( 'Author’s assistant' ),
		$( '<p>' ).text( 'Tell it what to change. You will see the diff before anything is saved, and the edit will be yours: your name, your responsibility, still needing a reviewer’s approval.' ),
		instruction, urls, attachToggle, attachBox, fileList, draftBtn, scoutBtn, scoutList, status,
		result.append( explanation, warn, diff, saveBtn, discardBtn,
			$( '<p class="okc-note">' ).text( 'The edit summary will say the assistant helped and name any sources used.' ) )
	);
	root.append( panel, openBtn );
	$( 'body' ).append( root );

	// In the visual editor the assistant works on the saved page and saves as you, then reopens
	// the editor on the new revision. Unsaved visual edits would be lost, so it refuses while there are any.
	function veUnsaved() {
		try {
			return $( 'html' ).hasClass( 've-active' ) && ve.init.target.getSurface().getModel().hasBeenModified();
		} catch ( e ) { return false; }
	}

	openBtn.on( 'click', function () {
		root.toggleClass( 'okc-is-open' );
		if ( root.hasClass( 'okc-is-open' ) && $( 'html' ).hasClass( 've-active' ) ) {
			status.text( veUnsaved()
				? 'You have unsaved changes in the visual editor. Save or discard them first; the assistant works on the saved page.'
				: 'In the visual editor the assistant works on the saved page and saves the result as your edit, then reopens the editor.' );
		}
		if ( root.hasClass( 'okc-is-open' ) ) {
			fetch( '/assistant/whoami', { credentials: 'same-origin' } ).then( function ( r ) {
				if ( !r.ok ) { status.text( r.status === 404 ? 'The assistant is switched off on this instance.' : 'Please log in to use the assistant.' ); draftBtn.prop( 'disabled', true ); }
			} );
		}
	} );

	draftBtn.on( 'click', function () {
		var text = instruction.val().trim();
		if ( !text ) { status.text( 'Say what should change.' ); return; }
		if ( veUnsaved() ) { status.text( 'You have unsaved changes in the visual editor. Save or discard them first; the assistant works on the saved page.' ); return; }
		status.text( 'Reading the page and drafting…' );
		draftBtn.prop( 'disabled', true );
		result.hide();
		var current = editing
			? $.Deferred().resolve( { wikitext: $( '#wpTextbox1' ).val(), revid: undefined } ).promise()
			: api.get( { action: 'query', prop: 'revisions', rvprop: 'content|ids', rvslots: 'main', titles: title, formatversion: 2 } ).then( function ( data ) {
				var page = data.query.pages[ 0 ];
				var rev = page.revisions && page.revisions[ 0 ];
				return { wikitext: rev ? rev.slots.main.content : '', revid: rev ? rev.revid : undefined };
			} );
		current.then( function ( cur ) {
			var wikitext = cur.wikitext;
			var rev = { revid: cur.revid };
			return fetch( '/assistant/draft', {
				method: 'POST', credentials: 'same-origin',
				headers: { 'content-type': 'application/json' },
				body: JSON.stringify( {
					title: title, wikitext: wikitext, instruction: text,
					urls: urls.val().split( /\s+/ ).filter( function ( u ) { return u && !/^(File|Image):/i.test( u ); } ),
					files: files.concat( urls.val().split( /\s+/ ).filter( function ( u ) { return /^(File|Image):/i.test( u ); } ) )
				} )
			} ).then( function ( r ) {
				if ( !r.ok ) { return r.json().then( function ( e ) { throw new Error( e.detail || r.status ); } ); }
				return r.json();
			} ).then( function ( d ) {
				proposal = { wikitext: d.wikitext, summary: d.summary, baserevid: rev ? rev.revid : undefined };
				if ( d.unchanged ) {
					// the assistant asked for a source instead of drafting: show its explanation and the scout's candidates
					explanation.text( d.explanation );
					diff.empty();
					warn.hide();
					result.show();
					saveBtn.hide();
					discardBtn.hide();
					renderSuggestions( d.suggestions, d.suggestions && d.suggestions.length
						? 'It needs a source for that. Tick one or more of these, click Use the ticked sources, then Draft the change again.'
						: 'It needs a source for that, and the scout found none. Paste an address you trust and draft again.' );
					return;
				}
				saveBtn.show().prop( 'disabled', false );
				discardBtn.show();
				explanation.text( d.explanation );
				if ( d.warning ) { warn.text( d.warning ).show(); } else { warn.hide(); }
				blockedLinks( d.wikitext, wikitext ).then( function ( bad ) {
					if ( !bad.length ) { return; }
					saveBtn.prop( 'disabled', true );
					warn.text( blockedMessage( bad ) ).show();
				} );
				return api.post( { action: 'compare', fromslots: 'main', 'fromtext-main': wikitext, 'fromcontentmodel-main': 'wikitext', toslots: 'main', 'totext-main': d.wikitext, 'tocontentmodel-main': 'wikitext', prop: 'diff', formatversion: 2 } ).then( function ( c ) {
					var body = c.compare.body || c.compare[ '*' ] || '';
					diff.html( body ? '<table class="diff"><colgroup><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content"></colgroup>' + body + '</table>' : '<p>No difference.</p>' );
					status.text( d.sources.length ? 'Sources used: ' + d.sources.join( ', ' ) : 'Drafted.' );
					result.show();
				} );
			} );
		} ).catch( function ( e ) {
			status.text( 'The assistant could not draft that: ' + ( e && e.message ? e.message : e ) );
		} ).always( function () { draftBtn.prop( 'disabled', false ); } );
	} );

	// Links the wiki will refuse. Link shorteners and some sites are on the spam blacklist; finding
	// that out here, in plain words, beats a failed save.
	function blockedLinks( newText, oldText ) {
		var found = ( newText.match( /https?:\/\/[^\s\]|<>"']+/g ) || [] ).filter( function ( u, i, all ) {
			return all.indexOf( u ) === i && oldText.indexOf( u ) < 0;
		} );
		if ( !found.length ) { return $.Deferred().resolve( [] ).promise(); }
		return api.get( { action: 'spamblacklist', url: found.slice( 0, 50 ), formatversion: 2 } ).then( function ( r ) {
			var res = r.spamblacklist || {};
			return res.result === 'blacklisted' ? ( res.matches || [] ) : [];
		}, function () { return []; } );
	}
	function blockedMessage( matches ) {
		return 'This draft links to ' + matches.join( ', ' ) + ', which the wiki does not accept. ' +
			'Short links such as share.google or bit.ly hide where they lead, so they are blocked. ' +
			'Open the link, copy the full address of the page it leads to, and draft again with that address.';
	}

	// Suggest sources: the commons first, then the web. The author ticks what to draw on.
	function currentWikitext() {
		if ( editing ) { return $.Deferred().resolve( $( '#wpTextbox1' ).val() ).promise(); }
		return api.get( { action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', titles: title, formatversion: 2 } ).then( function ( data ) {
			var page = data.query.pages[ 0 ];
			return page.revisions ? page.revisions[ 0 ].slots.main.content : '';
		} );
	}
	function renderSuggestions( suggestions, okMessage ) {
		scoutList.empty();
		if ( !suggestions || !suggestions.length ) { status.text( 'The scout found nothing suitable. You can still paste an address you trust.' ); return; }
		suggestions.forEach( function ( sug ) {
			var row = $( '<label class="okc-source">' );
			var cb = $( '<input type="checkbox">' ).val( sug.url );
			row.append( cb, ' ', $( '<b>' ).text( sug.title ), ' ', $( '<span class="okc-kind">' ).text( sug.kind === 'commons' ? 'in the commons' : 'web' ),
				$( '<div class="okc-why">' ).text( sug.why + ( sug.caveat ? ' Caveat: ' + sug.caveat : '' ) ),
				$( '<div class="okc-url">' ).append( $( '<a target="_blank" rel="noopener">' ).attr( 'href', sug.url ).text( sug.url ) ) );
			scoutList.append( row );
		} );
		var use = $( '<button class="okc-secondary">' ).text( 'Use the ticked sources' ).on( 'click', function () {
			var picked = scoutList.find( 'input:checked' ).map( function () { return this.value; } ).get();
			var existing = urls.val().split( /\s+/ ).filter( Boolean );
			urls.val( existing.concat( picked.filter( function ( u ) { return existing.indexOf( u ) < 0; } ) ).join( ' ' ) );
			status.text( picked.length ? picked.length + ' source' + ( picked.length > 1 ? 's' : '' ) + ' added. Now click Draft the change.' : 'Nothing ticked.' );
		} );
		scoutList.append( $( '<div style="margin-top:6px">' ).append( use ) ).show();
		status.text( okMessage );
	}

	scoutBtn.on( 'click', function () {
		var text = instruction.val().trim();
		if ( !text ) { status.text( 'Say what should change, so the scout knows what needs a source.' ); return; }
		status.text( 'Looking in the commons and on the web\u2026' );
		scoutBtn.prop( 'disabled', true );
		currentWikitext().then( function ( wikitext ) {
			return fetch( '/assistant/sources', {
				method: 'POST', credentials: 'same-origin',
				headers: { 'content-type': 'application/json' },
				body: JSON.stringify( { title: title, wikitext: wikitext, instruction: text } )
			} ).then( function ( r ) {
				if ( !r.ok ) { return r.json().then( function ( e ) { throw new Error( e.detail || r.status ); } ); }
				return r.json();
			} );
		} ).then( function ( d ) {
			renderSuggestions( d.suggestions, 'Tick the sources to draw on, then draft. Only ticked addresses are used.' );
		} ).catch( function ( e ) {
			status.text( 'The scout could not search: ' + ( e && e.message ? e.message : e ) );
		} ).always( function () { scoutBtn.prop( 'disabled', false ); } );
	} );

	saveBtn.on( 'click', function () {
		if ( !proposal ) { return; }
		if ( editing ) {
			$( '#wpTextbox1' ).val( proposal.wikitext );
			var summary = $( '#wpSummary' );
			if ( summary.length && !summary.val() ) { summary.val( proposal.summary ); }
			status.text( 'The draft is in the editor. Review it and save with the normal button; the edit will be yours.' );
			result.hide();
			root.removeClass( 'okc-is-open' );
			return;
		}
		saveBtn.prop( 'disabled', true );
		status.text( 'Saving as ' + mw.config.get( 'wgUserName' ) + '…' );
		api.postWithToken( 'csrf', { action: 'edit', title: title, text: proposal.wikitext, summary: proposal.summary, baserevid: proposal.baserevid, formatversion: 2 } ).then( function () {
			if ( $( 'html' ).hasClass( 've-active' ) ) {
				location.href = mw.util.getUrl( title, { veaction: 'edit' } );
			} else {
				location.reload();
			}
		}, function ( code, e ) {
			if ( code === 'spamblacklist' ) {
				var m = e && e.error && e.error.spamblacklist && e.error.spamblacklist.matches;
				status.text( blockedMessage( m || [ 'a blocked site' ] ) );
			} else {
				status.text( 'Save failed: ' + ( e && e.error ? e.error.info : code ) );
			}
			saveBtn.prop( 'disabled', false );
		} );
	} );

	discardBtn.on( 'click', function () { proposal = null; result.hide(); status.text( 'Discarded. Nothing was saved.' ); } );
} );