2 * In-progress edit recovery for action=edit
6 const storage = require( './storage.js' );
7 const LoadNotification = require( './LoadNotification.js' );
9 const pageName = mw.config.get( 'wgPageName' );
10 const section = $( 'input[name="wpSection"]' ).val() || null;
11 const inputFields = {};
12 const fieldNamePrefix = 'field_';
13 let originalData = {};
14 let changeDebounceTimer = null;
16 // Number of miliseconds to debounce form input.
17 const debounceTime = 5000;
19 // This module is loaded for every edit form, but not all should have Edit Recovery functioning.
20 const wasPosted = mw.config.get( 'wgEditRecoveryWasPosted' );
21 const isUndo = $( 'input[name="wpUndoAfter"]' ).length > 0;
22 const isOldRevision = $( 'input[name="oldid"]' ).val() > 0;
23 const isConflict = mw.config.get( 'wgEditMessage' ) === 'editconflict';
24 const useEditRecovery = !isUndo && !isOldRevision && !isConflict;
25 if ( useEditRecovery ) {
26 mw.hook( 'wikipage.editform' ).add( onLoadHandler );
28 // Always remove the data-saved flag when editing without Edit Recovery.
29 // It may have been set by a previous editing session (within 5 minutes) that did use ER.
30 mw.storage.session.remove( 'EditRecovery-data-saved' );
33 const windowManager = OO.ui.getWindowManager();
34 windowManager.addWindows( [ new mw.widgets.AbandonEditDialog() ] );
36 function onLoadHandler( $editForm ) {
37 mw.hook( 'wikipage.editform' ).remove( onLoadHandler );
39 // Monitor all text-entry inputs for changes/typing.
40 const inputsToMonitorSelector = 'textarea, select, input:not([type="hidden"], [type="submit"])';
41 const $inputsToMonitor = $editForm.find( inputsToMonitorSelector );
42 $inputsToMonitor.each( ( _i, field ) => {
43 if ( field.classList.contains( 'oo-ui-inputWidget-input' ) ) {
45 inputFields[ field.name ] = OO.ui.infuse( field.closest( '.oo-ui-widget' ) );
47 // Ignore any non-infusable widget because we won't be able to set its value.
50 inputFields[ field.name ] = field;
53 // Save the contents of all of those, as well as the following hidden inputs.
54 const inputsToSaveNames = [ 'wpSection', 'editRevId', 'oldid', 'parentRevId', 'format', 'model' ];
55 const $inputsToSave = $editForm.find( '[name="' + inputsToSaveNames.join( '"], [name="' ) + '"]' );
56 $inputsToSave.each( ( _i, field ) => {
57 inputFields[ field.name ] = field;
60 // Store the original data for later comparing to the data-to-save. Use the defaultValue/defaultChecked in order to
61 // avoid using any data remembered by the browser. Note that we have to be careful to store with the same types as
62 // it will be done later, in order to correctly compare it (e.g. checkboxes as booleans).
63 for ( const fieldName in inputFields ) {
64 const field = inputFields[ fieldName ];
65 if ( field.nodeName === 'INPUT' || field.nodeName === 'TEXTAREA' ) {
66 if ( field.type === 'checkbox' ) {
67 // Checkboxes (Minoredit and Watchthis are handled below as they are OOUI widgets).
68 originalData[ fieldNamePrefix + fieldName ] = field.defaultChecked;
70 // Other HTMLInputElements.
71 originalData[ fieldNamePrefix + fieldName ] = field.defaultValue;
73 } else if ( field.$input !== undefined ) {
74 // OOUI widgets, which may not have been infused by this point.
75 if ( field.$input[ 0 ].type === 'checkbox' ) {
77 originalData[ fieldNamePrefix + fieldName ] = field.$input[ 0 ].defaultChecked;
79 // Other OOUI widgets.
80 originalData[ fieldNamePrefix + fieldName ] = field.$input[ 0 ].defaultValue;
85 // Set a short-lived (5m / see postEdit.js) localStorage item to indicate which section is being edited.
87 mw.storage.session.set( pageName + '-editRecoverySection', section, 300 );
89 // Open indexedDB database and load any saved data that might be there.
90 storage.openDatabase().then( () => {
91 // Check for and delete any expired data for any page, before loading any saved data for the current page.
92 storage.deleteExpiredData().then( () => {
93 storage.loadData( pageName, section ).then( onLoadData );
97 // Set up cancel handler to delete data.
98 const cancelButton = OO.ui.infuse( $editForm.find( '#mw-editform-cancel' )[ 0 ] );
99 cancelButton.on( 'click', () => {
100 windowManager.openWindow( 'abandonedit' ).closed.then( ( data ) => {
101 if ( data && data.action === 'discard' ) {
102 // Note that originalData is used below in onLoadData() but that's always called before this method.
103 // Here we set originalData to null in order to signal to saveFormData() to deleted the stored data.
105 storage.deleteData( pageName, section ).finally( () => {
106 mw.storage.session.remove( pageName + '-editRecoverySection' );
107 // Release the beforeunload handler from mediawiki.action.edit.editWarning,
108 // per the documentation there
109 $( window ).off( 'beforeunload.editwarning' );
110 location.href = cancelButton.getHref();
117 function track( metric, value ) {
118 const dbName = mw.config.get( 'wgDBname' );
119 mw.track( `counter.MediaWiki.edit_recovery.${ metric }.by_wiki.${ dbName }`, value );
122 function onLoadData( pageData ) {
124 // If this is a POST request, save the current data (e.g. from a preview).
127 // If there is data stored, load it into the form.
128 if ( !wasPosted && pageData !== undefined && !isSameAsOriginal( pageData, true ) ) {
129 const loadNotification = new LoadNotification( {
130 differentRev: originalData.field_parentRevId !== pageData.field_parentRevId
133 // statsv: Track the number of times the edit recovery notification is shown.
136 const notification = loadNotification.getNotification();
137 // On 'restore changes'.
138 loadNotification.getRecoverButton().on( 'click', () => {
139 loadData( pageData );
140 notification.close();
141 // statsv: Track the number of times the edit recovery data is recovered.
142 track( 'recover', 1 );
144 // On 'discard changes'.
145 loadNotification.getDiscardButton().on( 'click', () => {
146 storage.deleteData( pageName, section ).then( () => {
147 notification.close();
149 // statsv: Track the number of times the edit recovery data is discarded.
150 track( 'discard', 1 );
154 // Add change handlers.
155 for ( const fieldName in inputFields ) {
156 const field = inputFields[ fieldName ];
157 if ( field.nodeName !== undefined && field.nodeName === 'TEXTAREA' ) {
158 field.addEventListener( 'input', fieldChangeHandler );
159 } else if ( field instanceof OO.ui.Widget ) {
160 field.on( 'change', fieldChangeHandler );
162 field.addEventListener( 'change', fieldChangeHandler );
165 // Also add handlers for when the window is closed or hidden. Saving the data at these points is not guaranteed to
166 // work, but it often does and the save operation is atomic so there's no harm in trying.
167 window.addEventListener( 'beforeunload', saveFormData );
168 window.addEventListener( 'blur', saveFormData );
171 * Fired after EditRecovery has loaded any recovery data, added event handlers, etc.
173 * @event ~'editRecovery.loadEnd'
175 * @param {Object} editRecovery
176 * @param {Function} editRecovery.fieldChangeHandler
178 mw.hook( 'editRecovery.loadEnd' ).fire( { fieldChangeHandler: fieldChangeHandler } );
181 function loadData( pageData ) {
182 for ( const fieldName in inputFields ) {
183 if ( pageData[ fieldNamePrefix + fieldName ] === undefined ) {
186 const field = inputFields[ fieldName ];
187 const $field = $( field );
188 // Set the field value depending on what type of field it is.
189 if ( field instanceof OO.ui.CheckboxInputWidget ) {
190 // OOUI checkbox widgets.
191 field.setSelected( pageData[ fieldNamePrefix + fieldName ] );
192 } else if ( field instanceof OO.ui.Widget ) {
193 // Other OOUI widgets.
194 field.setValue( pageData[ fieldNamePrefix + fieldName ], field );
195 } else if ( field.nodeName === 'TEXTAREA' ) {
196 // Textareas (also reset caret location to top).
197 $field.textSelection( 'setContents', pageData[ fieldNamePrefix + fieldName ] );
198 $field.textSelection( 'setSelection', { start: 0 } );
201 field.value = pageData[ fieldNamePrefix + fieldName ];
206 function fieldChangeHandler() {
207 clearTimeout( changeDebounceTimer );
208 changeDebounceTimer = setTimeout( saveFormData, debounceTime );
212 * Compare a set of form field values to their original values (as at page load time).
215 * @param {Object} pageData The page data to compare to the original.
216 * @param {boolean} [ignoreRevIds=false] Do not use parent revision info when determining similarity.
219 function isSameAsOriginal( pageData, ignoreRevIds = false ) {
220 for ( const fieldName in inputFields ) {
221 if ( ignoreRevIds && ( fieldName === 'editRevId' || fieldName === 'parentRevId' ) ) {
224 // Trim trailing whitespace from string fields, to approximate what PHP does when saving.
225 let currentVal = pageData[ fieldNamePrefix + fieldName ];
226 if ( typeof currentVal === 'string' ) {
227 currentVal = currentVal.replace( /\s+$/, '' );
229 let originalVal = originalData[ fieldNamePrefix + fieldName ];
230 if ( typeof originalVal === 'string' ) {
231 originalVal = originalVal.replace( /\s+$/, '' );
233 if ( currentVal !== originalVal ) {
240 function saveFormData() {
241 const pageData = getFormData();
242 if ( ( originalData === null || isSameAsOriginal( pageData ) ) && !wasPosted ) {
243 // Delete the stored data if there's no change,
244 // or if we've flagged originalData as irrelevant,
245 // or if we can't determine this because this page was POSTed.
246 storage.deleteData( pageName, section );
247 mw.storage.session.remove( 'EditRecovery-data-saved' );
249 storage.saveData( pageName, section, pageData );
250 // Flag the data for deletion in the postEdit handler in ./postEdit.js
251 mw.storage.session.set( 'EditRecovery-data-saved', true, 300 );
256 * Get the current form data.
261 function getFormData() {
263 for ( const fieldName in inputFields ) {
264 const field = inputFields[ fieldName ];
266 if ( !( field instanceof OO.ui.Widget ) && field.nodeName !== undefined && field.nodeName === 'TEXTAREA' ) {
268 newValue = $( field ).textSelection( 'getContents' );
269 } else if ( field instanceof OO.ui.CheckboxInputWidget ) {
270 // OOUI checkbox widgets.
271 newValue = field.isSelected();
272 } else if ( field instanceof OO.ui.Widget ) {
273 // Other OOUI widgets.
274 newValue = field.getValue();
277 newValue = field.value;
279 formData[ fieldNamePrefix + fieldName ] = newValue;