MediaWiki:Gadget-SettingsManager.js: Różnice pomiędzy wersjami
Przejdź do nawigacji
Przejdź do wyszukiwania
(Utworzono nową stronę "/** * commons:MediaWiki:Gadget-SettingsManager.js * Managing user preferences of scripts * Managing gadgets and gadget preferences * * Use it for good, not for...") |
(Brak różnic)
|
Aktualna wersja na dzień 21:50, 11 kwi 2016
/** * [[:commons:MediaWiki:Gadget-SettingsManager.js]] * Managing user preferences of scripts * Managing gadgets and gadget preferences * * Use it for good, not for evil. * * @author Rillke, 2012 * @license GPL v.3 * <nowiki> */ // List the global variables for jsHint-Validation. // Scheme: globalVariable:allowOverwriting[, globalVariable:allowOverwriting][, globalVariable:allowOverwriting] /*global jQuery:false, mediaWiki:false*/ // Set jsHint-options. /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, curly:false, browser:true*/ ( function ( $, mw ) { "use strict"; // Different tokes exist only to confuse the user (at least in 2012) // All of them carry the same value except the watchlist token if (!mw.user.tokens.exists('preferencesToken')) mw.user.tokens.set('preferencesToken', mw.user.tokens.get('editToken')); /** * Refresh preferences-token * * @example * refreshToken( function() { doGoodStuff.retry(); } ); * * @param cb {Function} Callback function. The first argument supplied is whether the operation succeeded. * @context {closure} private function * @return {Object} a jQuery deferred-object-queue */ var refreshToken = function(cb) { var mwa = new mw.Api(), apiDef = mwa.get( { meta: 'userinfo', uiprop: 'preferencestoken' } ); apiDef.done(function(result) { if (!result.query || !result.query.userinfo) return cb( false, 'wrong-response' ); mw.user.tokens.set( 'preferencesToken', result.query.userinfo.preferencestoken ); cb( true ); }); apiDef.fail(function(code, result) { if (!result.query || !result.query.userinfo) return cb( false, code ); }); return apiDef; }; var firstItem = function(o) { for (var i in o) { if (o.hasOwnProperty( i )) { return o[i]; } } }; var valByString = function(identifier) { var arr = identifier.split( '.' ), lenArr = arr.length, i, elemArr, objCurrent = window; for (i = 0; i < lenArr; i++) { elemArr = arr[i]; objCurrent = objCurrent[elemArr]; } return objCurrent; }; var mwPrefPrefix = 'userjs-sm-'; var sm = { version: '0.1.0.1', errorPrefix: "SettingsWizard encountered a problem. We regret the inconvenience. ", /** * Constructor-method. Returns an option-object you should perform the actions on. * * @example * var opt = mw.libs.settingsManager.option( { optionName: 'CatALotOptions', value: { watchCopy: false, watchRemove: false } } ); * // Save the options we've set before and make the script triggering events on the document, you can listen to * opt.save( $(document), 'CatALotSaveProgress' ); * // or, use the deferred-object returned: * opt.save().done(function(msg, status, jsFile) { * alert( msg ); * }).status(function(msg, status, jsFile) { * console.log( 'settings progress>' + msg ); * }); * * @param specsIn {Object} specifications passed in. List of defaults (cf. specs) follows. * @context {mw.libs.settingsManager} * @return {Object} option-object you can use for performing actions on. */ option: function(specsIn) { // List of defaults: var specs = { // The global name the option will be saved under // This can be also something like "mw.settingsOfToolX" optionName: '', // The position where to save them. By default options are // saved at the user's common.js or <skin>.js (e.g. vector.js) // specify other locations like "settingsOfToolX" --> "User:Example/prefs/settingsOfToolX.js" saveAt: false, // By default the option is not enclosed in a comment-block // Comment-blocks are recommended for larger configurations // specifies the signature that will be used for the enclosing comments // blockSettingsOfToolX --> //blockSettingsOfToolX/////////////////////// // (ignored when saveAt is set) encloseSignature: false, // Specify additional block comments added below the signature // Should be something that explains what the following JSON does or is good for // Recommended line-length for consistent alignment: 48 chars encloseBlock: false, // If no own location for saving the option is used (options that must be // available while loading the script should not be saved to a separate file while // complex options should), if the RegExp will have a match on either the common.js // or the <skin>.js, and this option is not saved yet to another .js, the option // will be saved to this js-file. In case the option is not saved yet and there is // no RegExp supplied or it did not match, the option will be saved to the larger // JavaScript triggerSaveAt: false, // Should the new content be insered in front of the match by triggerSaveAt // If none of the following options is specified, the new content // will be appended to the script insertBeforeTrigger: false, insertAfterTrigger: false, replaceTrigger: false, // Finally the option's value. Objects are possible. They will be automatically // transformed into a JSON-string value: undefined, // Edit summary to use while saving the JavaScript editSummary: "" }; if (!specsIn) throw new Error(sm.errorPrefix + "Data to save or retrieve was not supplied by the script using SettingsWizard."); if (!specsIn.optionName && !specsIn.saveAt) throw new Error(sm.errorPrefix + "The options\'s name was not supplied by the script using SettingsWizard."); $.extend( true, specs, specsIn ); // Prepare variables we need later var nsUser = mw.config.get('wgFormattedNamespaces')[2], skin = mw.config.get('skin'), user = mw.config.get('wgUserName'), skinJS = [nsUser, ':', user, '/', skin, '.js'].join(''), commonJS = [nsUser, ':', user, '/','common', '.js'].join(''); // Event-handler system var $el, evt, jsFiles, process, $progress = new $.Deferred(), customJS; var triggerEvt = function(any) { return (evt && $el && $el instanceof jQuery && $el.triggerHandler( evt, Array.prototype.slice.call( arguments, 0 ) )); }; process = { updateVars: function() { // Reset variables that could be polluted jsFiles = []; $progress = new $.Deferred(); customJS = [nsUser, ':', user, '/prefs/', specs.saveAt, '.js'].join(''); }, start: function() { this.updateVars(); // Subscribe to any event: We want to know everything :-) $progress.then( triggerEvt, triggerEvt, triggerEvt ); // Always async setTimeout( $.proxy( this.getScripts, this ), 1 ); return $progress; }, getScripts: function() { var i, len; $progress.notify( "Preparing", 1 ); // First, we need something to work on/ edit token, etc. - request the JavaScript(s) if (specs.saveAt) { jsFiles.push( sm.script( customJS ) ); } else { jsFiles.push( sm.script( skinJS ) ); jsFiles.push( sm.script( commonJS ) ); } len = jsFiles.length; for (i = 0; i < len; i++) { var jsFile = jsFiles[i]; jsFile.fetchText( process.gotJS, process.gotJSErr ); $progress.notify( "Requesting " + jsFile.getSource(), Math.round( (i+1)*(9/len) ) + 1, jsFile ); } return $progress; }, gotJS: function(jsFile, r){ jsFile.gotContent = true; var i, len = jsFiles.length, pendings = 0; for (i = 0; i < len; i++) { if (!jsFiles[i].gotContent) { pendings++; } } $progress.notify( "Got " + jsFile.getSource() + '. File length: ' + jsFile.get().length + ' characters.' , Math.round( (len - pendings)*(9/len) ) + 10, jsFile ); if (pendings) return; process.process(); }, gotJSErr: function(jsFile) { $progress.reject( "Failed. Could not retrieve " + jsFile.getSource(), -1, jsFile ); }, getStartBlock: function(sig) { // String concat is sloooow return '//' + sig + new Array(48 - 2 - sig.length + 1).join('/'); }, getEndBlock: function(sig) { return new Array(48 - 2 - 3 - sig.length + 1).join('/') + sig + 'End' + '//'; }, getBlockRegExp: function(sig) { var escSig = process.escapeRE(sig); return new RegExp('\\n?\\n?\\/\\/' + escSig + '(?:.|\\n)*' + escSig + 'End\\/\\/', 'g'); }, escapeRE: function(string) { string = $.escapeRE(string); var specials = ['t', 'n', 'v', '0', 'f']; $.each(specials, function(i, s) { var rx = new RegExp('\\'+s, 'g'); string = string.replace(rx, '\\'+s); }); return string; }, getVariableRegExp: function(varName) { var escVar = process.escapeRE(varName); return { varRE: new RegExp('\\s*(?:var\\s+|window\\.)?' + escVar + '\\s*=.+', 'g'), // Throw a warning if the last char of the line is a "+" , "{", "(" or "," varWarnRE: new RegExp('\\s*(?:var\\s+|window\\.)?' + escVar + '\\s*=.+(?:\\n?\\s*[\\,\\+\\{\\(])\\s*\\n') }; }, process: function() { var JSONVal = JSON.stringify( specs.value ), sig = specs.encloseSignature, tsa = specs.triggerSaveAt, opn = specs.optionName, jsFile, i, len = jsFiles.length, plainJSON = !opn && !!jsFile, oldText, newText, hadMatch; if (opn) { // No semicolon for valid JSON! JSONVal = 'window.' + opn + ' = ' + JSONVal + ';'; } if (!plainJSON) JSONVal = ((specs.encloseBlock && ('\n' + specs.encloseBlock)) || '') + JSONVal; if (sig && !plainJSON) JSONVal = process.getStartBlock( sig ) + JSONVal + '\n' + process.getEndBlock( sig ); JSONVal = '\n\n' + JSONVal; // Fine, we've constructed everything we'll need. Now look up where to insert. // Looking for signature if (sig) { var reBl = process.getBlockRegExp( sig ); for (i = 0; i < len; i++) { jsFile = jsFiles[i]; oldText = jsFile.get(); newText = oldText.replace( reBl, JSONVal ); if (reBl.test( oldText )) { $progress.notify( "Replacing text enclosed by signature " + jsFile.getSource(), 25, jsFile ); process.save( jsFile.set( newText ) ); hadMatch = true; } } } if (hadMatch) return; // Looking for variable-name if (opn) { var vre = process.getVariableRegExp( opn ), warnFile; for (i = 0; i < len; i++) { jsFile = jsFiles[i]; oldText = jsFile.get(); if (vre.varWarnRE.test(oldText)) { // WARNING!!! $progress.notify( "Unable to remove config from " + jsFile.getSource(), -2, jsFile ); warnFile = jsFile; } else { newText = oldText.replace( vre.varRE, JSONVal ); if (vre.varRE.test( oldText )) { $progress.notify( "Replacing variable " + jsFile.getSource(), 25, jsFile ); process.save( jsFile.set( newText ) ); hadMatch = true; } } // Only append in case of warning if it was not added to another file if (warnFile && !hadMatch) { $progress.notify( "Appending variable after warning to " + jsFile.getSource(), 25, jsFile ); process.save( warnFile.set( oldText + JSONVal ) ); hadMatch = true; } } } if (hadMatch) return; // If it's just JSON, replace the whole thingy if (!opn && specs.saveAt) { jsFile = jsFiles[0]; $progress.notify( "Replacing whole content of " + jsFile.getSource(), 25, jsFile ); process.save( jsFile.set( JSONVal ) ); hadMatch = true; } if (hadMatch) return; // Looking whether supplied RegExp can find something if (tsa) { var searchMatch, triggerLen = 0; for (i = 0; i < len; i++) { jsFile = jsFiles[i]; oldText = jsFile.get(); searchMatch = oldText.search( tsa ); if (-1 !== searchMatch) { if (specs.insertBeforeTrigger) { $progress.notify( "Inserting before pattern in " + jsFile.getSource(), 25, jsFile ); jsFile.set( oldText.slice( 0, searchMatch ) + JSONVal + oldText.slice( searchMatch ) ); } else if (specs.insertAfterTrigger) { triggerLen = oldText.match( tsa )[0].length; $progress.notify( "Inserting after pattern in " + jsFile.getSource(), 25, jsFile ); jsFile.set( oldText.slice( 0, searchMatch + triggerLen ) + JSONVal + oldText.slice( searchMatch + triggerLen ) ); } else if (specs.replaceTrigger) { $progress.notify( "Replacing pattern with new content in " + jsFile.getSource(), 25, jsFile ); jsFile.set( oldText.replace( tsa, JSONVal ) ); } else { $progress.notify( "Found pattern, appending to " + jsFile.getSource(), 25, jsFile ); jsFile.set( oldText + '\n//<nowiki>' + JSONVal + '\n//<\/nowiki>' ); } process.save( jsFile ); hadMatch = true; break; } } } if (hadMatch) return; // Finally compare file size var biggest = { size: 0, jsFile: null }; for (i = 0; i < len; i++) { jsFile = jsFiles[i]; oldText = jsFile.get(); var oldTextLen = oldText.length; if (oldTextLen >= biggest.size) biggest = { size: oldTextLen, jsFile: jsFile }; } $progress.notify( "Appending to bigger file: " + biggest.jsFile.getSource(), 25, biggest.jsFile ); biggest.jsFile.set( biggest.jsFile.get() + '\n//<nowiki>' + JSONVal + '\n//<\/nowiki>' ); process.save( biggest.jsFile ); }, save: function(jsFile) { jsFile.saving = true; $progress.notify( "Saving " + jsFile.getSource(), 30, jsFile ); jsFile.save( process.saved, process.savedErr, "[[MediaWiki:Gadget-SettingsManager.js|SettingsManager]]: " + specs.editSummary ); }, saved: function(jsFile) { var i, len = jsFiles.length, jsf, waitingFor = []; jsFile.saving = false; for (i = 0; i < len; i++) { jsf = jsFiles[i]; if (jsf.saving) { waitingFor.push(jsf.getSource()); } } $progress.notify( "Saved " + jsFile.getSource() + ". Waiting for " + (waitingFor.join(', ') || '-'), Math.round( (len - waitingFor.length)*(20/len) ) + 50, jsFile ); if (waitingFor.length) return; $progress.resolve( "Success!", 100, jsFile ); }, savedErr: function(jsFile, code, errObj) { $progress.reject( "Error saving " + jsFile.getSource() + ". Code is " + code + ".\n", -1, errObj ); } }; return { getSpecs: function() { return specs; }, setSpecs: function(specsIn) { specs = specsIn; return this; }, // Warning: If you specified a different save-position ("saveAt") // and also an optionName, the script has to be fetched and evaluated // We recommend omitting setting "optionName" when using "saveAt" fetchValue: function(cb, errCb) { process.updateVars(); if (specs.saveAt) { var s = sm.script( customJS ); if (specs.optionName) { s.fetchText(function() { s.doEval(); cb( valByString( specs.optionName ) ); }, errCb); } else { s.fetchJSON(function(scriptObj, JSON) { cb( JSON ); }, errCb); } return this; } cb( valByString( specs.optionName ) ); return this; }, getValue: function() { return specs.value; }, setValue: function(val) { specs.value = val; return this; }, save: function($elem, event) { // We won't check whether the value is undefined. This is your task. $el = $elem; evt = event; return process.start(); }, getProgress: function() { return $progress; } }; }, /** * Constructor-method. Returns a script-object you should perform the actions on. * * @example * var commonJS = mw.libs.settingsManager.script( 'User:Example/common.js' ); * commonJS.set( '// empty!' ).setSummary( 'Removing Content' ).save( function() { console.log( 'Successfully removed content from ' + commonJS.getSource() ) } ) * * @param source {String} The name of the JavaScript file with namespace. * @context {mw.libs.settingsManager} * @return {Object} script-object you can use for performing actions on. */ script: function(source) { var content, page, summary = "Changing configuration using [[:commons:MediaWiki:Gadget-SettingsManager.js]]", minor = 1, exists, fetch, save; fetch = function() { var mwa = new mw.Api(); return mwa.get( { prop: 'info|revisions', titles: source, rvprop: 'timestamp|content', intoken: 'edit' } ); }; save = function() { var mwa = new mw.Api(), edit = { action: 'edit', title: source, text: 'object' === typeof content ? JSON.stringify(content) : content, summary: summary, watchlist: 'nochange', recreate: 1 }; if (minor) edit.minor = 1; if (exists) { edit.basetimestamp = page.revisions[0].timestamp; } else { edit.starttimestamp = page.starttimestamp; } edit.token = page.edittoken; return mwa.post( edit ); }; return { get: function() { return content; }, getSource: function() { return source; }, doEval: function() { /*jshint evil:true */ return eval(content); }, parseJSON: function() { return ('string' === typeof content && content !== '') ? JSON.parse( content ) : ''; }, // Supplied callback called with a string as second argument fetchText: function(cb, errCb) { var pgs, pg, scriptObj = this; fetch().done( function(result) { pgs = result.query.pages; page = firstItem( pgs ); exists = !!(page.revisions && page.revisions[0]); content = (exists && page.revisions[0]['*']) || ''; cb( scriptObj, content ); } ).fail( function( status, errObj ) { errCb( scriptObj, status, errObj ); } ); return this; }, // Supplied callback called with parsed JSON-data as second argument fetchJSON: function(cb, errCb) { this.fetchText( function(scriptObj, content) { cb( scriptObj, scriptObj.parseJSON() ); }, function(scriptObj, status, errObj) { errCb( scriptObj, status, errObj ); } ); return this; }, set: function(newContent) { content = newContent; return this; }, setMinor: function(newMinor) { minor = !!newMinor; }, setSummary: function(newSummary) { summary = newSummary; }, save: function(cb, errCb, newSummary, newContent, newMinor) { var scriptObj = this; if (newContent !== undefined) content = newContent; if (newSummary !== undefined) summary = newSummary; if (newMinor !== undefined) minor = !!newMinor; save().done( function(result) { cb( scriptObj, result ); } ).fail( function(status, errObj) { errCb( scriptObj, status, errObj ); } ); return this; } }; }, /** * Switch a user preference using Ajax! * * @example * mw.libs.settingsManager.switchPref( 'myOption', 'new value' ); * * @param prefName {String} The name of the preference. * @param prefVal {String} The new value the preference should set to. * @param cb {Function} Callback in case of success. * @param cb {Function} Callback in case of an error. * @context {mw.libs.settingsManager} * @return {Object} a jQuery deferred-object-queue. Don't use it for error-handling - Done by this method. */ switchPref: function(prefName, prefVal, cb, errCb) { var mwa = new mw.Api(), args = arguments, prefString = (typeof prefVal === 'object') ? JSON.stringify(prefVal) : prefVal, apiDef = mwa.post( { action: 'options', token: mw.user.tokens.get('preferencesToken'), optionname: prefName, optionvalue: prefString || 0 } ); // If we changed a preference successfully, update user.options reflecting the change apiDef.done( function() { mw.user.options.set( prefName, prefString ); } ); if (cb) apiDef.done( cb ); // Catch badtoken and some other common errors apiDef.fail( function(code, result) { switch (code) { case 'badtoken': refreshToken(function (gotANewToken) { if (gotANewToken) return sm.switchPref.apply( sm, Array.prototype.slice.call( args, 0 ) ); } ); // Stop the propagation of return false; case 'http': case 'ok-but-empty': setTimeout( function() { return sm.switchPref.apply( sm, Array.prototype.slice.call(args, 0) ); }, 2500 ); return false; default: return (errCb && errCb(code, result) && false); } } ); return apiDef; }, /** * Switch a gadget preference using Ajax! * * @example * mw.libs.settingsManager.switchGadgetPref( 'myOption', 'new value' ).done(function() { console.log("DONE!") }); * * @param prefName {String} The name of the preference. * @param prefVal {String} The new value the preference should set to. * @context {mw.libs.settingsManager} * @return {Object} $.Deferred; a jQuery deferred-object-queue. */ switchGadgetPref: function(prefName, prefVal) { var $def = $.Deferred(); sm.switchPref( mwPrefPrefix + prefName, prefVal, $.proxy( $def.resolve, $def ), $.proxy( $def.reject, $def ) ); return $def; }, /** * Fetch a Gadget preference from various sources! * * @example * mw.libs.settingsManager.fetchGadgetSetting( 'mySetting', ['storage', 'option'] ).done(function(prefName, settingValue) { console.log("DONE!") }); * * @param prefName {String} The name of the preference. * @param prefSources {Array} One or more of the following values 'storage', 'cookie', 'option', 'window'. Default (if not passed): * All in the order listed there. Note that they are processed in the order you pass them in and as soon as one is found, * Script will return. * IMPORTANT: The Array is changed while processing. So make a copy if you need it again before passing it. * * @context {mw.libs.settingsManager} * @return {Object} $.Deferred; a jQuery deferred-object-queue. */ fetchGadgetSetting: function(prefName, prefSources) { var $def = $.Deferred(), requires = [], options = { 'storage': { requires: ['jquery.jStorage'], fetch: function() { var v = $.jStorage.get( prefName ); return (null === v || undefined === v) ? undefined : v; } }, 'cookie': { requires: ['jquery.cookie'], fetch: function() { var v = $.cookie( prefName ); try { v = JSON.parse( v ); } catch(invalidJSON) {} return (null === v || undefined === v) ? undefined : v; } }, 'option': { requires: ['mediawiki.user', 'user.options'], fetch: function() { var v = mw.user.options.get( mwPrefPrefix + prefName ); try { v = JSON.parse( v ); } catch(invalidJSON) {} return (null === v || undefined === v) ? undefined : v; } }, 'window': { requires: [], fetch: function() { return window[prefName]; } } }; if (!prefSources) prefSources = []; if (!prefSources.length) prefSources = ['storage', 'cookie', 'option', 'window']; var _fetch = function(s) { var so = options[s]; if (so) { mw.loader.using( so.requires, function() { var v = so.fetch(); if (undefined === v) { _fetched(); } else { $def.resolve( prefName, v ); } } ); } else { // Security guard: Don't load settings from unprotected pages if (!/^(?:User\:|MediaWiki\:).+\.js$/.test( s )) _fetched(); sm.script( s ).fetchJSON( function(me, jsonData) { if (jsonData) { $def.resolve( prefName, jsonData ); } else { _fetched(); } }, $.proxy( $def.reject, $def ) ); } }, _fetched = function() { prefSources.shift(); if (prefSources.length) { _fetch( prefSources[0] ); } else { $def.resolve( prefName /* no pref found */ ); } }; $.each( prefSources, function(i, s) { var so = options[s]; if (so) requires = requires.concat( options[s].requires ); } ); mw.loader.load( requires ); // ensure async setTimeout( function() { _fetch(prefSources[0]); }, 10 ); return $def; }, /** * Constructor-method. Returns an option-object you should perform the actions on. * * @example * var slideshowGadget = mw.libs.settingsManager.gadget( 'Slideshow' ); * if (slideshowGadget.isEnabled()) { slideshowGadget.disable( myCallback ) } * * // Enable a gadget and load it: * mw.libs.settingsManager.gadget( 'Slideshow' ).load().enable(); * * @param gadgetName {Object} The name of the gadget. (Not the script file; without Gadget- prefix or other decoration) * @context {mw.libs.settingsManager} * @return {Object} gadget-object you can use for performing actions on. */ gadget: function(gadgetName) { var optGadget = 'gadget-' + gadgetName, rlGadget = 'ext.gadget.' + gadgetName; return { getName: function() { return gadgetName; }, isDefault: function() { var opt = mw.user.options.get( optGadget ); return ('number' === typeof opt || '' === opt); }, isEnabled: function() { var opt = mw.user.options.get( optGadget ); return !!opt; }, getState: function() { return mw.loader.getState( rlGadget ); }, isLoaded: function() { return ('ready' === this.getState()); }, load: function(cb, errCb) { // Always async if (this.isLoaded && cb) return setTimeout( function() { cb( gadgetName, true ); }, 1 ); mw.loader.using( rlGadget, cb ? function() { cb( gadgetName ); } : undefined, errCb ? function() { errCb( gadgetName ); } : undefined ); return this; }, enable: function(cb, errCb) { // Type wouldn't matter due to URL-encoding but we also want to update // the user.options object sm.switchPref( optGadget, this.isDefault() ? 1 : '1', cb, errCb ); return this; }, disable: function(cb, errCb) { sm.switchPref( optGadget, '', cb, errCb ); return this; } }; } }; mw.libs.settingsManager = sm; // TODO add to gadget-def // mw.loader.load(['json', 'mediawiki.user', 'user.options', 'user.tokens']); }( jQuery, mediaWiki ));