Single-file modules to src/, the remaining as sub directories.
A few highlights:
* mediawiki.Upload.BookletLayout. (stylesheet: no image references)
* mediawiki.feedback - Also move the image to its own images/ subdir.
* mediawiki.searchSuggest. (stylesheet: no image references)
* mediawiki.toc. (stylesheet: no image references)
Also updated any other references to 'src/mediawiki/' that I could find
in core:
* Fixed references in docs/uidesign/*.html
* Remove redundant exclude from jsduck.json.
After this, there are 4 files remaining in src/mediawiki,
which are the 4 files used by the actual 'mediawiki' base module.
Bug: T193826
Change-Id: I8058652892a78b3f5976397bd850741dd5c92427
'!extensions/**/*.js',
'!skins/**/*.js',
// Skip functions aren't even parseable
- '!resources/src/mediawiki.hidpi-skip.js'
+ '!resources/src/mediawiki.hidpi/skip.js'
]
},
jsonlint: {
<html lang="en" dir="ltr">
<head>
<link rel="stylesheet" href="../../resources/src/mediawiki.legacy/shared.css">
- <link rel="stylesheet" href="../../resources/src/mediawiki/mediawiki.feedlink.css">
+ <link rel="stylesheet" href="../../resources/src/mediawiki.feedlink/feedlink.css">
</head>
<body style="font-size: small;">
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
- <link rel="stylesheet" href="../../resources/src/mediawiki/mediawiki.diff.styles.css">
- <link rel="stylesheet" media="print" href="../../resources/src/mediawiki/mediawiki.diff.styles.print.css">
+ <link rel="stylesheet" href="../../resources/src/mediawiki.diff.styles/diff.css">
+ <link rel="stylesheet" media="print" href="../../resources/src/mediawiki.diff.styles/print.css">
</head>
<body>
"resources/src/mediawiki.legacy",
"resources/src/mediawiki.libs.jpegmeta/jpegmeta.js",
"resources/src/mediawiki.skinning",
- "resources/src/mediawiki/mediawiki.Title.phpCharToUpper.js",
"resources/src/startup.js"
],
"--": [
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.apihelp' => [
- 'styles' => 'resources/src/mediawiki/mediawiki.apihelp.css',
+ 'styles' => 'resources/src/mediawiki.apihelp.css',
'targets' => [ 'desktop' ],
],
'mediawiki.template' => [
'mediawiki.template.mustache' => [
'scripts' => [
'resources/lib/mustache/mustache.js',
- 'resources/src/mediawiki/mediawiki.template.mustache.js',
+ 'resources/src/mediawiki.template.mustache.js',
],
'targets' => [ 'desktop', 'mobile' ],
'dependencies' => 'mediawiki.template',
],
],
'mediawiki.content.json' => [
- 'styles' => 'resources/src/mediawiki/mediawiki.content.json.less',
+ 'styles' => 'resources/src/mediawiki.content.json.less',
],
'mediawiki.confirmCloseWindow' => [
'scripts' => [
- 'resources/src/mediawiki/mediawiki.confirmCloseWindow.js',
+ 'resources/src/mediawiki.confirmCloseWindow.js',
],
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.debug' => [
'scripts' => [
- 'resources/src/mediawiki/mediawiki.debug.js',
+ 'resources/src/mediawiki.debug/debug.js',
],
'styles' => [
- 'resources/src/mediawiki/mediawiki.debug.less',
+ 'resources/src/mediawiki.debug/debug.less',
],
'dependencies' => [
'jquery.footHovzer',
],
'mediawiki.diff.styles' => [
'styles' => [
- 'resources/src/mediawiki/mediawiki.diff.styles.css',
- 'resources/src/mediawiki/mediawiki.diff.styles.print.css' => [
+ 'resources/src/mediawiki.diff.styles/diff.css',
+ 'resources/src/mediawiki.diff.styles/print.css' => [
'media' => 'print'
],
],
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.feedback' => [
- 'scripts' => 'resources/src/mediawiki/mediawiki.feedback.js',
- 'styles' => 'resources/src/mediawiki/mediawiki.feedback.css',
+ 'scripts' => 'resources/src/mediawiki.feedback/feedback.js',
+ 'styles' => 'resources/src/mediawiki.feedback/feedback.css',
'dependencies' => [
'mediawiki.messagePoster',
'mediawiki.Title',
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.feedlink' => [
- 'styles' => 'resources/src/mediawiki/mediawiki.feedlink.css',
+ 'styles' => 'resources/src/mediawiki.feedlink/feedlink.css',
],
'mediawiki.filewarning' => [
- 'scripts' => 'resources/src/mediawiki/mediawiki.filewarning.js',
- 'styles' => 'resources/src/mediawiki/mediawiki.filewarning.less',
+ 'scripts' => 'resources/src/mediawiki.filewarning/filewarning.js',
+ 'styles' => 'resources/src/mediawiki.filewarning/filewarning.less',
'dependencies' => [
'oojs-ui-core',
'oojs-ui.styles.icons-alerts',
],
'mediawiki.helplink' => [
'styles' => [
- 'resources/src/mediawiki/mediawiki.helplink.less',
+ 'resources/src/mediawiki.helplink/helplink.less',
],
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.hidpi' => [
- 'scripts' => 'resources/src/mediawiki/mediawiki.hidpi.js',
+ 'scripts' => 'resources/src/mediawiki.hidpi/hidpi.js',
'dependencies' => 'jquery.hidpi',
- 'skipFunction' => 'resources/src/mediawiki.hidpi-skip.js',
+ 'skipFunction' => 'resources/src/mediawiki.hidpi/skip.js',
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.hlist' => [
'targets' => [ 'desktop', 'mobile' ],
'styles' => [
- 'resources/src/mediawiki/mediawiki.hlist-allskins.less',
+ 'resources/src/mediawiki.hlist/hlist.less',
],
'skinStyles' => [
- 'default' => 'resources/src/mediawiki/mediawiki.hlist.css',
+ 'default' => 'resources/src/mediawiki.hlist/default.css',
],
],
'mediawiki.htmlform' => [
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.icon' => [
- 'styles' => 'resources/src/mediawiki/mediawiki.icon.less',
+ 'styles' => 'resources/src/mediawiki.icon/icon.less',
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.inspect' => [
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.pager.tablePager' => [
- 'styles' => 'resources/src/mediawiki/mediawiki.pager.tablePager.less',
+ 'styles' => 'resources/src/mediawiki.pager.tablePager/TablePager.less',
],
'mediawiki.searchSuggest' => [
'targets' => [ 'desktop', 'mobile' ],
- 'scripts' => 'resources/src/mediawiki/mediawiki.searchSuggest.js',
- 'styles' => 'resources/src/mediawiki/mediawiki.searchSuggest.css',
+ 'scripts' => 'resources/src/mediawiki.searchSuggest/searchSuggest.js',
+ 'styles' => 'resources/src/mediawiki.searchSuggest/searchSuggest.css',
'messages' => [
'searchsuggest-search',
'searchsuggest-containing',
],
'mediawiki.Title' => [
'scripts' => [
- 'resources/src/mediawiki/mediawiki.Title.js',
- 'resources/src/mediawiki/mediawiki.Title.phpCharToUpper.js',
+ 'resources/src/mediawiki.Title/Title.js',
+ 'resources/src/mediawiki.Title/phpCharToUpper.js',
],
'dependencies' => [
'mediawiki.String',
],
'mediawiki.Upload.BookletLayout' => [
'scripts' => [
- 'resources/src/mediawiki/mediawiki.Upload.BookletLayout.js',
+ 'resources/src/mediawiki.Upload.BookletLayout/BookletLayout.js',
],
'styles' => [
- 'resources/src/mediawiki/mediawiki.Upload.BookletLayout.css',
+ 'resources/src/mediawiki.Upload.BookletLayout/BookletLayout.css',
],
'dependencies' => [
'oojs-ui-core',
],
],
'mediawiki.ForeignStructuredUpload.BookletLayout' => [
- 'scripts' => 'resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.js',
- 'styles' => 'resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.less',
+ 'scripts' => 'resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.js',
+ 'styles' => 'resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.less',
'dependencies' => [
'mediawiki.ForeignStructuredUpload',
'mediawiki.Upload.BookletLayout',
],
],
'mediawiki.toc' => [
- 'scripts' => 'resources/src/mediawiki/mediawiki.toc.js',
+ 'scripts' => 'resources/src/mediawiki.toc/toc.js',
'styles' => [
- 'resources/src/mediawiki/mediawiki.toc.css'
+ 'resources/src/mediawiki.toc/toc.css'
=> [ 'media' => 'screen' ],
- 'resources/src/mediawiki/mediawiki.toc.print.css'
+ 'resources/src/mediawiki.toc/print.css'
=> [ 'media' => 'print' ],
],
'dependencies' => 'mediawiki.cookie',
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.Uri' => [
- 'scripts' => 'resources/src/mediawiki/mediawiki.Uri.js',
+ 'scripts' => 'resources/src/mediawiki.Uri/Uri.js',
'templates' => [
- 'strict.regexp' => 'resources/src/mediawiki/mediawiki.Uri.strict.regexp',
- 'loose.regexp' => 'resources/src/mediawiki/mediawiki.Uri.loose.regexp',
+ 'strict.regexp' => 'resources/src/mediawiki.Uri/strict.regexp',
+ 'loose.regexp' => 'resources/src/mediawiki.Uri/loose.regexp',
],
'dependencies' => 'mediawiki.util',
'targets' => [ 'desktop', 'mobile' ],
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.editfont.styles' => [
- 'styles' => 'resources/src/mediawiki/mediawiki.editfont.less',
+ 'styles' => 'resources/src/mediawiki.editfont.less',
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.visibleTimeout' => [
'mediawiki.jqueryMsg' => [
// Add data for mediawiki.jqueryMsg, such as allowed tags
'class' => ResourceLoaderJqueryMsgModule::class,
- 'scripts' => 'resources/src/mediawiki/mediawiki.jqueryMsg.js',
+ 'scripts' => 'resources/src/mediawiki.jqueryMsg/mediawiki.jqueryMsg.js',
'dependencies' => [
'mediawiki.util',
'mediawiki.language',
--- /dev/null
+/* global moment, Uint8Array */
+( function ( $, mw ) {
+
+ /**
+ * mw.ForeignStructuredUpload.BookletLayout encapsulates the process
+ * of uploading a file to MediaWiki using the mw.ForeignStructuredUpload model.
+ *
+ * var uploadDialog = new mw.Upload.Dialog( {
+ * bookletClass: mw.ForeignStructuredUpload.BookletLayout,
+ * booklet: {
+ * target: 'local'
+ * }
+ * } );
+ * var windowManager = new OO.ui.WindowManager();
+ * $( 'body' ).append( windowManager.$element );
+ * windowManager.addWindows( [ uploadDialog ] );
+ *
+ * @class mw.ForeignStructuredUpload.BookletLayout
+ * @uses mw.ForeignStructuredUpload
+ * @extends mw.Upload.BookletLayout
+ *
+ * @constructor
+ * @param {Object} config Configuration options
+ * @cfg {string} [target] Used to choose the target repository.
+ * If nothing is passed, the {@link mw.ForeignUpload#property-target default} is used.
+ */
+ mw.ForeignStructuredUpload.BookletLayout = function ( config ) {
+ config = config || {};
+ // Parent constructor
+ mw.ForeignStructuredUpload.BookletLayout.parent.call( this, config );
+
+ this.target = config.target;
+ };
+
+ /* Setup */
+
+ OO.inheritClass( mw.ForeignStructuredUpload.BookletLayout, mw.Upload.BookletLayout );
+
+ /* Uploading */
+
+ /**
+ * @inheritdoc
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.initialize = function () {
+ var booklet = this;
+ return mw.ForeignStructuredUpload.BookletLayout.parent.prototype.initialize.call( this ).then(
+ function () {
+ return $.when(
+ // Point the CategoryMultiselectWidget to the right wiki
+ booklet.upload.getApi().then( function ( api ) {
+ // If this is a ForeignApi, it will have a apiUrl, otherwise we don't need to do anything
+ if ( api.apiUrl ) {
+ // Can't reuse the same object, CategoryMultiselectWidget calls #abort on its mw.Api instance
+ booklet.categoriesWidget.api = new mw.ForeignApi( api.apiUrl );
+ }
+ return $.Deferred().resolve();
+ } ),
+ // Set up booklet fields and license messages to match configuration
+ booklet.upload.loadConfig().then( function ( config ) {
+ var
+ msgPromise,
+ isLocal = booklet.upload.target === 'local',
+ fields = config.fields,
+ msgs = config.licensemessages[ isLocal ? 'local' : 'foreign' ];
+
+ // Hide disabled fields
+ booklet.descriptionField.toggle( !!fields.description );
+ booklet.categoriesField.toggle( !!fields.categories );
+ booklet.dateField.toggle( !!fields.date );
+ // Update form validity
+ booklet.onInfoFormChange();
+
+ // Load license messages from the remote wiki if we don't have these messages locally
+ // (this means that we only load messages from the foreign wiki for custom config)
+ if ( mw.message( 'upload-form-label-own-work-message-' + msgs ).exists() ) {
+ msgPromise = $.Deferred().resolve();
+ } else {
+ msgPromise = booklet.upload.apiPromise.then( function ( api ) {
+ return api.loadMessages( [
+ 'upload-form-label-own-work-message-' + msgs,
+ 'upload-form-label-not-own-work-message-' + msgs,
+ 'upload-form-label-not-own-work-local-' + msgs
+ ] );
+ } );
+ }
+
+ // Update license messages
+ return msgPromise.then( function () {
+ var $labels;
+ booklet.$ownWorkMessage.msg( 'upload-form-label-own-work-message-' + msgs );
+ booklet.$notOwnWorkMessage.msg( 'upload-form-label-not-own-work-message-' + msgs );
+ booklet.$notOwnWorkLocal.msg( 'upload-form-label-not-own-work-local-' + msgs );
+
+ $labels = $( [
+ booklet.$ownWorkMessage[ 0 ],
+ booklet.$notOwnWorkMessage[ 0 ],
+ booklet.$notOwnWorkLocal[ 0 ]
+ ] );
+
+ // Improve the behavior of links inside these labels, which may point to important
+ // things like licensing requirements or terms of use
+ $labels.find( 'a' )
+ .attr( 'target', '_blank' )
+ .on( 'click', function ( e ) {
+ // OO.ui.FieldLayout#onLabelClick is trying to prevent default on all clicks,
+ // which causes the links to not be openable. Don't let it do that.
+ e.stopPropagation();
+ } );
+ } );
+ }, function ( errorMsg ) {
+ booklet.getPage( 'upload' ).$element.msg( errorMsg );
+ return $.Deferred().resolve();
+ } )
+ );
+ }
+ ).catch(
+ // Always resolve, never reject
+ function () { return $.Deferred().resolve(); }
+ );
+ };
+
+ /**
+ * Returns a {@link mw.ForeignStructuredUpload mw.ForeignStructuredUpload}
+ * with the {@link #cfg-target target} specified in config.
+ *
+ * @protected
+ * @return {mw.Upload}
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.createUpload = function () {
+ return new mw.ForeignStructuredUpload( this.target, {
+ parameters: {
+ errorformat: 'html',
+ errorlang: mw.config.get( 'wgUserLanguage' ),
+ errorsuselocal: 1,
+ formatversion: 2
+ }
+ } );
+ };
+
+ /* Form renderers */
+
+ /**
+ * @inheritdoc
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.renderUploadForm = function () {
+ var fieldset,
+ layout = this;
+
+ // These elements are filled with text in #initialize
+ // TODO Refactor this to be in one place
+ this.$ownWorkMessage = $( '<p>' )
+ .addClass( 'mw-foreignStructuredUpload-bookletLayout-license' );
+ this.$notOwnWorkMessage = $( '<p>' );
+ this.$notOwnWorkLocal = $( '<p>' );
+
+ this.selectFileWidget = new OO.ui.SelectFileWidget( {
+ showDropTarget: true
+ } );
+ this.messageLabel = new OO.ui.LabelWidget( {
+ label: $( '<div>' ).append(
+ this.$notOwnWorkMessage,
+ this.$notOwnWorkLocal
+ )
+ } );
+ this.ownWorkCheckbox = new OO.ui.CheckboxInputWidget().on( 'change', function ( on ) {
+ layout.messageLabel.toggle( !on );
+ } );
+
+ fieldset = new OO.ui.FieldsetLayout();
+ fieldset.addItems( [
+ new OO.ui.FieldLayout( this.selectFileWidget, {
+ align: 'top'
+ } ),
+ new OO.ui.FieldLayout( this.ownWorkCheckbox, {
+ align: 'inline',
+ label: $( '<div>' ).append(
+ $( '<p>' ).text( mw.msg( 'upload-form-label-own-work' ) ),
+ this.$ownWorkMessage
+ )
+ } ),
+ new OO.ui.FieldLayout( this.messageLabel, {
+ align: 'top'
+ } )
+ ] );
+ this.uploadForm = new OO.ui.FormLayout( { items: [ fieldset ] } );
+
+ // Validation
+ this.selectFileWidget.on( 'change', this.onUploadFormChange.bind( this ) );
+ this.ownWorkCheckbox.on( 'change', this.onUploadFormChange.bind( this ) );
+
+ this.selectFileWidget.on( 'change', function () {
+ var file = layout.getFile();
+
+ // Set the date to lastModified once we have the file
+ if ( layout.getDateFromLastModified( file ) !== undefined ) {
+ layout.dateWidget.setValue( layout.getDateFromLastModified( file ) );
+ }
+
+ // Check if we have EXIF data and set to that where available
+ layout.getDateFromExif( file ).done( function ( date ) {
+ layout.dateWidget.setValue( date );
+ } );
+
+ layout.updateFilePreview();
+ } );
+
+ return this.uploadForm;
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.onUploadFormChange = function () {
+ var file = this.selectFileWidget.getValue(),
+ ownWork = this.ownWorkCheckbox.isSelected(),
+ valid = !!file && ownWork;
+ this.emit( 'uploadValid', valid );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.renderInfoForm = function () {
+ var fieldset;
+
+ this.filePreview = new OO.ui.Widget( {
+ classes: [ 'mw-upload-bookletLayout-filePreview' ]
+ } );
+ this.progressBarWidget = new OO.ui.ProgressBarWidget( {
+ progress: 0
+ } );
+ this.filePreview.$element.append( this.progressBarWidget.$element );
+
+ this.filenameWidget = new OO.ui.TextInputWidget( {
+ required: true,
+ validate: /.+/
+ } );
+ this.descriptionWidget = new OO.ui.MultilineTextInputWidget( {
+ required: true,
+ validate: /\S+/,
+ autosize: true
+ } );
+ this.categoriesWidget = new mw.widgets.CategoryMultiselectWidget( {
+ // Can't be done here because we don't know the target wiki yet... done in #initialize.
+ // api: new mw.ForeignApi( ... ),
+ $overlay: this.$overlay
+ } );
+ this.dateWidget = new mw.widgets.DateInputWidget( {
+ $overlay: this.$overlay,
+ required: true,
+ mustBeBefore: moment().add( 1, 'day' ).locale( 'en' ).format( 'YYYY-MM-DD' ) // Tomorrow
+ } );
+
+ this.filenameField = new OO.ui.FieldLayout( this.filenameWidget, {
+ label: mw.msg( 'upload-form-label-infoform-name' ),
+ align: 'top',
+ classes: [ 'mw-foreignStructuredUploa-bookletLayout-small-notice' ],
+ notices: [ mw.msg( 'upload-form-label-infoform-name-tooltip' ) ]
+ } );
+ this.descriptionField = new OO.ui.FieldLayout( this.descriptionWidget, {
+ label: mw.msg( 'upload-form-label-infoform-description' ),
+ align: 'top',
+ classes: [ 'mw-foreignStructuredUploa-bookletLayout-small-notice' ],
+ notices: [ mw.msg( 'upload-form-label-infoform-description-tooltip' ) ]
+ } );
+ this.categoriesField = new OO.ui.FieldLayout( this.categoriesWidget, {
+ label: mw.msg( 'upload-form-label-infoform-categories' ),
+ align: 'top'
+ } );
+ this.dateField = new OO.ui.FieldLayout( this.dateWidget, {
+ label: mw.msg( 'upload-form-label-infoform-date' ),
+ align: 'top'
+ } );
+
+ fieldset = new OO.ui.FieldsetLayout( {
+ label: mw.msg( 'upload-form-label-infoform-title' )
+ } );
+ fieldset.addItems( [
+ this.filenameField,
+ this.descriptionField,
+ this.categoriesField,
+ this.dateField
+ ] );
+ this.infoForm = new OO.ui.FormLayout( {
+ classes: [ 'mw-upload-bookletLayout-infoForm' ],
+ items: [ this.filePreview, fieldset ]
+ } );
+
+ // Validation
+ this.filenameWidget.on( 'change', this.onInfoFormChange.bind( this ) );
+ this.descriptionWidget.on( 'change', this.onInfoFormChange.bind( this ) );
+ this.dateWidget.on( 'change', this.onInfoFormChange.bind( this ) );
+
+ this.on( 'fileUploadProgress', function ( progress ) {
+ this.progressBarWidget.setProgress( progress * 100 );
+ }.bind( this ) );
+
+ return this.infoForm;
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.onInfoFormChange = function () {
+ var layout = this,
+ validityPromises = [];
+
+ validityPromises.push( this.filenameWidget.getValidity() );
+ if ( this.descriptionField.isVisible() ) {
+ validityPromises.push( this.descriptionWidget.getValidity() );
+ }
+ if ( this.dateField.isVisible() ) {
+ validityPromises.push( this.dateWidget.getValidity() );
+ }
+
+ $.when.apply( $, validityPromises ).done( function () {
+ layout.emit( 'infoValid', true );
+ } ).fail( function () {
+ layout.emit( 'infoValid', false );
+ } );
+ };
+
+ /**
+ * @param {mw.Title} filename
+ * @return {jQuery.Promise} Resolves (on success) or rejects with OO.ui.Error
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.validateFilename = function ( filename ) {
+ return ( new mw.Api() ).get( {
+ action: 'query',
+ prop: 'info',
+ titles: filename.getPrefixedDb(),
+ formatversion: 2
+ } ).then(
+ function ( result ) {
+ // if the file already exists, reject right away, before
+ // ever firing finishStashUpload()
+ if ( !result.query.pages[ 0 ].missing ) {
+ return $.Deferred().reject( new OO.ui.Error(
+ $( '<p>' ).msg( 'fileexists', filename.getPrefixedDb() ),
+ { recoverable: false }
+ ) );
+ }
+ },
+ function () {
+ // API call failed - this could be a connection hiccup...
+ // Let's just ignore this validation step and turn this
+ // failure into a successful resolve ;)
+ return $.Deferred().resolve();
+ }
+ );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.saveFile = function () {
+ var title = mw.Title.newFromText(
+ this.getFilename(),
+ mw.config.get( 'wgNamespaceIds' ).file
+ );
+
+ return this.uploadPromise
+ .then( this.validateFilename.bind( this, title ) )
+ .then( mw.ForeignStructuredUpload.BookletLayout.parent.prototype.saveFile.bind( this ) );
+ };
+
+ /* Getters */
+
+ /**
+ * @inheritdoc
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.getText = function () {
+ var language = mw.config.get( 'wgContentLanguage' );
+ this.upload.clearDescriptions();
+ this.upload.addDescription( language, this.descriptionWidget.getValue() );
+ this.upload.setDate( this.dateWidget.getValue() );
+ this.upload.clearCategories();
+ this.upload.addCategories( this.categoriesWidget.getItemsData() );
+ return this.upload.getText();
+ };
+
+ /**
+ * Get original date from EXIF data
+ *
+ * @param {Object} file
+ * @return {jQuery.Promise} Promise resolved with the EXIF date
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.getDateFromExif = function ( file ) {
+ var fileReader,
+ deferred = $.Deferred();
+
+ if ( file && file.type === 'image/jpeg' ) {
+ fileReader = new FileReader();
+ fileReader.onload = function () {
+ var fileStr, arr, i, metadata,
+ jpegmeta = mw.loader.require( 'mediawiki.libs.jpegmeta' );
+
+ if ( typeof fileReader.result === 'string' ) {
+ fileStr = fileReader.result;
+ } else {
+ // Array buffer; convert to binary string for the library.
+ arr = new Uint8Array( fileReader.result );
+ fileStr = '';
+ for ( i = 0; i < arr.byteLength; i++ ) {
+ fileStr += String.fromCharCode( arr[ i ] );
+ }
+ }
+
+ try {
+ metadata = jpegmeta( fileStr, file.name );
+ } catch ( e ) {
+ metadata = null;
+ }
+
+ if ( metadata !== null && metadata.exif !== undefined && metadata.exif.DateTimeOriginal ) {
+ deferred.resolve( moment( metadata.exif.DateTimeOriginal, 'YYYY:MM:DD' ).format( 'YYYY-MM-DD' ) );
+ } else {
+ deferred.reject();
+ }
+ };
+
+ if ( 'readAsBinaryString' in fileReader ) {
+ fileReader.readAsBinaryString( file );
+ } else if ( 'readAsArrayBuffer' in fileReader ) {
+ fileReader.readAsArrayBuffer( file );
+ } else {
+ // We should never get here
+ deferred.reject();
+ throw new Error( 'Cannot read thumbnail as binary string or array buffer.' );
+ }
+ }
+
+ return deferred.promise();
+ };
+
+ /**
+ * Get last modified date from file
+ *
+ * @param {Object} file
+ * @return {Object} Last modified date from file
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.getDateFromLastModified = function ( file ) {
+ if ( file && file.lastModified ) {
+ return moment( file.lastModified ).format( 'YYYY-MM-DD' );
+ }
+ };
+
+ /* Setters */
+
+ /**
+ * @inheritdoc
+ */
+ mw.ForeignStructuredUpload.BookletLayout.prototype.clear = function () {
+ mw.ForeignStructuredUpload.BookletLayout.parent.prototype.clear.call( this );
+
+ this.ownWorkCheckbox.setSelected( false );
+ this.categoriesWidget.setItemsFromData( [] );
+ this.dateWidget.setValue( '' ).setValidityFlag( true );
+ };
+
+}( jQuery, mediaWiki ) );
--- /dev/null
+.mw-foreignStructuredUpload-bookletLayout-license {
+ font-size: 90%;
+ line-height: 1.4em;
+ color: #54595d;
+}
+
+.mw-foreignStructuredUploa-bookletLayout-small-notice {
+ .oo-ui-fieldLayout-messages-notice {
+ .oo-ui-iconWidget {
+ display: none;
+ }
+
+ .oo-ui-labelWidget {
+ line-height: 1.2em;
+ font-size: 0.9em;
+ color: #54595d;
+ }
+ }
+}
--- /dev/null
+/*!
+ * @author Neil Kandalgaonkar, 2010
+ * @author Timo Tijhof
+ * @since 1.18
+ */
+
+( function ( mw, $ ) {
+ /**
+ * Parse titles into an object structure. Note that when using the constructor
+ * directly, passing invalid titles will result in an exception. Use #newFromText to use the
+ * logic directly and get null for invalid titles which is easier to work with.
+ *
+ * Note that in the constructor and #newFromText method, `namespace` is the **default** namespace
+ * only, and can be overridden by a namespace prefix in `title`. If you do not want this behavior,
+ * use #makeTitle. Compare:
+ *
+ * new mw.Title( 'Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
+ * mw.Title.newFromText( 'Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
+ * mw.Title.makeTitle( NS_TEMPLATE, 'Foo' ).getPrefixedText(); // => 'Template:Foo'
+ *
+ * new mw.Title( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo'
+ * mw.Title.newFromText( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo'
+ * mw.Title.makeTitle( NS_TEMPLATE, 'Category:Foo' ).getPrefixedText(); // => 'Template:Category:Foo'
+ *
+ * new mw.Title( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
+ * mw.Title.newFromText( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
+ * mw.Title.makeTitle( NS_TEMPLATE, 'Template:Foo' ).getPrefixedText(); // => 'Template:Template:Foo'
+ *
+ * @class mw.Title
+ */
+
+ /* Private members */
+
+ var
+ mwString = require( 'mediawiki.String' ),
+
+ namespaceIds = mw.config.get( 'wgNamespaceIds' ),
+
+ /**
+ * @private
+ * @static
+ * @property NS_MAIN
+ */
+ NS_MAIN = namespaceIds[ '' ],
+
+ /**
+ * @private
+ * @static
+ * @property NS_TALK
+ */
+ NS_TALK = namespaceIds.talk,
+
+ /**
+ * @private
+ * @static
+ * @property NS_SPECIAL
+ */
+ NS_SPECIAL = namespaceIds.special,
+
+ /**
+ * @private
+ * @static
+ * @property NS_MEDIA
+ */
+ NS_MEDIA = namespaceIds.media,
+
+ /**
+ * @private
+ * @static
+ * @property NS_FILE
+ */
+ NS_FILE = namespaceIds.file,
+
+ /**
+ * @private
+ * @static
+ * @property FILENAME_MAX_BYTES
+ */
+ FILENAME_MAX_BYTES = 240,
+
+ /**
+ * @private
+ * @static
+ * @property TITLE_MAX_BYTES
+ */
+ TITLE_MAX_BYTES = 255,
+
+ /**
+ * Get the namespace id from a namespace name (either from the localized, canonical or alias
+ * name).
+ *
+ * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or
+ * even 'Bild'.
+ *
+ * @private
+ * @static
+ * @method getNsIdByName
+ * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored)
+ * @return {number|boolean} Namespace id or boolean false
+ */
+ getNsIdByName = function ( ns ) {
+ var id;
+
+ // Don't cast non-strings to strings, because null or undefined should not result in
+ // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki)
+ // Also, toLowerCase throws exception on null/undefined, because it is a String method.
+ if ( typeof ns !== 'string' ) {
+ return false;
+ }
+ // TODO: Should just use local var namespaceIds here but it
+ // breaks test which modify the config
+ id = mw.config.get( 'wgNamespaceIds' )[ ns.toLowerCase() ];
+ if ( id === undefined ) {
+ return false;
+ }
+ return id;
+ },
+
+ /**
+ * @private
+ * @method getNamespacePrefix_
+ * @param {number} namespace
+ * @return {string}
+ */
+ getNamespacePrefix = function ( namespace ) {
+ return namespace === NS_MAIN ?
+ '' :
+ ( mw.config.get( 'wgFormattedNamespaces' )[ namespace ].replace( / /g, '_' ) + ':' );
+ },
+
+ rUnderscoreTrim = /^_+|_+$/g,
+
+ rSplit = /^(.+?)_*:_*(.*)$/,
+
+ // See MediaWikiTitleCodec.php#getTitleInvalidRegex
+ rInvalid = new RegExp(
+ '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' +
+ // URL percent encoding sequences interfere with the ability
+ // to round-trip titles -- you can't link to them consistently.
+ '|%[0-9A-Fa-f]{2}' +
+ // XML/HTML character references produce similar issues.
+ '|&[A-Za-z0-9\u0080-\uFFFF]+;' +
+ '|&#[0-9]+;' +
+ '|&#x[0-9A-Fa-f]+;'
+ ),
+
+ // From MediaWikiTitleCodec::splitTitleString() in PHP
+ // Note that this is not equivalent to /\s/, e.g. underscore is included, tab is not included.
+ rWhitespace = /[ _\u00A0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+/g,
+
+ // From MediaWikiTitleCodec::splitTitleString() in PHP
+ rUnicodeBidi = /[\u200E\u200F\u202A-\u202E]/g,
+
+ /**
+ * Slightly modified from Flinfo. Credit goes to Lupo and Flominator.
+ * @private
+ * @static
+ * @property sanitationRules
+ */
+ sanitationRules = [
+ // "signature"
+ {
+ pattern: /~{3}/g,
+ replace: '',
+ generalRule: true
+ },
+ // control characters
+ {
+ // eslint-disable-next-line no-control-regex
+ pattern: /[\x00-\x1f\x7f]/g,
+ replace: '',
+ generalRule: true
+ },
+ // URL encoding (possibly)
+ {
+ pattern: /%([0-9A-Fa-f]{2})/g,
+ replace: '% $1',
+ generalRule: true
+ },
+ // HTML-character-entities
+ {
+ pattern: /&(([A-Za-z0-9\x80-\xff]+|#[0-9]+|#x[0-9A-Fa-f]+);)/g,
+ replace: '& $1',
+ generalRule: true
+ },
+ // slash, colon (not supported by file systems like NTFS/Windows, Mac OS 9 [:], ext4 [/])
+ {
+ pattern: new RegExp( '[' + mw.config.get( 'wgIllegalFileChars', '' ) + ']', 'g' ),
+ replace: '-',
+ fileRule: true
+ },
+ // brackets, greater than
+ {
+ pattern: /[}\]>]/g,
+ replace: ')',
+ generalRule: true
+ },
+ // brackets, lower than
+ {
+ pattern: /[{[<]/g,
+ replace: '(',
+ generalRule: true
+ },
+ // everything that wasn't covered yet
+ {
+ pattern: new RegExp( rInvalid.source, 'g' ),
+ replace: '-',
+ generalRule: true
+ },
+ // directory structures
+ {
+ pattern: /^(\.|\.\.|\.\/.*|\.\.\/.*|.*\/\.\/.*|.*\/\.\.\/.*|.*\/\.|.*\/\.\.)$/g,
+ replace: '',
+ generalRule: true
+ }
+ ],
+
+ /**
+ * Internal helper for #constructor and #newFromText.
+ *
+ * Based on Title.php#secureAndSplit
+ *
+ * @private
+ * @static
+ * @method parse
+ * @param {string} title
+ * @param {number} [defaultNamespace=NS_MAIN]
+ * @return {Object|boolean}
+ */
+ parse = function ( title, defaultNamespace ) {
+ var namespace, m, id, i, fragment, ext;
+
+ namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
+
+ title = title
+ // Strip Unicode bidi override characters
+ .replace( rUnicodeBidi, '' )
+ // Normalise whitespace to underscores and remove duplicates
+ .replace( rWhitespace, '_' )
+ // Trim underscores
+ .replace( rUnderscoreTrim, '' );
+
+ // Process initial colon
+ if ( title !== '' && title[ 0 ] === ':' ) {
+ // Initial colon means main namespace instead of specified default
+ namespace = NS_MAIN;
+ title = title
+ // Strip colon
+ .slice( 1 )
+ // Trim underscores
+ .replace( rUnderscoreTrim, '' );
+ }
+
+ if ( title === '' ) {
+ return false;
+ }
+
+ // Process namespace prefix (if any)
+ m = title.match( rSplit );
+ if ( m ) {
+ id = getNsIdByName( m[ 1 ] );
+ if ( id !== false ) {
+ // Ordinary namespace
+ namespace = id;
+ title = m[ 2 ];
+
+ // For Talk:X pages, make sure X has no "namespace" prefix
+ if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) {
+ // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x)
+ if ( getNsIdByName( m[ 1 ] ) !== false ) {
+ return false;
+ }
+ }
+ }
+ }
+
+ // Process fragment
+ i = title.indexOf( '#' );
+ if ( i === -1 ) {
+ fragment = null;
+ } else {
+ fragment = title
+ // Get segment starting after the hash
+ .slice( i + 1 )
+ // Convert to text
+ // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo")
+ .replace( /_/g, ' ' );
+
+ title = title
+ // Strip hash
+ .slice( 0, i )
+ // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux")
+ .replace( rUnderscoreTrim, '' );
+ }
+
+ // Reject illegal characters
+ if ( title.match( rInvalid ) ) {
+ return false;
+ }
+
+ // Disallow titles that browsers or servers might resolve as directory navigation
+ if (
+ title.indexOf( '.' ) !== -1 && (
+ title === '.' || title === '..' ||
+ title.indexOf( './' ) === 0 ||
+ title.indexOf( '../' ) === 0 ||
+ title.indexOf( '/./' ) !== -1 ||
+ title.indexOf( '/../' ) !== -1 ||
+ title.slice( -2 ) === '/.' ||
+ title.slice( -3 ) === '/..'
+ )
+ ) {
+ return false;
+ }
+
+ // Disallow magic tilde sequence
+ if ( title.indexOf( '~~~' ) !== -1 ) {
+ return false;
+ }
+
+ // Disallow titles exceeding the TITLE_MAX_BYTES byte size limit (size of underlying database field)
+ // Except for special pages, e.g. [[Special:Block/Long name]]
+ // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should
+ // be less than 512 bytes.
+ if ( namespace !== NS_SPECIAL && mwString.byteLength( title ) > TITLE_MAX_BYTES ) {
+ return false;
+ }
+
+ // Can't make a link to a namespace alone.
+ if ( title === '' && namespace !== NS_MAIN ) {
+ return false;
+ }
+
+ // Any remaining initial :s are illegal.
+ if ( title[ 0 ] === ':' ) {
+ return false;
+ }
+
+ // For backwards-compatibility with old mw.Title, we separate the extension from the
+ // rest of the title.
+ i = title.lastIndexOf( '.' );
+ if ( i === -1 || title.length <= i + 1 ) {
+ // Extensions are the non-empty segment after the last dot
+ ext = null;
+ } else {
+ ext = title.slice( i + 1 );
+ title = title.slice( 0, i );
+ }
+
+ return {
+ namespace: namespace,
+ title: title,
+ ext: ext,
+ fragment: fragment
+ };
+ },
+
+ /**
+ * Convert db-key to readable text.
+ *
+ * @private
+ * @static
+ * @method text
+ * @param {string} s
+ * @return {string}
+ */
+ text = function ( s ) {
+ if ( s !== null && s !== undefined ) {
+ return s.replace( /_/g, ' ' );
+ } else {
+ return '';
+ }
+ },
+
+ /**
+ * Sanitizes a string based on a rule set and a filter
+ *
+ * @private
+ * @static
+ * @method sanitize
+ * @param {string} s
+ * @param {Array} filter
+ * @return {string}
+ */
+ sanitize = function ( s, filter ) {
+ var i, ruleLength, rule, m, filterLength,
+ rules = sanitationRules;
+
+ for ( i = 0, ruleLength = rules.length; i < ruleLength; ++i ) {
+ rule = rules[ i ];
+ for ( m = 0, filterLength = filter.length; m < filterLength; ++m ) {
+ if ( rule[ filter[ m ] ] ) {
+ s = s.replace( rule.pattern, rule.replace );
+ }
+ }
+ }
+ return s;
+ },
+
+ /**
+ * Cuts a string to a specific byte length, assuming UTF-8
+ * or less, if the last character is a multi-byte one
+ *
+ * @private
+ * @static
+ * @method trimToByteLength
+ * @param {string} s
+ * @param {number} length
+ * @return {string}
+ */
+ trimToByteLength = function ( s, length ) {
+ return mwString.trimByteLength( '', s, length ).newVal;
+ },
+
+ /**
+ * Cuts a file name to a specific byte length
+ *
+ * @private
+ * @static
+ * @method trimFileNameToByteLength
+ * @param {string} name without extension
+ * @param {string} extension file extension
+ * @return {string} The full name, including extension
+ */
+ trimFileNameToByteLength = function ( name, extension ) {
+ // There is a special byte limit for file names and ... remember the dot
+ return trimToByteLength( name, FILENAME_MAX_BYTES - extension.length - 1 ) + '.' + extension;
+ };
+
+ /**
+ * @method constructor
+ * @param {string} title Title of the page. If no second argument given,
+ * this will be searched for a namespace
+ * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title
+ * @throws {Error} When the title is invalid
+ */
+ function Title( title, namespace ) {
+ var parsed = parse( title, namespace );
+ if ( !parsed ) {
+ throw new Error( 'Unable to parse title' );
+ }
+
+ this.namespace = parsed.namespace;
+ this.title = parsed.title;
+ this.ext = parsed.ext;
+ this.fragment = parsed.fragment;
+ }
+
+ /* Static members */
+
+ /**
+ * Constructor for Title objects with a null return instead of an exception for invalid titles.
+ *
+ * Note that `namespace` is the **default** namespace only, and can be overridden by a namespace
+ * prefix in `title`. If you do not want this behavior, use #makeTitle. See #constructor for
+ * details.
+ *
+ * @static
+ * @param {string} title
+ * @param {number} [namespace=NS_MAIN] Default namespace
+ * @return {mw.Title|null} A valid Title object or null if the title is invalid
+ */
+ Title.newFromText = function ( title, namespace ) {
+ var t, parsed = parse( title, namespace );
+ if ( !parsed ) {
+ return null;
+ }
+
+ t = Object.create( Title.prototype );
+ t.namespace = parsed.namespace;
+ t.title = parsed.title;
+ t.ext = parsed.ext;
+ t.fragment = parsed.fragment;
+
+ return t;
+ };
+
+ /**
+ * Constructor for Title objects with predefined namespace.
+ *
+ * Unlike #newFromText or #constructor, this function doesn't allow the given `namespace` to be
+ * overridden by a namespace prefix in `title`. See #constructor for details about this behavior.
+ *
+ * The single exception to this is when `namespace` is 0, indicating the main namespace. The
+ * function behaves like #newFromText in that case.
+ *
+ * @static
+ * @param {number} namespace Namespace to use for the title
+ * @param {string} title
+ * @return {mw.Title|null} A valid Title object or null if the title is invalid
+ */
+ Title.makeTitle = function ( namespace, title ) {
+ return mw.Title.newFromText( getNamespacePrefix( namespace ) + title );
+ };
+
+ /**
+ * Constructor for Title objects from user input altering that input to
+ * produce a title that MediaWiki will accept as legal
+ *
+ * @static
+ * @param {string} title
+ * @param {number} [defaultNamespace=NS_MAIN]
+ * If given, will used as default namespace for the given title.
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.forUploading=true]
+ * Makes sure that a file is uploadable under the title returned.
+ * There are pages in the file namespace under which file upload is impossible.
+ * Automatically assumed if the title is created in the Media namespace.
+ * @return {mw.Title|null} A valid Title object or null if the input cannot be turned into a valid title
+ */
+ Title.newFromUserInput = function ( title, defaultNamespace, options ) {
+ var namespace, m, id, ext, parts;
+
+ // defaultNamespace is optional; check whether options moves up
+ if ( arguments.length < 3 && $.type( defaultNamespace ) === 'object' ) {
+ options = defaultNamespace;
+ defaultNamespace = undefined;
+ }
+
+ // merge options into defaults
+ options = $.extend( {
+ forUploading: true
+ }, options );
+
+ namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
+
+ // Normalise additional whitespace
+ title = title.replace( /\s/g, ' ' ).trim();
+
+ // Process initial colon
+ if ( title !== '' && title[ 0 ] === ':' ) {
+ // Initial colon means main namespace instead of specified default
+ namespace = NS_MAIN;
+ title = title
+ // Strip colon
+ .substr( 1 )
+ // Trim underscores
+ .replace( rUnderscoreTrim, '' );
+ }
+
+ // Process namespace prefix (if any)
+ m = title.match( rSplit );
+ if ( m ) {
+ id = getNsIdByName( m[ 1 ] );
+ if ( id !== false ) {
+ // Ordinary namespace
+ namespace = id;
+ title = m[ 2 ];
+ }
+ }
+
+ if (
+ namespace === NS_MEDIA ||
+ ( options.forUploading && ( namespace === NS_FILE ) )
+ ) {
+
+ title = sanitize( title, [ 'generalRule', 'fileRule' ] );
+
+ // Operate on the file extension
+ // Although it is possible having spaces between the name and the ".ext" this isn't nice for
+ // operating systems hiding file extensions -> strip them later on
+ parts = title.split( '.' );
+
+ if ( parts.length > 1 ) {
+
+ // Get the last part, which is supposed to be the file extension
+ ext = parts.pop();
+
+ // Remove whitespace of the name part (that W/O extension)
+ title = parts.join( '.' ).trim();
+
+ // Cut, if too long and append file extension
+ title = trimFileNameToByteLength( title, ext );
+
+ } else {
+
+ // Missing file extension
+ title = parts.join( '.' ).trim();
+
+ // Name has no file extension and a fallback wasn't provided either
+ return null;
+ }
+ } else {
+
+ title = sanitize( title, [ 'generalRule' ] );
+
+ // Cut titles exceeding the TITLE_MAX_BYTES byte size limit
+ // (size of underlying database field)
+ if ( namespace !== NS_SPECIAL ) {
+ title = trimToByteLength( title, TITLE_MAX_BYTES );
+ }
+ }
+
+ // Any remaining initial :s are illegal.
+ title = title.replace( /^:+/, '' );
+
+ return Title.newFromText( title, namespace );
+ };
+
+ /**
+ * Sanitizes a file name as supplied by the user, originating in the user's file system
+ * so it is most likely a valid MediaWiki title and file name after processing.
+ * Returns null on fatal errors.
+ *
+ * @static
+ * @param {string} uncleanName The unclean file name including file extension but
+ * without namespace
+ * @return {mw.Title|null} A valid Title object or null if the title is invalid
+ */
+ Title.newFromFileName = function ( uncleanName ) {
+
+ return Title.newFromUserInput( 'File:' + uncleanName, {
+ forUploading: true
+ } );
+ };
+
+ /**
+ * Get the file title from an image element
+ *
+ * var title = mw.Title.newFromImg( $( 'img:first' ) );
+ *
+ * @static
+ * @param {HTMLElement|jQuery} img The image to use as a base
+ * @return {mw.Title|null} The file title or null if unsuccessful
+ */
+ Title.newFromImg = function ( img ) {
+ var matches, i, regex, src, decodedSrc,
+
+ // thumb.php-generated thumbnails
+ thumbPhpRegex = /thumb\.php/,
+ regexes = [
+ // Thumbnails
+ /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)\/[^\s/]+-[^\s/]*$/,
+
+ // Full size images
+ /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)$/,
+
+ // Thumbnails in non-hashed upload directories
+ /\/([^\s/]+)\/[^\s/]+-(?:\1|thumbnail)[^\s/]*$/,
+
+ // Full-size images in non-hashed upload directories
+ /\/([^\s/]+)$/
+ ],
+
+ recount = regexes.length;
+
+ src = img.jquery ? img[ 0 ].src : img.src;
+
+ matches = src.match( thumbPhpRegex );
+
+ if ( matches ) {
+ return mw.Title.newFromText( 'File:' + mw.util.getParamValue( 'f', src ) );
+ }
+
+ decodedSrc = decodeURIComponent( src );
+
+ for ( i = 0; i < recount; i++ ) {
+ regex = regexes[ i ];
+ matches = decodedSrc.match( regex );
+
+ if ( matches && matches[ 1 ] ) {
+ return mw.Title.newFromText( 'File:' + matches[ 1 ] );
+ }
+ }
+
+ return null;
+ };
+
+ /**
+ * Whether this title exists on the wiki.
+ *
+ * @static
+ * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title
+ * @return {boolean|null} Boolean if the information is available, otherwise null
+ */
+ Title.exists = function ( title ) {
+ var match,
+ obj = Title.exist.pages;
+
+ if ( typeof title === 'string' ) {
+ match = obj[ title ];
+ } else if ( title instanceof Title ) {
+ match = obj[ title.toString() ];
+ } else {
+ throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' );
+ }
+
+ if ( typeof match !== 'boolean' ) {
+ return null;
+ }
+
+ return match;
+ };
+
+ /**
+ * Store page existence
+ *
+ * @static
+ * @property {Object} exist
+ * @property {Object} exist.pages Keyed by title. Boolean true value indicates page does exist.
+ *
+ * @property {Function} exist.set The setter function.
+ *
+ * Example to declare existing titles:
+ *
+ * Title.exist.set( ['User:John_Doe', ...] );
+ *
+ * Example to declare titles nonexistent:
+ *
+ * Title.exist.set( ['File:Foo_bar.jpg', ...], false );
+ *
+ * @property {string|Array} exist.set.titles Title(s) in strict prefixedDb title form
+ * @property {boolean} [exist.set.state=true] State of the given titles
+ * @return {boolean}
+ */
+ Title.exist = {
+ pages: {},
+
+ set: function ( titles, state ) {
+ var i, len,
+ pages = this.pages;
+
+ titles = Array.isArray( titles ) ? titles : [ titles ];
+ state = state === undefined ? true : !!state;
+
+ for ( i = 0, len = titles.length; i < len; i++ ) {
+ pages[ titles[ i ] ] = state;
+ }
+ return true;
+ }
+ };
+
+ /**
+ * Normalize a file extension to the common form, making it lowercase and checking some synonyms,
+ * and ensure it's clean. Extensions with non-alphanumeric characters will be discarded.
+ * Keep in sync with File::normalizeExtension() in PHP.
+ *
+ * @param {string} extension File extension (without the leading dot)
+ * @return {string} File extension in canonical form
+ */
+ Title.normalizeExtension = function ( extension ) {
+ var
+ lower = extension.toLowerCase(),
+ squish = {
+ htm: 'html',
+ jpeg: 'jpg',
+ mpeg: 'mpg',
+ tiff: 'tif',
+ ogv: 'ogg'
+ };
+ if ( squish.hasOwnProperty( lower ) ) {
+ return squish[ lower ];
+ } else if ( /^[0-9a-z]+$/.test( lower ) ) {
+ return lower;
+ } else {
+ return '';
+ }
+ };
+
+ /* Public members */
+
+ Title.prototype = {
+ constructor: Title,
+
+ /**
+ * Get the namespace number
+ *
+ * Example: 6 for "File:Example_image.svg".
+ *
+ * @return {number}
+ */
+ getNamespaceId: function () {
+ return this.namespace;
+ },
+
+ /**
+ * Get the namespace prefix (in the content language)
+ *
+ * Example: "File:" for "File:Example_image.svg".
+ * In #NS_MAIN this is '', otherwise namespace name plus ':'
+ *
+ * @return {string}
+ */
+ getNamespacePrefix: function () {
+ return getNamespacePrefix( this.namespace );
+ },
+
+ /**
+ * Get the page name without extension or namespace prefix
+ *
+ * Example: "Example_image" for "File:Example_image.svg".
+ *
+ * For the page title (full page name without namespace prefix), see #getMain.
+ *
+ * @return {string}
+ */
+ getName: function () {
+ if (
+ $.inArray( this.namespace, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ||
+ !this.title.length
+ ) {
+ return this.title;
+ }
+ // PHP's strtoupper differs from String.toUpperCase in a number of cases
+ // Bug: T147646
+ return mw.Title.phpCharToUpper( this.title[ 0 ] ) + this.title.slice( 1 );
+ },
+
+ /**
+ * Get the page name (transformed by #text)
+ *
+ * Example: "Example image" for "File:Example_image.svg".
+ *
+ * For the page title (full page name without namespace prefix), see #getMainText.
+ *
+ * @return {string}
+ */
+ getNameText: function () {
+ return text( this.getName() );
+ },
+
+ /**
+ * Get the extension of the page name (if any)
+ *
+ * @return {string|null} Name extension or null if there is none
+ */
+ getExtension: function () {
+ return this.ext;
+ },
+
+ /**
+ * Shortcut for appendable string to form the main page name.
+ *
+ * Returns a string like ".json", or "" if no extension.
+ *
+ * @return {string}
+ */
+ getDotExtension: function () {
+ return this.ext === null ? '' : '.' + this.ext;
+ },
+
+ /**
+ * Get the main page name
+ *
+ * Example: "Example_image.svg" for "File:Example_image.svg".
+ *
+ * @return {string}
+ */
+ getMain: function () {
+ return this.getName() + this.getDotExtension();
+ },
+
+ /**
+ * Get the main page name (transformed by #text)
+ *
+ * Example: "Example image.svg" for "File:Example_image.svg".
+ *
+ * @return {string}
+ */
+ getMainText: function () {
+ return text( this.getMain() );
+ },
+
+ /**
+ * Get the full page name
+ *
+ * Example: "File:Example_image.svg".
+ * Most useful for API calls, anything that must identify the "title".
+ *
+ * @return {string}
+ */
+ getPrefixedDb: function () {
+ return this.getNamespacePrefix() + this.getMain();
+ },
+
+ /**
+ * Get the full page name (transformed by #text)
+ *
+ * Example: "File:Example image.svg" for "File:Example_image.svg".
+ *
+ * @return {string}
+ */
+ getPrefixedText: function () {
+ return text( this.getPrefixedDb() );
+ },
+
+ /**
+ * Get the page name relative to a namespace
+ *
+ * Example:
+ *
+ * - "Foo:Bar" relative to the Foo namespace becomes "Bar".
+ * - "Bar" relative to any non-main namespace becomes ":Bar".
+ * - "Foo:Bar" relative to any namespace other than Foo stays "Foo:Bar".
+ *
+ * @param {number} namespace The namespace to be relative to
+ * @return {string}
+ */
+ getRelativeText: function ( namespace ) {
+ if ( this.getNamespaceId() === namespace ) {
+ return this.getMainText();
+ } else if ( this.getNamespaceId() === NS_MAIN ) {
+ return ':' + this.getPrefixedText();
+ } else {
+ return this.getPrefixedText();
+ }
+ },
+
+ /**
+ * Get the fragment (if any).
+ *
+ * Note that this method (by design) does not include the hash character and
+ * the value is not url encoded.
+ *
+ * @return {string|null}
+ */
+ getFragment: function () {
+ return this.fragment;
+ },
+
+ /**
+ * Get the URL to this title
+ *
+ * @see mw.util#getUrl
+ * @param {Object} [params] A mapping of query parameter names to values,
+ * e.g. `{ action: 'edit' }`.
+ * @return {string}
+ */
+ getUrl: function ( params ) {
+ var fragment = this.getFragment();
+ if ( fragment ) {
+ return mw.util.getUrl( this.toString() + '#' + fragment, params );
+ } else {
+ return mw.util.getUrl( this.toString(), params );
+ }
+ },
+
+ /**
+ * Whether this title exists on the wiki.
+ *
+ * @see #static-method-exists
+ * @return {boolean|null} Boolean if the information is available, otherwise null
+ */
+ exists: function () {
+ return Title.exists( this );
+ }
+ };
+
+ /**
+ * @alias #getPrefixedDb
+ * @method
+ */
+ Title.prototype.toString = Title.prototype.getPrefixedDb;
+
+ /**
+ * @alias #getPrefixedText
+ * @method
+ */
+ Title.prototype.toText = Title.prototype.getPrefixedText;
+
+ // Expose
+ mw.Title = Title;
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+// This file can't be parsed by JSDuck due to <https://github.com/tenderlove/rkelly/issues/35>.
+// (It is excluded in jsduck.json.)
+// ESLint suggests unquoting some object keys, which would render the file unparseable by Opera 12.
+/* eslint-disable quote-props */
+( function ( mw ) {
+ var toUpperMapping = {
+ 'ß': 'ß',
+ 'ʼn': 'ʼn',
+ 'Dž': 'Dž',
+ 'dž': 'Dž',
+ 'Lj': 'Lj',
+ 'lj': 'Lj',
+ 'Nj': 'Nj',
+ 'nj': 'Nj',
+ 'ǰ': 'ǰ',
+ 'Dz': 'Dz',
+ 'dz': 'Dz',
+ 'ʝ': 'Ʝ',
+ 'ͅ': 'ͅ',
+ 'ΐ': 'ΐ',
+ 'ΰ': 'ΰ',
+ 'և': 'և',
+ 'ᏸ': 'Ᏸ',
+ 'ᏹ': 'Ᏹ',
+ 'ᏺ': 'Ᏺ',
+ 'ᏻ': 'Ᏻ',
+ 'ᏼ': 'Ᏼ',
+ 'ᏽ': 'Ᏽ',
+ 'ẖ': 'ẖ',
+ 'ẗ': 'ẗ',
+ 'ẘ': 'ẘ',
+ 'ẙ': 'ẙ',
+ 'ẚ': 'ẚ',
+ 'ὐ': 'ὐ',
+ 'ὒ': 'ὒ',
+ 'ὔ': 'ὔ',
+ 'ὖ': 'ὖ',
+ 'ᾀ': 'ᾈ',
+ 'ᾁ': 'ᾉ',
+ 'ᾂ': 'ᾊ',
+ 'ᾃ': 'ᾋ',
+ 'ᾄ': 'ᾌ',
+ 'ᾅ': 'ᾍ',
+ 'ᾆ': 'ᾎ',
+ 'ᾇ': 'ᾏ',
+ 'ᾈ': 'ᾈ',
+ 'ᾉ': 'ᾉ',
+ 'ᾊ': 'ᾊ',
+ 'ᾋ': 'ᾋ',
+ 'ᾌ': 'ᾌ',
+ 'ᾍ': 'ᾍ',
+ 'ᾎ': 'ᾎ',
+ 'ᾏ': 'ᾏ',
+ 'ᾐ': 'ᾘ',
+ 'ᾑ': 'ᾙ',
+ 'ᾒ': 'ᾚ',
+ 'ᾓ': 'ᾛ',
+ 'ᾔ': 'ᾜ',
+ 'ᾕ': 'ᾝ',
+ 'ᾖ': 'ᾞ',
+ 'ᾗ': 'ᾟ',
+ 'ᾘ': 'ᾘ',
+ 'ᾙ': 'ᾙ',
+ 'ᾚ': 'ᾚ',
+ 'ᾛ': 'ᾛ',
+ 'ᾜ': 'ᾜ',
+ 'ᾝ': 'ᾝ',
+ 'ᾞ': 'ᾞ',
+ 'ᾟ': 'ᾟ',
+ 'ᾠ': 'ᾨ',
+ 'ᾡ': 'ᾩ',
+ 'ᾢ': 'ᾪ',
+ 'ᾣ': 'ᾫ',
+ 'ᾤ': 'ᾬ',
+ 'ᾥ': 'ᾭ',
+ 'ᾦ': 'ᾮ',
+ 'ᾧ': 'ᾯ',
+ 'ᾨ': 'ᾨ',
+ 'ᾩ': 'ᾩ',
+ 'ᾪ': 'ᾪ',
+ 'ᾫ': 'ᾫ',
+ 'ᾬ': 'ᾬ',
+ 'ᾭ': 'ᾭ',
+ 'ᾮ': 'ᾮ',
+ 'ᾯ': 'ᾯ',
+ 'ᾲ': 'ᾲ',
+ 'ᾳ': 'ᾼ',
+ 'ᾴ': 'ᾴ',
+ 'ᾶ': 'ᾶ',
+ 'ᾷ': 'ᾷ',
+ 'ᾼ': 'ᾼ',
+ 'ῂ': 'ῂ',
+ 'ῃ': 'ῌ',
+ 'ῄ': 'ῄ',
+ 'ῆ': 'ῆ',
+ 'ῇ': 'ῇ',
+ 'ῌ': 'ῌ',
+ 'ῒ': 'ῒ',
+ 'ΐ': 'ΐ',
+ 'ῖ': 'ῖ',
+ 'ῗ': 'ῗ',
+ 'ῢ': 'ῢ',
+ 'ΰ': 'ΰ',
+ 'ῤ': 'ῤ',
+ 'ῦ': 'ῦ',
+ 'ῧ': 'ῧ',
+ 'ῲ': 'ῲ',
+ 'ῳ': 'ῼ',
+ 'ῴ': 'ῴ',
+ 'ῶ': 'ῶ',
+ 'ῷ': 'ῷ',
+ 'ῼ': 'ῼ',
+ 'ⅰ': 'ⅰ',
+ 'ⅱ': 'ⅱ',
+ 'ⅲ': 'ⅲ',
+ 'ⅳ': 'ⅳ',
+ 'ⅴ': 'ⅴ',
+ 'ⅵ': 'ⅵ',
+ 'ⅶ': 'ⅶ',
+ 'ⅷ': 'ⅷ',
+ 'ⅸ': 'ⅸ',
+ 'ⅹ': 'ⅹ',
+ 'ⅺ': 'ⅺ',
+ 'ⅻ': 'ⅻ',
+ 'ⅼ': 'ⅼ',
+ 'ⅽ': 'ⅽ',
+ 'ⅾ': 'ⅾ',
+ 'ⅿ': 'ⅿ',
+ 'ⓐ': 'ⓐ',
+ 'ⓑ': 'ⓑ',
+ 'ⓒ': 'ⓒ',
+ 'ⓓ': 'ⓓ',
+ 'ⓔ': 'ⓔ',
+ 'ⓕ': 'ⓕ',
+ 'ⓖ': 'ⓖ',
+ 'ⓗ': 'ⓗ',
+ 'ⓘ': 'ⓘ',
+ 'ⓙ': 'ⓙ',
+ 'ⓚ': 'ⓚ',
+ 'ⓛ': 'ⓛ',
+ 'ⓜ': 'ⓜ',
+ 'ⓝ': 'ⓝ',
+ 'ⓞ': 'ⓞ',
+ 'ⓟ': 'ⓟ',
+ 'ⓠ': 'ⓠ',
+ 'ⓡ': 'ⓡ',
+ 'ⓢ': 'ⓢ',
+ 'ⓣ': 'ⓣ',
+ 'ⓤ': 'ⓤ',
+ 'ⓥ': 'ⓥ',
+ 'ⓦ': 'ⓦ',
+ 'ⓧ': 'ⓧ',
+ 'ⓨ': 'ⓨ',
+ 'ⓩ': 'ⓩ',
+ 'ꞵ': 'Ꞵ',
+ 'ꞷ': 'Ꞷ',
+ 'ꭓ': 'Ꭓ',
+ 'ꭰ': 'Ꭰ',
+ 'ꭱ': 'Ꭱ',
+ 'ꭲ': 'Ꭲ',
+ 'ꭳ': 'Ꭳ',
+ 'ꭴ': 'Ꭴ',
+ 'ꭵ': 'Ꭵ',
+ 'ꭶ': 'Ꭶ',
+ 'ꭷ': 'Ꭷ',
+ 'ꭸ': 'Ꭸ',
+ 'ꭹ': 'Ꭹ',
+ 'ꭺ': 'Ꭺ',
+ 'ꭻ': 'Ꭻ',
+ 'ꭼ': 'Ꭼ',
+ 'ꭽ': 'Ꭽ',
+ 'ꭾ': 'Ꭾ',
+ 'ꭿ': 'Ꭿ',
+ 'ꮀ': 'Ꮀ',
+ 'ꮁ': 'Ꮁ',
+ 'ꮂ': 'Ꮂ',
+ 'ꮃ': 'Ꮃ',
+ 'ꮄ': 'Ꮄ',
+ 'ꮅ': 'Ꮅ',
+ 'ꮆ': 'Ꮆ',
+ 'ꮇ': 'Ꮇ',
+ 'ꮈ': 'Ꮈ',
+ 'ꮉ': 'Ꮉ',
+ 'ꮊ': 'Ꮊ',
+ 'ꮋ': 'Ꮋ',
+ 'ꮌ': 'Ꮌ',
+ 'ꮍ': 'Ꮍ',
+ 'ꮎ': 'Ꮎ',
+ 'ꮏ': 'Ꮏ',
+ 'ꮐ': 'Ꮐ',
+ 'ꮑ': 'Ꮑ',
+ 'ꮒ': 'Ꮒ',
+ 'ꮓ': 'Ꮓ',
+ 'ꮔ': 'Ꮔ',
+ 'ꮕ': 'Ꮕ',
+ 'ꮖ': 'Ꮖ',
+ 'ꮗ': 'Ꮗ',
+ 'ꮘ': 'Ꮘ',
+ 'ꮙ': 'Ꮙ',
+ 'ꮚ': 'Ꮚ',
+ 'ꮛ': 'Ꮛ',
+ 'ꮜ': 'Ꮜ',
+ 'ꮝ': 'Ꮝ',
+ 'ꮞ': 'Ꮞ',
+ 'ꮟ': 'Ꮟ',
+ 'ꮠ': 'Ꮠ',
+ 'ꮡ': 'Ꮡ',
+ 'ꮢ': 'Ꮢ',
+ 'ꮣ': 'Ꮣ',
+ 'ꮤ': 'Ꮤ',
+ 'ꮥ': 'Ꮥ',
+ 'ꮦ': 'Ꮦ',
+ 'ꮧ': 'Ꮧ',
+ 'ꮨ': 'Ꮨ',
+ 'ꮩ': 'Ꮩ',
+ 'ꮪ': 'Ꮪ',
+ 'ꮫ': 'Ꮫ',
+ 'ꮬ': 'Ꮬ',
+ 'ꮭ': 'Ꮭ',
+ 'ꮮ': 'Ꮮ',
+ 'ꮯ': 'Ꮯ',
+ 'ꮰ': 'Ꮰ',
+ 'ꮱ': 'Ꮱ',
+ 'ꮲ': 'Ꮲ',
+ 'ꮳ': 'Ꮳ',
+ 'ꮴ': 'Ꮴ',
+ 'ꮵ': 'Ꮵ',
+ 'ꮶ': 'Ꮶ',
+ 'ꮷ': 'Ꮷ',
+ 'ꮸ': 'Ꮸ',
+ 'ꮹ': 'Ꮹ',
+ 'ꮺ': 'Ꮺ',
+ 'ꮻ': 'Ꮻ',
+ 'ꮼ': 'Ꮼ',
+ 'ꮽ': 'Ꮽ',
+ 'ꮾ': 'Ꮾ',
+ 'ꮿ': 'Ꮿ',
+ 'ff': 'ff',
+ 'fi': 'fi',
+ 'fl': 'fl',
+ 'ffi': 'ffi',
+ 'ffl': 'ffl',
+ 'ſt': 'ſt',
+ 'st': 'st',
+ 'ﬓ': 'ﬓ',
+ 'ﬔ': 'ﬔ',
+ 'ﬕ': 'ﬕ',
+ 'ﬖ': 'ﬖ',
+ 'ﬗ': 'ﬗ'
+ };
+ mw.Title.phpCharToUpper = function ( chr ) {
+ var mapped = toUpperMapping[ chr ];
+ return mapped || chr.toUpperCase();
+ };
+}( mediaWiki ) );
--- /dev/null
+.mw-upload-bookletLayout-filePreview {
+ width: 100%;
+ height: 1em;
+ background-color: #eaecf0;
+ background-size: cover;
+ background-position: center center;
+ padding: 1.5em;
+ margin: -1.5em;
+ margin-bottom: 1.5em;
+ position: relative;
+}
+
+.mw-upload-bookletLayout-infoForm.mw-upload-bookletLayout-hasThumbnail .mw-upload-bookletLayout-filePreview {
+ height: 10em;
+}
+
+.mw-upload-bookletLayout-filePreview p {
+ line-height: 1em;
+ margin: 0;
+}
+
+.mw-upload-bookletLayout-filePreview .oo-ui-progressBarWidget {
+ border: 0;
+ border-radius: 0;
+ background-color: transparent;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+}
+
+.mw-upload-bookletLayout-filePreview .oo-ui-progressBarWidget-bar {
+ height: 0.5em;
+}
--- /dev/null
+/* global moment */
+( function ( $, mw, moment ) {
+
+ /**
+ * mw.Upload.BookletLayout encapsulates the process of uploading a file
+ * to MediaWiki using the {@link mw.Upload upload model}.
+ * The booklet emits events that can be used to get the stashed
+ * upload and the final file. It can be extended to accept
+ * additional fields from the user for specific scenarios like
+ * for Commons, or campaigns.
+ *
+ * ## Structure
+ *
+ * The {@link OO.ui.BookletLayout booklet layout} has three steps:
+ *
+ * - **Upload**: Has a {@link OO.ui.SelectFileWidget field} to get the file object.
+ *
+ * - **Information**: Has a {@link OO.ui.FormLayout form} to collect metadata. This can be
+ * extended.
+ *
+ * - **Insert**: Has details on how to use the file that was uploaded.
+ *
+ * Each step has a form associated with it defined in
+ * {@link #renderUploadForm renderUploadForm},
+ * {@link #renderInfoForm renderInfoForm}, and
+ * {@link #renderInsertForm renderInfoForm}. The
+ * {@link #getFile getFile},
+ * {@link #getFilename getFilename}, and
+ * {@link #getText getText} methods are used to get
+ * the information filled in these forms, required to call
+ * {@link mw.Upload mw.Upload}.
+ *
+ * ## Usage
+ *
+ * See the {@link mw.Upload.Dialog upload dialog}.
+ *
+ * The {@link #event-fileUploaded fileUploaded},
+ * and {@link #event-fileSaved fileSaved} events can
+ * be used to get details of the upload.
+ *
+ * ## Extending
+ *
+ * To extend using {@link mw.Upload mw.Upload}, override
+ * {@link #renderInfoForm renderInfoForm} to render
+ * the form required for the specific use-case. Update the
+ * {@link #getFilename getFilename}, and
+ * {@link #getText getText} methods to return data
+ * from your newly created form. If you added new fields you'll also have
+ * to update the {@link #clear} method.
+ *
+ * If you plan to use a different upload model, apart from what is mentioned
+ * above, you'll also have to override the
+ * {@link #createUpload createUpload} method to
+ * return the new model. The {@link #saveFile saveFile}, and
+ * the {@link #uploadFile uploadFile} methods need to be
+ * overridden to use the new model and data returned from the forms.
+ *
+ * @class
+ * @extends OO.ui.BookletLayout
+ *
+ * @constructor
+ * @param {Object} config Configuration options
+ * @cfg {jQuery} [$overlay] Overlay to use for widgets in the booklet
+ * @cfg {string} [filekey] Sets the stashed file to finish uploading. Overrides most of the file selection process, and fetches a thumbnail from the server.
+ */
+ mw.Upload.BookletLayout = function ( config ) {
+ // Parent constructor
+ mw.Upload.BookletLayout.parent.call( this, config );
+
+ this.$overlay = config.$overlay;
+
+ this.filekey = config.filekey;
+
+ this.renderUploadForm();
+ this.renderInfoForm();
+ this.renderInsertForm();
+
+ this.addPages( [
+ new OO.ui.PageLayout( 'upload', {
+ scrollable: true,
+ padded: true,
+ content: [ this.uploadForm ]
+ } ),
+ new OO.ui.PageLayout( 'info', {
+ scrollable: true,
+ padded: true,
+ content: [ this.infoForm ]
+ } ),
+ new OO.ui.PageLayout( 'insert', {
+ scrollable: true,
+ padded: true,
+ content: [ this.insertForm ]
+ } )
+ ] );
+ };
+
+ /* Setup */
+
+ OO.inheritClass( mw.Upload.BookletLayout, OO.ui.BookletLayout );
+
+ /* Events */
+
+ /**
+ * Progress events for the uploaded file
+ *
+ * @event fileUploadProgress
+ * @param {number} progress In percentage
+ * @param {Object} duration Duration object from `moment.duration()`
+ */
+
+ /**
+ * The file has finished uploading
+ *
+ * @event fileUploaded
+ */
+
+ /**
+ * The file has been saved to the database
+ *
+ * @event fileSaved
+ * @param {Object} imageInfo See mw.Upload#getImageInfo
+ */
+
+ /**
+ * The upload form has changed
+ *
+ * @event uploadValid
+ * @param {boolean} isValid The form is valid
+ */
+
+ /**
+ * The info form has changed
+ *
+ * @event infoValid
+ * @param {boolean} isValid The form is valid
+ */
+
+ /* Properties */
+
+ /**
+ * @property {OO.ui.FormLayout} uploadForm
+ * The form rendered in the first step to get the file object.
+ * Rendered in {@link #renderUploadForm renderUploadForm}.
+ */
+
+ /**
+ * @property {OO.ui.FormLayout} infoForm
+ * The form rendered in the second step to get metadata.
+ * Rendered in {@link #renderInfoForm renderInfoForm}
+ */
+
+ /**
+ * @property {OO.ui.FormLayout} insertForm
+ * The form rendered in the third step to show usage
+ * Rendered in {@link #renderInsertForm renderInsertForm}
+ */
+
+ /* Methods */
+
+ /**
+ * Initialize for a new upload
+ *
+ * @return {jQuery.Promise} Promise resolved when everything is initialized
+ */
+ mw.Upload.BookletLayout.prototype.initialize = function () {
+ var booklet = this;
+
+ this.clear();
+ this.upload = this.createUpload();
+
+ this.setPage( 'upload' );
+
+ if ( this.filekey ) {
+ this.setFilekey( this.filekey );
+ }
+
+ return this.upload.getApi().then(
+ function ( api ) {
+ // If the user can't upload anything, don't give them the option to.
+ return api.getUserInfo().then(
+ function ( userInfo ) {
+ if ( userInfo.rights.indexOf( 'upload' ) === -1 ) {
+ if ( mw.user.isAnon() ) {
+ booklet.getPage( 'upload' ).$element.msg( 'apierror-mustbeloggedin', mw.msg( 'action-upload' ) );
+ } else {
+ booklet.getPage( 'upload' ).$element.msg( 'apierror-permissiondenied', mw.msg( 'action-upload' ) );
+ }
+ }
+ return $.Deferred().resolve();
+ },
+ // Always resolve, never reject
+ function () { return $.Deferred().resolve(); }
+ );
+ },
+ function ( errorMsg ) {
+ booklet.getPage( 'upload' ).$element.msg( errorMsg );
+ return $.Deferred().resolve();
+ }
+ );
+ };
+
+ /**
+ * Create a new upload model
+ *
+ * @protected
+ * @return {mw.Upload} Upload model
+ */
+ mw.Upload.BookletLayout.prototype.createUpload = function () {
+ return new mw.Upload( {
+ parameters: {
+ errorformat: 'html',
+ errorlang: mw.config.get( 'wgUserLanguage' ),
+ errorsuselocal: 1,
+ formatversion: 2
+ }
+ } );
+ };
+
+ /* Uploading */
+
+ /**
+ * Uploads the file that was added in the upload form. Uses
+ * {@link #getFile getFile} to get the HTML5
+ * file object.
+ *
+ * @protected
+ * @fires fileUploadProgress
+ * @fires fileUploaded
+ * @return {jQuery.Promise}
+ */
+ mw.Upload.BookletLayout.prototype.uploadFile = function () {
+ var deferred = $.Deferred(),
+ startTime = mw.now(),
+ layout = this,
+ file = this.getFile();
+
+ this.setPage( 'info' );
+
+ if ( this.filekey ) {
+ if ( file === null ) {
+ // Someone gonna get-a hurt real bad
+ throw new Error( 'filekey not passed into file select widget, which is impossible. Quitting while we\'re behind.' );
+ }
+
+ // Stashed file already uploaded.
+ deferred.resolve();
+ this.uploadPromise = deferred;
+ this.emit( 'fileUploaded' );
+ return deferred;
+ }
+
+ this.setFilename( file.name );
+
+ this.upload.setFile( file );
+ // The original file name might contain invalid characters, so use our sanitized one
+ this.upload.setFilename( this.getFilename() );
+
+ this.uploadPromise = this.upload.uploadToStash();
+ this.uploadPromise.then( function () {
+ deferred.resolve();
+ layout.emit( 'fileUploaded' );
+ }, function () {
+ // These errors will be thrown while the user is on the info page.
+ layout.getErrorMessageForStateDetails().then( function ( errorMessage ) {
+ deferred.reject( errorMessage );
+ } );
+ }, function ( progress ) {
+ var elapsedTime = mw.now() - startTime,
+ estimatedTotalTime = ( 1 / progress ) * elapsedTime,
+ estimatedRemainingTime = moment.duration( estimatedTotalTime - elapsedTime );
+ layout.emit( 'fileUploadProgress', progress, estimatedRemainingTime );
+ } );
+
+ // If there is an error in uploading, come back to the upload page
+ deferred.fail( function () {
+ layout.setPage( 'upload' );
+ } );
+
+ return deferred;
+ };
+
+ /**
+ * Saves the stash finalizes upload. Uses
+ * {@link #getFilename getFilename}, and
+ * {@link #getText getText} to get details from
+ * the form.
+ *
+ * @protected
+ * @fires fileSaved
+ * @return {jQuery.Promise} Rejects the promise with an
+ * {@link OO.ui.Error error}, or resolves if the upload was successful.
+ */
+ mw.Upload.BookletLayout.prototype.saveFile = function () {
+ var layout = this,
+ deferred = $.Deferred();
+
+ this.upload.setFilename( this.getFilename() );
+ this.upload.setText( this.getText() );
+
+ this.uploadPromise.then( function () {
+ layout.upload.finishStashUpload().then( function () {
+ var name;
+
+ // Normalize page name and localise the 'File:' prefix
+ name = new mw.Title( 'File:' + layout.upload.getFilename() ).toString();
+ layout.filenameUsageWidget.setValue( '[[' + name + ']]' );
+ layout.setPage( 'insert' );
+
+ deferred.resolve();
+ layout.emit( 'fileSaved', layout.upload.getImageInfo() );
+ }, function () {
+ layout.getErrorMessageForStateDetails().then( function ( errorMessage ) {
+ deferred.reject( errorMessage );
+ } );
+ } );
+ } );
+
+ return deferred.promise();
+ };
+
+ /**
+ * Get an error message (as OO.ui.Error object) that should be displayed to the user for current
+ * state and state details.
+ *
+ * @protected
+ * @return {jQuery.Promise} A Promise that will be resolved with an OO.ui.Error.
+ */
+ mw.Upload.BookletLayout.prototype.getErrorMessageForStateDetails = function () {
+ var state = this.upload.getState(),
+ stateDetails = this.upload.getStateDetails(),
+ error = stateDetails.errors ? stateDetails.errors[ 0 ] : false,
+ warnings = stateDetails.upload && stateDetails.upload.warnings,
+ $ul = $( '<ul>' ),
+ errorText;
+
+ if ( state === mw.Upload.State.ERROR ) {
+ if ( !error ) {
+ if ( stateDetails.textStatus === 'timeout' ) {
+ // in case of $.ajax.fail(), there is no response json
+ errorText = mw.message( 'apierror-timeout' ).parse();
+ } else if ( stateDetails.xhr && stateDetails.xhr.status === 0 ) {
+ // failed to even connect to server
+ errorText = mw.message( 'apierror-offline' ).parse();
+ } else if ( stateDetails.textStatus ) {
+ errorText = stateDetails.textStatus;
+ } else {
+ errorText = mw.message( 'apierror-unknownerror', JSON.stringify( stateDetails ) ).parse();
+ }
+
+ // If there's an 'exception' key, this might be a timeout, or other connection problem
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).html( errorText ),
+ { recoverable: false }
+ ) );
+ }
+
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).html( error.html ),
+ { recoverable: false }
+ ) );
+ }
+
+ if ( state === mw.Upload.State.WARNING ) {
+ // We could get more than one of these errors, these are in order
+ // of importance. For example fixing the thumbnail like file name
+ // won't help the fact that the file already exists.
+ if ( warnings.exists !== undefined ) {
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).msg( 'fileexists', 'File:' + warnings.exists ),
+ { recoverable: false }
+ ) );
+ } else if ( warnings[ 'exists-normalized' ] !== undefined ) {
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).msg( 'fileexists', 'File:' + warnings[ 'exists-normalized' ] ),
+ { recoverable: false }
+ ) );
+ } else if ( warnings[ 'page-exists' ] !== undefined ) {
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).msg( 'filepageexists', 'File:' + warnings[ 'page-exists' ] ),
+ { recoverable: false }
+ ) );
+ } else if ( Array.isArray( warnings.duplicate ) ) {
+ warnings.duplicate.forEach( function ( filename ) {
+ var $a = $( '<a>' ).text( filename ),
+ href = mw.Title.makeTitle( mw.config.get( 'wgNamespaceIds' ).file, filename ).getUrl( {} );
+
+ $a.attr( { href: href, target: '_blank' } );
+ $ul.append( $( '<li>' ).append( $a ) );
+ } );
+
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).msg( 'file-exists-duplicate', warnings.duplicate.length ).append( $ul ),
+ { recoverable: false }
+ ) );
+ } else if ( warnings[ 'thumb-name' ] !== undefined ) {
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).msg( 'filename-thumb-name' ),
+ { recoverable: false }
+ ) );
+ } else if ( warnings[ 'bad-prefix' ] !== undefined ) {
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).msg( 'filename-bad-prefix', warnings[ 'bad-prefix' ] ),
+ { recoverable: false }
+ ) );
+ } else if ( warnings[ 'duplicate-archive' ] !== undefined ) {
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).msg( 'file-deleted-duplicate', 'File:' + warnings[ 'duplicate-archive' ] ),
+ { recoverable: false }
+ ) );
+ } else if ( warnings[ 'was-deleted' ] !== undefined ) {
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).msg( 'filewasdeleted', 'File:' + warnings[ 'was-deleted' ] ),
+ { recoverable: false }
+ ) );
+ } else if ( warnings.badfilename !== undefined ) {
+ // Change the name if the current name isn't acceptable
+ // TODO This might not really be the best place to do this
+ this.setFilename( warnings.badfilename );
+ return $.Deferred().resolve( new OO.ui.Error(
+ $( '<p>' ).msg( 'badfilename', warnings.badfilename )
+ ) );
+ } else {
+ return $.Deferred().resolve( new OO.ui.Error(
+ // Let's get all the help we can if we can't pin point the error
+ $( '<p>' ).msg( 'api-error-unknown-warning', JSON.stringify( stateDetails ) ),
+ { recoverable: false }
+ ) );
+ }
+ }
+ };
+
+ /* Form renderers */
+
+ /**
+ * Renders and returns the upload form and sets the
+ * {@link #uploadForm uploadForm} property.
+ *
+ * @protected
+ * @fires selectFile
+ * @return {OO.ui.FormLayout}
+ */
+ mw.Upload.BookletLayout.prototype.renderUploadForm = function () {
+ var fieldset,
+ layout = this;
+
+ this.selectFileWidget = this.getFileWidget();
+ fieldset = new OO.ui.FieldsetLayout();
+ fieldset.addItems( [ this.selectFileWidget ] );
+ this.uploadForm = new OO.ui.FormLayout( { items: [ fieldset ] } );
+
+ // Validation (if the SFW is for a stashed file, this never fires)
+ this.selectFileWidget.on( 'change', this.onUploadFormChange.bind( this ) );
+
+ this.selectFileWidget.on( 'change', function () {
+ layout.updateFilePreview();
+ } );
+
+ return this.uploadForm;
+ };
+
+ /**
+ * Gets the widget for displaying or inputting the file to upload.
+ *
+ * @return {OO.ui.SelectFileWidget|mw.widgets.StashedFileWidget}
+ */
+ mw.Upload.BookletLayout.prototype.getFileWidget = function () {
+ if ( this.filekey ) {
+ return new mw.widgets.StashedFileWidget( {
+ filekey: this.filekey
+ } );
+ }
+
+ return new OO.ui.SelectFileWidget( {
+ showDropTarget: true
+ } );
+ };
+
+ /**
+ * Updates the file preview on the info form when a file is added.
+ *
+ * @protected
+ */
+ mw.Upload.BookletLayout.prototype.updateFilePreview = function () {
+ this.selectFileWidget.loadAndGetImageUrl().done( function ( url ) {
+ this.filePreview.$element.find( 'p' ).remove();
+ this.filePreview.$element.css( 'background-image', 'url(' + url + ')' );
+ this.infoForm.$element.addClass( 'mw-upload-bookletLayout-hasThumbnail' );
+ }.bind( this ) ).fail( function () {
+ this.filePreview.$element.find( 'p' ).remove();
+ if ( this.selectFileWidget.getValue() ) {
+ this.filePreview.$element.append(
+ $( '<p>' ).text( this.selectFileWidget.getValue().name )
+ );
+ }
+ this.filePreview.$element.css( 'background-image', '' );
+ this.infoForm.$element.removeClass( 'mw-upload-bookletLayout-hasThumbnail' );
+ }.bind( this ) );
+ };
+
+ /**
+ * Handle change events to the upload form
+ *
+ * @protected
+ * @fires uploadValid
+ */
+ mw.Upload.BookletLayout.prototype.onUploadFormChange = function () {
+ this.emit( 'uploadValid', !!this.selectFileWidget.getValue() );
+ };
+
+ /**
+ * Renders and returns the information form for collecting
+ * metadata and sets the {@link #infoForm infoForm}
+ * property.
+ *
+ * @protected
+ * @return {OO.ui.FormLayout}
+ */
+ mw.Upload.BookletLayout.prototype.renderInfoForm = function () {
+ var fieldset;
+
+ this.filePreview = new OO.ui.Widget( {
+ classes: [ 'mw-upload-bookletLayout-filePreview' ]
+ } );
+ this.progressBarWidget = new OO.ui.ProgressBarWidget( {
+ progress: 0
+ } );
+ this.filePreview.$element.append( this.progressBarWidget.$element );
+
+ this.filenameWidget = new OO.ui.TextInputWidget( {
+ indicator: 'required',
+ required: true,
+ validate: /.+/
+ } );
+ this.descriptionWidget = new OO.ui.MultilineTextInputWidget( {
+ indicator: 'required',
+ required: true,
+ validate: /\S+/,
+ autosize: true
+ } );
+
+ fieldset = new OO.ui.FieldsetLayout( {
+ label: mw.msg( 'upload-form-label-infoform-title' )
+ } );
+ fieldset.addItems( [
+ new OO.ui.FieldLayout( this.filenameWidget, {
+ label: mw.msg( 'upload-form-label-infoform-name' ),
+ align: 'top',
+ help: mw.msg( 'upload-form-label-infoform-name-tooltip' )
+ } ),
+ new OO.ui.FieldLayout( this.descriptionWidget, {
+ label: mw.msg( 'upload-form-label-infoform-description' ),
+ align: 'top',
+ help: mw.msg( 'upload-form-label-infoform-description-tooltip' )
+ } )
+ ] );
+ this.infoForm = new OO.ui.FormLayout( {
+ classes: [ 'mw-upload-bookletLayout-infoForm' ],
+ items: [ this.filePreview, fieldset ]
+ } );
+
+ this.on( 'fileUploadProgress', function ( progress ) {
+ this.progressBarWidget.setProgress( progress * 100 );
+ }.bind( this ) );
+
+ this.filenameWidget.on( 'change', this.onInfoFormChange.bind( this ) );
+ this.descriptionWidget.on( 'change', this.onInfoFormChange.bind( this ) );
+
+ return this.infoForm;
+ };
+
+ /**
+ * Handle change events to the info form
+ *
+ * @protected
+ * @fires infoValid
+ */
+ mw.Upload.BookletLayout.prototype.onInfoFormChange = function () {
+ var layout = this;
+ $.when(
+ this.filenameWidget.getValidity(),
+ this.descriptionWidget.getValidity()
+ ).done( function () {
+ layout.emit( 'infoValid', true );
+ } ).fail( function () {
+ layout.emit( 'infoValid', false );
+ } );
+ };
+
+ /**
+ * Renders and returns the insert form to show file usage and
+ * sets the {@link #insertForm insertForm} property.
+ *
+ * @protected
+ * @return {OO.ui.FormLayout}
+ */
+ mw.Upload.BookletLayout.prototype.renderInsertForm = function () {
+ var fieldset;
+
+ this.filenameUsageWidget = new OO.ui.TextInputWidget();
+ fieldset = new OO.ui.FieldsetLayout( {
+ label: mw.msg( 'upload-form-label-usage-title' )
+ } );
+ fieldset.addItems( [
+ new OO.ui.FieldLayout( this.filenameUsageWidget, {
+ label: mw.msg( 'upload-form-label-usage-filename' ),
+ align: 'top'
+ } )
+ ] );
+ this.insertForm = new OO.ui.FormLayout( { items: [ fieldset ] } );
+
+ return this.insertForm;
+ };
+
+ /* Getters */
+
+ /**
+ * Gets the file object from the
+ * {@link #uploadForm upload form}.
+ *
+ * @protected
+ * @return {File|null}
+ */
+ mw.Upload.BookletLayout.prototype.getFile = function () {
+ return this.selectFileWidget.getValue();
+ };
+
+ /**
+ * Gets the file name from the
+ * {@link #infoForm information form}.
+ *
+ * @protected
+ * @return {string}
+ */
+ mw.Upload.BookletLayout.prototype.getFilename = function () {
+ var filename = this.filenameWidget.getValue();
+ if ( this.filenameExtension ) {
+ filename += '.' + this.filenameExtension;
+ }
+ return filename;
+ };
+
+ /**
+ * Prefills the {@link #infoForm information form} with the given filename.
+ *
+ * @protected
+ * @param {string} filename
+ */
+ mw.Upload.BookletLayout.prototype.setFilename = function ( filename ) {
+ var title = mw.Title.newFromFileName( filename );
+
+ if ( title ) {
+ this.filenameWidget.setValue( title.getNameText() );
+ this.filenameExtension = mw.Title.normalizeExtension( title.getExtension() );
+ } else {
+ // Seems to happen for files with no extension, which should fail some checks anyway...
+ this.filenameWidget.setValue( filename );
+ this.filenameExtension = null;
+ }
+ };
+
+ /**
+ * Gets the page text from the
+ * {@link #infoForm information form}.
+ *
+ * @protected
+ * @return {string}
+ */
+ mw.Upload.BookletLayout.prototype.getText = function () {
+ return this.descriptionWidget.getValue();
+ };
+
+ /* Setters */
+
+ /**
+ * Sets the file object
+ *
+ * @protected
+ * @param {File|null} file File to select
+ */
+ mw.Upload.BookletLayout.prototype.setFile = function ( file ) {
+ this.selectFileWidget.setValue( file );
+ };
+
+ /**
+ * Sets the filekey of a file already stashed on the server
+ * as the target of this upload operation.
+ *
+ * @protected
+ * @param {string} filekey
+ */
+ mw.Upload.BookletLayout.prototype.setFilekey = function ( filekey ) {
+ this.upload.setFilekey( this.filekey );
+ this.selectFileWidget.setValue( filekey );
+
+ this.onUploadFormChange();
+ };
+
+ /**
+ * Clear the values of all fields
+ *
+ * @protected
+ */
+ mw.Upload.BookletLayout.prototype.clear = function () {
+ this.selectFileWidget.setValue( null );
+ this.progressBarWidget.setProgress( 0 );
+ this.filenameWidget.setValue( null ).setValidityFlag( true );
+ this.descriptionWidget.setValue( null ).setValidityFlag( true );
+ this.filenameUsageWidget.setValue( null );
+ };
+
+}( jQuery, mediaWiki, moment ) );
--- /dev/null
+/**
+ * Library for simple URI parsing and manipulation.
+ *
+ * Intended to be minimal, but featureful; do not expect full RFC 3986 compliance. The use cases we
+ * have in mind are constructing 'next page' or 'previous page' URLs, detecting whether we need to
+ * use cross-domain proxies for an API, constructing simple URL-based API calls, etc. Parsing here
+ * is regex-based, so may not work on all URIs, but is good enough for most.
+ *
+ * You can modify the properties directly, then use the #toString method to extract the full URI
+ * string again. Example:
+ *
+ * var uri = new mw.Uri( 'http://example.com/mysite/mypage.php?quux=2' );
+ *
+ * if ( uri.host == 'example.com' ) {
+ * uri.host = 'foo.example.com';
+ * uri.extend( { bar: 1 } );
+ *
+ * $( 'a#id1' ).attr( 'href', uri );
+ * // anchor with id 'id1' now links to http://foo.example.com/mysite/mypage.php?bar=1&quux=2
+ *
+ * $( 'a#id2' ).attr( 'href', uri.clone().extend( { bar: 3, pif: 'paf' } ) );
+ * // anchor with id 'id2' now links to http://foo.example.com/mysite/mypage.php?bar=3&quux=2&pif=paf
+ * }
+ *
+ * Given a URI like
+ * `http://usr:pwd@www.example.com:81/dir/dir.2/index.htm?q1=0&&test1&test2=&test3=value+%28escaped%29&r=1&r=2#top`
+ * the returned object will have the following properties:
+ *
+ * protocol 'http'
+ * user 'usr'
+ * password 'pwd'
+ * host 'www.example.com'
+ * port '81'
+ * path '/dir/dir.2/index.htm'
+ * query {
+ * q1: '0',
+ * test1: null,
+ * test2: '',
+ * test3: 'value (escaped)'
+ * r: ['1', '2']
+ * }
+ * fragment 'top'
+ *
+ * (N.b., 'password' is technically not allowed for HTTP URIs, but it is possible with other kinds
+ * of URIs.)
+ *
+ * Parsing based on parseUri 1.2.2 (c) Steven Levithan <http://stevenlevithan.com>, MIT License.
+ * <http://stevenlevithan.com/demo/parseuri/js/>
+ *
+ * @class mw.Uri
+ */
+
+/* eslint-disable no-use-before-define */
+
+( function ( mw, $ ) {
+ var parser, properties;
+
+ /**
+ * Function that's useful when constructing the URI string -- we frequently encounter the pattern
+ * of having to add something to the URI as we go, but only if it's present, and to include a
+ * character before or after if so.
+ *
+ * @private
+ * @static
+ * @param {string|undefined} pre To prepend
+ * @param {string} val To include
+ * @param {string} post To append
+ * @param {boolean} raw If true, val will not be encoded
+ * @return {string} Result
+ */
+ function cat( pre, val, post, raw ) {
+ if ( val === undefined || val === null || val === '' ) {
+ return '';
+ }
+
+ return pre + ( raw ? val : mw.Uri.encode( val ) ) + post;
+ }
+
+ /**
+ * Regular expressions to parse many common URIs.
+ *
+ * As they are gnarly, they have been moved to separate files to allow us to format them in the
+ * 'extended' regular expression format (which JavaScript normally doesn't support). The subset of
+ * features handled is minimal, but just the free whitespace gives us a lot.
+ *
+ * @private
+ * @static
+ * @property {Object} parser
+ */
+ parser = {
+ strict: mw.template.get( 'mediawiki.Uri', 'strict.regexp' ).render(),
+ loose: mw.template.get( 'mediawiki.Uri', 'loose.regexp' ).render()
+ };
+
+ /**
+ * The order here matches the order of captured matches in the `parser` property regexes.
+ *
+ * @private
+ * @static
+ * @property {Array} properties
+ */
+ properties = [
+ 'protocol',
+ 'user',
+ 'password',
+ 'host',
+ 'port',
+ 'path',
+ 'query',
+ 'fragment'
+ ];
+
+ /**
+ * @property {string} protocol For example `http` (always present)
+ */
+ /**
+ * @property {string|undefined} user For example `usr`
+ */
+ /**
+ * @property {string|undefined} password For example `pwd`
+ */
+ /**
+ * @property {string} host For example `www.example.com` (always present)
+ */
+ /**
+ * @property {string|undefined} port For example `81`
+ */
+ /**
+ * @property {string} path For example `/dir/dir.2/index.htm` (always present)
+ */
+ /**
+ * @property {Object} query For example `{ a: '0', b: '', c: 'value' }` (always present)
+ */
+ /**
+ * @property {string|undefined} fragment For example `top`
+ */
+
+ /**
+ * A factory method to create a Uri class with a default location to resolve relative URLs
+ * against (including protocol-relative URLs).
+ *
+ * @method
+ * @param {string|Function} documentLocation A full url, or function returning one.
+ * If passed a function, the return value may change over time and this will be honoured. (T74334)
+ * @member mw
+ * @return {Function} Uri class
+ */
+ mw.UriRelative = function ( documentLocation ) {
+ var getDefaultUri = ( function () {
+ // Cache
+ var href, uri;
+
+ return function () {
+ var hrefCur = typeof documentLocation === 'string' ? documentLocation : documentLocation();
+ if ( href === hrefCur ) {
+ return uri;
+ }
+ href = hrefCur;
+ uri = new Uri( href );
+ return uri;
+ };
+ }() );
+
+ /**
+ * Construct a new URI object. Throws error if arguments are illegal/impossible, or
+ * otherwise don't parse.
+ *
+ * @class mw.Uri
+ * @constructor
+ * @param {Object|string} [uri] URI string, or an Object with appropriate properties (especially
+ * another URI object to clone). Object must have non-blank `protocol`, `host`, and `path`
+ * properties. If omitted (or set to `undefined`, `null` or empty string), then an object
+ * will be created for the default `uri` of this constructor (`location.href` for mw.Uri,
+ * other values for other instances -- see mw.UriRelative for details).
+ * @param {Object|boolean} [options] Object with options, or (backwards compatibility) a boolean
+ * for strictMode
+ * @param {boolean} [options.strictMode=false] Trigger strict mode parsing of the url.
+ * @param {boolean} [options.overrideKeys=false] Whether to let duplicate query parameters
+ * override each other (`true`) or automagically convert them to an array (`false`).
+ */
+ function Uri( uri, options ) {
+ var prop, hrefCur,
+ hasOptions = ( options !== undefined ),
+ defaultUri = getDefaultUri();
+
+ options = typeof options === 'object' ? options : { strictMode: !!options };
+ options = $.extend( {
+ strictMode: false,
+ overrideKeys: false
+ }, options );
+
+ if ( uri !== undefined && uri !== null && uri !== '' ) {
+ if ( typeof uri === 'string' ) {
+ this.parse( uri, options );
+ } else if ( typeof uri === 'object' ) {
+ // Copy data over from existing URI object
+ for ( prop in uri ) {
+ // Only copy direct properties, not inherited ones
+ if ( uri.hasOwnProperty( prop ) ) {
+ // Deep copy object properties
+ if ( Array.isArray( uri[ prop ] ) || $.isPlainObject( uri[ prop ] ) ) {
+ this[ prop ] = $.extend( true, {}, uri[ prop ] );
+ } else {
+ this[ prop ] = uri[ prop ];
+ }
+ }
+ }
+ if ( !this.query ) {
+ this.query = {};
+ }
+ }
+ } else if ( hasOptions ) {
+ // We didn't get a URI in the constructor, but we got options.
+ hrefCur = typeof documentLocation === 'string' ? documentLocation : documentLocation();
+ this.parse( hrefCur, options );
+ } else {
+ // We didn't get a URI or options in the constructor, use the default instance.
+ return defaultUri.clone();
+ }
+
+ // protocol-relative URLs
+ if ( !this.protocol ) {
+ this.protocol = defaultUri.protocol;
+ }
+ // No host given:
+ if ( !this.host ) {
+ this.host = defaultUri.host;
+ // port ?
+ if ( !this.port ) {
+ this.port = defaultUri.port;
+ }
+ }
+ if ( this.path && this.path[ 0 ] !== '/' ) {
+ // A real relative URL, relative to defaultUri.path. We can't really handle that since we cannot
+ // figure out whether the last path component of defaultUri.path is a directory or a file.
+ throw new Error( 'Bad constructor arguments' );
+ }
+ if ( !( this.protocol && this.host && this.path ) ) {
+ throw new Error( 'Bad constructor arguments' );
+ }
+ }
+
+ /**
+ * Encode a value for inclusion in a url.
+ *
+ * Standard encodeURIComponent, with extra stuff to make all browsers work similarly and more
+ * compliant with RFC 3986. Similar to rawurlencode from PHP and our JS library
+ * mw.util.rawurlencode, except this also replaces spaces with `+`.
+ *
+ * @static
+ * @param {string} s String to encode
+ * @return {string} Encoded string for URI
+ */
+ Uri.encode = function ( s ) {
+ return encodeURIComponent( s )
+ .replace( /!/g, '%21' ).replace( /'/g, '%27' ).replace( /\(/g, '%28' )
+ .replace( /\)/g, '%29' ).replace( /\*/g, '%2A' )
+ .replace( /%20/g, '+' );
+ };
+
+ /**
+ * Decode a url encoded value.
+ *
+ * Reversed #encode. Standard decodeURIComponent, with addition of replacing
+ * `+` with a space.
+ *
+ * @static
+ * @param {string} s String to decode
+ * @return {string} Decoded string
+ */
+ Uri.decode = function ( s ) {
+ return decodeURIComponent( s.replace( /\+/g, '%20' ) );
+ };
+
+ Uri.prototype = {
+
+ /**
+ * Parse a string and set our properties accordingly.
+ *
+ * @private
+ * @param {string} str URI, see constructor.
+ * @param {Object} options See constructor.
+ */
+ parse: function ( str, options ) {
+ var q, matches,
+ uri = this,
+ hasOwn = Object.prototype.hasOwnProperty;
+
+ // Apply parser regex and set all properties based on the result
+ matches = parser[ options.strictMode ? 'strict' : 'loose' ].exec( str );
+ properties.forEach( function ( property, i ) {
+ uri[ property ] = matches[ i + 1 ];
+ } );
+
+ // uri.query starts out as the query string; we will parse it into key-val pairs then make
+ // that object the "query" property.
+ // we overwrite query in uri way to make cloning easier, it can use the same list of properties.
+ q = {};
+ // using replace to iterate over a string
+ if ( uri.query ) {
+ uri.query.replace( /(?:^|&)([^&=]*)(?:(=)([^&]*))?/g, function ( $0, $1, $2, $3 ) {
+ var k, v;
+ if ( $1 ) {
+ k = Uri.decode( $1 );
+ v = ( $2 === '' || $2 === undefined ) ? null : Uri.decode( $3 );
+
+ // If overrideKeys, always (re)set top level value.
+ // If not overrideKeys but this key wasn't set before, then we set it as well.
+ if ( options.overrideKeys || !hasOwn.call( q, k ) ) {
+ q[ k ] = v;
+
+ // Use arrays if overrideKeys is false and key was already seen before
+ } else {
+ // Once before, still a string, turn into an array
+ if ( typeof q[ k ] === 'string' ) {
+ q[ k ] = [ q[ k ] ];
+ }
+ // Add to the array
+ if ( Array.isArray( q[ k ] ) ) {
+ q[ k ].push( v );
+ }
+ }
+ }
+ } );
+ }
+ uri.query = q;
+
+ // Decode uri.fragment, otherwise it gets double-encoded when serializing
+ if ( uri.fragment !== undefined ) {
+ uri.fragment = Uri.decode( uri.fragment );
+ }
+ },
+
+ /**
+ * Get user and password section of a URI.
+ *
+ * @return {string}
+ */
+ getUserInfo: function () {
+ return cat( '', this.user, cat( ':', this.password, '' ) );
+ },
+
+ /**
+ * Get host and port section of a URI.
+ *
+ * @return {string}
+ */
+ getHostPort: function () {
+ return this.host + cat( ':', this.port, '' );
+ },
+
+ /**
+ * Get the userInfo, host and port section of the URI.
+ *
+ * In most real-world URLs this is simply the hostname, but the definition of 'authority' section is more general.
+ *
+ * @return {string}
+ */
+ getAuthority: function () {
+ return cat( '', this.getUserInfo(), '@' ) + this.getHostPort();
+ },
+
+ /**
+ * Get the query arguments of the URL, encoded into a string.
+ *
+ * Does not preserve the original order of arguments passed in the URI. Does handle escaping.
+ *
+ * @return {string}
+ */
+ getQueryString: function () {
+ var args = [];
+ $.each( this.query, function ( key, val ) {
+ var k = Uri.encode( key ),
+ vals = Array.isArray( val ) ? val : [ val ];
+ vals.forEach( function ( v ) {
+ if ( v === null ) {
+ args.push( k );
+ } else if ( k === 'title' ) {
+ args.push( k + '=' + mw.util.wikiUrlencode( v ) );
+ } else {
+ args.push( k + '=' + Uri.encode( v ) );
+ }
+ } );
+ } );
+ return args.join( '&' );
+ },
+
+ /**
+ * Get everything after the authority section of the URI.
+ *
+ * @return {string}
+ */
+ getRelativePath: function () {
+ return this.path + cat( '?', this.getQueryString(), '', true ) + cat( '#', this.fragment, '' );
+ },
+
+ /**
+ * Get the entire URI string.
+ *
+ * May not be precisely the same as input due to order of query arguments.
+ *
+ * @return {string} The URI string
+ */
+ toString: function () {
+ return this.protocol + '://' + this.getAuthority() + this.getRelativePath();
+ },
+
+ /**
+ * Clone this URI
+ *
+ * @return {Object} New URI object with same properties
+ */
+ clone: function () {
+ return new Uri( this );
+ },
+
+ /**
+ * Extend the query section of the URI with new parameters.
+ *
+ * @param {Object} parameters Query parameters to add to ours (or to override ours with) as an
+ * object
+ * @return {Object} This URI object
+ */
+ extend: function ( parameters ) {
+ $.extend( this.query, parameters );
+ return this;
+ }
+ };
+
+ return Uri;
+ };
+
+ // Default to the current browsing location (for relative URLs).
+ mw.Uri = mw.UriRelative( function () {
+ return location.href;
+ } );
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+^
+(?:
+ (?![^:@]+:[^:@/]*@)
+ (?<protocol>[^:/?#.]+):
+)?
+(?://)?
+(?:(?:
+ (?<user>[^:@/?#]*)
+ (?::(?<password>[^:@/?#]*))?
+)?@)?
+(?<host>[^:/?#]*)
+(?::(?<port>\d*))?
+(
+ (?:/
+ (?:[^?#]
+ (?![^?#/]*\.[^?#/.]+(?:[?#]|$))
+ )*/?
+ )?
+ [^?#/]*
+)
+(?:\?(?<query>[^#]*))?
+(?:\#(?<fragment>.*))?
--- /dev/null
+^
+(?:(?<protocol>[^:/?#]+):)?
+(?://(?:
+ (?:
+ (?<user>[^:@/?#]*)
+ (?::(?<password>[^:@/?#]*))?
+ )?@)?
+ (?<host>[^:/?#]*)
+ (?::(?<port>\d*))?
+)?
+(?<path>(?:[^?#/]*/)*[^?#]*)
+(?:\?(?<query>[^#]*))?
+(?:\#(?<fragment>.*))?
--- /dev/null
+.apihelp-header {
+ clear: both;
+ margin-bottom: 0.1em;
+}
+
+.apihelp-header.apihelp-module-name {
+ /*
+ * This element is explicitly set to dir="ltr" in HTML.
+ * Set explicit alignment so that CSSJanus will flip it to "right";
+ * otherwise the alignment will be automatically set to "left" according
+ * to the element's direction, and this will have an inconsistent look.
+ */
+ text-align: left;
+}
+
+div.apihelp-linktrail {
+ font-size: smaller;
+}
+
+.apihelp-block {
+ margin-top: 0.5em;
+}
+
+.apihelp-block-head {
+ font-weight: bold;
+}
+
+.apihelp-flags {
+ font-size: smaller;
+ float: right;
+ border: 1px solid #000;
+ padding: 0.25em;
+ width: 20em;
+}
+
+.apihelp-deprecated,
+.apihelp-flag-deprecated,
+.apihelp-flag-internal strong {
+ font-weight: bold;
+ color: #d33;
+}
+
+.apihelp-deprecated-value {
+ text-decoration: line-through;
+}
+
+.apihelp-unknown {
+ color: #72777d;
+}
+
+.apihelp-empty {
+ color: #72777d;
+}
+
+.apihelp-help-urls ul {
+ list-style-image: none;
+ list-style-type: none;
+ margin-left: 0;
+}
+
+.apihelp-parameters dl,
+.apihelp-examples dl,
+.apihelp-permissions dl {
+ margin-left: 2em;
+}
+
+.apihelp-parameters dt {
+ float: left;
+ clear: left;
+ min-width: 10em;
+ white-space: nowrap;
+ line-height: 1.5em;
+}
+
+.apihelp-parameters dt:after {
+ content: ':\A0';
+}
+
+.apihelp-parameters dd {
+ margin: 0 0 0.5em 10em;
+ line-height: 1.5em;
+}
+
+.apihelp-parameters dd p:first-child {
+ margin-top: 0;
+}
+
+.apihelp-parameters dd.info {
+ margin-left: 12em;
+ text-indent: -2em;
+}
+
+.apihelp-examples dt {
+ font-weight: normal;
+}
+
+.api-main-links {
+ text-align: center;
+}
+.api-main-links ul:before {
+ content: '[';
+}
+.api-main-links ul:after {
+ content: ']';
+}
--- /dev/null
+( function ( mw, $ ) {
+ /**
+ * Prevent the closing of a window with a confirm message (the onbeforeunload event seems to
+ * work in most browsers.)
+ *
+ * This supersedes any previous onbeforeunload handler. If there was a handler before, it is
+ * restored when you execute the returned release() function.
+ *
+ * var allowCloseWindow = mw.confirmCloseWindow();
+ * // ... do stuff that can't be interrupted ...
+ * allowCloseWindow.release();
+ *
+ * The second function returned is a trigger function to trigger the check and an alert
+ * window manually, e.g.:
+ *
+ * var allowCloseWindow = mw.confirmCloseWindow();
+ * // ... do stuff that can't be interrupted ...
+ * if ( allowCloseWindow.trigger() ) {
+ * // don't do anything (e.g. destroy the input field)
+ * } else {
+ * // do whatever you wanted to do
+ * }
+ *
+ * @method confirmCloseWindow
+ * @member mw
+ * @param {Object} [options]
+ * @param {string} [options.namespace] Namespace for the event registration
+ * @param {string} [options.message]
+ * @param {string} options.message.return The string message to show in the confirm dialog.
+ * @param {Function} [options.test]
+ * @param {boolean} [options.test.return=true] Whether to show the dialog to the user.
+ * @return {Object} An object of functions to work with this module
+ */
+ mw.confirmCloseWindow = function ( options ) {
+ var savedUnloadHandler,
+ mainEventName = 'beforeunload',
+ showEventName = 'pageshow',
+ message;
+
+ options = $.extend( {
+ message: mw.message( 'mwe-prevent-close' ).text(),
+ test: function () { return true; }
+ }, options );
+
+ if ( options.namespace ) {
+ mainEventName += '.' + options.namespace;
+ showEventName += '.' + options.namespace;
+ }
+
+ if ( $.isFunction( options.message ) ) {
+ message = options.message();
+ } else {
+ message = options.message;
+ }
+
+ $( window ).on( mainEventName, function () {
+ if ( options.test() ) {
+ // remove the handler while the alert is showing - otherwise breaks caching in Firefox (3?).
+ // but if they continue working on this page, immediately re-register this handler
+ savedUnloadHandler = window.onbeforeunload;
+ window.onbeforeunload = null;
+ setTimeout( function () {
+ window.onbeforeunload = savedUnloadHandler;
+ }, 1 );
+
+ // show an alert with this message
+ return message;
+ }
+ } ).on( showEventName, function () {
+ // Re-add onbeforeunload handler
+ if ( !window.onbeforeunload && savedUnloadHandler ) {
+ window.onbeforeunload = savedUnloadHandler;
+ }
+ } );
+
+ /**
+ * Return the object with functions to release and manually trigger the confirm alert
+ *
+ * @ignore
+ */
+ return {
+ /**
+ * Remove all event listeners and don't show an alert anymore, if the user wants to leave
+ * the page.
+ *
+ * @ignore
+ */
+ release: function () {
+ $( window ).off( mainEventName + ' ' + showEventName );
+ },
+ /**
+ * Trigger the module's function manually: Check, if options.test() returns true and show
+ * an alert to the user if he/she want to leave this page. Returns false, if options.test() returns
+ * false or the user cancelled the alert window (~don't leave the page), true otherwise.
+ *
+ * @ignore
+ * @return {boolean}
+ */
+ trigger: function () {
+ // use confirm to show the message to the user (if options.text() is true)
+ // eslint-disable-next-line no-alert
+ if ( options.test() && !confirm( message ) ) {
+ // the user want to keep the actual page
+ return false;
+ }
+ // otherwise return true
+ return true;
+ }
+ };
+ };
+}( mediaWiki, jQuery ) );
--- /dev/null
+/*!
+ * CSS for styling HTML-formatted JSON Schema objects
+ *
+ * @file
+ * @author Munaf Assaf <massaf@wikimedia.org>
+ */
+
+.mw-json {
+ border-collapse: collapse;
+ border-spacing: 0;
+ font-style: normal;
+}
+
+.mw-json th,
+.mw-json td {
+ border: 1px solid #72777d;
+ font-size: 16px;
+ padding: 0.5em 1em;
+}
+
+.mw-json .value,
+.mw-json-single-value {
+ background-color: #dcfae3;
+ font-family: monospace, monospace;
+ white-space: pre-wrap;
+}
+
+.mw-json-single-value {
+ background-color: #eaecf0;
+}
+
+.mw-json-empty {
+ background-color: #fff;
+ font-style: italic;
+}
+
+.mw-json tr {
+ background-color: #eaecf0;
+ margin-bottom: 0.5em;
+}
+
+.mw-json th {
+ background-color: #fff;
+ font-weight: normal;
+}
+
+.mw-json caption {
+ /* For stylistic reasons, suppress the caption of the outermost table */
+ display: none;
+}
+
+.mw-json table caption {
+ color: #72777d;
+ display: inline-block;
+ font-size: 10px;
+ font-style: italic;
+ margin-bottom: 0.5em;
+ text-align: left;
+}
--- /dev/null
+( function ( mw, $ ) {
+ 'use strict';
+
+ var debug,
+ hovzer = $.getFootHovzer();
+
+ OO.ui.getViewportSpacing = function () {
+ return {
+ top: 0,
+ right: 0,
+ bottom: hovzer.$.outerHeight(),
+ left: 0
+ };
+ };
+
+ /**
+ * Debug toolbar.
+ *
+ * Enabled server-side through `$wgDebugToolbar`.
+ *
+ * @class mw.Debug
+ * @singleton
+ * @author John Du Hart
+ * @since 1.19
+ */
+ debug = mw.Debug = {
+ /**
+ * Toolbar container element
+ *
+ * @property {jQuery}
+ */
+ $container: null,
+
+ /**
+ * Object containing data for the debug toolbar
+ *
+ * @property {Object}
+ */
+ data: {},
+
+ /**
+ * Initialize the debugging pane
+ *
+ * Shouldn't be called before the document is ready
+ * (since it binds to elements on the page).
+ *
+ * @param {Object} [data] Defaults to 'debugInfo' from mw.config
+ */
+ init: function ( data ) {
+
+ this.data = data || mw.config.get( 'debugInfo' );
+ this.buildHtml();
+
+ // Insert the container into the DOM
+ hovzer.$.append( this.$container );
+ hovzer.update();
+
+ $( '.mw-debug-panelink' ).click( this.switchPane );
+ },
+
+ /**
+ * Switch between panes
+ *
+ * Should be called with an HTMLElement as its thisArg,
+ * because it's meant to be an event handler.
+ *
+ * TODO: Store cookie for last pane open.
+ *
+ * @param {jQuery.Event} e
+ */
+ switchPane: function ( e ) {
+ var currentPaneId = debug.$container.data( 'currentPane' ),
+ requestedPaneId = $( this ).prop( 'id' ).slice( 9 ),
+ $currentPane = $( '#mw-debug-pane-' + currentPaneId ),
+ $requestedPane = $( '#mw-debug-pane-' + requestedPaneId ),
+ hovDone = false;
+
+ function updateHov() {
+ if ( !hovDone ) {
+ hovzer.update();
+ hovDone = true;
+ }
+ }
+
+ // Skip hash fragment handling. Prevents screen from jumping.
+ e.preventDefault();
+
+ $( this ).addClass( 'current ' );
+ $( '.mw-debug-panelink' ).not( this ).removeClass( 'current ' );
+
+ // Hide the current pane
+ if ( requestedPaneId === currentPaneId ) {
+ $currentPane.slideUp( updateHov );
+ debug.$container.data( 'currentPane', null );
+ return;
+ }
+
+ debug.$container.data( 'currentPane', requestedPaneId );
+
+ if ( currentPaneId === undefined || currentPaneId === null ) {
+ $requestedPane.slideDown( updateHov );
+ } else {
+ $currentPane.hide();
+ $requestedPane.show();
+ updateHov();
+ }
+ },
+
+ /**
+ * Construct the HTML for the debugging toolbar
+ */
+ buildHtml: function () {
+ var $container, $bits, panes, id, gitInfo;
+
+ $container = $( '<div id="mw-debug-toolbar" class="mw-debug" lang="en" dir="ltr"></div>' );
+
+ $bits = $( '<div class="mw-debug-bits"></div>' );
+
+ /**
+ * Returns a jQuery element for a debug-bit div
+ *
+ * @ignore
+ * @param {string} id
+ * @return {jQuery}
+ */
+ function bitDiv( id ) {
+ return $( '<div>' ).prop( {
+ id: 'mw-debug-' + id,
+ className: 'mw-debug-bit'
+ } ).appendTo( $bits );
+ }
+
+ /**
+ * Returns a jQuery element for a pane link
+ *
+ * @ignore
+ * @param {string} id
+ * @param {string} text
+ * @return {jQuery}
+ */
+ function paneLabel( id, text ) {
+ return $( '<a>' )
+ .prop( {
+ className: 'mw-debug-panelabel',
+ href: '#mw-debug-pane-' + id
+ } )
+ .text( text );
+ }
+
+ /**
+ * Returns a jQuery element for a debug-bit div with a for a pane link
+ *
+ * @ignore
+ * @param {string} id CSS id snippet. Will be prefixed with 'mw-debug-'
+ * @param {string} text Text to show
+ * @param {string} count Optional count to show
+ * @return {jQuery}
+ */
+ function paneTriggerBitDiv( id, text, count ) {
+ if ( count ) {
+ text = text + ' (' + count + ')';
+ }
+ return $( '<div>' ).prop( {
+ id: 'mw-debug-' + id,
+ className: 'mw-debug-bit mw-debug-panelink'
+ } )
+ .append( paneLabel( id, text ) )
+ .appendTo( $bits );
+ }
+
+ paneTriggerBitDiv( 'console', 'Console', this.data.log.length );
+
+ paneTriggerBitDiv( 'querylist', 'Queries', this.data.queries.length );
+
+ paneTriggerBitDiv( 'debuglog', 'Debug log', this.data.debugLog.length );
+
+ paneTriggerBitDiv( 'request', 'Request' );
+
+ paneTriggerBitDiv( 'includes', 'PHP includes', this.data.includes.length );
+
+ gitInfo = '';
+ if ( this.data.gitRevision !== false ) {
+ gitInfo = '(' + this.data.gitRevision.slice( 0, 7 ) + ')';
+ if ( this.data.gitViewUrl !== false ) {
+ gitInfo = $( '<a>' )
+ .attr( 'href', this.data.gitViewUrl )
+ .text( gitInfo );
+ }
+ }
+
+ bitDiv( 'mwversion' )
+ .append( $( '<a href="//www.mediawiki.org/">MediaWiki</a>' ) )
+ .append( document.createTextNode( ': ' + this.data.mwVersion + ' ' ) )
+ .append( gitInfo );
+
+ if ( this.data.gitBranch !== false ) {
+ bitDiv( 'gitbranch' ).text( 'Git branch: ' + this.data.gitBranch );
+ }
+
+ bitDiv( 'phpversion' )
+ .append( $( this.data.phpEngine === 'HHVM' ?
+ '<a href="http://hhvm.com/">HHVM</a>' :
+ '<a href="https://php.net/">PHP</a>'
+ ) )
+ .append( ': ' + this.data.phpVersion );
+
+ bitDiv( 'time' )
+ .text( 'Time: ' + this.data.time.toFixed( 5 ) );
+
+ bitDiv( 'memory' )
+ .text( 'Memory: ' + this.data.memory + ' (Peak: ' + this.data.memoryPeak + ')' );
+
+ $bits.appendTo( $container );
+
+ panes = {
+ console: this.buildConsoleTable(),
+ querylist: this.buildQueryTable(),
+ debuglog: this.buildDebugLogTable(),
+ request: this.buildRequestPane(),
+ includes: this.buildIncludesPane()
+ };
+
+ for ( id in panes ) {
+ if ( !panes.hasOwnProperty( id ) ) {
+ continue;
+ }
+
+ $( '<div>' )
+ .prop( {
+ className: 'mw-debug-pane',
+ id: 'mw-debug-pane-' + id
+ } )
+ .append( panes[ id ] )
+ .appendTo( $container );
+ }
+
+ this.$container = $container;
+ },
+
+ /**
+ * Build the console panel
+ *
+ * @return {jQuery} Console panel
+ */
+ buildConsoleTable: function () {
+ var $table, entryTypeText, i, length, entry;
+
+ $table = $( '<table id="mw-debug-console">' );
+
+ $( '<colgroup>' ).css( 'width', /* padding = */ 20 + ( 10 * /* fontSize = */ 11 ) ).appendTo( $table );
+ $( '<colgroup>' ).appendTo( $table );
+ $( '<colgroup>' ).css( 'width', 350 ).appendTo( $table );
+
+ entryTypeText = function ( entryType ) {
+ switch ( entryType ) {
+ case 'log':
+ return 'Log';
+ case 'warn':
+ return 'Warning';
+ case 'deprecated':
+ return 'Deprecated';
+ default:
+ return 'Unknown';
+ }
+ };
+
+ for ( i = 0, length = this.data.log.length; i < length; i += 1 ) {
+ entry = this.data.log[ i ];
+ entry.typeText = entryTypeText( entry.type );
+
+ $( '<tr>' )
+ .append( $( '<td>' )
+ .text( entry.typeText )
+ .addClass( 'mw-debug-console-' + entry.type )
+ )
+ .append( $( '<td>' ).html( entry.msg ) )
+ .append( $( '<td>' ).text( entry.caller ) )
+ .appendTo( $table );
+ }
+
+ return $table;
+ },
+
+ /**
+ * Build query list pane
+ *
+ * @return {jQuery}
+ */
+ buildQueryTable: function () {
+ var $table, i, length, query;
+
+ $table = $( '<table id="mw-debug-querylist"></table>' );
+
+ $( '<tr>' )
+ .append( $( '<th>#</th>' ).css( 'width', '4em' ) )
+ .append( $( '<th>SQL</th>' ) )
+ .append( $( '<th>Time</th>' ).css( 'width', '8em' ) )
+ .append( $( '<th>Call</th>' ).css( 'width', '18em' ) )
+ .appendTo( $table );
+
+ for ( i = 0, length = this.data.queries.length; i < length; i += 1 ) {
+ query = this.data.queries[ i ];
+
+ $( '<tr>' )
+ .append( $( '<td>' ).text( i + 1 ) )
+ .append( $( '<td>' ).text( query.sql ) )
+ .append( $( '<td class="stats">' ).text( ( query.time * 1000 ).toFixed( 4 ) + 'ms' ) )
+ .append( $( '<td>' ).text( query[ 'function' ] ) )
+ .appendTo( $table );
+ }
+
+ return $table;
+ },
+
+ /**
+ * Build legacy debug log pane
+ *
+ * @return {jQuery}
+ */
+ buildDebugLogTable: function () {
+ var $list, i, length, line;
+ $list = $( '<ul>' );
+
+ for ( i = 0, length = this.data.debugLog.length; i < length; i += 1 ) {
+ line = this.data.debugLog[ i ];
+ $( '<li>' )
+ .html( mw.html.escape( line ).replace( /\n/g, '<br />\n' ) )
+ .appendTo( $list );
+ }
+
+ return $list;
+ },
+
+ /**
+ * Build request information pane
+ *
+ * @return {jQuery}
+ */
+ buildRequestPane: function () {
+
+ function buildTable( title, data ) {
+ var $unit, $table, key;
+
+ $unit = $( '<div>' ).append( $( '<h2>' ).text( title ) );
+
+ $table = $( '<table>' ).appendTo( $unit );
+
+ $( '<tr>' )
+ .html( '<th>Key</th><th>Value</th>' )
+ .appendTo( $table );
+
+ for ( key in data ) {
+ if ( !data.hasOwnProperty( key ) ) {
+ continue;
+ }
+
+ $( '<tr>' )
+ .append( $( '<th>' ).text( key ) )
+ .append( $( '<td>' ).text( data[ key ] ) )
+ .appendTo( $table );
+ }
+
+ return $unit;
+ }
+
+ return $( '<div>' )
+ .text( this.data.request.method + ' ' + this.data.request.url )
+ .append( buildTable( 'Headers', this.data.request.headers ) )
+ .append( buildTable( 'Parameters', this.data.request.params ) );
+ },
+
+ /**
+ * Build included files pane
+ *
+ * @return {jQuery}
+ */
+ buildIncludesPane: function () {
+ var $table, i, length, file;
+
+ $table = $( '<table>' );
+
+ for ( i = 0, length = this.data.includes.length; i < length; i += 1 ) {
+ file = this.data.includes[ i ];
+ $( '<tr>' )
+ .append( $( '<td>' ).text( file.name ) )
+ .append( $( '<td class="nr">' ).text( file.size ) )
+ .appendTo( $table );
+ }
+
+ return $table;
+ }
+ };
+
+ $( function () {
+ debug.init();
+ } );
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+.mw-debug {
+ width: 100%;
+ background-color: #eee;
+ border-top: 1px solid #aaa;
+
+ pre {
+ font-size: 11px;
+ padding: 0;
+ margin: 0;
+ background: none;
+ border: 0;
+ }
+
+ table {
+ border-spacing: 0;
+ width: 100%;
+ table-layout: fixed;
+
+ tr {
+ background-color: #fff;
+
+ &:nth-child( even ) {
+ background-color: #f9f9f9;
+ }
+ }
+
+ td,
+ th {
+ padding: 4px 10px;
+ }
+
+ td {
+ border-bottom: 1px solid #eee;
+ word-wrap: break-word;
+
+ &.nr {
+ text-align: right;
+ }
+
+ span.stats {
+ color: #727272;
+ }
+ }
+ }
+
+ ul {
+ margin: 0;
+ list-style: none;
+ }
+
+ li {
+ padding: 4px 0;
+ width: 100%;
+ }
+}
+
+.mw-debug-bits {
+ text-align: center;
+ border-bottom: 1px solid #aaa;
+}
+
+.mw-debug-bit {
+ display: inline-block;
+ padding: 10px 5px;
+ font-size: 13px;
+}
+
+.mw-debug-panelink {
+ background-color: #eee;
+ border-right: 1px solid #ccc;
+
+ &:first-child {
+ border-left: 1px solid #ccc;
+ }
+
+ &:hover {
+ background-color: #fefefe;
+ cursor: pointer;
+ }
+
+ &.current {
+ background-color: #dedede;
+ }
+}
+
+a.mw-debug-panelabel,
+a.mw-debug-panelabel:visited {
+ color: #000;
+}
+
+.mw-debug-pane {
+ height: 300px;
+ overflow: scroll;
+ display: none;
+ font-family: monospace, monospace;
+ font-size: 11px;
+ background-color: #e1eff2;
+ box-sizing: border-box;
+}
+
+#mw-debug-pane-debuglog,
+#mw-debug-pane-request {
+ padding: 20px;
+}
+
+#mw-debug-pane-request {
+ table {
+ width: 100%;
+ margin: 10px 0 30px;
+ }
+
+ tr,
+ th,
+ td,
+ table {
+ border: 1px solid #d0dbb3;
+ border-collapse: collapse;
+ margin: 0;
+ }
+
+ th,
+ td {
+ font-size: 12px;
+ padding: 8px 10px;
+ }
+
+ th {
+ background-color: #f1f7e2;
+ font-weight: bold;
+ }
+
+ td {
+ background-color: #fff;
+ }
+}
+
+#mw-debug-console tr td {
+ &:first-child {
+ font-weight: bold;
+ vertical-align: top;
+ }
+
+ &:last-child {
+ vertical-align: top;
+ }
+}
+
+.mw-debug-backtrace {
+ padding: 5px 10px;
+ margin: 5px;
+ background-color: #dedede;
+
+ span {
+ font-weight: bold;
+ color: #111;
+ }
+
+ ul {
+ padding-left: 10px;
+ }
+
+ li {
+ width: auto;
+ padding: 0;
+ color: #333;
+ font-size: 10px;
+ margin-bottom: 0;
+ line-height: 1em;
+ }
+}
+
+.mw-debug-console-log {
+ background-color: #add8e6;
+}
+
+.mw-debug-console-warn {
+ background-color: #ffa07a;
+}
+
+.mw-debug-console-deprecated {
+ background-color: #ffb6c1;
+}
+
+/* Cheapo hack to hide the first 3 lines of the backtrace */
+.mw-debug-backtrace li:nth-child( -n+3 ) {
+ display: none;
+}
--- /dev/null
+/*!
+ * Diff rendering
+ */
+
+.diff {
+ border: 0;
+ border-spacing: 4px;
+ margin: 0;
+ width: 100%;
+ /* Ensure that colums are of equal width */
+ table-layout: fixed;
+}
+
+.diff td {
+ padding: 0.33em 0.5em;
+}
+
+.diff td.diff-marker {
+ /* Compensate padding for increased font-size */
+ padding: 0.25em;
+}
+
+.diff col.diff-marker {
+ width: 2%;
+}
+
+.diff .diff-content {
+ width: 48%;
+}
+
+.diff td div {
+ /* Force-wrap very long lines such as URLs or page-widening char strings */
+ word-wrap: break-word;
+}
+
+.diff-title {
+ vertical-align: top;
+}
+
+.diff-notice,
+.diff-multi,
+.diff-otitle,
+.diff-ntitle {
+ text-align: center;
+}
+
+.diff-lineno {
+ font-weight: bold;
+}
+
+td.diff-marker {
+ text-align: right;
+ font-weight: bold;
+ font-size: 1.25em;
+ line-height: 1.2;
+}
+
+.diff-addedline,
+.diff-deletedline,
+.diff-context {
+ font-size: 88%;
+ line-height: 1.6;
+ vertical-align: top;
+ white-space: -moz-pre-wrap;
+ white-space: pre-wrap;
+ border-style: solid;
+ border-width: 1px 1px 1px 4px;
+ border-radius: 0.33em;
+}
+
+.diff-addedline {
+ border-color: #a3d3ff;
+}
+
+.diff-deletedline {
+ border-color: #ffe49c;
+}
+
+.diff-context {
+ background: #f8f9fa;
+ border-color: #eaecf0;
+ color: #222;
+}
+
+.diffchange {
+ font-weight: bold;
+ text-decoration: none;
+}
+
+.diff-addedline .diffchange,
+.diff-deletedline .diffchange {
+ border-radius: 0.33em;
+ padding: 0.25em 0;
+}
+
+.diff-addedline .diffchange {
+ background: #d8ecff;
+}
+
+.diff-deletedline .diffchange {
+ background: #feeec8;
+}
+
+/* Correct user & content directionality when viewing a diff */
+.diff-currentversion-title,
+.diff {
+ direction: ltr;
+ unicode-bidi: embed;
+}
+
+/* @noflip */ .diff-contentalign-right td {
+ direction: rtl;
+ unicode-bidi: embed;
+}
+
+/* @noflip */ .diff-contentalign-left td {
+ direction: ltr;
+ unicode-bidi: embed;
+}
+
+.diff-multi,
+.diff-otitle,
+.diff-ntitle,
+.diff-lineno {
+ direction: ltr !important; /* stylelint-disable-line declaration-no-important */
+ unicode-bidi: embed;
+}
+
+/*!
+ * Wikidiff2 rendering for moved paragraphs
+ */
+
+.mw-diff-movedpara-left,
+.mw-diff-movedpara-right,
+.mw-diff-movedpara-left:visited,
+.mw-diff-movedpara-right:visited,
+.mw-diff-movedpara-left:active,
+.mw-diff-movedpara-right:active {
+ display: block;
+ color: transparent;
+}
+
+.mw-diff-movedpara-left:hover,
+.mw-diff-movedpara-right:hover {
+ text-decoration: none;
+ color: transparent;
+}
+
+.mw-diff-movedpara-left:after,
+.mw-diff-movedpara-right:after {
+ display: block;
+ color: #222;
+ margin-top: -1.25em;
+}
+
+.mw-diff-movedpara-left:after,
+.rtl .mw-diff-movedpara-right:after {
+ content: '↪';
+}
+
+.mw-diff-movedpara-right:after,
+.rtl .mw-diff-movedpara-left:after {
+ content: '↩';
+}
--- /dev/null
+/*!
+ * Diff rendering
+ */
+td.diff-context,
+td.diff-addedline .diffchange,
+td.diff-deletedline .diffchange {
+ background-color: transparent;
+}
+
+td.diff-addedline .diffchange {
+ text-decoration: underline;
+}
+
+td.diff-deletedline .diffchange {
+ text-decoration: line-through;
+}
--- /dev/null
+/* Edit font preference */
+.mw-editfont-monospace {
+ font-family: monospace, monospace;
+}
+
+.mw-editfont-sans-serif {
+ font-family: sans-serif;
+}
+
+.mw-editfont-serif {
+ font-family: serif;
+}
+
+/* Standardize font size for edit areas using edit-fonts T182320 */
+.mw-editfont-monospace,
+.mw-editfont-sans-serif,
+.mw-editfont-serif {
+ font-size: 13px;
+
+ /* For OOUI TextInputWidget, the parent <div> element uses normal font size, and only
+ the <textarea>/<input> inside of it has the adjusted font size. This allows the width
+ of the widget and size of icons etc. (which are expressed in ems) to stay the same. */
+ &.oo-ui-textInputWidget {
+ font-size: inherit;
+ }
+
+ > .oo-ui-inputWidget-input {
+ font-size: 13px;
+ }
+}
--- /dev/null
+.feedback-spinner {
+ display: inline-block;
+ zoom: 1;
+ *display: inline; /* IE7 and below */ /* stylelint-disable declaration-block-no-duplicate-properties */
+ /* @embed */
+ background: url( images/spinner.gif );
+ width: 18px;
+ height: 18px;
+}
+
+.mw-feedbackDialog-welcome-message,
+.mw-feedbackDialog-feedback-terms {
+ line-height: 1.4;
+}
+
+.mw-feedbackDialog-feedback-terms p:first-child {
+ margin-top: 0;
+}
+
+.mw-feedbackDialog-welcome-message {
+ margin-bottom: 1em;
+}
+
+/* Overwriting OOUI is no fun */
+.mw-feedbackDialog-feedback-form .oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header {
+ min-width: 4.2em;
+ width: 10%;
+}
+.mw-feedbackDialog-feedback-form .oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
+ width: 80%;
+}
+
+.mw-feedbackDialog-feedback-termsofuse {
+ margin-left: 2em;
+}
--- /dev/null
+/*!
+ * mediawiki.feedback
+ *
+ * @author Ryan Kaldari, 2010
+ * @author Neil Kandalgaonkar, 2010-11
+ * @author Moriel Schottlender, 2015
+ * @since 1.19
+ */
+( function ( mw, $ ) {
+ /**
+ * This is a way of getting simple feedback from users. It's useful
+ * for testing new features -- users can give you feedback without
+ * the difficulty of opening a whole new talk page. For this reason,
+ * it also tends to collect a wider range of both positive and negative
+ * comments. However you do need to tend to the feedback page. It will
+ * get long relatively quickly, and you often get multiple messages
+ * reporting the same issue.
+ *
+ * It takes the form of thing on your page which, when clicked, opens a small
+ * dialog box. Submitting that dialog box appends its contents to a
+ * wiki page that you specify, as a new section.
+ *
+ * This feature works with any content model that defines a
+ * `mw.messagePoster.MessagePoster`.
+ *
+ * Minimal usage example:
+ *
+ * var feedback = new mw.Feedback();
+ * $( '#myButton' ).click( function () { feedback.launch(); } );
+ *
+ * You can also launch the feedback form with a prefilled subject and body.
+ * See the docs for the #launch() method.
+ *
+ * @class
+ * @constructor
+ * @param {Object} [config] Configuration object
+ * @cfg {mw.Title} [title="Feedback"] The title of the page where you collect
+ * feedback.
+ * @cfg {string} [apiUrl] api.php URL if the feedback page is on another wiki
+ * @cfg {string} [dialogTitleMessageKey="feedback-dialog-title"] Message key for the
+ * title of the dialog box
+ * @cfg {mw.Uri|string} [bugsLink="//phabricator.wikimedia.org/maniphest/task/edit/form/1/"] URL where
+ * bugs can be posted
+ * @cfg {mw.Uri|string} [bugsListLink="//phabricator.wikimedia.org/maniphest/query/advanced"] URL
+ * where bugs can be listed
+ * @cfg {boolean} [showUseragentCheckbox=false] Show a Useragent agreement checkbox as part of the form.
+ * @cfg {boolean} [useragentCheckboxMandatory=false] Make the Useragent checkbox mandatory.
+ * @cfg {string|jQuery} [useragentCheckboxMessage] Supply a custom message for the useragent checkbox.
+ * defaults to the message 'feedback-terms'.
+ */
+ mw.Feedback = function MwFeedback( config ) {
+ config = config || {};
+
+ this.dialogTitleMessageKey = config.dialogTitleMessageKey || 'feedback-dialog-title';
+
+ // Feedback page title
+ this.feedbackPageTitle = config.title || new mw.Title( 'Feedback' );
+
+ this.messagePosterPromise = mw.messagePoster.factory.create( this.feedbackPageTitle, config.apiUrl );
+ this.foreignApi = config.apiUrl ? new mw.ForeignApi( config.apiUrl ) : null;
+
+ // Links
+ this.bugsTaskSubmissionLink = config.bugsLink || '//phabricator.wikimedia.org/maniphest/task/edit/form/1/';
+ this.bugsTaskListLink = config.bugsListLink || '//phabricator.wikimedia.org/maniphest/query/advanced';
+
+ // Terms of use
+ this.useragentCheckboxShow = !!config.showUseragentCheckbox;
+ this.useragentCheckboxMandatory = !!config.useragentCheckboxMandatory;
+ this.useragentCheckboxMessage = config.useragentCheckboxMessage ||
+ $( '<p>' ).append( mw.msg( 'feedback-terms' ) );
+
+ // Message dialog
+ this.thankYouDialog = new OO.ui.MessageDialog();
+ };
+
+ /* Initialize */
+ OO.initClass( mw.Feedback );
+
+ /* Static Properties */
+ mw.Feedback.static.windowManager = null;
+ mw.Feedback.static.dialog = null;
+
+ /* Methods */
+
+ /**
+ * Respond to dialog submit event. If the information was
+ * submitted successfully, open a MessageDialog to thank the user.
+ *
+ * @param {string} status A status of the end of operation
+ * of the main feedback dialog. Empty if the dialog was
+ * dismissed with no action or the user followed the button
+ * to the external task reporting site.
+ * @param {string} feedbackPageName
+ * @param {string} feedbackPageUrl
+ */
+ mw.Feedback.prototype.onDialogSubmit = function ( status, feedbackPageName, feedbackPageUrl ) {
+ var dialogConfig;
+
+ if ( status !== 'submitted' ) {
+ return;
+ }
+
+ dialogConfig = {
+ title: mw.msg( 'feedback-thanks-title' ),
+ message: $( '<span>' ).msg(
+ 'feedback-thanks',
+ feedbackPageName,
+ $( '<a>' ).attr( {
+ target: '_blank',
+ href: feedbackPageUrl
+ } )
+ ),
+ actions: [
+ {
+ action: 'accept',
+ label: mw.msg( 'feedback-close' ),
+ flags: 'primary'
+ }
+ ]
+ };
+
+ // Show the message dialog
+ this.constructor.static.windowManager.openWindow(
+ this.thankYouDialog,
+ dialogConfig
+ );
+ };
+
+ /**
+ * Modify the display form, and then open it, focusing interface on the subject.
+ *
+ * @param {Object} [contents] Prefilled contents for the feedback form.
+ * @param {string} [contents.subject] The subject of the feedback, as plaintext
+ * @param {string} [contents.message] The content of the feedback, as wikitext
+ */
+ mw.Feedback.prototype.launch = function ( contents ) {
+ // Dialog
+ if ( !this.constructor.static.dialog ) {
+ this.constructor.static.dialog = new mw.Feedback.Dialog();
+ this.constructor.static.dialog.connect( this, { submit: 'onDialogSubmit' } );
+ }
+ if ( !this.constructor.static.windowManager ) {
+ this.constructor.static.windowManager = new OO.ui.WindowManager();
+ this.constructor.static.windowManager.addWindows( [
+ this.constructor.static.dialog,
+ this.thankYouDialog
+ ] );
+ $( 'body' )
+ .append( this.constructor.static.windowManager.$element );
+ }
+ // Open the dialog
+ this.constructor.static.windowManager.openWindow(
+ this.constructor.static.dialog,
+ {
+ title: mw.msg( this.dialogTitleMessageKey ),
+ foreignApi: this.foreignApi,
+ settings: {
+ messagePosterPromise: this.messagePosterPromise,
+ title: this.feedbackPageTitle,
+ dialogTitleMessageKey: this.dialogTitleMessageKey,
+ bugsTaskSubmissionLink: this.bugsTaskSubmissionLink,
+ bugsTaskListLink: this.bugsTaskListLink,
+ useragentCheckbox: {
+ show: this.useragentCheckboxShow,
+ mandatory: this.useragentCheckboxMandatory,
+ message: this.useragentCheckboxMessage
+ }
+ },
+ contents: contents
+ }
+ );
+ };
+
+ /**
+ * mw.Feedback Dialog
+ *
+ * @class
+ * @extends OO.ui.ProcessDialog
+ *
+ * @constructor
+ * @param {Object} config Configuration object
+ */
+ mw.Feedback.Dialog = function mwFeedbackDialog( config ) {
+ // Parent constructor
+ mw.Feedback.Dialog.parent.call( this, config );
+
+ this.status = '';
+ this.feedbackPageTitle = null;
+ // Initialize
+ this.$element.addClass( 'mwFeedback-Dialog' );
+ };
+
+ OO.inheritClass( mw.Feedback.Dialog, OO.ui.ProcessDialog );
+
+ /* Static properties */
+ mw.Feedback.Dialog.static.name = 'mwFeedbackDialog';
+ mw.Feedback.Dialog.static.title = mw.msg( 'feedback-dialog-title' );
+ mw.Feedback.Dialog.static.size = 'medium';
+ mw.Feedback.Dialog.static.actions = [
+ {
+ action: 'submit',
+ label: mw.msg( 'feedback-submit' ),
+ flags: [ 'primary', 'progressive' ]
+ },
+ {
+ action: 'external',
+ label: mw.msg( 'feedback-external-bug-report-button' ),
+ flags: 'progressive'
+ },
+ {
+ action: 'cancel',
+ label: mw.msg( 'feedback-cancel' ),
+ flags: 'safe'
+ }
+ ];
+
+ /**
+ * @inheritdoc
+ */
+ mw.Feedback.Dialog.prototype.initialize = function () {
+ var feedbackSubjectFieldLayout, feedbackMessageFieldLayout,
+ feedbackFieldsetLayout, termsOfUseLabel;
+
+ // Parent method
+ mw.Feedback.Dialog.parent.prototype.initialize.call( this );
+
+ this.feedbackPanel = new OO.ui.PanelLayout( {
+ scrollable: false,
+ expanded: false,
+ padded: true
+ } );
+
+ this.$spinner = $( '<div>' )
+ .addClass( 'feedback-spinner' );
+
+ // Feedback form
+ this.feedbackMessageLabel = new OO.ui.LabelWidget( {
+ classes: [ 'mw-feedbackDialog-welcome-message' ]
+ } );
+ this.feedbackSubjectInput = new OO.ui.TextInputWidget( {
+ indicator: 'required'
+ } );
+ this.feedbackMessageInput = new OO.ui.MultilineTextInputWidget( {
+ autosize: true
+ } );
+ feedbackSubjectFieldLayout = new OO.ui.FieldLayout( this.feedbackSubjectInput, {
+ label: mw.msg( 'feedback-subject' )
+ } );
+ feedbackMessageFieldLayout = new OO.ui.FieldLayout( this.feedbackMessageInput, {
+ label: mw.msg( 'feedback-message' )
+ } );
+ feedbackFieldsetLayout = new OO.ui.FieldsetLayout( {
+ items: [ feedbackSubjectFieldLayout, feedbackMessageFieldLayout ],
+ classes: [ 'mw-feedbackDialog-feedback-form' ]
+ } );
+
+ // Useragent terms of use
+ this.useragentCheckbox = new OO.ui.CheckboxInputWidget();
+ this.useragentFieldLayout = new OO.ui.FieldLayout( this.useragentCheckbox, {
+ classes: [ 'mw-feedbackDialog-feedback-terms' ],
+ align: 'inline'
+ } );
+
+ termsOfUseLabel = new OO.ui.LabelWidget( {
+ classes: [ 'mw-feedbackDialog-feedback-termsofuse' ],
+ label: $( '<p>' ).append( mw.msg( 'feedback-termsofuse' ) )
+ } );
+
+ this.feedbackPanel.$element.append(
+ this.feedbackMessageLabel.$element,
+ feedbackFieldsetLayout.$element,
+ this.useragentFieldLayout.$element,
+ termsOfUseLabel.$element
+ );
+
+ // Events
+ this.feedbackSubjectInput.connect( this, { change: 'validateFeedbackForm' } );
+ this.feedbackMessageInput.connect( this, { change: 'validateFeedbackForm' } );
+ this.feedbackMessageInput.connect( this, { change: 'updateSize' } );
+ this.useragentCheckbox.connect( this, { change: 'validateFeedbackForm' } );
+
+ this.$body.append( this.feedbackPanel.$element );
+ };
+
+ /**
+ * Validate the feedback form
+ */
+ mw.Feedback.Dialog.prototype.validateFeedbackForm = function () {
+ var isValid = (
+ (
+ !this.useragentMandatory ||
+ this.useragentCheckbox.isSelected()
+ ) &&
+ this.feedbackSubjectInput.getValue()
+ );
+
+ this.actions.setAbilities( { submit: isValid } );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.Feedback.Dialog.prototype.getBodyHeight = function () {
+ return this.feedbackPanel.$element.outerHeight( true );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.Feedback.Dialog.prototype.getSetupProcess = function ( data ) {
+ return mw.Feedback.Dialog.parent.prototype.getSetupProcess.call( this, data )
+ .next( function () {
+ // Get the URL of the target page, we want to use that in links in the intro
+ // and in the success dialog
+ var dialog = this;
+ if ( data.foreignApi ) {
+ return data.foreignApi.get( {
+ action: 'query',
+ prop: 'info',
+ inprop: 'url',
+ formatversion: 2,
+ titles: data.settings.title.getPrefixedText()
+ } ).then( function ( data ) {
+ dialog.feedbackPageUrl = OO.getProp( data, 'query', 'pages', 0, 'canonicalurl' );
+ } );
+ } else {
+ this.feedbackPageUrl = data.settings.title.getUrl();
+ }
+ }, this )
+ .next( function () {
+ var plainMsg, parsedMsg,
+ settings = data.settings;
+ data.contents = data.contents || {};
+
+ // Prefill subject/message
+ this.feedbackSubjectInput.setValue( data.contents.subject );
+ this.feedbackMessageInput.setValue( data.contents.message );
+
+ this.status = '';
+ this.messagePosterPromise = settings.messagePosterPromise;
+ this.setBugReportLink( settings.bugsTaskSubmissionLink );
+ this.feedbackPageTitle = settings.title;
+ this.feedbackPageName = settings.title.getNameText();
+
+ // Useragent checkbox
+ if ( settings.useragentCheckbox.show ) {
+ this.useragentFieldLayout.setLabel( settings.useragentCheckbox.message );
+ }
+
+ this.useragentMandatory = settings.useragentCheckbox.mandatory;
+ this.useragentFieldLayout.toggle( settings.useragentCheckbox.show );
+
+ // HACK: Setting a link in the messages doesn't work. There is already a report
+ // about this, and the bug report offers a somewhat hacky work around that
+ // includes setting a separate message to be parsed.
+ // We want to make sure the user can configure both the title of the page and
+ // a separate url, so this must be allowed to parse correctly.
+ // See https://phabricator.wikimedia.org/T49395#490610
+ mw.messages.set( {
+ 'feedback-dialog-temporary-message':
+ '<a href="' + this.feedbackPageUrl + '" target="_blank">' + this.feedbackPageName + '</a>'
+ } );
+ plainMsg = mw.message( 'feedback-dialog-temporary-message' ).plain();
+ mw.messages.set( { 'feedback-dialog-temporary-message-parsed': plainMsg } );
+ parsedMsg = mw.message( 'feedback-dialog-temporary-message-parsed' );
+ this.feedbackMessageLabel.setLabel(
+ // Double-parse
+ $( '<span>' )
+ .append( mw.message( 'feedback-dialog-intro', parsedMsg ).parse() )
+ );
+
+ this.validateFeedbackForm();
+ }, this );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.Feedback.Dialog.prototype.getReadyProcess = function ( data ) {
+ return mw.Feedback.Dialog.parent.prototype.getReadyProcess.call( this, data )
+ .next( function () {
+ this.feedbackSubjectInput.focus();
+ }, this );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.Feedback.Dialog.prototype.getActionProcess = function ( action ) {
+ if ( action === 'cancel' ) {
+ return new OO.ui.Process( function () {
+ this.close( { action: action } );
+ }, this );
+ } else if ( action === 'external' ) {
+ return new OO.ui.Process( function () {
+ // Open in a new window
+ window.open( this.getBugReportLink(), '_blank' );
+ // Close the dialog
+ this.close();
+ }, this );
+ } else if ( action === 'submit' ) {
+ return new OO.ui.Process( function () {
+ var fb = this,
+ userAgentMessage = ':' +
+ '<small>' +
+ mw.msg( 'feedback-useragent' ) +
+ ' ' +
+ mw.html.escape( navigator.userAgent ) +
+ '</small>\n\n',
+ subject = this.feedbackSubjectInput.getValue(),
+ message = this.feedbackMessageInput.getValue();
+
+ // Add user agent if checkbox is selected
+ if ( this.useragentCheckbox.isSelected() ) {
+ message = userAgentMessage + message;
+ }
+
+ // Post the message
+ return this.messagePosterPromise.then( function ( poster ) {
+ return fb.postMessage( poster, subject, message );
+ }, function () {
+ fb.status = 'error4';
+ mw.log.warn( 'Feedback report failed because MessagePoster could not be fetched' );
+ } ).then( function () {
+ fb.close();
+ }, function () {
+ return fb.getErrorMessage();
+ } );
+ }, this );
+ }
+ // Fallback to parent handler
+ return mw.Feedback.Dialog.parent.prototype.getActionProcess.call( this, action );
+ };
+
+ /**
+ * Returns an error message for the current status.
+ *
+ * @private
+ *
+ * @return {OO.ui.Error}
+ */
+ mw.Feedback.Dialog.prototype.getErrorMessage = function () {
+ // Messages: feedback-error1, feedback-error2, feedback-error3, feedback-error4
+ return new OO.ui.Error( mw.msg( 'feedback-' + this.status ) );
+ };
+
+ /**
+ * Posts the message
+ *
+ * @private
+ *
+ * @param {mw.messagePoster.MessagePoster} poster Poster implementation used to leave feedback
+ * @param {string} subject Subject of message
+ * @param {string} message Body of message
+ * @return {jQuery.Promise} Promise representing success of message posting action
+ */
+ mw.Feedback.Dialog.prototype.postMessage = function ( poster, subject, message ) {
+ var fb = this;
+
+ return poster.post(
+ subject,
+ message
+ ).then( function () {
+ fb.status = 'submitted';
+ }, function ( mainCode, secondaryCode, details ) {
+ if ( mainCode === 'api-fail' ) {
+ if ( secondaryCode === 'http' ) {
+ fb.status = 'error3';
+ // ajax request failed
+ mw.log.warn( 'Feedback report failed with HTTP error: ' + details.textStatus );
+ } else {
+ fb.status = 'error2';
+ mw.log.warn( 'Feedback report failed with API error: ' + secondaryCode );
+ }
+ } else {
+ fb.status = 'error1';
+ }
+ } );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.Feedback.Dialog.prototype.getTeardownProcess = function ( data ) {
+ return mw.Feedback.Dialog.parent.prototype.getTeardownProcess.call( this, data )
+ .first( function () {
+ this.emit( 'submit', this.status, this.feedbackPageName, this.feedbackPageUrl );
+ // Cleanup
+ this.status = '';
+ this.feedbackPageTitle = null;
+ this.feedbackSubjectInput.setValue( '' );
+ this.feedbackMessageInput.setValue( '' );
+ this.useragentCheckbox.setSelected( false );
+ }, this );
+ };
+
+ /**
+ * Set the bug report link
+ *
+ * @param {string} link Link to the external bug report form
+ */
+ mw.Feedback.Dialog.prototype.setBugReportLink = function ( link ) {
+ this.bugReportLink = link;
+ };
+
+ /**
+ * Get the bug report link
+ *
+ * @return {string} Link to the external bug report form
+ */
+ mw.Feedback.Dialog.prototype.getBugReportLink = function () {
+ return this.bugReportLink;
+ };
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+/* Styles for links to RSS/Atom feeds in sidebar */
+
+a.feedlink {
+ /* SVG support using a transparent gradient to guarantee cross-browser
+ * compatibility (browsers able to understand gradient syntax support also SVG).
+ * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique */
+ background-image: url( images/feed-icon.png );
+ /* @embed */
+ background-image: linear-gradient( transparent, transparent ), url( images/feed-icon.svg );
+ background-position: center left;
+ background-repeat: no-repeat;
+ background-size: 12px 12px;
+ padding-left: 16px;
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 256 256">
+ <defs>
+ <linearGradient id="a" x1=".085" x2=".915" y1=".085" y2=".915">
+ <stop offset="0" stop-color="#e3702d"/>
+ <stop offset=".107" stop-color="#ea7d31"/>
+ <stop offset=".35" stop-color="#f69537"/>
+ <stop offset=".5" stop-color="#fb9e3a"/>
+ <stop offset=".702" stop-color="#ea7c31"/>
+ <stop offset=".887" stop-color="#de642b"/>
+ <stop offset="1" stop-color="#d95b29"/>
+ </linearGradient>
+ </defs>
+ <rect width="256" height="256" fill="#cc5d15" rx="55" ry="55"/>
+ <rect width="246" height="246" x="5" y="5" fill="#f49c52" rx="50" ry="50"/>
+ <rect width="236" height="236" x="10" y="10" fill="url(#a)" rx="47" ry="47"/>
+ <circle cx="68" cy="189" r="24" fill="#fff"/>
+ <path fill="#fff" d="M160 213h-34a82 82 0 0 0-82-82v-34a116 116 0 0 1 116 116zM184 213a140 140 0 0 0-140-140v-35a175 175 0 0 1 175 175z"/>
+</svg>
--- /dev/null
+/*!
+ * mediawiki.filewarning
+ *
+ * @author Mark Holmquist, 2015
+ * @since 1.25
+ */
+( function ( mw, $, oo ) {
+ var warningConfig = mw.config.get( 'wgFileWarning' ),
+ warningMessages = warningConfig.messages,
+ warningLink = warningConfig.link,
+ $origMimetype = $( '.fullMedia .fileInfo .mime-type' ),
+ $mimetype = $origMimetype.clone(),
+ $header = $( '<h3>' )
+ .addClass( 'mediawiki-filewarning-header empty' ),
+ $main = $( '<p>' )
+ .addClass( 'mediawiki-filewarning-main empty' ),
+ $info = $( '<a>' )
+ .addClass( 'mediawiki-filewarning-info empty' ),
+ $footer = $( '<p>' )
+ .addClass( 'mediawiki-filewarning-footer empty' ),
+ dialog = new oo.ui.PopupButtonWidget( {
+ classes: [ 'mediawiki-filewarning-anchor' ],
+ label: $mimetype,
+ flags: [ 'warning' ],
+ icon: 'alert',
+ framed: false,
+ popup: {
+ classes: [ 'mediawiki-filewarning' ],
+ padded: true,
+ width: 400,
+ $content: $header.add( $main ).add( $info ).add( $footer )
+ }
+ } );
+
+ function loadMessage( $target, message ) {
+ if ( message ) {
+ $target.removeClass( 'empty' )
+ .text( mw.message( message ).text() );
+ }
+ }
+
+ // The main message must be populated for the dialog to show.
+ if ( warningConfig && warningConfig.messages && warningConfig.messages.main ) {
+ $mimetype.addClass( 'has-warning' );
+
+ $origMimetype.replaceWith( dialog.$element );
+
+ if ( warningMessages ) {
+ loadMessage( $main, warningMessages.main );
+ loadMessage( $header, warningMessages.header );
+ loadMessage( $footer, warningMessages.footer );
+
+ if ( warningLink ) {
+ loadMessage( $info, warningMessages.info );
+ $info.attr( 'href', warningLink );
+ }
+ }
+
+ // Make OOUI open the dialog, it won't appear until the user
+ // hovers over the warning.
+ dialog.getPopup().toggle( true );
+
+ // Override toggle handler because we don't need it for this popup
+ // object at all. Sort of nasty, but it gets the job done.
+ dialog.getPopup().toggle = $.noop;
+ }
+}( mediaWiki, jQuery, OO ) );
--- /dev/null
+@import 'mediawiki.ui/variables';
+
+// Increase the area of the button, so that the user can move the mouse cursor
+// to the popup without the popup disappearing. (T157544)
+.mediawiki-filewarning-anchor {
+ padding-bottom: 10px;
+ margin-bottom: -10px;
+}
+
+.mediawiki-filewarning {
+ visibility: hidden;
+
+ .mediawiki-filewarning-header {
+ padding: 0;
+ font-weight: 600;
+ }
+
+ .mediawiki-filewarning-footer {
+ color: #72777d;
+ }
+
+ .empty {
+ display: none;
+ }
+
+ .mediawiki-filewarning-anchor:hover & {
+ visibility: visible;
+ }
+}
+
+.mime-type {
+ &.has-warning {
+ font-weight: bold;
+ color: @colorMediumSevere;
+ }
+}
--- /dev/null
+@import 'mediawiki.mixins';
+
+#mw-indicator-mw-helplink a {
+ .background-image-svg('images/help.svg', 'images/help.png');
+ background-repeat: no-repeat;
+ background-position: left center;
+ padding-left: 28px;
+ display: inline-block;
+ height: 24px;
+ line-height: 24px;
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <path d="M12.001 2.085c-5.478 0-9.916 4.438-9.916 9.916 0 5.476 4.438 9.914 9.916 9.914 5.476 0 9.914-4.438 9.914-9.914 0-5.478-4.438-9.916-9.914-9.916zm.001 18c-4.465 0-8.084-3.619-8.084-8.083 0-4.465 3.619-8.084 8.084-8.084 4.464 0 8.083 3.619 8.083 8.084 0 4.464-3.619 8.083-8.083 8.083z"/>
+ <path d="M11.766 6.688c-2.5 0-3.219 2.188-3.219 2.188l1.411.854s.298-.791.901-1.229c.516-.375 1.625-.625 2.219.125.701.885-.17 1.587-1.078 2.719-.953 1.186-1 3.655-1 3.655h1.969s.135-2.318 1.041-3.381c.603-.707 1.443-1.338 1.443-2.494s-1.187-2.437-3.687-2.437zM11 16h2v2h-2z"/>
+</svg>
+++ /dev/null
-/*!
- * Skip function for mediawiki.hdpi.js.
- */
-return 'srcset' in new Image();
--- /dev/null
+jQuery( function ( $ ) {
+ // Apply hidpi images on DOM-ready
+ // Some may have already partly preloaded at low resolution.
+ $( 'body' ).hidpi();
+} );
--- /dev/null
+/*!
+ * Skip function for mediawiki.hdpi.js.
+ */
+return 'srcset' in new Image();
--- /dev/null
+/*!
+ * Stylesheet for mediawiki.hlist module
+ * @author [[User:Edokter]]
+ */
+/* Generate interpuncts */
+.hlist dt:after {
+ content: ':';
+}
+.hlist dd:after,
+.hlist li:after {
+ content: ' ·';
+ font-weight: bold;
+}
+.hlist dd:last-child:after,
+.hlist dt:last-child:after,
+.hlist li:last-child:after {
+ content: none;
+}
+/* For IE8 */
+.hlist dd.hlist-last-child:after,
+.hlist dt.hlist-last-child:after,
+.hlist li.hlist-last-child:after {
+ content: none;
+}
+/* Add parentheses around nested lists */
+.hlist dd dd:first-child:before,
+.hlist dd dt:first-child:before,
+.hlist dd li:first-child:before,
+.hlist dt dd:first-child:before,
+.hlist dt dt:first-child:before,
+.hlist dt li:first-child:before,
+.hlist li dd:first-child:before,
+.hlist li dt:first-child:before,
+.hlist li li:first-child:before {
+ content: '(';
+ font-weight: normal;
+}
+.hlist dd dd:last-child:after,
+.hlist dd dt:last-child:after,
+.hlist dd li:last-child:after,
+.hlist dt dd:last-child:after,
+.hlist dt dt:last-child:after,
+.hlist dt li:last-child:after,
+.hlist li dd:last-child:after,
+.hlist li dt:last-child:after,
+.hlist li li:last-child:after {
+ content: ')';
+ font-weight: normal;
+}
+/* For IE8 */
+.hlist dd dd.hlist-last-child:after,
+.hlist dd dt.hlist-last-child:after,
+.hlist dd li.hlist-last-child:after,
+.hlist dt dd.hlist-last-child:after,
+.hlist dt dt.hlist-last-child:after,
+.hlist dt li.hlist-last-child:after,
+.hlist li dd.hlist-last-child:after,
+.hlist li dt.hlist-last-child:after,
+.hlist li li.hlist-last-child:after {
+ content: ')';
+ font-weight: normal;
+}
+/* Put ordinals in front of ordered list items */
+.hlist ol {
+ counter-reset: list-item;
+}
+.hlist ol > li {
+ counter-increment: list-item;
+}
+.hlist ol > li:before {
+ content: counter( list-item ) ' ';
+}
+.hlist dd ol > li:first-child:before,
+.hlist dt ol > li:first-child:before,
+.hlist li ol > li:first-child:before {
+ content: '(' counter( list-item ) ' ';
+}
+
+/* Support hlist styles inside *boxes */
+.errorbox .hlist,
+.successbox .hlist,
+.warningbox .hlist {
+ margin-left: 0;
+}
+
+.errorbox .hlist li:after,
+.successbox .hlist li:after,
+.warningbox .hlist li:after {
+ margin-right: 3px;
+}
--- /dev/null
+.hlist {
+ dl,
+ ol,
+ ul {
+ margin: 0;
+ padding: 0;
+
+ dl,
+ ol,
+ ul {
+ display: inline;
+ }
+ }
+
+ dd,
+ dt,
+ li {
+ margin: 0;
+ display: inline;
+ }
+}
--- /dev/null
+/* General-purpose icons via CSS. Classes here should be named "mw-icon-*". */
+
+@import 'mediawiki.mixins';
+
+/* For the collapsed and expanded arrows, we also provide selectors to make it
+ * easy to use them with jquery.makeCollapsible. */
+.mw-icon-arrow-collapsed,
+.mw-collapsible-arrow.mw-collapsible-toggle-collapsed {
+ .background-image-svg('images/arrow-collapsed-ltr.svg', 'images/arrow-collapsed-ltr.png');
+ background-repeat: no-repeat;
+ background-position: left bottom;
+}
+
+.mw-icon-arrow-expanded,
+.mw-collapsible-arrow.mw-collapsible-toggle-expanded {
+ .background-image-svg('images/arrow-expanded.svg', 'images/arrow-expanded.png');
+ background-repeat: no-repeat;
+ background-position: left bottom;
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
+ <path fill="#72777d" d="M4 1.533v9.671l4.752-4.871z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
+ <path fill="#72777d" d="M8 1.533v9.671l-4.752-4.871z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
+ <path fill="#72777d" d="M1.165 3.624h9.671l-4.871 4.752z"/>
+</svg>
--- /dev/null
+/*!
+* Experimental advanced wikitext parser-emitter.
+* See: https://www.mediawiki.org/wiki/Extension:UploadWizard/MessageParser for docs
+*
+* @author neilk@wikimedia.org
+* @author mflaschen@wikimedia.org
+*/
+( function ( mw, $ ) {
+ /**
+ * @class mw.jqueryMsg
+ * @singleton
+ */
+
+ var oldParser,
+ slice = Array.prototype.slice,
+ parserDefaults = {
+ magic: {
+ PAGENAME: mw.config.get( 'wgPageName' ),
+ PAGENAMEE: mw.util.wikiUrlencode( mw.config.get( 'wgPageName' ) )
+ },
+ // Whitelist for allowed HTML elements in wikitext.
+ // Self-closing tags are not currently supported.
+ // Can be populated via setPrivateData().
+ allowedHtmlElements: [],
+ // Key tag name, value allowed attributes for that tag.
+ // See Sanitizer::setupAttributeWhitelist
+ allowedHtmlCommonAttributes: [
+ // HTML
+ 'id',
+ 'class',
+ 'style',
+ 'lang',
+ 'dir',
+ 'title',
+
+ // WAI-ARIA
+ 'role'
+ ],
+
+ // Attributes allowed for specific elements.
+ // Key is element name in lower case
+ // Value is array of allowed attributes for that element
+ allowedHtmlAttributesByElement: {},
+ messages: mw.messages,
+ language: mw.language,
+
+ // Same meaning as in mediawiki.js.
+ //
+ // Only 'text', 'parse', and 'escaped' are supported, and the
+ // actual escaping for 'escaped' is done by other code (generally
+ // through mediawiki.js).
+ //
+ // However, note that this default only
+ // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
+ // is 'text', including when it uses jqueryMsg.
+ format: 'parse'
+ };
+
+ /**
+ * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
+ * convert what it detects as an htmlString to an element.
+ *
+ * If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to
+ * new parent.
+ *
+ * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
+ *
+ * @private
+ * @param {jQuery} $parent Parent node wrapped by jQuery
+ * @param {Object|string|Array} children What to append, with the same possible types as jQuery
+ * @return {jQuery} $parent
+ */
+ function appendWithoutParsing( $parent, children ) {
+ var i, len;
+
+ if ( !Array.isArray( children ) ) {
+ children = [ children ];
+ }
+
+ for ( i = 0, len = children.length; i < len; i++ ) {
+ if ( typeof children[ i ] !== 'object' ) {
+ children[ i ] = document.createTextNode( children[ i ] );
+ }
+ if ( children[ i ] instanceof jQuery && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
+ children[ i ] = children[ i ].contents();
+ }
+ }
+
+ return $parent.append( children );
+ }
+
+ /**
+ * Decodes the main HTML entities, those encoded by mw.html.escape.
+ *
+ * @private
+ * @param {string} encoded Encoded string
+ * @return {string} String with those entities decoded
+ */
+ function decodePrimaryHtmlEntities( encoded ) {
+ return encoded
+ .replace( /'/g, '\'' )
+ .replace( /"/g, '"' )
+ .replace( /</g, '<' )
+ .replace( />/g, '>' )
+ .replace( /&/g, '&' );
+ }
+
+ /**
+ * Turn input into a string.
+ *
+ * @private
+ * @param {string|jQuery} input
+ * @return {string} Textual value of input
+ */
+ function textify( input ) {
+ if ( input instanceof jQuery ) {
+ input = input.text();
+ }
+ return String( input );
+ }
+
+ /**
+ * Given parser options, return a function that parses a key and replacements, returning jQuery object
+ *
+ * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
+ * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
+ * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
+ *
+ * @private
+ * @param {Object} options Parser options
+ * @return {Function}
+ * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
+ * @return {jQuery} return.return
+ */
+ function getFailableParserFn( options ) {
+ return function ( args ) {
+ var fallback,
+ parser = new mw.jqueryMsg.Parser( options ),
+ key = args[ 0 ],
+ argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 );
+ try {
+ return parser.parse( key, argsArray );
+ } catch ( e ) {
+ fallback = parser.settings.messages.get( key );
+ mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
+ mw.track( 'mediawiki.jqueryMsg.error', {
+ messageKey: key,
+ errorMessage: e.message
+ } );
+ return $( '<span>' ).text( fallback );
+ }
+ };
+ }
+
+ mw.jqueryMsg = {};
+
+ /**
+ * Initialize parser defaults.
+ *
+ * ResourceLoaderJqueryMsgModule calls this to provide default values from
+ * Sanitizer.php for allowed HTML elements. To override this data for individual
+ * parsers, pass the relevant options to mw.jqueryMsg.Parser.
+ *
+ * @private
+ * @param {Object} data New data to extend parser defaults with
+ * @param {boolean} [deep=false] Whether the extend is done recursively (deep)
+ */
+ mw.jqueryMsg.setParserDefaults = function ( data, deep ) {
+ if ( deep ) {
+ $.extend( true, parserDefaults, data );
+ } else {
+ $.extend( parserDefaults, data );
+ }
+ };
+
+ /**
+ * Get current parser defaults.
+ *
+ * Primarily used for the unit test. Returns a copy.
+ *
+ * @private
+ * @return {Object}
+ */
+ mw.jqueryMsg.getParserDefaults = function () {
+ return $.extend( {}, parserDefaults );
+ };
+
+ /**
+ * Returns a function suitable for static use, to construct strings from a message key (and optional replacements).
+ *
+ * Example:
+ *
+ * var format = mediaWiki.jqueryMsg.getMessageFunction( options );
+ * $( '#example' ).text( format( 'hello-user', username ) );
+ *
+ * Tthis returns only strings, so it destroys any bindings. If you want to preserve bindings, use the
+ * jQuery plugin version instead. This was originally created to ease migration from `window.gM()`,
+ * from a time when the parser used by `mw.message` was not extendable.
+ *
+ * N.B. replacements are variadic arguments or an array in second parameter. In other words:
+ * somefunction( a, b, c, d )
+ * is equivalent to
+ * somefunction( a, [b, c, d] )
+ *
+ * @param {Object} options parser options
+ * @return {Function} Function The message formatter
+ * @return {string} return.key Message key.
+ * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
+ * @return {string} return.return Rendered HTML.
+ */
+ mw.jqueryMsg.getMessageFunction = function ( options ) {
+ var failableParserFn, format;
+
+ if ( options && options.format !== undefined ) {
+ format = options.format;
+ } else {
+ format = parserDefaults.format;
+ }
+
+ return function () {
+ var failableResult;
+ if ( !failableParserFn ) {
+ failableParserFn = getFailableParserFn( options );
+ }
+ failableResult = failableParserFn( arguments );
+ if ( format === 'text' || format === 'escaped' ) {
+ return failableResult.text();
+ } else {
+ return failableResult.html();
+ }
+ };
+ };
+
+ /**
+ * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
+ * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
+ * e.g.
+ *
+ * $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options );
+ * var userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
+ * $( 'p#headline' ).msg( 'hello-user', userlink );
+ *
+ * N.B. replacements are variadic arguments or an array in second parameter. In other words:
+ * somefunction( a, b, c, d )
+ * is equivalent to
+ * somefunction( a, [b, c, d] )
+ *
+ * We append to 'this', which in a jQuery plugin context will be the selected elements.
+ *
+ * @param {Object} options Parser options
+ * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
+ * @return {string} return.key Message key.
+ * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
+ * @return {jQuery} return.return
+ */
+ mw.jqueryMsg.getPlugin = function ( options ) {
+ var failableParserFn;
+
+ return function () {
+ var $target;
+ if ( !failableParserFn ) {
+ failableParserFn = getFailableParserFn( options );
+ }
+ $target = this.empty();
+ appendWithoutParsing( $target, failableParserFn( arguments ) );
+ return $target;
+ };
+ };
+
+ /**
+ * The parser itself.
+ * Describes an object, whose primary duty is to .parse() message keys.
+ *
+ * @class
+ * @private
+ * @param {Object} options
+ */
+ mw.jqueryMsg.Parser = function ( options ) {
+ this.settings = $.extend( {}, parserDefaults, options );
+ this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
+ this.astCache = {};
+
+ this.emitter = new mw.jqueryMsg.HtmlEmitter( this.settings.language, this.settings.magic );
+ };
+ // Backwards-compatible alias
+ // @deprecated since 1.31
+ mw.jqueryMsg.parser = mw.jqueryMsg.Parser;
+
+ mw.jqueryMsg.Parser.prototype = {
+ /**
+ * Where the magic happens.
+ * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
+ * If an error is thrown, returns original key, and logs the error
+ *
+ * @param {string} key Message key.
+ * @param {Array} replacements Variable replacements for $1, $2... $n
+ * @return {jQuery}
+ */
+ parse: function ( key, replacements ) {
+ var ast = this.getAst( key );
+ return this.emitter.emit( ast, replacements );
+ },
+
+ /**
+ * Fetch the message string associated with a key, return parsed structure. Memoized.
+ * Note that we pass '⧼' + key + '⧽' back for a missing message here.
+ *
+ * @param {string} key
+ * @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing
+ */
+ getAst: function ( key ) {
+ var wikiText;
+
+ if ( !this.astCache.hasOwnProperty( key ) ) {
+ wikiText = this.settings.messages.get( key );
+ if ( typeof wikiText !== 'string' ) {
+ wikiText = '⧼' + key + '⧽';
+ }
+ this.astCache[ key ] = this.wikiTextToAst( wikiText );
+ }
+ return this.astCache[ key ];
+ },
+
+ /**
+ * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
+ *
+ * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
+ * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
+ *
+ * @param {string} input Message string wikitext
+ * @throws Error
+ * @return {Mixed} abstract syntax tree
+ */
+ wikiTextToAst: function ( input ) {
+ var pos,
+ regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
+ doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
+ escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
+ whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
+ htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
+ openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
+ templateContents, openTemplate, closeTemplate,
+ nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result,
+ settings = this.settings,
+ concat = Array.prototype.concat;
+
+ // Indicates current position in input as we parse through it.
+ // Shared among all parsing functions below.
+ pos = 0;
+
+ // =========================================================
+ // parsing combinators - could be a library on its own
+ // =========================================================
+
+ /**
+ * Try parsers until one works, if none work return null
+ *
+ * @private
+ * @param {Function[]} ps
+ * @return {string|null}
+ */
+ function choice( ps ) {
+ return function () {
+ var i, result;
+ for ( i = 0; i < ps.length; i++ ) {
+ result = ps[ i ]();
+ if ( result !== null ) {
+ return result;
+ }
+ }
+ return null;
+ };
+ }
+
+ /**
+ * Try several ps in a row, all must succeed or return null.
+ * This is the only eager one.
+ *
+ * @private
+ * @param {Function[]} ps
+ * @return {string|null}
+ */
+ function sequence( ps ) {
+ var i, res,
+ originalPos = pos,
+ result = [];
+ for ( i = 0; i < ps.length; i++ ) {
+ res = ps[ i ]();
+ if ( res === null ) {
+ pos = originalPos;
+ return null;
+ }
+ result.push( res );
+ }
+ return result;
+ }
+
+ /**
+ * Run the same parser over and over until it fails.
+ * Must succeed a minimum of n times or return null.
+ *
+ * @private
+ * @param {number} n
+ * @param {Function} p
+ * @return {string|null}
+ */
+ function nOrMore( n, p ) {
+ return function () {
+ var originalPos = pos,
+ result = [],
+ parsed = p();
+ while ( parsed !== null ) {
+ result.push( parsed );
+ parsed = p();
+ }
+ if ( result.length < n ) {
+ pos = originalPos;
+ return null;
+ }
+ return result;
+ };
+ }
+
+ /**
+ * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
+ *
+ * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
+ * May be some scoping issue
+ *
+ * @private
+ * @param {Function} p
+ * @param {Function} fn
+ * @return {string|null}
+ */
+ function transform( p, fn ) {
+ return function () {
+ var result = p();
+ return result === null ? null : fn( result );
+ };
+ }
+
+ /**
+ * Just make parsers out of simpler JS builtin types
+ *
+ * @private
+ * @param {string} s
+ * @return {Function}
+ * @return {string} return.return
+ */
+ function makeStringParser( s ) {
+ var len = s.length;
+ return function () {
+ var result = null;
+ if ( input.substr( pos, len ) === s ) {
+ result = s;
+ pos += len;
+ }
+ return result;
+ };
+ }
+
+ /**
+ * Makes a regex parser, given a RegExp object.
+ * The regex being passed in should start with a ^ to anchor it to the start
+ * of the string.
+ *
+ * @private
+ * @param {RegExp} regex anchored regex
+ * @return {Function} function to parse input based on the regex
+ */
+ function makeRegexParser( regex ) {
+ return function () {
+ var matches = input.slice( pos ).match( regex );
+ if ( matches === null ) {
+ return null;
+ }
+ pos += matches[ 0 ].length;
+ return matches[ 0 ];
+ };
+ }
+
+ // ===================================================================
+ // General patterns above this line -- wikitext specific parsers below
+ // ===================================================================
+
+ // Parsing functions follow. All parsing functions work like this:
+ // They don't accept any arguments.
+ // Instead, they just operate non destructively on the string 'input'
+ // As they can consume parts of the string, they advance the shared variable pos,
+ // and return tokens (or whatever else they want to return).
+ // some things are defined as closures and other things as ordinary functions
+ // converting everything to a closure makes it a lot harder to debug... errors pop up
+ // but some debuggers can't tell you exactly where they come from. Also the mutually
+ // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
+ // This may be because, to save code, memoization was removed
+
+ /* eslint-disable no-useless-escape */
+ regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
+ regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
+ regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
+ regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
+ /* eslint-enable no-useless-escape */
+
+ backslash = makeStringParser( '\\' );
+ doubleQuote = makeStringParser( '"' );
+ singleQuote = makeStringParser( '\'' );
+ anyCharacter = makeRegexParser( /^./ );
+
+ openHtmlStartTag = makeStringParser( '<' );
+ optionalForwardSlash = makeRegexParser( /^\/?/ );
+ openHtmlEndTag = makeStringParser( '</' );
+ htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
+ closeHtmlTag = makeRegexParser( /^\s*>/ );
+
+ function escapedLiteral() {
+ var result = sequence( [
+ backslash,
+ anyCharacter
+ ] );
+ return result === null ? null : result[ 1 ];
+ }
+ escapedOrLiteralWithoutSpace = choice( [
+ escapedLiteral,
+ regularLiteralWithoutSpace
+ ] );
+ escapedOrLiteralWithoutBar = choice( [
+ escapedLiteral,
+ regularLiteralWithoutBar
+ ] );
+ escapedOrRegularLiteral = choice( [
+ escapedLiteral,
+ regularLiteral
+ ] );
+ // Used to define "literals" without spaces, in space-delimited situations
+ function literalWithoutSpace() {
+ var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
+ return result === null ? null : result.join( '' );
+ }
+ // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
+ // it is not a literal in the parameter
+ function literalWithoutBar() {
+ var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
+ return result === null ? null : result.join( '' );
+ }
+
+ function literal() {
+ var result = nOrMore( 1, escapedOrRegularLiteral )();
+ return result === null ? null : result.join( '' );
+ }
+
+ function curlyBraceTransformExpressionLiteral() {
+ var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
+ return result === null ? null : result.join( '' );
+ }
+
+ asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
+ htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
+ htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
+
+ whitespace = makeRegexParser( /^\s+/ );
+ dollar = makeStringParser( '$' );
+ digits = makeRegexParser( /^\d+/ );
+
+ function replacement() {
+ var result = sequence( [
+ dollar,
+ digits
+ ] );
+ if ( result === null ) {
+ return null;
+ }
+ return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
+ }
+ openExtlink = makeStringParser( '[' );
+ closeExtlink = makeStringParser( ']' );
+ // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
+ function extlink() {
+ var result, parsedResult, target;
+ result = null;
+ parsedResult = sequence( [
+ openExtlink,
+ nOrMore( 1, nonWhitespaceExpression ),
+ whitespace,
+ nOrMore( 1, expression ),
+ closeExtlink
+ ] );
+ if ( parsedResult !== null ) {
+ // When the entire link target is a single parameter, we can't use CONCAT, as we allow
+ // passing fancy parameters (like a whole jQuery object or a function) to use for the
+ // link. Check only if it's a single match, since we can either do CONCAT or not for
+ // singles with the same effect.
+ target = parsedResult[ 1 ].length === 1 ?
+ parsedResult[ 1 ][ 0 ] :
+ [ 'CONCAT' ].concat( parsedResult[ 1 ] );
+ result = [
+ 'EXTLINK',
+ target,
+ [ 'CONCAT' ].concat( parsedResult[ 3 ] )
+ ];
+ }
+ return result;
+ }
+ openWikilink = makeStringParser( '[[' );
+ closeWikilink = makeStringParser( ']]' );
+ pipe = makeStringParser( '|' );
+
+ function template() {
+ var result = sequence( [
+ openTemplate,
+ templateContents,
+ closeTemplate
+ ] );
+ return result === null ? null : result[ 1 ];
+ }
+
+ function pipedWikilink() {
+ var result = sequence( [
+ nOrMore( 1, paramExpression ),
+ pipe,
+ nOrMore( 1, expression )
+ ] );
+ return result === null ? null : [
+ [ 'CONCAT' ].concat( result[ 0 ] ),
+ [ 'CONCAT' ].concat( result[ 2 ] )
+ ];
+ }
+
+ function unpipedWikilink() {
+ var result = sequence( [
+ nOrMore( 1, paramExpression )
+ ] );
+ return result === null ? null : [
+ [ 'CONCAT' ].concat( result[ 0 ] )
+ ];
+ }
+
+ wikilinkContents = choice( [
+ pipedWikilink,
+ unpipedWikilink
+ ] );
+
+ function wikilink() {
+ var result, parsedResult, parsedLinkContents;
+ result = null;
+
+ parsedResult = sequence( [
+ openWikilink,
+ wikilinkContents,
+ closeWikilink
+ ] );
+ if ( parsedResult !== null ) {
+ parsedLinkContents = parsedResult[ 1 ];
+ result = [ 'WIKILINK' ].concat( parsedLinkContents );
+ }
+ return result;
+ }
+
+ // TODO: Support data- if appropriate
+ function doubleQuotedHtmlAttributeValue() {
+ var parsedResult = sequence( [
+ doubleQuote,
+ htmlDoubleQuoteAttributeValue,
+ doubleQuote
+ ] );
+ return parsedResult === null ? null : parsedResult[ 1 ];
+ }
+
+ function singleQuotedHtmlAttributeValue() {
+ var parsedResult = sequence( [
+ singleQuote,
+ htmlSingleQuoteAttributeValue,
+ singleQuote
+ ] );
+ return parsedResult === null ? null : parsedResult[ 1 ];
+ }
+
+ function htmlAttribute() {
+ var parsedResult = sequence( [
+ whitespace,
+ asciiAlphabetLiteral,
+ htmlAttributeEquals,
+ choice( [
+ doubleQuotedHtmlAttributeValue,
+ singleQuotedHtmlAttributeValue
+ ] )
+ ] );
+ return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
+ }
+
+ /**
+ * Checks if HTML is allowed
+ *
+ * @param {string} startTagName HTML start tag name
+ * @param {string} endTagName HTML start tag name
+ * @param {Object} attributes array of consecutive key value pairs,
+ * with index 2 * n being a name and 2 * n + 1 the associated value
+ * @return {boolean} true if this is HTML is allowed, false otherwise
+ */
+ function isAllowedHtml( startTagName, endTagName, attributes ) {
+ var i, len, attributeName;
+
+ startTagName = startTagName.toLowerCase();
+ endTagName = endTagName.toLowerCase();
+ if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) {
+ return false;
+ }
+
+ for ( i = 0, len = attributes.length; i < len; i += 2 ) {
+ attributeName = attributes[ i ];
+ if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 &&
+ ( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ function htmlAttributes() {
+ var parsedResult = nOrMore( 0, htmlAttribute )();
+ // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
+ return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult );
+ }
+
+ // Subset of allowed HTML markup.
+ // Most elements and many attributes allowed on the server are not supported yet.
+ function html() {
+ var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult,
+ wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos,
+ startCloseTagPos, endOpenTagPos, endCloseTagPos,
+ result = null;
+
+ // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
+ // 1. open through closeHtmlTag
+ // 2. expression
+ // 3. openHtmlEnd through close
+ // This will allow recording the positions to reconstruct if HTML is to be treated as text.
+
+ startOpenTagPos = pos;
+ parsedOpenTagResult = sequence( [
+ openHtmlStartTag,
+ asciiAlphabetLiteral,
+ htmlAttributes,
+ optionalForwardSlash,
+ closeHtmlTag
+ ] );
+
+ if ( parsedOpenTagResult === null ) {
+ return null;
+ }
+
+ endOpenTagPos = pos;
+ startTagName = parsedOpenTagResult[ 1 ];
+
+ parsedHtmlContents = nOrMore( 0, expression )();
+
+ startCloseTagPos = pos;
+ parsedCloseTagResult = sequence( [
+ openHtmlEndTag,
+ asciiAlphabetLiteral,
+ closeHtmlTag
+ ] );
+
+ if ( parsedCloseTagResult === null ) {
+ // Closing tag failed. Return the start tag and contents.
+ return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
+ .concat( parsedHtmlContents );
+ }
+
+ endCloseTagPos = pos;
+ endTagName = parsedCloseTagResult[ 1 ];
+ wrappedAttributes = parsedOpenTagResult[ 2 ];
+ attributes = wrappedAttributes.slice( 1 );
+ if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
+ result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
+ .concat( parsedHtmlContents );
+ } else {
+ // HTML is not allowed, so contents will remain how
+ // it was, while HTML markup at this level will be
+ // treated as text
+ // E.g. assuming script tags are not allowed:
+ //
+ // <script>[[Foo|bar]]</script>
+ //
+ // results in '<script>' and '</script>'
+ // (not treated as an HTML tag), surrounding a fully
+ // parsed HTML link.
+ //
+ // Concatenate everything from the tag, flattening the contents.
+ result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
+ .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
+ }
+
+ return result;
+ }
+
+ // <nowiki>...</nowiki> tag. The tags are stripped and the contents are returned unparsed.
+ function nowiki() {
+ var parsedResult, plainText,
+ result = null;
+
+ parsedResult = sequence( [
+ makeStringParser( '<nowiki>' ),
+ // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much
+ makeRegexParser( /^.*?(?=<\/nowiki>)/ ),
+ makeStringParser( '</nowiki>' )
+ ] );
+ if ( parsedResult !== null ) {
+ plainText = parsedResult[ 1 ];
+ result = [ 'CONCAT' ].concat( plainText );
+ }
+
+ return result;
+ }
+
+ templateName = transform(
+ // see $wgLegalTitleChars
+ // not allowing : due to the need to catch "PLURAL:$1"
+ makeRegexParser( /^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ ),
+ function ( result ) { return result.toString(); }
+ );
+ function templateParam() {
+ var expr, result;
+ result = sequence( [
+ pipe,
+ nOrMore( 0, paramExpression )
+ ] );
+ if ( result === null ) {
+ return null;
+ }
+ expr = result[ 1 ];
+ // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
+ return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ];
+ }
+
+ function templateWithReplacement() {
+ var result = sequence( [
+ templateName,
+ colon,
+ replacement
+ ] );
+ return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+ }
+ function templateWithOutReplacement() {
+ var result = sequence( [
+ templateName,
+ colon,
+ paramExpression
+ ] );
+ return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+ }
+ function templateWithOutFirstParameter() {
+ var result = sequence( [
+ templateName,
+ colon
+ ] );
+ return result === null ? null : [ result[ 0 ], '' ];
+ }
+ colon = makeStringParser( ':' );
+ templateContents = choice( [
+ function () {
+ var res = sequence( [
+ // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
+ // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
+ choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
+ nOrMore( 0, templateParam )
+ ] );
+ return res === null ? null : res[ 0 ].concat( res[ 1 ] );
+ },
+ function () {
+ var res = sequence( [
+ templateName,
+ nOrMore( 0, templateParam )
+ ] );
+ if ( res === null ) {
+ return null;
+ }
+ return [ res[ 0 ] ].concat( res[ 1 ] );
+ }
+ ] );
+ openTemplate = makeStringParser( '{{' );
+ closeTemplate = makeStringParser( '}}' );
+ nonWhitespaceExpression = choice( [
+ template,
+ wikilink,
+ extlink,
+ replacement,
+ literalWithoutSpace
+ ] );
+ paramExpression = choice( [
+ template,
+ wikilink,
+ extlink,
+ replacement,
+ literalWithoutBar
+ ] );
+
+ expression = choice( [
+ template,
+ wikilink,
+ extlink,
+ replacement,
+ nowiki,
+ html,
+ literal
+ ] );
+
+ // Used when only {{-transformation is wanted, for 'text'
+ // or 'escaped' formats
+ curlyBraceTransformExpression = choice( [
+ template,
+ replacement,
+ curlyBraceTransformExpressionLiteral
+ ] );
+
+ /**
+ * Starts the parse
+ *
+ * @param {Function} rootExpression Root parse function
+ * @return {Array|null}
+ */
+ function start( rootExpression ) {
+ var result = nOrMore( 0, rootExpression )();
+ if ( result === null ) {
+ return null;
+ }
+ return [ 'CONCAT' ].concat( result );
+ }
+ // everything above this point is supposed to be stateless/static, but
+ // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
+ // finally let's do some actual work...
+
+ result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
+
+ /*
+ * For success, the p must have gotten to the end of the input
+ * and returned a non-null.
+ * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
+ */
+ if ( result === null || pos !== input.length ) {
+ throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
+ }
+ return result;
+ }
+
+ };
+
+ /**
+ * Class that primarily exists to emit HTML from parser ASTs.
+ *
+ * @private
+ * @class
+ * @param {Object} language
+ * @param {Object} magic
+ */
+ mw.jqueryMsg.HtmlEmitter = function ( language, magic ) {
+ var jmsg = this;
+ this.language = language;
+ $.each( magic, function ( key, val ) {
+ jmsg[ key.toLowerCase() ] = function () {
+ return val;
+ };
+ } );
+
+ /**
+ * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
+ * Walk entire node structure, applying replacements and template functions when appropriate
+ *
+ * @param {Mixed} node Abstract syntax tree (top node or subnode)
+ * @param {Array} replacements for $1, $2, ... $n
+ * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
+ */
+ this.emit = function ( node, replacements ) {
+ var ret, subnodes, operation,
+ jmsg = this;
+ switch ( typeof node ) {
+ case 'string':
+ case 'number':
+ ret = node;
+ break;
+ // typeof returns object for arrays
+ case 'object':
+ // node is an array of nodes
+ subnodes = $.map( node.slice( 1 ), function ( n ) {
+ return jmsg.emit( n, replacements );
+ } );
+ operation = node[ 0 ].toLowerCase();
+ if ( typeof jmsg[ operation ] === 'function' ) {
+ ret = jmsg[ operation ]( subnodes, replacements );
+ } else {
+ throw new Error( 'Unknown operation "' + operation + '"' );
+ }
+ break;
+ case 'undefined':
+ // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
+ // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
+ // The logical thing is probably to return the empty string here when we encounter undefined.
+ ret = '';
+ break;
+ default:
+ throw new Error( 'Unexpected type in AST: ' + typeof node );
+ }
+ return ret;
+ };
+ };
+
+ // For everything in input that follows double-open-curly braces, there should be an equivalent parser
+ // function. For instance {{PLURAL ... }} will be processed by 'plural'.
+ // If you have 'magic words' then configure the parser to have them upon creation.
+ //
+ // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
+ // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
+ mw.jqueryMsg.HtmlEmitter.prototype = {
+ /**
+ * Parsing has been applied depth-first we can assume that all nodes here are single nodes
+ * Must return a single node to parents -- a jQuery with synthetic span
+ * However, unwrap any other synthetic spans in our children and pass them upwards
+ *
+ * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
+ * @return {jQuery}
+ */
+ concat: function ( nodes ) {
+ var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
+ $.each( nodes, function ( i, node ) {
+ // Let jQuery append nodes, arrays of nodes and jQuery objects
+ // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
+ appendWithoutParsing( $span, node );
+ } );
+ return $span;
+ },
+
+ /**
+ * Return escaped replacement of correct index, or string if unavailable.
+ * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
+ * if the specified parameter is not found return the same string
+ * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
+ *
+ * TODO: Throw error if nodes.length > 1 ?
+ *
+ * @param {Array} nodes List of one element, integer, n >= 0
+ * @param {Array} replacements List of at least n strings
+ * @return {string} replacement
+ */
+ replace: function ( nodes, replacements ) {
+ var index = parseInt( nodes[ 0 ], 10 );
+
+ if ( index < replacements.length ) {
+ return replacements[ index ];
+ } else {
+ // index not found, fallback to displaying variable
+ return '$' + ( index + 1 );
+ }
+ },
+
+ /**
+ * Transform wiki-link
+ *
+ * TODO:
+ * It only handles basic cases, either no pipe, or a pipe with an explicit
+ * anchor.
+ *
+ * It does not attempt to handle features like the pipe trick.
+ * However, the pipe trick should usually not be present in wikitext retrieved
+ * from the server, since the replacement is done at save time.
+ * It may, though, if the wikitext appears in extension-controlled content.
+ *
+ * @param {string[]} nodes
+ * @return {jQuery}
+ */
+ wikilink: function ( nodes ) {
+ var page, anchor, url, $el;
+
+ page = textify( nodes[ 0 ] );
+ // Strip leading ':', which is used to suppress special behavior in wikitext links,
+ // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
+ if ( page.charAt( 0 ) === ':' ) {
+ page = page.slice( 1 );
+ }
+ url = mw.util.getUrl( page );
+
+ if ( nodes.length === 1 ) {
+ // [[Some Page]] or [[Namespace:Some Page]]
+ anchor = page;
+ } else {
+ // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
+ anchor = nodes[ 1 ];
+ }
+
+ $el = $( '<a>' ).attr( {
+ title: page,
+ href: url
+ } );
+ return appendWithoutParsing( $el, anchor );
+ },
+
+ /**
+ * Converts array of HTML element key value pairs to object
+ *
+ * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
+ * name and 2 * n + 1 the associated value
+ * @return {Object} Object mapping attribute name to attribute value
+ */
+ htmlattributes: function ( nodes ) {
+ var i, len, mapping = {};
+ for ( i = 0, len = nodes.length; i < len; i += 2 ) {
+ mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
+ }
+ return mapping;
+ },
+
+ /**
+ * Handles an (already-validated) HTML element.
+ *
+ * @param {Array} nodes Nodes to process when creating element
+ * @return {jQuery|Array} jQuery node for valid HTML or array for disallowed element
+ */
+ htmlelement: function ( nodes ) {
+ var tagName, attributes, contents, $element;
+
+ tagName = nodes.shift();
+ attributes = nodes.shift();
+ contents = nodes;
+ $element = $( document.createElement( tagName ) ).attr( attributes );
+ return appendWithoutParsing( $element, contents );
+ },
+
+ /**
+ * Transform parsed structure into external link.
+ *
+ * The "href" can be:
+ * - a jQuery object, treat it as "enclosing" the link text.
+ * - a function, treat it as the click handler.
+ * - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying.
+ *
+ * TODO: throw an error if nodes.length > 2 ?
+ *
+ * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
+ * @return {jQuery}
+ */
+ extlink: function ( nodes ) {
+ var $el,
+ arg = nodes[ 0 ],
+ contents = nodes[ 1 ];
+ if ( arg instanceof jQuery && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+ $el = arg;
+ } else {
+ $el = $( '<a>' );
+ if ( typeof arg === 'function' ) {
+ $el.attr( {
+ role: 'button',
+ tabindex: 0
+ } ).on( 'click keypress', function ( e ) {
+ if (
+ e.type === 'click' ||
+ e.type === 'keypress' && e.which === 13
+ ) {
+ arg.call( this, e );
+ }
+ } );
+ } else {
+ $el.attr( 'href', textify( arg ) );
+ }
+ }
+ return appendWithoutParsing( $el.empty(), contents );
+ },
+
+ /**
+ * Transform parsed structure into pluralization
+ * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
+ * So convert it back with the current language's convertNumber.
+ *
+ * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
+ * @return {string} selected pluralized form according to current language
+ */
+ plural: function ( nodes ) {
+ var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count,
+ explicitPluralForms = {};
+
+ count = parseFloat( this.language.convertNumber( nodes[ 0 ], true ) );
+ forms = nodes.slice( 1 );
+ for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
+ form = forms[ formIndex ];
+
+ if ( form instanceof jQuery && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+ // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
+ firstChild = form.contents().get( 0 );
+ if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
+ firstChildText = firstChild.textContent;
+ if ( /^\d+=/.test( firstChildText ) ) {
+ explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
+ // Use the digit part as key and rest of first text node and
+ // rest of child nodes as value.
+ firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
+ explicitPluralForms[ explicitPluralFormNumber ] = form;
+ forms[ formIndex ] = undefined;
+ }
+ }
+ } else if ( /^\d+=/.test( form ) ) {
+ // Simple explicit plural forms like 12=a dozen
+ explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
+ explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
+ forms[ formIndex ] = undefined;
+ }
+ }
+
+ // Remove explicit plural forms from the forms. They were set undefined in the above loop.
+ forms = $.map( forms, function ( form ) {
+ return form;
+ } );
+
+ return this.language.convertPlural( count, forms, explicitPluralForms );
+ },
+
+ /**
+ * Transform parsed structure according to gender.
+ *
+ * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
+ *
+ * The first node must be one of:
+ * - the mw.user object (or a compatible one)
+ * - an empty string - indicating the current user, same effect as passing the mw.user object
+ * - a gender string ('male', 'female' or 'unknown')
+ *
+ * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
+ * @return {string} Selected gender form according to current language
+ */
+ gender: function ( nodes ) {
+ var gender,
+ maybeUser = nodes[ 0 ],
+ forms = nodes.slice( 1 );
+
+ if ( maybeUser === '' ) {
+ maybeUser = mw.user;
+ }
+
+ // If we are passed a mw.user-like object, check their gender.
+ // Otherwise, assume the gender string itself was passed .
+ if ( maybeUser && maybeUser.options instanceof mw.Map ) {
+ gender = maybeUser.options.get( 'gender' );
+ } else {
+ gender = maybeUser;
+ }
+
+ return this.language.gender( gender, forms );
+ },
+
+ /**
+ * Transform parsed structure into grammar conversion.
+ * Invoked by putting `{{grammar:form|word}}` in a message
+ *
+ * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
+ * @return {string} selected grammatical form according to current language
+ */
+ grammar: function ( nodes ) {
+ var form = nodes[ 0 ],
+ word = nodes[ 1 ];
+ return word && form && this.language.convertGrammar( word, form );
+ },
+
+ /**
+ * Tranform parsed structure into a int: (interface language) message include
+ * Invoked by putting `{{int:othermessage}}` into a message
+ *
+ * @param {Array} nodes List of nodes
+ * @return {string} Other message
+ */
+ 'int': function ( nodes ) {
+ var msg = nodes[ 0 ];
+ return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) );
+ },
+
+ /**
+ * Get localized namespace name from canonical name or namespace number.
+ * Invoked by putting `{{ns:foo}}` into a message
+ *
+ * @param {Array} nodes List of nodes
+ * @return {string} Localized namespace name
+ */
+ ns: function ( nodes ) {
+ var ns = textify( nodes[ 0 ] ).trim();
+ if ( !/^\d+$/.test( ns ) ) {
+ ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
+ }
+ ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
+ return ns || '';
+ },
+
+ /**
+ * Takes an unformatted number (arab, no group separators and . as decimal separator)
+ * and outputs it in the localized digit script and formatted with decimal
+ * separator, according to the current language.
+ *
+ * @param {Array} nodes List of nodes
+ * @return {number|string} Formatted number
+ */
+ formatnum: function ( nodes ) {
+ var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R',
+ number = nodes[ 0 ];
+
+ return this.language.convertNumber( number, isInteger );
+ },
+
+ /**
+ * Lowercase text
+ *
+ * @param {Array} nodes List of nodes
+ * @return {string} The given text, all in lowercase
+ */
+ lc: function ( nodes ) {
+ return textify( nodes[ 0 ] ).toLowerCase();
+ },
+
+ /**
+ * Uppercase text
+ *
+ * @param {Array} nodes List of nodes
+ * @return {string} The given text, all in uppercase
+ */
+ uc: function ( nodes ) {
+ return textify( nodes[ 0 ] ).toUpperCase();
+ },
+
+ /**
+ * Lowercase first letter of input, leaving the rest unchanged
+ *
+ * @param {Array} nodes List of nodes
+ * @return {string} The given text, with the first character in lowercase
+ */
+ lcfirst: function ( nodes ) {
+ var text = textify( nodes[ 0 ] );
+ return text.charAt( 0 ).toLowerCase() + text.slice( 1 );
+ },
+
+ /**
+ * Uppercase first letter of input, leaving the rest unchanged
+ *
+ * @param {Array} nodes List of nodes
+ * @return {string} The given text, with the first character in uppercase
+ */
+ ucfirst: function ( nodes ) {
+ var text = textify( nodes[ 0 ] );
+ return text.charAt( 0 ).toUpperCase() + text.slice( 1 );
+ }
+ };
+
+ /**
+ * @method
+ * @member jQuery
+ * @see mw.jqueryMsg#getPlugin
+ */
+ $.fn.msg = mw.jqueryMsg.getPlugin();
+
+ // Replace the default message parser with jqueryMsg
+ oldParser = mw.Message.prototype.parser;
+ mw.Message.prototype.parser = function () {
+ if ( this.format === 'plain' || !/\{\{|[<>[&]/.test( this.map.get( this.key ) ) ) {
+ // Fall back to mw.msg's simple parser
+ return oldParser.apply( this );
+ }
+
+ if ( !this.map.hasOwnProperty( this.format ) ) {
+ this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( {
+ messages: this.map,
+ // For format 'escaped', escaping part is handled by mediawiki.js
+ format: this.format
+ } );
+ }
+ return this.map[ this.format ]( this.key, this.parameters );
+ };
+
+ /**
+ * Parse the message to DOM nodes, rather than HTML string like #parse.
+ *
+ * This method is only available when jqueryMsg is loaded.
+ *
+ * @since 1.27
+ * @method parseDom
+ * @member mw.Message
+ * @return {jQuery}
+ */
+ mw.Message.prototype.parseDom = ( function () {
+ var reusableParent = $( '<div>' );
+ return function () {
+ return reusableParent.msg( this.key, this.parameters ).contents().detach();
+ };
+ }() );
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+/* PEG grammar for a subset of wikitext, useful in the MediaWiki frontend */
+
+start
+ = e:expression* { return e.length > 1 ? [ "CONCAT" ].concat(e) : e[0]; }
+
+expression
+ = template
+ / link
+ / extlink
+ / replacement
+ / literal
+
+paramExpression
+ = template
+ / link
+ / extlink
+ / replacement
+ / literalWithoutBar
+
+template
+ = "{{" t:templateContents "}}" { return t; }
+
+templateContents
+ = twr:templateWithReplacement p:templateParam* { return twr.concat(p) }
+ / twr:templateWithOutReplacement p:templateParam* { return twr.concat(p) }
+ / twr:templateWithOutFirstParameter p:templateParam* { return twr.concat(p) }
+ / t:templateName p:templateParam* { return p.length ? [ t, p ] : [ t ] }
+
+templateWithReplacement
+ = t:templateName ":" r:replacement { return [ t, r ] }
+
+templateWithOutReplacement
+ = t:templateName ":" p:paramExpression { return [ t, p ] }
+
+templateWithOutFirstParameter
+ = t:templateName ":" { return [ t, "" ] }
+
+templateParam
+ = "|" e:paramExpression* { return e.length > 1 ? [ "CONCAT" ].concat(e) : e[0]; }
+
+templateName
+ = tn:[A-Za-z_]+ { return tn.join('').toUpperCase() }
+
+/* TODO: Update to reflect separate piped and unpiped handling */
+link
+ = "[[" w:expression "]]" { return [ 'WLINK', w ]; }
+
+extlink
+ = "[" url:url whitespace text:expression "]" { return [ 'LINK', url, text ] }
+
+url
+ = url:[^ ]+ { return url.join(''); }
+
+whitespace
+ = [ ]+
+
+replacement
+ = '$' digits:digits { return [ 'REPLACE', parseInt( digits, 10 ) - 1 ] }
+
+digits
+ = [0-9]+
+
+literal
+ = lit:escapedOrRegularLiteral+ { return lit.join(''); }
+
+literalWithoutBar
+ = lit:escapedOrLiteralWithoutBar+ { return lit.join(''); }
+
+escapedOrRegularLiteral
+ = escapedLiteral
+ / regularLiteral
+
+escapedOrLiteralWithoutBar
+ = escapedLiteral
+ / regularLiteralWithoutBar
+
+escapedLiteral
+ = "\\" escaped:. { return escaped; }
+
+regularLiteral
+ = [^{}\[\]$\\]
+
+regularLiteralWithoutBar
+ = [^{}\[\]$\\|]
+
--- /dev/null
+/*!
+ * Structures generated by the TablePager PHP class
+ * in MediaWiki (used e.g. on Special:ListFiles).
+ */
+
+@import 'mediawiki.mixins';
+
+.TablePager {
+ min-width: 80%;
+}
+
+.TablePager .TablePager_sort-ascending a {
+ padding-left: 15px;
+ background: none left center no-repeat;
+ .background-image-svg('images/arrow-sort-ascending.svg', 'images/arrow-sort-ascending.png');
+}
+
+.TablePager .TablePager_sort-descending a {
+ padding-left: 15px;
+ background: none left center no-repeat;
+ .background-image-svg('images/arrow-sort-descending.svg', 'images/arrow-sort-descending.png');
+}
+
+.TablePager_nav.oo-ui-buttonGroupWidget {
+ display: block;
+ text-align: center;
+ margin: 1em;
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
+ <path fill="#36c" d="M1 10h10l-5-8.658z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
+ <path fill="#36c" d="M1 2h10l-5 8.658z"/>
+</svg>
--- /dev/null
+/* Make sure the links are not underlined or colored, ever. */
+/* There is already a :focus / :hover indication on the <div>. */
+.suggestions a.mw-searchSuggest-link,
+.suggestions a.mw-searchSuggest-link:hover,
+.suggestions a.mw-searchSuggest-link:active,
+.suggestions a.mw-searchSuggest-link:focus {
+ color: #000;
+ text-decoration: none;
+}
+
+.suggestions-result-current a.mw-searchSuggest-link,
+.suggestions-result-current a.mw-searchSuggest-link:hover,
+.suggestions-result-current a.mw-searchSuggest-link:active,
+.suggestions-result-current a.mw-searchSuggest-link:focus {
+ color: #fff;
+}
+
+.suggestions a.mw-searchSuggest-link .special-query {
+ /* Apply ellipsis to suggestions */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
--- /dev/null
+/*!
+ * Add search suggestions to the search form.
+ */
+( function ( mw, $ ) {
+ var searchNS = $.map( mw.config.get( 'wgFormattedNamespaces' ), function ( nsName, nsID ) {
+ if ( nsID >= 0 && mw.user.options.get( 'searchNs' + nsID ) ) {
+ // Cast string key to number
+ return Number( nsID );
+ }
+ } );
+ mw.searchSuggest = {
+ // queries the wiki and calls response with the result
+ request: function ( api, query, response, maxRows, namespace ) {
+ return api.get( {
+ formatversion: 2,
+ action: 'opensearch',
+ search: query,
+ namespace: namespace || searchNS,
+ limit: maxRows,
+ suggest: true
+ } ).done( function ( data, jqXHR ) {
+ response( data[ 1 ], {
+ type: jqXHR.getResponseHeader( 'X-OpenSearch-Type' ),
+ query: query
+ } );
+ } );
+ }
+ };
+
+ $( function () {
+ var api, searchboxesSelectors,
+ // Region where the suggestions box will appear directly below
+ // (using the same width). Can be a container element or the input
+ // itself, depending on what suits best in the environment.
+ // For Vector the suggestion box should align with the simpleSearch
+ // container's borders, in other skins it should align with the input
+ // element (not the search form, as that would leave the buttons
+ // vertically between the input and the suggestions).
+ $searchRegion = $( '#simpleSearch, #searchInput' ).first(),
+ $searchInput = $( '#searchInput' ),
+ previousSearchText = $searchInput.val();
+
+ // Compute form data for search suggestions functionality.
+ function getFormData( context ) {
+ var $form, baseHref, linkParams;
+
+ if ( !context.formData ) {
+ // Compute common parameters for links' hrefs
+ $form = context.config.$region.closest( 'form' );
+
+ baseHref = $form.attr( 'action' );
+ baseHref += baseHref.indexOf( '?' ) > -1 ? '&' : '?';
+
+ linkParams = $form.serializeObject();
+
+ context.formData = {
+ textParam: context.data.$textbox.attr( 'name' ),
+ linkParams: linkParams,
+ baseHref: baseHref
+ };
+ }
+
+ return context.formData;
+ }
+
+ /**
+ * Callback that's run when the user changes the search input text
+ * 'this' is the search input box (jQuery object)
+ *
+ * @ignore
+ */
+ function onBeforeUpdate() {
+ var searchText = this.val();
+
+ if ( searchText && searchText !== previousSearchText ) {
+ mw.track( 'mediawiki.searchSuggest', {
+ action: 'session-start'
+ } );
+ }
+ previousSearchText = searchText;
+ }
+
+ /**
+ * Defines the location of autocomplete. Typically either
+ * header, which is in the top right of vector (for example)
+ * and content which identifies the main search bar on
+ * Special:Search. Defaults to header for skins that don't set
+ * explicitly.
+ *
+ * @ignore
+ * @param {Object} context
+ * @return {string}
+ */
+ function getInputLocation( context ) {
+ return context.config.$region
+ .closest( 'form' )
+ .find( '[data-search-loc]' )
+ .data( 'search-loc' ) || 'header';
+ }
+
+ /**
+ * Callback that's run when suggestions have been updated either from the cache or the API
+ * 'this' is the search input box (jQuery object)
+ *
+ * @ignore
+ * @param {Object} metadata
+ */
+ function onAfterUpdate( metadata ) {
+ var context = this.data( 'suggestionsContext' );
+
+ mw.track( 'mediawiki.searchSuggest', {
+ action: 'impression-results',
+ numberOfResults: context.config.suggestions.length,
+ resultSetType: metadata.type || 'unknown',
+ query: metadata.query,
+ inputLocation: getInputLocation( context )
+ } );
+ }
+
+ // The function used to render the suggestions.
+ function renderFunction( text, context ) {
+ var formData = getFormData( context ),
+ textboxConfig = context.data.$textbox.data( 'mw-searchsuggest' ) || {};
+
+ // linkParams object is modified and reused
+ formData.linkParams[ formData.textParam ] = text;
+
+ // Allow trackers to attach tracking information, such
+ // as wprov, to clicked links.
+ mw.track( 'mediawiki.searchSuggest', {
+ action: 'render-one',
+ formData: formData,
+ index: context.config.suggestions.indexOf( text )
+ } );
+
+ // this is the container <div>, jQueryfied
+ this.text( text );
+
+ // wrap only as link, if the config doesn't disallow it
+ if ( textboxConfig.wrapAsLink !== false ) {
+ this.wrap(
+ $( '<a>' )
+ .attr( 'href', formData.baseHref + $.param( formData.linkParams ) )
+ .attr( 'title', text )
+ .addClass( 'mw-searchSuggest-link' )
+ );
+ }
+ }
+
+ // The function used when the user makes a selection
+ function selectFunction( $input, source ) {
+ var context = $input.data( 'suggestionsContext' ),
+ text = $input.val();
+
+ // Selecting via keyboard triggers a form submission. That will fire
+ // the submit-form event in addition to this click-result event.
+ if ( source !== 'keyboard' ) {
+ mw.track( 'mediawiki.searchSuggest', {
+ action: 'click-result',
+ numberOfResults: context.config.suggestions.length,
+ index: context.config.suggestions.indexOf( text )
+ } );
+ }
+
+ // allow the form to be submitted
+ return true;
+ }
+
+ function specialRenderFunction( query, context ) {
+ var $el = this,
+ formData = getFormData( context );
+
+ // linkParams object is modified and reused
+ formData.linkParams[ formData.textParam ] = query;
+
+ mw.track( 'mediawiki.searchSuggest', {
+ action: 'render-one',
+ formData: formData,
+ index: context.config.suggestions.indexOf( query )
+ } );
+
+ if ( $el.children().length === 0 ) {
+ $el
+ .append(
+ $( '<div>' )
+ .addClass( 'special-label' )
+ .text( mw.msg( 'searchsuggest-containing' ) ),
+ $( '<div>' )
+ .addClass( 'special-query' )
+ .text( query )
+ )
+ .show();
+ } else {
+ $el.find( '.special-query' )
+ .text( query );
+ }
+
+ if ( $el.parent().hasClass( 'mw-searchSuggest-link' ) ) {
+ $el.parent().attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' );
+ } else {
+ $el.wrap(
+ $( '<a>' )
+ .attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' )
+ .addClass( 'mw-searchSuggest-link' )
+ );
+ }
+ }
+
+ // Generic suggestions functionality for all search boxes
+ searchboxesSelectors = [
+ // Primary searchbox on every page in standard skins
+ '#searchInput',
+ // Generic selector for skins with multiple searchboxes (used by CologneBlue)
+ // and for MediaWiki itself (special pages with page title inputs)
+ '.mw-searchInput'
+ ];
+ $( searchboxesSelectors.join( ', ' ) )
+ .suggestions( {
+ fetch: function ( query, response, maxRows ) {
+ var node = this[ 0 ];
+
+ api = api || new mw.Api();
+
+ $.data( node, 'request', mw.searchSuggest.request( api, query, response, maxRows ) );
+ },
+ cancel: function () {
+ var node = this[ 0 ],
+ request = $.data( node, 'request' );
+
+ if ( request ) {
+ request.abort();
+ $.removeData( node, 'request' );
+ }
+ },
+ result: {
+ render: renderFunction,
+ select: function () {
+ // allow the form to be submitted
+ return true;
+ }
+ },
+ update: {
+ before: onBeforeUpdate,
+ after: onAfterUpdate
+ },
+ cache: true,
+ highlightInput: true
+ } )
+ .on( 'paste cut drop', function () {
+ // make sure paste and cut events from the mouse and drag&drop events
+ // trigger the keypress handler and cause the suggestions to update
+ $( this ).trigger( 'keypress' );
+ } )
+ // In most skins (at least Monobook and Vector), the font-size is messed up in <body>.
+ // (they use 2 elements to get a sane font-height). So, instead of making exceptions for
+ // each skin or adding more stylesheets, just copy it from the active element so auto-fit.
+ .each( function () {
+ var $this = $( this );
+ $this
+ .data( 'suggestions-context' )
+ .data.$container.css( 'fontSize', $this.css( 'fontSize' ) );
+ } );
+
+ // Ensure that the thing is actually present!
+ if ( $searchRegion.length === 0 ) {
+ // Don't try to set anything up if simpleSearch is disabled sitewide.
+ // The loader code loads us if the option is present, even if we're
+ // not actually enabled (anymore).
+ return;
+ }
+
+ // Special suggestions functionality and tracking for skin-provided search box
+ $searchInput.suggestions( {
+ update: {
+ before: onBeforeUpdate,
+ after: onAfterUpdate
+ },
+ result: {
+ render: renderFunction,
+ select: selectFunction
+ },
+ special: {
+ render: specialRenderFunction,
+ select: function ( $input, source ) {
+ var context = $input.data( 'suggestionsContext' ),
+ text = $input.val();
+ if ( source === 'mouse' ) {
+ // mouse click won't trigger form submission, so we need to send a click event
+ mw.track( 'mediawiki.searchSuggest', {
+ action: 'click-result',
+ numberOfResults: context.config.suggestions.length,
+ index: context.config.suggestions.indexOf( text )
+ } );
+ } else {
+ $input.closest( 'form' )
+ .append( $( '<input type="hidden" name="fulltext" value="1"/>' ) );
+ }
+ return true; // allow the form to be submitted
+ }
+ },
+ $region: $searchRegion
+ } );
+
+ $searchInput.closest( 'form' )
+ // track the form submit event
+ .on( 'submit', function () {
+ var context = $searchInput.data( 'suggestionsContext' );
+ mw.track( 'mediawiki.searchSuggest', {
+ action: 'submit-form',
+ numberOfResults: context.config.suggestions.length,
+ $form: context.config.$region.closest( 'form' ),
+ inputLocation: getInputLocation( context ),
+ index: context.config.suggestions.indexOf(
+ context.data.$textbox.val()
+ )
+ } );
+ } )
+ // If the form includes any fallback fulltext search buttons, remove them
+ .find( '.mw-fallbackSearchButton' ).remove();
+ } );
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+/* global Mustache */
+( function ( mw, $ ) {
+ // Register mustache compiler
+ mw.template.registerCompiler( 'mustache', {
+ compile: function ( src ) {
+ return {
+ /**
+ * @ignore
+ * @return {string} The raw source code of the template
+ */
+ getSource: function () {
+ return src;
+ },
+ /**
+ * @ignore
+ * @param {Object} data Data to render
+ * @param {Object} partialTemplates Map partial names to Mustache template objects
+ * returned by mw.template.get()
+ * @return {jQuery} Rendered HTML
+ */
+ render: function ( data, partialTemplates ) {
+ var partials = {};
+ if ( partialTemplates ) {
+ $.each( partialTemplates, function ( name, template ) {
+ partials[ name ] = template.getSource();
+ } );
+ }
+ return $( $.parseHTML( Mustache.render( src, data, partials ) ) );
+ }
+ };
+ }
+ } );
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+.toc.tochidden,
+.toctoggle {
+ display: none;
+}
--- /dev/null
+.tochidden,
+.toctoggle {
+ -moz-user-select: none;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+.toctoggle {
+ font-size: 94%;
+}
--- /dev/null
+( function ( mw, $ ) {
+ 'use strict';
+
+ // Table of contents toggle
+ mw.hook( 'wikipage.content' ).add( function ( $content ) {
+ $content.find( '.toc' ).addBack( '.toc' ).each( function () {
+ var hideToc,
+ $this = $( this ),
+ $tocTitle = $this.find( '.toctitle' ),
+ $tocToggleLink = $this.find( '.togglelink' ),
+ $tocList = $this.find( 'ul' ).eq( 0 );
+
+ // Hide/show the table of contents element
+ function toggleToc() {
+ if ( $tocList.is( ':hidden' ) ) {
+ $tocList.slideDown( 'fast' );
+ $tocToggleLink.text( mw.msg( 'hidetoc' ) );
+ $this.removeClass( 'tochidden' );
+ mw.cookie.set( 'hidetoc', null );
+ } else {
+ $tocList.slideUp( 'fast' );
+ $tocToggleLink.text( mw.msg( 'showtoc' ) );
+ $this.addClass( 'tochidden' );
+ mw.cookie.set( 'hidetoc', '1' );
+ }
+ }
+
+ // Only add it if there is a complete TOC and it doesn't
+ // have a toggle added already
+ if ( $tocTitle.length && $tocList.length && !$tocToggleLink.length ) {
+ hideToc = mw.cookie.get( 'hidetoc' ) === '1';
+
+ $tocToggleLink = $( '<a role="button" tabindex="0" class="togglelink"></a>' )
+ .text( mw.msg( hideToc ? 'showtoc' : 'hidetoc' ) )
+ .on( 'click keypress', function ( e ) {
+ if (
+ e.type === 'click' ||
+ e.type === 'keypress' && e.which === 13
+ ) {
+ toggleToc();
+ }
+ } );
+
+ $tocTitle.append(
+ $tocToggleLink
+ .wrap( '<span class="toctoggle"></span>' )
+ .parent()
+ .prepend( ' [' )
+ .append( '] ' )
+ );
+
+ if ( hideToc ) {
+ $tocList.hide();
+ $this.addClass( 'tochidden' );
+ }
+ }
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
- <path fill="#72777d" d="M4 1.533v9.671l4.752-4.871z"/>
-</svg>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
- <path fill="#72777d" d="M8 1.533v9.671l-4.752-4.871z"/>
-</svg>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
- <path fill="#72777d" d="M1.165 3.624h9.671l-4.871 4.752z"/>
-</svg>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
- <path fill="#36c" d="M1 10h10l-5-8.658z"/>
-</svg>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
- <path fill="#36c" d="M1 2h10l-5 8.658z"/>
-</svg>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 256 256">
- <defs>
- <linearGradient id="a" x1=".085" x2=".915" y1=".085" y2=".915">
- <stop offset="0" stop-color="#e3702d"/>
- <stop offset=".107" stop-color="#ea7d31"/>
- <stop offset=".35" stop-color="#f69537"/>
- <stop offset=".5" stop-color="#fb9e3a"/>
- <stop offset=".702" stop-color="#ea7c31"/>
- <stop offset=".887" stop-color="#de642b"/>
- <stop offset="1" stop-color="#d95b29"/>
- </linearGradient>
- </defs>
- <rect width="256" height="256" fill="#cc5d15" rx="55" ry="55"/>
- <rect width="246" height="246" x="5" y="5" fill="#f49c52" rx="50" ry="50"/>
- <rect width="236" height="236" x="10" y="10" fill="url(#a)" rx="47" ry="47"/>
- <circle cx="68" cy="189" r="24" fill="#fff"/>
- <path fill="#fff" d="M160 213h-34a82 82 0 0 0-82-82v-34a116 116 0 0 1 116 116zM184 213a140 140 0 0 0-140-140v-35a175 175 0 0 1 175 175z"/>
-</svg>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
- <path d="M12.001 2.085c-5.478 0-9.916 4.438-9.916 9.916 0 5.476 4.438 9.914 9.916 9.914 5.476 0 9.914-4.438 9.914-9.914 0-5.478-4.438-9.916-9.914-9.916zm.001 18c-4.465 0-8.084-3.619-8.084-8.083 0-4.465 3.619-8.084 8.084-8.084 4.464 0 8.083 3.619 8.083 8.084 0 4.464-3.619 8.083-8.083 8.083z"/>
- <path d="M11.766 6.688c-2.5 0-3.219 2.188-3.219 2.188l1.411.854s.298-.791.901-1.229c.516-.375 1.625-.625 2.219.125.701.885-.17 1.587-1.078 2.719-.953 1.186-1 3.655-1 3.655h1.969s.135-2.318 1.041-3.381c.603-.707 1.443-1.338 1.443-2.494s-1.187-2.437-3.687-2.437zM11 16h2v2h-2z"/>
-</svg>
+++ /dev/null
-/* global moment, Uint8Array */
-( function ( $, mw ) {
-
- /**
- * mw.ForeignStructuredUpload.BookletLayout encapsulates the process
- * of uploading a file to MediaWiki using the mw.ForeignStructuredUpload model.
- *
- * var uploadDialog = new mw.Upload.Dialog( {
- * bookletClass: mw.ForeignStructuredUpload.BookletLayout,
- * booklet: {
- * target: 'local'
- * }
- * } );
- * var windowManager = new OO.ui.WindowManager();
- * $( 'body' ).append( windowManager.$element );
- * windowManager.addWindows( [ uploadDialog ] );
- *
- * @class mw.ForeignStructuredUpload.BookletLayout
- * @uses mw.ForeignStructuredUpload
- * @extends mw.Upload.BookletLayout
- *
- * @constructor
- * @param {Object} config Configuration options
- * @cfg {string} [target] Used to choose the target repository.
- * If nothing is passed, the {@link mw.ForeignUpload#property-target default} is used.
- */
- mw.ForeignStructuredUpload.BookletLayout = function ( config ) {
- config = config || {};
- // Parent constructor
- mw.ForeignStructuredUpload.BookletLayout.parent.call( this, config );
-
- this.target = config.target;
- };
-
- /* Setup */
-
- OO.inheritClass( mw.ForeignStructuredUpload.BookletLayout, mw.Upload.BookletLayout );
-
- /* Uploading */
-
- /**
- * @inheritdoc
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.initialize = function () {
- var booklet = this;
- return mw.ForeignStructuredUpload.BookletLayout.parent.prototype.initialize.call( this ).then(
- function () {
- return $.when(
- // Point the CategoryMultiselectWidget to the right wiki
- booklet.upload.getApi().then( function ( api ) {
- // If this is a ForeignApi, it will have a apiUrl, otherwise we don't need to do anything
- if ( api.apiUrl ) {
- // Can't reuse the same object, CategoryMultiselectWidget calls #abort on its mw.Api instance
- booklet.categoriesWidget.api = new mw.ForeignApi( api.apiUrl );
- }
- return $.Deferred().resolve();
- } ),
- // Set up booklet fields and license messages to match configuration
- booklet.upload.loadConfig().then( function ( config ) {
- var
- msgPromise,
- isLocal = booklet.upload.target === 'local',
- fields = config.fields,
- msgs = config.licensemessages[ isLocal ? 'local' : 'foreign' ];
-
- // Hide disabled fields
- booklet.descriptionField.toggle( !!fields.description );
- booklet.categoriesField.toggle( !!fields.categories );
- booklet.dateField.toggle( !!fields.date );
- // Update form validity
- booklet.onInfoFormChange();
-
- // Load license messages from the remote wiki if we don't have these messages locally
- // (this means that we only load messages from the foreign wiki for custom config)
- if ( mw.message( 'upload-form-label-own-work-message-' + msgs ).exists() ) {
- msgPromise = $.Deferred().resolve();
- } else {
- msgPromise = booklet.upload.apiPromise.then( function ( api ) {
- return api.loadMessages( [
- 'upload-form-label-own-work-message-' + msgs,
- 'upload-form-label-not-own-work-message-' + msgs,
- 'upload-form-label-not-own-work-local-' + msgs
- ] );
- } );
- }
-
- // Update license messages
- return msgPromise.then( function () {
- var $labels;
- booklet.$ownWorkMessage.msg( 'upload-form-label-own-work-message-' + msgs );
- booklet.$notOwnWorkMessage.msg( 'upload-form-label-not-own-work-message-' + msgs );
- booklet.$notOwnWorkLocal.msg( 'upload-form-label-not-own-work-local-' + msgs );
-
- $labels = $( [
- booklet.$ownWorkMessage[ 0 ],
- booklet.$notOwnWorkMessage[ 0 ],
- booklet.$notOwnWorkLocal[ 0 ]
- ] );
-
- // Improve the behavior of links inside these labels, which may point to important
- // things like licensing requirements or terms of use
- $labels.find( 'a' )
- .attr( 'target', '_blank' )
- .on( 'click', function ( e ) {
- // OO.ui.FieldLayout#onLabelClick is trying to prevent default on all clicks,
- // which causes the links to not be openable. Don't let it do that.
- e.stopPropagation();
- } );
- } );
- }, function ( errorMsg ) {
- booklet.getPage( 'upload' ).$element.msg( errorMsg );
- return $.Deferred().resolve();
- } )
- );
- }
- ).catch(
- // Always resolve, never reject
- function () { return $.Deferred().resolve(); }
- );
- };
-
- /**
- * Returns a {@link mw.ForeignStructuredUpload mw.ForeignStructuredUpload}
- * with the {@link #cfg-target target} specified in config.
- *
- * @protected
- * @return {mw.Upload}
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.createUpload = function () {
- return new mw.ForeignStructuredUpload( this.target, {
- parameters: {
- errorformat: 'html',
- errorlang: mw.config.get( 'wgUserLanguage' ),
- errorsuselocal: 1,
- formatversion: 2
- }
- } );
- };
-
- /* Form renderers */
-
- /**
- * @inheritdoc
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.renderUploadForm = function () {
- var fieldset,
- layout = this;
-
- // These elements are filled with text in #initialize
- // TODO Refactor this to be in one place
- this.$ownWorkMessage = $( '<p>' )
- .addClass( 'mw-foreignStructuredUpload-bookletLayout-license' );
- this.$notOwnWorkMessage = $( '<p>' );
- this.$notOwnWorkLocal = $( '<p>' );
-
- this.selectFileWidget = new OO.ui.SelectFileWidget( {
- showDropTarget: true
- } );
- this.messageLabel = new OO.ui.LabelWidget( {
- label: $( '<div>' ).append(
- this.$notOwnWorkMessage,
- this.$notOwnWorkLocal
- )
- } );
- this.ownWorkCheckbox = new OO.ui.CheckboxInputWidget().on( 'change', function ( on ) {
- layout.messageLabel.toggle( !on );
- } );
-
- fieldset = new OO.ui.FieldsetLayout();
- fieldset.addItems( [
- new OO.ui.FieldLayout( this.selectFileWidget, {
- align: 'top'
- } ),
- new OO.ui.FieldLayout( this.ownWorkCheckbox, {
- align: 'inline',
- label: $( '<div>' ).append(
- $( '<p>' ).text( mw.msg( 'upload-form-label-own-work' ) ),
- this.$ownWorkMessage
- )
- } ),
- new OO.ui.FieldLayout( this.messageLabel, {
- align: 'top'
- } )
- ] );
- this.uploadForm = new OO.ui.FormLayout( { items: [ fieldset ] } );
-
- // Validation
- this.selectFileWidget.on( 'change', this.onUploadFormChange.bind( this ) );
- this.ownWorkCheckbox.on( 'change', this.onUploadFormChange.bind( this ) );
-
- this.selectFileWidget.on( 'change', function () {
- var file = layout.getFile();
-
- // Set the date to lastModified once we have the file
- if ( layout.getDateFromLastModified( file ) !== undefined ) {
- layout.dateWidget.setValue( layout.getDateFromLastModified( file ) );
- }
-
- // Check if we have EXIF data and set to that where available
- layout.getDateFromExif( file ).done( function ( date ) {
- layout.dateWidget.setValue( date );
- } );
-
- layout.updateFilePreview();
- } );
-
- return this.uploadForm;
- };
-
- /**
- * @inheritdoc
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.onUploadFormChange = function () {
- var file = this.selectFileWidget.getValue(),
- ownWork = this.ownWorkCheckbox.isSelected(),
- valid = !!file && ownWork;
- this.emit( 'uploadValid', valid );
- };
-
- /**
- * @inheritdoc
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.renderInfoForm = function () {
- var fieldset;
-
- this.filePreview = new OO.ui.Widget( {
- classes: [ 'mw-upload-bookletLayout-filePreview' ]
- } );
- this.progressBarWidget = new OO.ui.ProgressBarWidget( {
- progress: 0
- } );
- this.filePreview.$element.append( this.progressBarWidget.$element );
-
- this.filenameWidget = new OO.ui.TextInputWidget( {
- required: true,
- validate: /.+/
- } );
- this.descriptionWidget = new OO.ui.MultilineTextInputWidget( {
- required: true,
- validate: /\S+/,
- autosize: true
- } );
- this.categoriesWidget = new mw.widgets.CategoryMultiselectWidget( {
- // Can't be done here because we don't know the target wiki yet... done in #initialize.
- // api: new mw.ForeignApi( ... ),
- $overlay: this.$overlay
- } );
- this.dateWidget = new mw.widgets.DateInputWidget( {
- $overlay: this.$overlay,
- required: true,
- mustBeBefore: moment().add( 1, 'day' ).locale( 'en' ).format( 'YYYY-MM-DD' ) // Tomorrow
- } );
-
- this.filenameField = new OO.ui.FieldLayout( this.filenameWidget, {
- label: mw.msg( 'upload-form-label-infoform-name' ),
- align: 'top',
- classes: [ 'mw-foreignStructuredUploa-bookletLayout-small-notice' ],
- notices: [ mw.msg( 'upload-form-label-infoform-name-tooltip' ) ]
- } );
- this.descriptionField = new OO.ui.FieldLayout( this.descriptionWidget, {
- label: mw.msg( 'upload-form-label-infoform-description' ),
- align: 'top',
- classes: [ 'mw-foreignStructuredUploa-bookletLayout-small-notice' ],
- notices: [ mw.msg( 'upload-form-label-infoform-description-tooltip' ) ]
- } );
- this.categoriesField = new OO.ui.FieldLayout( this.categoriesWidget, {
- label: mw.msg( 'upload-form-label-infoform-categories' ),
- align: 'top'
- } );
- this.dateField = new OO.ui.FieldLayout( this.dateWidget, {
- label: mw.msg( 'upload-form-label-infoform-date' ),
- align: 'top'
- } );
-
- fieldset = new OO.ui.FieldsetLayout( {
- label: mw.msg( 'upload-form-label-infoform-title' )
- } );
- fieldset.addItems( [
- this.filenameField,
- this.descriptionField,
- this.categoriesField,
- this.dateField
- ] );
- this.infoForm = new OO.ui.FormLayout( {
- classes: [ 'mw-upload-bookletLayout-infoForm' ],
- items: [ this.filePreview, fieldset ]
- } );
-
- // Validation
- this.filenameWidget.on( 'change', this.onInfoFormChange.bind( this ) );
- this.descriptionWidget.on( 'change', this.onInfoFormChange.bind( this ) );
- this.dateWidget.on( 'change', this.onInfoFormChange.bind( this ) );
-
- this.on( 'fileUploadProgress', function ( progress ) {
- this.progressBarWidget.setProgress( progress * 100 );
- }.bind( this ) );
-
- return this.infoForm;
- };
-
- /**
- * @inheritdoc
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.onInfoFormChange = function () {
- var layout = this,
- validityPromises = [];
-
- validityPromises.push( this.filenameWidget.getValidity() );
- if ( this.descriptionField.isVisible() ) {
- validityPromises.push( this.descriptionWidget.getValidity() );
- }
- if ( this.dateField.isVisible() ) {
- validityPromises.push( this.dateWidget.getValidity() );
- }
-
- $.when.apply( $, validityPromises ).done( function () {
- layout.emit( 'infoValid', true );
- } ).fail( function () {
- layout.emit( 'infoValid', false );
- } );
- };
-
- /**
- * @param {mw.Title} filename
- * @return {jQuery.Promise} Resolves (on success) or rejects with OO.ui.Error
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.validateFilename = function ( filename ) {
- return ( new mw.Api() ).get( {
- action: 'query',
- prop: 'info',
- titles: filename.getPrefixedDb(),
- formatversion: 2
- } ).then(
- function ( result ) {
- // if the file already exists, reject right away, before
- // ever firing finishStashUpload()
- if ( !result.query.pages[ 0 ].missing ) {
- return $.Deferred().reject( new OO.ui.Error(
- $( '<p>' ).msg( 'fileexists', filename.getPrefixedDb() ),
- { recoverable: false }
- ) );
- }
- },
- function () {
- // API call failed - this could be a connection hiccup...
- // Let's just ignore this validation step and turn this
- // failure into a successful resolve ;)
- return $.Deferred().resolve();
- }
- );
- };
-
- /**
- * @inheritdoc
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.saveFile = function () {
- var title = mw.Title.newFromText(
- this.getFilename(),
- mw.config.get( 'wgNamespaceIds' ).file
- );
-
- return this.uploadPromise
- .then( this.validateFilename.bind( this, title ) )
- .then( mw.ForeignStructuredUpload.BookletLayout.parent.prototype.saveFile.bind( this ) );
- };
-
- /* Getters */
-
- /**
- * @inheritdoc
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.getText = function () {
- var language = mw.config.get( 'wgContentLanguage' );
- this.upload.clearDescriptions();
- this.upload.addDescription( language, this.descriptionWidget.getValue() );
- this.upload.setDate( this.dateWidget.getValue() );
- this.upload.clearCategories();
- this.upload.addCategories( this.categoriesWidget.getItemsData() );
- return this.upload.getText();
- };
-
- /**
- * Get original date from EXIF data
- *
- * @param {Object} file
- * @return {jQuery.Promise} Promise resolved with the EXIF date
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.getDateFromExif = function ( file ) {
- var fileReader,
- deferred = $.Deferred();
-
- if ( file && file.type === 'image/jpeg' ) {
- fileReader = new FileReader();
- fileReader.onload = function () {
- var fileStr, arr, i, metadata,
- jpegmeta = mw.loader.require( 'mediawiki.libs.jpegmeta' );
-
- if ( typeof fileReader.result === 'string' ) {
- fileStr = fileReader.result;
- } else {
- // Array buffer; convert to binary string for the library.
- arr = new Uint8Array( fileReader.result );
- fileStr = '';
- for ( i = 0; i < arr.byteLength; i++ ) {
- fileStr += String.fromCharCode( arr[ i ] );
- }
- }
-
- try {
- metadata = jpegmeta( fileStr, file.name );
- } catch ( e ) {
- metadata = null;
- }
-
- if ( metadata !== null && metadata.exif !== undefined && metadata.exif.DateTimeOriginal ) {
- deferred.resolve( moment( metadata.exif.DateTimeOriginal, 'YYYY:MM:DD' ).format( 'YYYY-MM-DD' ) );
- } else {
- deferred.reject();
- }
- };
-
- if ( 'readAsBinaryString' in fileReader ) {
- fileReader.readAsBinaryString( file );
- } else if ( 'readAsArrayBuffer' in fileReader ) {
- fileReader.readAsArrayBuffer( file );
- } else {
- // We should never get here
- deferred.reject();
- throw new Error( 'Cannot read thumbnail as binary string or array buffer.' );
- }
- }
-
- return deferred.promise();
- };
-
- /**
- * Get last modified date from file
- *
- * @param {Object} file
- * @return {Object} Last modified date from file
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.getDateFromLastModified = function ( file ) {
- if ( file && file.lastModified ) {
- return moment( file.lastModified ).format( 'YYYY-MM-DD' );
- }
- };
-
- /* Setters */
-
- /**
- * @inheritdoc
- */
- mw.ForeignStructuredUpload.BookletLayout.prototype.clear = function () {
- mw.ForeignStructuredUpload.BookletLayout.parent.prototype.clear.call( this );
-
- this.ownWorkCheckbox.setSelected( false );
- this.categoriesWidget.setItemsFromData( [] );
- this.dateWidget.setValue( '' ).setValidityFlag( true );
- };
-
-}( jQuery, mediaWiki ) );
+++ /dev/null
-.mw-foreignStructuredUpload-bookletLayout-license {
- font-size: 90%;
- line-height: 1.4em;
- color: #54595d;
-}
-
-.mw-foreignStructuredUploa-bookletLayout-small-notice {
- .oo-ui-fieldLayout-messages-notice {
- .oo-ui-iconWidget {
- display: none;
- }
-
- .oo-ui-labelWidget {
- line-height: 1.2em;
- font-size: 0.9em;
- color: #54595d;
- }
- }
-}
+++ /dev/null
-/*!
- * @author Neil Kandalgaonkar, 2010
- * @author Timo Tijhof
- * @since 1.18
- */
-
-( function ( mw, $ ) {
- /**
- * Parse titles into an object structure. Note that when using the constructor
- * directly, passing invalid titles will result in an exception. Use #newFromText to use the
- * logic directly and get null for invalid titles which is easier to work with.
- *
- * Note that in the constructor and #newFromText method, `namespace` is the **default** namespace
- * only, and can be overridden by a namespace prefix in `title`. If you do not want this behavior,
- * use #makeTitle. Compare:
- *
- * new mw.Title( 'Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
- * mw.Title.newFromText( 'Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
- * mw.Title.makeTitle( NS_TEMPLATE, 'Foo' ).getPrefixedText(); // => 'Template:Foo'
- *
- * new mw.Title( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo'
- * mw.Title.newFromText( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo'
- * mw.Title.makeTitle( NS_TEMPLATE, 'Category:Foo' ).getPrefixedText(); // => 'Template:Category:Foo'
- *
- * new mw.Title( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
- * mw.Title.newFromText( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
- * mw.Title.makeTitle( NS_TEMPLATE, 'Template:Foo' ).getPrefixedText(); // => 'Template:Template:Foo'
- *
- * @class mw.Title
- */
-
- /* Private members */
-
- var
- mwString = require( 'mediawiki.String' ),
-
- namespaceIds = mw.config.get( 'wgNamespaceIds' ),
-
- /**
- * @private
- * @static
- * @property NS_MAIN
- */
- NS_MAIN = namespaceIds[ '' ],
-
- /**
- * @private
- * @static
- * @property NS_TALK
- */
- NS_TALK = namespaceIds.talk,
-
- /**
- * @private
- * @static
- * @property NS_SPECIAL
- */
- NS_SPECIAL = namespaceIds.special,
-
- /**
- * @private
- * @static
- * @property NS_MEDIA
- */
- NS_MEDIA = namespaceIds.media,
-
- /**
- * @private
- * @static
- * @property NS_FILE
- */
- NS_FILE = namespaceIds.file,
-
- /**
- * @private
- * @static
- * @property FILENAME_MAX_BYTES
- */
- FILENAME_MAX_BYTES = 240,
-
- /**
- * @private
- * @static
- * @property TITLE_MAX_BYTES
- */
- TITLE_MAX_BYTES = 255,
-
- /**
- * Get the namespace id from a namespace name (either from the localized, canonical or alias
- * name).
- *
- * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or
- * even 'Bild'.
- *
- * @private
- * @static
- * @method getNsIdByName
- * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored)
- * @return {number|boolean} Namespace id or boolean false
- */
- getNsIdByName = function ( ns ) {
- var id;
-
- // Don't cast non-strings to strings, because null or undefined should not result in
- // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki)
- // Also, toLowerCase throws exception on null/undefined, because it is a String method.
- if ( typeof ns !== 'string' ) {
- return false;
- }
- // TODO: Should just use local var namespaceIds here but it
- // breaks test which modify the config
- id = mw.config.get( 'wgNamespaceIds' )[ ns.toLowerCase() ];
- if ( id === undefined ) {
- return false;
- }
- return id;
- },
-
- /**
- * @private
- * @method getNamespacePrefix_
- * @param {number} namespace
- * @return {string}
- */
- getNamespacePrefix = function ( namespace ) {
- return namespace === NS_MAIN ?
- '' :
- ( mw.config.get( 'wgFormattedNamespaces' )[ namespace ].replace( / /g, '_' ) + ':' );
- },
-
- rUnderscoreTrim = /^_+|_+$/g,
-
- rSplit = /^(.+?)_*:_*(.*)$/,
-
- // See MediaWikiTitleCodec.php#getTitleInvalidRegex
- rInvalid = new RegExp(
- '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' +
- // URL percent encoding sequences interfere with the ability
- // to round-trip titles -- you can't link to them consistently.
- '|%[0-9A-Fa-f]{2}' +
- // XML/HTML character references produce similar issues.
- '|&[A-Za-z0-9\u0080-\uFFFF]+;' +
- '|&#[0-9]+;' +
- '|&#x[0-9A-Fa-f]+;'
- ),
-
- // From MediaWikiTitleCodec::splitTitleString() in PHP
- // Note that this is not equivalent to /\s/, e.g. underscore is included, tab is not included.
- rWhitespace = /[ _\u00A0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+/g,
-
- // From MediaWikiTitleCodec::splitTitleString() in PHP
- rUnicodeBidi = /[\u200E\u200F\u202A-\u202E]/g,
-
- /**
- * Slightly modified from Flinfo. Credit goes to Lupo and Flominator.
- * @private
- * @static
- * @property sanitationRules
- */
- sanitationRules = [
- // "signature"
- {
- pattern: /~{3}/g,
- replace: '',
- generalRule: true
- },
- // control characters
- {
- // eslint-disable-next-line no-control-regex
- pattern: /[\x00-\x1f\x7f]/g,
- replace: '',
- generalRule: true
- },
- // URL encoding (possibly)
- {
- pattern: /%([0-9A-Fa-f]{2})/g,
- replace: '% $1',
- generalRule: true
- },
- // HTML-character-entities
- {
- pattern: /&(([A-Za-z0-9\x80-\xff]+|#[0-9]+|#x[0-9A-Fa-f]+);)/g,
- replace: '& $1',
- generalRule: true
- },
- // slash, colon (not supported by file systems like NTFS/Windows, Mac OS 9 [:], ext4 [/])
- {
- pattern: new RegExp( '[' + mw.config.get( 'wgIllegalFileChars', '' ) + ']', 'g' ),
- replace: '-',
- fileRule: true
- },
- // brackets, greater than
- {
- pattern: /[}\]>]/g,
- replace: ')',
- generalRule: true
- },
- // brackets, lower than
- {
- pattern: /[{[<]/g,
- replace: '(',
- generalRule: true
- },
- // everything that wasn't covered yet
- {
- pattern: new RegExp( rInvalid.source, 'g' ),
- replace: '-',
- generalRule: true
- },
- // directory structures
- {
- pattern: /^(\.|\.\.|\.\/.*|\.\.\/.*|.*\/\.\/.*|.*\/\.\.\/.*|.*\/\.|.*\/\.\.)$/g,
- replace: '',
- generalRule: true
- }
- ],
-
- /**
- * Internal helper for #constructor and #newFromText.
- *
- * Based on Title.php#secureAndSplit
- *
- * @private
- * @static
- * @method parse
- * @param {string} title
- * @param {number} [defaultNamespace=NS_MAIN]
- * @return {Object|boolean}
- */
- parse = function ( title, defaultNamespace ) {
- var namespace, m, id, i, fragment, ext;
-
- namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
-
- title = title
- // Strip Unicode bidi override characters
- .replace( rUnicodeBidi, '' )
- // Normalise whitespace to underscores and remove duplicates
- .replace( rWhitespace, '_' )
- // Trim underscores
- .replace( rUnderscoreTrim, '' );
-
- // Process initial colon
- if ( title !== '' && title[ 0 ] === ':' ) {
- // Initial colon means main namespace instead of specified default
- namespace = NS_MAIN;
- title = title
- // Strip colon
- .slice( 1 )
- // Trim underscores
- .replace( rUnderscoreTrim, '' );
- }
-
- if ( title === '' ) {
- return false;
- }
-
- // Process namespace prefix (if any)
- m = title.match( rSplit );
- if ( m ) {
- id = getNsIdByName( m[ 1 ] );
- if ( id !== false ) {
- // Ordinary namespace
- namespace = id;
- title = m[ 2 ];
-
- // For Talk:X pages, make sure X has no "namespace" prefix
- if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) {
- // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x)
- if ( getNsIdByName( m[ 1 ] ) !== false ) {
- return false;
- }
- }
- }
- }
-
- // Process fragment
- i = title.indexOf( '#' );
- if ( i === -1 ) {
- fragment = null;
- } else {
- fragment = title
- // Get segment starting after the hash
- .slice( i + 1 )
- // Convert to text
- // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo")
- .replace( /_/g, ' ' );
-
- title = title
- // Strip hash
- .slice( 0, i )
- // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux")
- .replace( rUnderscoreTrim, '' );
- }
-
- // Reject illegal characters
- if ( title.match( rInvalid ) ) {
- return false;
- }
-
- // Disallow titles that browsers or servers might resolve as directory navigation
- if (
- title.indexOf( '.' ) !== -1 && (
- title === '.' || title === '..' ||
- title.indexOf( './' ) === 0 ||
- title.indexOf( '../' ) === 0 ||
- title.indexOf( '/./' ) !== -1 ||
- title.indexOf( '/../' ) !== -1 ||
- title.slice( -2 ) === '/.' ||
- title.slice( -3 ) === '/..'
- )
- ) {
- return false;
- }
-
- // Disallow magic tilde sequence
- if ( title.indexOf( '~~~' ) !== -1 ) {
- return false;
- }
-
- // Disallow titles exceeding the TITLE_MAX_BYTES byte size limit (size of underlying database field)
- // Except for special pages, e.g. [[Special:Block/Long name]]
- // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should
- // be less than 512 bytes.
- if ( namespace !== NS_SPECIAL && mwString.byteLength( title ) > TITLE_MAX_BYTES ) {
- return false;
- }
-
- // Can't make a link to a namespace alone.
- if ( title === '' && namespace !== NS_MAIN ) {
- return false;
- }
-
- // Any remaining initial :s are illegal.
- if ( title[ 0 ] === ':' ) {
- return false;
- }
-
- // For backwards-compatibility with old mw.Title, we separate the extension from the
- // rest of the title.
- i = title.lastIndexOf( '.' );
- if ( i === -1 || title.length <= i + 1 ) {
- // Extensions are the non-empty segment after the last dot
- ext = null;
- } else {
- ext = title.slice( i + 1 );
- title = title.slice( 0, i );
- }
-
- return {
- namespace: namespace,
- title: title,
- ext: ext,
- fragment: fragment
- };
- },
-
- /**
- * Convert db-key to readable text.
- *
- * @private
- * @static
- * @method text
- * @param {string} s
- * @return {string}
- */
- text = function ( s ) {
- if ( s !== null && s !== undefined ) {
- return s.replace( /_/g, ' ' );
- } else {
- return '';
- }
- },
-
- /**
- * Sanitizes a string based on a rule set and a filter
- *
- * @private
- * @static
- * @method sanitize
- * @param {string} s
- * @param {Array} filter
- * @return {string}
- */
- sanitize = function ( s, filter ) {
- var i, ruleLength, rule, m, filterLength,
- rules = sanitationRules;
-
- for ( i = 0, ruleLength = rules.length; i < ruleLength; ++i ) {
- rule = rules[ i ];
- for ( m = 0, filterLength = filter.length; m < filterLength; ++m ) {
- if ( rule[ filter[ m ] ] ) {
- s = s.replace( rule.pattern, rule.replace );
- }
- }
- }
- return s;
- },
-
- /**
- * Cuts a string to a specific byte length, assuming UTF-8
- * or less, if the last character is a multi-byte one
- *
- * @private
- * @static
- * @method trimToByteLength
- * @param {string} s
- * @param {number} length
- * @return {string}
- */
- trimToByteLength = function ( s, length ) {
- return mwString.trimByteLength( '', s, length ).newVal;
- },
-
- /**
- * Cuts a file name to a specific byte length
- *
- * @private
- * @static
- * @method trimFileNameToByteLength
- * @param {string} name without extension
- * @param {string} extension file extension
- * @return {string} The full name, including extension
- */
- trimFileNameToByteLength = function ( name, extension ) {
- // There is a special byte limit for file names and ... remember the dot
- return trimToByteLength( name, FILENAME_MAX_BYTES - extension.length - 1 ) + '.' + extension;
- };
-
- /**
- * @method constructor
- * @param {string} title Title of the page. If no second argument given,
- * this will be searched for a namespace
- * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title
- * @throws {Error} When the title is invalid
- */
- function Title( title, namespace ) {
- var parsed = parse( title, namespace );
- if ( !parsed ) {
- throw new Error( 'Unable to parse title' );
- }
-
- this.namespace = parsed.namespace;
- this.title = parsed.title;
- this.ext = parsed.ext;
- this.fragment = parsed.fragment;
- }
-
- /* Static members */
-
- /**
- * Constructor for Title objects with a null return instead of an exception for invalid titles.
- *
- * Note that `namespace` is the **default** namespace only, and can be overridden by a namespace
- * prefix in `title`. If you do not want this behavior, use #makeTitle. See #constructor for
- * details.
- *
- * @static
- * @param {string} title
- * @param {number} [namespace=NS_MAIN] Default namespace
- * @return {mw.Title|null} A valid Title object or null if the title is invalid
- */
- Title.newFromText = function ( title, namespace ) {
- var t, parsed = parse( title, namespace );
- if ( !parsed ) {
- return null;
- }
-
- t = Object.create( Title.prototype );
- t.namespace = parsed.namespace;
- t.title = parsed.title;
- t.ext = parsed.ext;
- t.fragment = parsed.fragment;
-
- return t;
- };
-
- /**
- * Constructor for Title objects with predefined namespace.
- *
- * Unlike #newFromText or #constructor, this function doesn't allow the given `namespace` to be
- * overridden by a namespace prefix in `title`. See #constructor for details about this behavior.
- *
- * The single exception to this is when `namespace` is 0, indicating the main namespace. The
- * function behaves like #newFromText in that case.
- *
- * @static
- * @param {number} namespace Namespace to use for the title
- * @param {string} title
- * @return {mw.Title|null} A valid Title object or null if the title is invalid
- */
- Title.makeTitle = function ( namespace, title ) {
- return mw.Title.newFromText( getNamespacePrefix( namespace ) + title );
- };
-
- /**
- * Constructor for Title objects from user input altering that input to
- * produce a title that MediaWiki will accept as legal
- *
- * @static
- * @param {string} title
- * @param {number} [defaultNamespace=NS_MAIN]
- * If given, will used as default namespace for the given title.
- * @param {Object} [options] additional options
- * @param {boolean} [options.forUploading=true]
- * Makes sure that a file is uploadable under the title returned.
- * There are pages in the file namespace under which file upload is impossible.
- * Automatically assumed if the title is created in the Media namespace.
- * @return {mw.Title|null} A valid Title object or null if the input cannot be turned into a valid title
- */
- Title.newFromUserInput = function ( title, defaultNamespace, options ) {
- var namespace, m, id, ext, parts;
-
- // defaultNamespace is optional; check whether options moves up
- if ( arguments.length < 3 && $.type( defaultNamespace ) === 'object' ) {
- options = defaultNamespace;
- defaultNamespace = undefined;
- }
-
- // merge options into defaults
- options = $.extend( {
- forUploading: true
- }, options );
-
- namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
-
- // Normalise additional whitespace
- title = title.replace( /\s/g, ' ' ).trim();
-
- // Process initial colon
- if ( title !== '' && title[ 0 ] === ':' ) {
- // Initial colon means main namespace instead of specified default
- namespace = NS_MAIN;
- title = title
- // Strip colon
- .substr( 1 )
- // Trim underscores
- .replace( rUnderscoreTrim, '' );
- }
-
- // Process namespace prefix (if any)
- m = title.match( rSplit );
- if ( m ) {
- id = getNsIdByName( m[ 1 ] );
- if ( id !== false ) {
- // Ordinary namespace
- namespace = id;
- title = m[ 2 ];
- }
- }
-
- if (
- namespace === NS_MEDIA ||
- ( options.forUploading && ( namespace === NS_FILE ) )
- ) {
-
- title = sanitize( title, [ 'generalRule', 'fileRule' ] );
-
- // Operate on the file extension
- // Although it is possible having spaces between the name and the ".ext" this isn't nice for
- // operating systems hiding file extensions -> strip them later on
- parts = title.split( '.' );
-
- if ( parts.length > 1 ) {
-
- // Get the last part, which is supposed to be the file extension
- ext = parts.pop();
-
- // Remove whitespace of the name part (that W/O extension)
- title = parts.join( '.' ).trim();
-
- // Cut, if too long and append file extension
- title = trimFileNameToByteLength( title, ext );
-
- } else {
-
- // Missing file extension
- title = parts.join( '.' ).trim();
-
- // Name has no file extension and a fallback wasn't provided either
- return null;
- }
- } else {
-
- title = sanitize( title, [ 'generalRule' ] );
-
- // Cut titles exceeding the TITLE_MAX_BYTES byte size limit
- // (size of underlying database field)
- if ( namespace !== NS_SPECIAL ) {
- title = trimToByteLength( title, TITLE_MAX_BYTES );
- }
- }
-
- // Any remaining initial :s are illegal.
- title = title.replace( /^:+/, '' );
-
- return Title.newFromText( title, namespace );
- };
-
- /**
- * Sanitizes a file name as supplied by the user, originating in the user's file system
- * so it is most likely a valid MediaWiki title and file name after processing.
- * Returns null on fatal errors.
- *
- * @static
- * @param {string} uncleanName The unclean file name including file extension but
- * without namespace
- * @return {mw.Title|null} A valid Title object or null if the title is invalid
- */
- Title.newFromFileName = function ( uncleanName ) {
-
- return Title.newFromUserInput( 'File:' + uncleanName, {
- forUploading: true
- } );
- };
-
- /**
- * Get the file title from an image element
- *
- * var title = mw.Title.newFromImg( $( 'img:first' ) );
- *
- * @static
- * @param {HTMLElement|jQuery} img The image to use as a base
- * @return {mw.Title|null} The file title or null if unsuccessful
- */
- Title.newFromImg = function ( img ) {
- var matches, i, regex, src, decodedSrc,
-
- // thumb.php-generated thumbnails
- thumbPhpRegex = /thumb\.php/,
- regexes = [
- // Thumbnails
- /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)\/[^\s/]+-[^\s/]*$/,
-
- // Full size images
- /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)$/,
-
- // Thumbnails in non-hashed upload directories
- /\/([^\s/]+)\/[^\s/]+-(?:\1|thumbnail)[^\s/]*$/,
-
- // Full-size images in non-hashed upload directories
- /\/([^\s/]+)$/
- ],
-
- recount = regexes.length;
-
- src = img.jquery ? img[ 0 ].src : img.src;
-
- matches = src.match( thumbPhpRegex );
-
- if ( matches ) {
- return mw.Title.newFromText( 'File:' + mw.util.getParamValue( 'f', src ) );
- }
-
- decodedSrc = decodeURIComponent( src );
-
- for ( i = 0; i < recount; i++ ) {
- regex = regexes[ i ];
- matches = decodedSrc.match( regex );
-
- if ( matches && matches[ 1 ] ) {
- return mw.Title.newFromText( 'File:' + matches[ 1 ] );
- }
- }
-
- return null;
- };
-
- /**
- * Whether this title exists on the wiki.
- *
- * @static
- * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title
- * @return {boolean|null} Boolean if the information is available, otherwise null
- */
- Title.exists = function ( title ) {
- var match,
- obj = Title.exist.pages;
-
- if ( typeof title === 'string' ) {
- match = obj[ title ];
- } else if ( title instanceof Title ) {
- match = obj[ title.toString() ];
- } else {
- throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' );
- }
-
- if ( typeof match !== 'boolean' ) {
- return null;
- }
-
- return match;
- };
-
- /**
- * Store page existence
- *
- * @static
- * @property {Object} exist
- * @property {Object} exist.pages Keyed by title. Boolean true value indicates page does exist.
- *
- * @property {Function} exist.set The setter function.
- *
- * Example to declare existing titles:
- *
- * Title.exist.set( ['User:John_Doe', ...] );
- *
- * Example to declare titles nonexistent:
- *
- * Title.exist.set( ['File:Foo_bar.jpg', ...], false );
- *
- * @property {string|Array} exist.set.titles Title(s) in strict prefixedDb title form
- * @property {boolean} [exist.set.state=true] State of the given titles
- * @return {boolean}
- */
- Title.exist = {
- pages: {},
-
- set: function ( titles, state ) {
- var i, len,
- pages = this.pages;
-
- titles = Array.isArray( titles ) ? titles : [ titles ];
- state = state === undefined ? true : !!state;
-
- for ( i = 0, len = titles.length; i < len; i++ ) {
- pages[ titles[ i ] ] = state;
- }
- return true;
- }
- };
-
- /**
- * Normalize a file extension to the common form, making it lowercase and checking some synonyms,
- * and ensure it's clean. Extensions with non-alphanumeric characters will be discarded.
- * Keep in sync with File::normalizeExtension() in PHP.
- *
- * @param {string} extension File extension (without the leading dot)
- * @return {string} File extension in canonical form
- */
- Title.normalizeExtension = function ( extension ) {
- var
- lower = extension.toLowerCase(),
- squish = {
- htm: 'html',
- jpeg: 'jpg',
- mpeg: 'mpg',
- tiff: 'tif',
- ogv: 'ogg'
- };
- if ( squish.hasOwnProperty( lower ) ) {
- return squish[ lower ];
- } else if ( /^[0-9a-z]+$/.test( lower ) ) {
- return lower;
- } else {
- return '';
- }
- };
-
- /* Public members */
-
- Title.prototype = {
- constructor: Title,
-
- /**
- * Get the namespace number
- *
- * Example: 6 for "File:Example_image.svg".
- *
- * @return {number}
- */
- getNamespaceId: function () {
- return this.namespace;
- },
-
- /**
- * Get the namespace prefix (in the content language)
- *
- * Example: "File:" for "File:Example_image.svg".
- * In #NS_MAIN this is '', otherwise namespace name plus ':'
- *
- * @return {string}
- */
- getNamespacePrefix: function () {
- return getNamespacePrefix( this.namespace );
- },
-
- /**
- * Get the page name without extension or namespace prefix
- *
- * Example: "Example_image" for "File:Example_image.svg".
- *
- * For the page title (full page name without namespace prefix), see #getMain.
- *
- * @return {string}
- */
- getName: function () {
- if (
- $.inArray( this.namespace, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ||
- !this.title.length
- ) {
- return this.title;
- }
- // PHP's strtoupper differs from String.toUpperCase in a number of cases
- // Bug: T147646
- return mw.Title.phpCharToUpper( this.title[ 0 ] ) + this.title.slice( 1 );
- },
-
- /**
- * Get the page name (transformed by #text)
- *
- * Example: "Example image" for "File:Example_image.svg".
- *
- * For the page title (full page name without namespace prefix), see #getMainText.
- *
- * @return {string}
- */
- getNameText: function () {
- return text( this.getName() );
- },
-
- /**
- * Get the extension of the page name (if any)
- *
- * @return {string|null} Name extension or null if there is none
- */
- getExtension: function () {
- return this.ext;
- },
-
- /**
- * Shortcut for appendable string to form the main page name.
- *
- * Returns a string like ".json", or "" if no extension.
- *
- * @return {string}
- */
- getDotExtension: function () {
- return this.ext === null ? '' : '.' + this.ext;
- },
-
- /**
- * Get the main page name
- *
- * Example: "Example_image.svg" for "File:Example_image.svg".
- *
- * @return {string}
- */
- getMain: function () {
- return this.getName() + this.getDotExtension();
- },
-
- /**
- * Get the main page name (transformed by #text)
- *
- * Example: "Example image.svg" for "File:Example_image.svg".
- *
- * @return {string}
- */
- getMainText: function () {
- return text( this.getMain() );
- },
-
- /**
- * Get the full page name
- *
- * Example: "File:Example_image.svg".
- * Most useful for API calls, anything that must identify the "title".
- *
- * @return {string}
- */
- getPrefixedDb: function () {
- return this.getNamespacePrefix() + this.getMain();
- },
-
- /**
- * Get the full page name (transformed by #text)
- *
- * Example: "File:Example image.svg" for "File:Example_image.svg".
- *
- * @return {string}
- */
- getPrefixedText: function () {
- return text( this.getPrefixedDb() );
- },
-
- /**
- * Get the page name relative to a namespace
- *
- * Example:
- *
- * - "Foo:Bar" relative to the Foo namespace becomes "Bar".
- * - "Bar" relative to any non-main namespace becomes ":Bar".
- * - "Foo:Bar" relative to any namespace other than Foo stays "Foo:Bar".
- *
- * @param {number} namespace The namespace to be relative to
- * @return {string}
- */
- getRelativeText: function ( namespace ) {
- if ( this.getNamespaceId() === namespace ) {
- return this.getMainText();
- } else if ( this.getNamespaceId() === NS_MAIN ) {
- return ':' + this.getPrefixedText();
- } else {
- return this.getPrefixedText();
- }
- },
-
- /**
- * Get the fragment (if any).
- *
- * Note that this method (by design) does not include the hash character and
- * the value is not url encoded.
- *
- * @return {string|null}
- */
- getFragment: function () {
- return this.fragment;
- },
-
- /**
- * Get the URL to this title
- *
- * @see mw.util#getUrl
- * @param {Object} [params] A mapping of query parameter names to values,
- * e.g. `{ action: 'edit' }`.
- * @return {string}
- */
- getUrl: function ( params ) {
- var fragment = this.getFragment();
- if ( fragment ) {
- return mw.util.getUrl( this.toString() + '#' + fragment, params );
- } else {
- return mw.util.getUrl( this.toString(), params );
- }
- },
-
- /**
- * Whether this title exists on the wiki.
- *
- * @see #static-method-exists
- * @return {boolean|null} Boolean if the information is available, otherwise null
- */
- exists: function () {
- return Title.exists( this );
- }
- };
-
- /**
- * @alias #getPrefixedDb
- * @method
- */
- Title.prototype.toString = Title.prototype.getPrefixedDb;
-
- /**
- * @alias #getPrefixedText
- * @method
- */
- Title.prototype.toText = Title.prototype.getPrefixedText;
-
- // Expose
- mw.Title = Title;
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-// This file can't be parsed by JSDuck due to <https://github.com/tenderlove/rkelly/issues/35>.
-// (It is excluded in jsduck.json.)
-// ESLint suggests unquoting some object keys, which would render the file unparseable by Opera 12.
-/* eslint-disable quote-props */
-( function ( mw ) {
- var toUpperMapping = {
- 'ß': 'ß',
- 'ʼn': 'ʼn',
- 'Dž': 'Dž',
- 'dž': 'Dž',
- 'Lj': 'Lj',
- 'lj': 'Lj',
- 'Nj': 'Nj',
- 'nj': 'Nj',
- 'ǰ': 'ǰ',
- 'Dz': 'Dz',
- 'dz': 'Dz',
- 'ʝ': 'Ʝ',
- 'ͅ': 'ͅ',
- 'ΐ': 'ΐ',
- 'ΰ': 'ΰ',
- 'և': 'և',
- 'ᏸ': 'Ᏸ',
- 'ᏹ': 'Ᏹ',
- 'ᏺ': 'Ᏺ',
- 'ᏻ': 'Ᏻ',
- 'ᏼ': 'Ᏼ',
- 'ᏽ': 'Ᏽ',
- 'ẖ': 'ẖ',
- 'ẗ': 'ẗ',
- 'ẘ': 'ẘ',
- 'ẙ': 'ẙ',
- 'ẚ': 'ẚ',
- 'ὐ': 'ὐ',
- 'ὒ': 'ὒ',
- 'ὔ': 'ὔ',
- 'ὖ': 'ὖ',
- 'ᾀ': 'ᾈ',
- 'ᾁ': 'ᾉ',
- 'ᾂ': 'ᾊ',
- 'ᾃ': 'ᾋ',
- 'ᾄ': 'ᾌ',
- 'ᾅ': 'ᾍ',
- 'ᾆ': 'ᾎ',
- 'ᾇ': 'ᾏ',
- 'ᾈ': 'ᾈ',
- 'ᾉ': 'ᾉ',
- 'ᾊ': 'ᾊ',
- 'ᾋ': 'ᾋ',
- 'ᾌ': 'ᾌ',
- 'ᾍ': 'ᾍ',
- 'ᾎ': 'ᾎ',
- 'ᾏ': 'ᾏ',
- 'ᾐ': 'ᾘ',
- 'ᾑ': 'ᾙ',
- 'ᾒ': 'ᾚ',
- 'ᾓ': 'ᾛ',
- 'ᾔ': 'ᾜ',
- 'ᾕ': 'ᾝ',
- 'ᾖ': 'ᾞ',
- 'ᾗ': 'ᾟ',
- 'ᾘ': 'ᾘ',
- 'ᾙ': 'ᾙ',
- 'ᾚ': 'ᾚ',
- 'ᾛ': 'ᾛ',
- 'ᾜ': 'ᾜ',
- 'ᾝ': 'ᾝ',
- 'ᾞ': 'ᾞ',
- 'ᾟ': 'ᾟ',
- 'ᾠ': 'ᾨ',
- 'ᾡ': 'ᾩ',
- 'ᾢ': 'ᾪ',
- 'ᾣ': 'ᾫ',
- 'ᾤ': 'ᾬ',
- 'ᾥ': 'ᾭ',
- 'ᾦ': 'ᾮ',
- 'ᾧ': 'ᾯ',
- 'ᾨ': 'ᾨ',
- 'ᾩ': 'ᾩ',
- 'ᾪ': 'ᾪ',
- 'ᾫ': 'ᾫ',
- 'ᾬ': 'ᾬ',
- 'ᾭ': 'ᾭ',
- 'ᾮ': 'ᾮ',
- 'ᾯ': 'ᾯ',
- 'ᾲ': 'ᾲ',
- 'ᾳ': 'ᾼ',
- 'ᾴ': 'ᾴ',
- 'ᾶ': 'ᾶ',
- 'ᾷ': 'ᾷ',
- 'ᾼ': 'ᾼ',
- 'ῂ': 'ῂ',
- 'ῃ': 'ῌ',
- 'ῄ': 'ῄ',
- 'ῆ': 'ῆ',
- 'ῇ': 'ῇ',
- 'ῌ': 'ῌ',
- 'ῒ': 'ῒ',
- 'ΐ': 'ΐ',
- 'ῖ': 'ῖ',
- 'ῗ': 'ῗ',
- 'ῢ': 'ῢ',
- 'ΰ': 'ΰ',
- 'ῤ': 'ῤ',
- 'ῦ': 'ῦ',
- 'ῧ': 'ῧ',
- 'ῲ': 'ῲ',
- 'ῳ': 'ῼ',
- 'ῴ': 'ῴ',
- 'ῶ': 'ῶ',
- 'ῷ': 'ῷ',
- 'ῼ': 'ῼ',
- 'ⅰ': 'ⅰ',
- 'ⅱ': 'ⅱ',
- 'ⅲ': 'ⅲ',
- 'ⅳ': 'ⅳ',
- 'ⅴ': 'ⅴ',
- 'ⅵ': 'ⅵ',
- 'ⅶ': 'ⅶ',
- 'ⅷ': 'ⅷ',
- 'ⅸ': 'ⅸ',
- 'ⅹ': 'ⅹ',
- 'ⅺ': 'ⅺ',
- 'ⅻ': 'ⅻ',
- 'ⅼ': 'ⅼ',
- 'ⅽ': 'ⅽ',
- 'ⅾ': 'ⅾ',
- 'ⅿ': 'ⅿ',
- 'ⓐ': 'ⓐ',
- 'ⓑ': 'ⓑ',
- 'ⓒ': 'ⓒ',
- 'ⓓ': 'ⓓ',
- 'ⓔ': 'ⓔ',
- 'ⓕ': 'ⓕ',
- 'ⓖ': 'ⓖ',
- 'ⓗ': 'ⓗ',
- 'ⓘ': 'ⓘ',
- 'ⓙ': 'ⓙ',
- 'ⓚ': 'ⓚ',
- 'ⓛ': 'ⓛ',
- 'ⓜ': 'ⓜ',
- 'ⓝ': 'ⓝ',
- 'ⓞ': 'ⓞ',
- 'ⓟ': 'ⓟ',
- 'ⓠ': 'ⓠ',
- 'ⓡ': 'ⓡ',
- 'ⓢ': 'ⓢ',
- 'ⓣ': 'ⓣ',
- 'ⓤ': 'ⓤ',
- 'ⓥ': 'ⓥ',
- 'ⓦ': 'ⓦ',
- 'ⓧ': 'ⓧ',
- 'ⓨ': 'ⓨ',
- 'ⓩ': 'ⓩ',
- 'ꞵ': 'Ꞵ',
- 'ꞷ': 'Ꞷ',
- 'ꭓ': 'Ꭓ',
- 'ꭰ': 'Ꭰ',
- 'ꭱ': 'Ꭱ',
- 'ꭲ': 'Ꭲ',
- 'ꭳ': 'Ꭳ',
- 'ꭴ': 'Ꭴ',
- 'ꭵ': 'Ꭵ',
- 'ꭶ': 'Ꭶ',
- 'ꭷ': 'Ꭷ',
- 'ꭸ': 'Ꭸ',
- 'ꭹ': 'Ꭹ',
- 'ꭺ': 'Ꭺ',
- 'ꭻ': 'Ꭻ',
- 'ꭼ': 'Ꭼ',
- 'ꭽ': 'Ꭽ',
- 'ꭾ': 'Ꭾ',
- 'ꭿ': 'Ꭿ',
- 'ꮀ': 'Ꮀ',
- 'ꮁ': 'Ꮁ',
- 'ꮂ': 'Ꮂ',
- 'ꮃ': 'Ꮃ',
- 'ꮄ': 'Ꮄ',
- 'ꮅ': 'Ꮅ',
- 'ꮆ': 'Ꮆ',
- 'ꮇ': 'Ꮇ',
- 'ꮈ': 'Ꮈ',
- 'ꮉ': 'Ꮉ',
- 'ꮊ': 'Ꮊ',
- 'ꮋ': 'Ꮋ',
- 'ꮌ': 'Ꮌ',
- 'ꮍ': 'Ꮍ',
- 'ꮎ': 'Ꮎ',
- 'ꮏ': 'Ꮏ',
- 'ꮐ': 'Ꮐ',
- 'ꮑ': 'Ꮑ',
- 'ꮒ': 'Ꮒ',
- 'ꮓ': 'Ꮓ',
- 'ꮔ': 'Ꮔ',
- 'ꮕ': 'Ꮕ',
- 'ꮖ': 'Ꮖ',
- 'ꮗ': 'Ꮗ',
- 'ꮘ': 'Ꮘ',
- 'ꮙ': 'Ꮙ',
- 'ꮚ': 'Ꮚ',
- 'ꮛ': 'Ꮛ',
- 'ꮜ': 'Ꮜ',
- 'ꮝ': 'Ꮝ',
- 'ꮞ': 'Ꮞ',
- 'ꮟ': 'Ꮟ',
- 'ꮠ': 'Ꮠ',
- 'ꮡ': 'Ꮡ',
- 'ꮢ': 'Ꮢ',
- 'ꮣ': 'Ꮣ',
- 'ꮤ': 'Ꮤ',
- 'ꮥ': 'Ꮥ',
- 'ꮦ': 'Ꮦ',
- 'ꮧ': 'Ꮧ',
- 'ꮨ': 'Ꮨ',
- 'ꮩ': 'Ꮩ',
- 'ꮪ': 'Ꮪ',
- 'ꮫ': 'Ꮫ',
- 'ꮬ': 'Ꮬ',
- 'ꮭ': 'Ꮭ',
- 'ꮮ': 'Ꮮ',
- 'ꮯ': 'Ꮯ',
- 'ꮰ': 'Ꮰ',
- 'ꮱ': 'Ꮱ',
- 'ꮲ': 'Ꮲ',
- 'ꮳ': 'Ꮳ',
- 'ꮴ': 'Ꮴ',
- 'ꮵ': 'Ꮵ',
- 'ꮶ': 'Ꮶ',
- 'ꮷ': 'Ꮷ',
- 'ꮸ': 'Ꮸ',
- 'ꮹ': 'Ꮹ',
- 'ꮺ': 'Ꮺ',
- 'ꮻ': 'Ꮻ',
- 'ꮼ': 'Ꮼ',
- 'ꮽ': 'Ꮽ',
- 'ꮾ': 'Ꮾ',
- 'ꮿ': 'Ꮿ',
- 'ff': 'ff',
- 'fi': 'fi',
- 'fl': 'fl',
- 'ffi': 'ffi',
- 'ffl': 'ffl',
- 'ſt': 'ſt',
- 'st': 'st',
- 'ﬓ': 'ﬓ',
- 'ﬔ': 'ﬔ',
- 'ﬕ': 'ﬕ',
- 'ﬖ': 'ﬖ',
- 'ﬗ': 'ﬗ'
- };
- mw.Title.phpCharToUpper = function ( chr ) {
- var mapped = toUpperMapping[ chr ];
- return mapped || chr.toUpperCase();
- };
-}( mediaWiki ) );
+++ /dev/null
-.mw-upload-bookletLayout-filePreview {
- width: 100%;
- height: 1em;
- background-color: #eaecf0;
- background-size: cover;
- background-position: center center;
- padding: 1.5em;
- margin: -1.5em;
- margin-bottom: 1.5em;
- position: relative;
-}
-
-.mw-upload-bookletLayout-infoForm.mw-upload-bookletLayout-hasThumbnail .mw-upload-bookletLayout-filePreview {
- height: 10em;
-}
-
-.mw-upload-bookletLayout-filePreview p {
- line-height: 1em;
- margin: 0;
-}
-
-.mw-upload-bookletLayout-filePreview .oo-ui-progressBarWidget {
- border: 0;
- border-radius: 0;
- background-color: transparent;
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
-}
-
-.mw-upload-bookletLayout-filePreview .oo-ui-progressBarWidget-bar {
- height: 0.5em;
-}
+++ /dev/null
-/* global moment */
-( function ( $, mw, moment ) {
-
- /**
- * mw.Upload.BookletLayout encapsulates the process of uploading a file
- * to MediaWiki using the {@link mw.Upload upload model}.
- * The booklet emits events that can be used to get the stashed
- * upload and the final file. It can be extended to accept
- * additional fields from the user for specific scenarios like
- * for Commons, or campaigns.
- *
- * ## Structure
- *
- * The {@link OO.ui.BookletLayout booklet layout} has three steps:
- *
- * - **Upload**: Has a {@link OO.ui.SelectFileWidget field} to get the file object.
- *
- * - **Information**: Has a {@link OO.ui.FormLayout form} to collect metadata. This can be
- * extended.
- *
- * - **Insert**: Has details on how to use the file that was uploaded.
- *
- * Each step has a form associated with it defined in
- * {@link #renderUploadForm renderUploadForm},
- * {@link #renderInfoForm renderInfoForm}, and
- * {@link #renderInsertForm renderInfoForm}. The
- * {@link #getFile getFile},
- * {@link #getFilename getFilename}, and
- * {@link #getText getText} methods are used to get
- * the information filled in these forms, required to call
- * {@link mw.Upload mw.Upload}.
- *
- * ## Usage
- *
- * See the {@link mw.Upload.Dialog upload dialog}.
- *
- * The {@link #event-fileUploaded fileUploaded},
- * and {@link #event-fileSaved fileSaved} events can
- * be used to get details of the upload.
- *
- * ## Extending
- *
- * To extend using {@link mw.Upload mw.Upload}, override
- * {@link #renderInfoForm renderInfoForm} to render
- * the form required for the specific use-case. Update the
- * {@link #getFilename getFilename}, and
- * {@link #getText getText} methods to return data
- * from your newly created form. If you added new fields you'll also have
- * to update the {@link #clear} method.
- *
- * If you plan to use a different upload model, apart from what is mentioned
- * above, you'll also have to override the
- * {@link #createUpload createUpload} method to
- * return the new model. The {@link #saveFile saveFile}, and
- * the {@link #uploadFile uploadFile} methods need to be
- * overridden to use the new model and data returned from the forms.
- *
- * @class
- * @extends OO.ui.BookletLayout
- *
- * @constructor
- * @param {Object} config Configuration options
- * @cfg {jQuery} [$overlay] Overlay to use for widgets in the booklet
- * @cfg {string} [filekey] Sets the stashed file to finish uploading. Overrides most of the file selection process, and fetches a thumbnail from the server.
- */
- mw.Upload.BookletLayout = function ( config ) {
- // Parent constructor
- mw.Upload.BookletLayout.parent.call( this, config );
-
- this.$overlay = config.$overlay;
-
- this.filekey = config.filekey;
-
- this.renderUploadForm();
- this.renderInfoForm();
- this.renderInsertForm();
-
- this.addPages( [
- new OO.ui.PageLayout( 'upload', {
- scrollable: true,
- padded: true,
- content: [ this.uploadForm ]
- } ),
- new OO.ui.PageLayout( 'info', {
- scrollable: true,
- padded: true,
- content: [ this.infoForm ]
- } ),
- new OO.ui.PageLayout( 'insert', {
- scrollable: true,
- padded: true,
- content: [ this.insertForm ]
- } )
- ] );
- };
-
- /* Setup */
-
- OO.inheritClass( mw.Upload.BookletLayout, OO.ui.BookletLayout );
-
- /* Events */
-
- /**
- * Progress events for the uploaded file
- *
- * @event fileUploadProgress
- * @param {number} progress In percentage
- * @param {Object} duration Duration object from `moment.duration()`
- */
-
- /**
- * The file has finished uploading
- *
- * @event fileUploaded
- */
-
- /**
- * The file has been saved to the database
- *
- * @event fileSaved
- * @param {Object} imageInfo See mw.Upload#getImageInfo
- */
-
- /**
- * The upload form has changed
- *
- * @event uploadValid
- * @param {boolean} isValid The form is valid
- */
-
- /**
- * The info form has changed
- *
- * @event infoValid
- * @param {boolean} isValid The form is valid
- */
-
- /* Properties */
-
- /**
- * @property {OO.ui.FormLayout} uploadForm
- * The form rendered in the first step to get the file object.
- * Rendered in {@link #renderUploadForm renderUploadForm}.
- */
-
- /**
- * @property {OO.ui.FormLayout} infoForm
- * The form rendered in the second step to get metadata.
- * Rendered in {@link #renderInfoForm renderInfoForm}
- */
-
- /**
- * @property {OO.ui.FormLayout} insertForm
- * The form rendered in the third step to show usage
- * Rendered in {@link #renderInsertForm renderInsertForm}
- */
-
- /* Methods */
-
- /**
- * Initialize for a new upload
- *
- * @return {jQuery.Promise} Promise resolved when everything is initialized
- */
- mw.Upload.BookletLayout.prototype.initialize = function () {
- var booklet = this;
-
- this.clear();
- this.upload = this.createUpload();
-
- this.setPage( 'upload' );
-
- if ( this.filekey ) {
- this.setFilekey( this.filekey );
- }
-
- return this.upload.getApi().then(
- function ( api ) {
- // If the user can't upload anything, don't give them the option to.
- return api.getUserInfo().then(
- function ( userInfo ) {
- if ( userInfo.rights.indexOf( 'upload' ) === -1 ) {
- if ( mw.user.isAnon() ) {
- booklet.getPage( 'upload' ).$element.msg( 'apierror-mustbeloggedin', mw.msg( 'action-upload' ) );
- } else {
- booklet.getPage( 'upload' ).$element.msg( 'apierror-permissiondenied', mw.msg( 'action-upload' ) );
- }
- }
- return $.Deferred().resolve();
- },
- // Always resolve, never reject
- function () { return $.Deferred().resolve(); }
- );
- },
- function ( errorMsg ) {
- booklet.getPage( 'upload' ).$element.msg( errorMsg );
- return $.Deferred().resolve();
- }
- );
- };
-
- /**
- * Create a new upload model
- *
- * @protected
- * @return {mw.Upload} Upload model
- */
- mw.Upload.BookletLayout.prototype.createUpload = function () {
- return new mw.Upload( {
- parameters: {
- errorformat: 'html',
- errorlang: mw.config.get( 'wgUserLanguage' ),
- errorsuselocal: 1,
- formatversion: 2
- }
- } );
- };
-
- /* Uploading */
-
- /**
- * Uploads the file that was added in the upload form. Uses
- * {@link #getFile getFile} to get the HTML5
- * file object.
- *
- * @protected
- * @fires fileUploadProgress
- * @fires fileUploaded
- * @return {jQuery.Promise}
- */
- mw.Upload.BookletLayout.prototype.uploadFile = function () {
- var deferred = $.Deferred(),
- startTime = mw.now(),
- layout = this,
- file = this.getFile();
-
- this.setPage( 'info' );
-
- if ( this.filekey ) {
- if ( file === null ) {
- // Someone gonna get-a hurt real bad
- throw new Error( 'filekey not passed into file select widget, which is impossible. Quitting while we\'re behind.' );
- }
-
- // Stashed file already uploaded.
- deferred.resolve();
- this.uploadPromise = deferred;
- this.emit( 'fileUploaded' );
- return deferred;
- }
-
- this.setFilename( file.name );
-
- this.upload.setFile( file );
- // The original file name might contain invalid characters, so use our sanitized one
- this.upload.setFilename( this.getFilename() );
-
- this.uploadPromise = this.upload.uploadToStash();
- this.uploadPromise.then( function () {
- deferred.resolve();
- layout.emit( 'fileUploaded' );
- }, function () {
- // These errors will be thrown while the user is on the info page.
- layout.getErrorMessageForStateDetails().then( function ( errorMessage ) {
- deferred.reject( errorMessage );
- } );
- }, function ( progress ) {
- var elapsedTime = mw.now() - startTime,
- estimatedTotalTime = ( 1 / progress ) * elapsedTime,
- estimatedRemainingTime = moment.duration( estimatedTotalTime - elapsedTime );
- layout.emit( 'fileUploadProgress', progress, estimatedRemainingTime );
- } );
-
- // If there is an error in uploading, come back to the upload page
- deferred.fail( function () {
- layout.setPage( 'upload' );
- } );
-
- return deferred;
- };
-
- /**
- * Saves the stash finalizes upload. Uses
- * {@link #getFilename getFilename}, and
- * {@link #getText getText} to get details from
- * the form.
- *
- * @protected
- * @fires fileSaved
- * @return {jQuery.Promise} Rejects the promise with an
- * {@link OO.ui.Error error}, or resolves if the upload was successful.
- */
- mw.Upload.BookletLayout.prototype.saveFile = function () {
- var layout = this,
- deferred = $.Deferred();
-
- this.upload.setFilename( this.getFilename() );
- this.upload.setText( this.getText() );
-
- this.uploadPromise.then( function () {
- layout.upload.finishStashUpload().then( function () {
- var name;
-
- // Normalize page name and localise the 'File:' prefix
- name = new mw.Title( 'File:' + layout.upload.getFilename() ).toString();
- layout.filenameUsageWidget.setValue( '[[' + name + ']]' );
- layout.setPage( 'insert' );
-
- deferred.resolve();
- layout.emit( 'fileSaved', layout.upload.getImageInfo() );
- }, function () {
- layout.getErrorMessageForStateDetails().then( function ( errorMessage ) {
- deferred.reject( errorMessage );
- } );
- } );
- } );
-
- return deferred.promise();
- };
-
- /**
- * Get an error message (as OO.ui.Error object) that should be displayed to the user for current
- * state and state details.
- *
- * @protected
- * @return {jQuery.Promise} A Promise that will be resolved with an OO.ui.Error.
- */
- mw.Upload.BookletLayout.prototype.getErrorMessageForStateDetails = function () {
- var state = this.upload.getState(),
- stateDetails = this.upload.getStateDetails(),
- error = stateDetails.errors ? stateDetails.errors[ 0 ] : false,
- warnings = stateDetails.upload && stateDetails.upload.warnings,
- $ul = $( '<ul>' ),
- errorText;
-
- if ( state === mw.Upload.State.ERROR ) {
- if ( !error ) {
- if ( stateDetails.textStatus === 'timeout' ) {
- // in case of $.ajax.fail(), there is no response json
- errorText = mw.message( 'apierror-timeout' ).parse();
- } else if ( stateDetails.xhr && stateDetails.xhr.status === 0 ) {
- // failed to even connect to server
- errorText = mw.message( 'apierror-offline' ).parse();
- } else if ( stateDetails.textStatus ) {
- errorText = stateDetails.textStatus;
- } else {
- errorText = mw.message( 'apierror-unknownerror', JSON.stringify( stateDetails ) ).parse();
- }
-
- // If there's an 'exception' key, this might be a timeout, or other connection problem
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).html( errorText ),
- { recoverable: false }
- ) );
- }
-
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).html( error.html ),
- { recoverable: false }
- ) );
- }
-
- if ( state === mw.Upload.State.WARNING ) {
- // We could get more than one of these errors, these are in order
- // of importance. For example fixing the thumbnail like file name
- // won't help the fact that the file already exists.
- if ( warnings.exists !== undefined ) {
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).msg( 'fileexists', 'File:' + warnings.exists ),
- { recoverable: false }
- ) );
- } else if ( warnings[ 'exists-normalized' ] !== undefined ) {
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).msg( 'fileexists', 'File:' + warnings[ 'exists-normalized' ] ),
- { recoverable: false }
- ) );
- } else if ( warnings[ 'page-exists' ] !== undefined ) {
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).msg( 'filepageexists', 'File:' + warnings[ 'page-exists' ] ),
- { recoverable: false }
- ) );
- } else if ( Array.isArray( warnings.duplicate ) ) {
- warnings.duplicate.forEach( function ( filename ) {
- var $a = $( '<a>' ).text( filename ),
- href = mw.Title.makeTitle( mw.config.get( 'wgNamespaceIds' ).file, filename ).getUrl( {} );
-
- $a.attr( { href: href, target: '_blank' } );
- $ul.append( $( '<li>' ).append( $a ) );
- } );
-
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).msg( 'file-exists-duplicate', warnings.duplicate.length ).append( $ul ),
- { recoverable: false }
- ) );
- } else if ( warnings[ 'thumb-name' ] !== undefined ) {
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).msg( 'filename-thumb-name' ),
- { recoverable: false }
- ) );
- } else if ( warnings[ 'bad-prefix' ] !== undefined ) {
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).msg( 'filename-bad-prefix', warnings[ 'bad-prefix' ] ),
- { recoverable: false }
- ) );
- } else if ( warnings[ 'duplicate-archive' ] !== undefined ) {
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).msg( 'file-deleted-duplicate', 'File:' + warnings[ 'duplicate-archive' ] ),
- { recoverable: false }
- ) );
- } else if ( warnings[ 'was-deleted' ] !== undefined ) {
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).msg( 'filewasdeleted', 'File:' + warnings[ 'was-deleted' ] ),
- { recoverable: false }
- ) );
- } else if ( warnings.badfilename !== undefined ) {
- // Change the name if the current name isn't acceptable
- // TODO This might not really be the best place to do this
- this.setFilename( warnings.badfilename );
- return $.Deferred().resolve( new OO.ui.Error(
- $( '<p>' ).msg( 'badfilename', warnings.badfilename )
- ) );
- } else {
- return $.Deferred().resolve( new OO.ui.Error(
- // Let's get all the help we can if we can't pin point the error
- $( '<p>' ).msg( 'api-error-unknown-warning', JSON.stringify( stateDetails ) ),
- { recoverable: false }
- ) );
- }
- }
- };
-
- /* Form renderers */
-
- /**
- * Renders and returns the upload form and sets the
- * {@link #uploadForm uploadForm} property.
- *
- * @protected
- * @fires selectFile
- * @return {OO.ui.FormLayout}
- */
- mw.Upload.BookletLayout.prototype.renderUploadForm = function () {
- var fieldset,
- layout = this;
-
- this.selectFileWidget = this.getFileWidget();
- fieldset = new OO.ui.FieldsetLayout();
- fieldset.addItems( [ this.selectFileWidget ] );
- this.uploadForm = new OO.ui.FormLayout( { items: [ fieldset ] } );
-
- // Validation (if the SFW is for a stashed file, this never fires)
- this.selectFileWidget.on( 'change', this.onUploadFormChange.bind( this ) );
-
- this.selectFileWidget.on( 'change', function () {
- layout.updateFilePreview();
- } );
-
- return this.uploadForm;
- };
-
- /**
- * Gets the widget for displaying or inputting the file to upload.
- *
- * @return {OO.ui.SelectFileWidget|mw.widgets.StashedFileWidget}
- */
- mw.Upload.BookletLayout.prototype.getFileWidget = function () {
- if ( this.filekey ) {
- return new mw.widgets.StashedFileWidget( {
- filekey: this.filekey
- } );
- }
-
- return new OO.ui.SelectFileWidget( {
- showDropTarget: true
- } );
- };
-
- /**
- * Updates the file preview on the info form when a file is added.
- *
- * @protected
- */
- mw.Upload.BookletLayout.prototype.updateFilePreview = function () {
- this.selectFileWidget.loadAndGetImageUrl().done( function ( url ) {
- this.filePreview.$element.find( 'p' ).remove();
- this.filePreview.$element.css( 'background-image', 'url(' + url + ')' );
- this.infoForm.$element.addClass( 'mw-upload-bookletLayout-hasThumbnail' );
- }.bind( this ) ).fail( function () {
- this.filePreview.$element.find( 'p' ).remove();
- if ( this.selectFileWidget.getValue() ) {
- this.filePreview.$element.append(
- $( '<p>' ).text( this.selectFileWidget.getValue().name )
- );
- }
- this.filePreview.$element.css( 'background-image', '' );
- this.infoForm.$element.removeClass( 'mw-upload-bookletLayout-hasThumbnail' );
- }.bind( this ) );
- };
-
- /**
- * Handle change events to the upload form
- *
- * @protected
- * @fires uploadValid
- */
- mw.Upload.BookletLayout.prototype.onUploadFormChange = function () {
- this.emit( 'uploadValid', !!this.selectFileWidget.getValue() );
- };
-
- /**
- * Renders and returns the information form for collecting
- * metadata and sets the {@link #infoForm infoForm}
- * property.
- *
- * @protected
- * @return {OO.ui.FormLayout}
- */
- mw.Upload.BookletLayout.prototype.renderInfoForm = function () {
- var fieldset;
-
- this.filePreview = new OO.ui.Widget( {
- classes: [ 'mw-upload-bookletLayout-filePreview' ]
- } );
- this.progressBarWidget = new OO.ui.ProgressBarWidget( {
- progress: 0
- } );
- this.filePreview.$element.append( this.progressBarWidget.$element );
-
- this.filenameWidget = new OO.ui.TextInputWidget( {
- indicator: 'required',
- required: true,
- validate: /.+/
- } );
- this.descriptionWidget = new OO.ui.MultilineTextInputWidget( {
- indicator: 'required',
- required: true,
- validate: /\S+/,
- autosize: true
- } );
-
- fieldset = new OO.ui.FieldsetLayout( {
- label: mw.msg( 'upload-form-label-infoform-title' )
- } );
- fieldset.addItems( [
- new OO.ui.FieldLayout( this.filenameWidget, {
- label: mw.msg( 'upload-form-label-infoform-name' ),
- align: 'top',
- help: mw.msg( 'upload-form-label-infoform-name-tooltip' )
- } ),
- new OO.ui.FieldLayout( this.descriptionWidget, {
- label: mw.msg( 'upload-form-label-infoform-description' ),
- align: 'top',
- help: mw.msg( 'upload-form-label-infoform-description-tooltip' )
- } )
- ] );
- this.infoForm = new OO.ui.FormLayout( {
- classes: [ 'mw-upload-bookletLayout-infoForm' ],
- items: [ this.filePreview, fieldset ]
- } );
-
- this.on( 'fileUploadProgress', function ( progress ) {
- this.progressBarWidget.setProgress( progress * 100 );
- }.bind( this ) );
-
- this.filenameWidget.on( 'change', this.onInfoFormChange.bind( this ) );
- this.descriptionWidget.on( 'change', this.onInfoFormChange.bind( this ) );
-
- return this.infoForm;
- };
-
- /**
- * Handle change events to the info form
- *
- * @protected
- * @fires infoValid
- */
- mw.Upload.BookletLayout.prototype.onInfoFormChange = function () {
- var layout = this;
- $.when(
- this.filenameWidget.getValidity(),
- this.descriptionWidget.getValidity()
- ).done( function () {
- layout.emit( 'infoValid', true );
- } ).fail( function () {
- layout.emit( 'infoValid', false );
- } );
- };
-
- /**
- * Renders and returns the insert form to show file usage and
- * sets the {@link #insertForm insertForm} property.
- *
- * @protected
- * @return {OO.ui.FormLayout}
- */
- mw.Upload.BookletLayout.prototype.renderInsertForm = function () {
- var fieldset;
-
- this.filenameUsageWidget = new OO.ui.TextInputWidget();
- fieldset = new OO.ui.FieldsetLayout( {
- label: mw.msg( 'upload-form-label-usage-title' )
- } );
- fieldset.addItems( [
- new OO.ui.FieldLayout( this.filenameUsageWidget, {
- label: mw.msg( 'upload-form-label-usage-filename' ),
- align: 'top'
- } )
- ] );
- this.insertForm = new OO.ui.FormLayout( { items: [ fieldset ] } );
-
- return this.insertForm;
- };
-
- /* Getters */
-
- /**
- * Gets the file object from the
- * {@link #uploadForm upload form}.
- *
- * @protected
- * @return {File|null}
- */
- mw.Upload.BookletLayout.prototype.getFile = function () {
- return this.selectFileWidget.getValue();
- };
-
- /**
- * Gets the file name from the
- * {@link #infoForm information form}.
- *
- * @protected
- * @return {string}
- */
- mw.Upload.BookletLayout.prototype.getFilename = function () {
- var filename = this.filenameWidget.getValue();
- if ( this.filenameExtension ) {
- filename += '.' + this.filenameExtension;
- }
- return filename;
- };
-
- /**
- * Prefills the {@link #infoForm information form} with the given filename.
- *
- * @protected
- * @param {string} filename
- */
- mw.Upload.BookletLayout.prototype.setFilename = function ( filename ) {
- var title = mw.Title.newFromFileName( filename );
-
- if ( title ) {
- this.filenameWidget.setValue( title.getNameText() );
- this.filenameExtension = mw.Title.normalizeExtension( title.getExtension() );
- } else {
- // Seems to happen for files with no extension, which should fail some checks anyway...
- this.filenameWidget.setValue( filename );
- this.filenameExtension = null;
- }
- };
-
- /**
- * Gets the page text from the
- * {@link #infoForm information form}.
- *
- * @protected
- * @return {string}
- */
- mw.Upload.BookletLayout.prototype.getText = function () {
- return this.descriptionWidget.getValue();
- };
-
- /* Setters */
-
- /**
- * Sets the file object
- *
- * @protected
- * @param {File|null} file File to select
- */
- mw.Upload.BookletLayout.prototype.setFile = function ( file ) {
- this.selectFileWidget.setValue( file );
- };
-
- /**
- * Sets the filekey of a file already stashed on the server
- * as the target of this upload operation.
- *
- * @protected
- * @param {string} filekey
- */
- mw.Upload.BookletLayout.prototype.setFilekey = function ( filekey ) {
- this.upload.setFilekey( this.filekey );
- this.selectFileWidget.setValue( filekey );
-
- this.onUploadFormChange();
- };
-
- /**
- * Clear the values of all fields
- *
- * @protected
- */
- mw.Upload.BookletLayout.prototype.clear = function () {
- this.selectFileWidget.setValue( null );
- this.progressBarWidget.setProgress( 0 );
- this.filenameWidget.setValue( null ).setValidityFlag( true );
- this.descriptionWidget.setValue( null ).setValidityFlag( true );
- this.filenameUsageWidget.setValue( null );
- };
-
-}( jQuery, mediaWiki, moment ) );
+++ /dev/null
-/**
- * Library for simple URI parsing and manipulation.
- *
- * Intended to be minimal, but featureful; do not expect full RFC 3986 compliance. The use cases we
- * have in mind are constructing 'next page' or 'previous page' URLs, detecting whether we need to
- * use cross-domain proxies for an API, constructing simple URL-based API calls, etc. Parsing here
- * is regex-based, so may not work on all URIs, but is good enough for most.
- *
- * You can modify the properties directly, then use the #toString method to extract the full URI
- * string again. Example:
- *
- * var uri = new mw.Uri( 'http://example.com/mysite/mypage.php?quux=2' );
- *
- * if ( uri.host == 'example.com' ) {
- * uri.host = 'foo.example.com';
- * uri.extend( { bar: 1 } );
- *
- * $( 'a#id1' ).attr( 'href', uri );
- * // anchor with id 'id1' now links to http://foo.example.com/mysite/mypage.php?bar=1&quux=2
- *
- * $( 'a#id2' ).attr( 'href', uri.clone().extend( { bar: 3, pif: 'paf' } ) );
- * // anchor with id 'id2' now links to http://foo.example.com/mysite/mypage.php?bar=3&quux=2&pif=paf
- * }
- *
- * Given a URI like
- * `http://usr:pwd@www.example.com:81/dir/dir.2/index.htm?q1=0&&test1&test2=&test3=value+%28escaped%29&r=1&r=2#top`
- * the returned object will have the following properties:
- *
- * protocol 'http'
- * user 'usr'
- * password 'pwd'
- * host 'www.example.com'
- * port '81'
- * path '/dir/dir.2/index.htm'
- * query {
- * q1: '0',
- * test1: null,
- * test2: '',
- * test3: 'value (escaped)'
- * r: ['1', '2']
- * }
- * fragment 'top'
- *
- * (N.b., 'password' is technically not allowed for HTTP URIs, but it is possible with other kinds
- * of URIs.)
- *
- * Parsing based on parseUri 1.2.2 (c) Steven Levithan <http://stevenlevithan.com>, MIT License.
- * <http://stevenlevithan.com/demo/parseuri/js/>
- *
- * @class mw.Uri
- */
-
-/* eslint-disable no-use-before-define */
-
-( function ( mw, $ ) {
- var parser, properties;
-
- /**
- * Function that's useful when constructing the URI string -- we frequently encounter the pattern
- * of having to add something to the URI as we go, but only if it's present, and to include a
- * character before or after if so.
- *
- * @private
- * @static
- * @param {string|undefined} pre To prepend
- * @param {string} val To include
- * @param {string} post To append
- * @param {boolean} raw If true, val will not be encoded
- * @return {string} Result
- */
- function cat( pre, val, post, raw ) {
- if ( val === undefined || val === null || val === '' ) {
- return '';
- }
-
- return pre + ( raw ? val : mw.Uri.encode( val ) ) + post;
- }
-
- /**
- * Regular expressions to parse many common URIs.
- *
- * As they are gnarly, they have been moved to separate files to allow us to format them in the
- * 'extended' regular expression format (which JavaScript normally doesn't support). The subset of
- * features handled is minimal, but just the free whitespace gives us a lot.
- *
- * @private
- * @static
- * @property {Object} parser
- */
- parser = {
- strict: mw.template.get( 'mediawiki.Uri', 'strict.regexp' ).render(),
- loose: mw.template.get( 'mediawiki.Uri', 'loose.regexp' ).render()
- };
-
- /**
- * The order here matches the order of captured matches in the `parser` property regexes.
- *
- * @private
- * @static
- * @property {Array} properties
- */
- properties = [
- 'protocol',
- 'user',
- 'password',
- 'host',
- 'port',
- 'path',
- 'query',
- 'fragment'
- ];
-
- /**
- * @property {string} protocol For example `http` (always present)
- */
- /**
- * @property {string|undefined} user For example `usr`
- */
- /**
- * @property {string|undefined} password For example `pwd`
- */
- /**
- * @property {string} host For example `www.example.com` (always present)
- */
- /**
- * @property {string|undefined} port For example `81`
- */
- /**
- * @property {string} path For example `/dir/dir.2/index.htm` (always present)
- */
- /**
- * @property {Object} query For example `{ a: '0', b: '', c: 'value' }` (always present)
- */
- /**
- * @property {string|undefined} fragment For example `top`
- */
-
- /**
- * A factory method to create a Uri class with a default location to resolve relative URLs
- * against (including protocol-relative URLs).
- *
- * @method
- * @param {string|Function} documentLocation A full url, or function returning one.
- * If passed a function, the return value may change over time and this will be honoured. (T74334)
- * @member mw
- * @return {Function} Uri class
- */
- mw.UriRelative = function ( documentLocation ) {
- var getDefaultUri = ( function () {
- // Cache
- var href, uri;
-
- return function () {
- var hrefCur = typeof documentLocation === 'string' ? documentLocation : documentLocation();
- if ( href === hrefCur ) {
- return uri;
- }
- href = hrefCur;
- uri = new Uri( href );
- return uri;
- };
- }() );
-
- /**
- * Construct a new URI object. Throws error if arguments are illegal/impossible, or
- * otherwise don't parse.
- *
- * @class mw.Uri
- * @constructor
- * @param {Object|string} [uri] URI string, or an Object with appropriate properties (especially
- * another URI object to clone). Object must have non-blank `protocol`, `host`, and `path`
- * properties. If omitted (or set to `undefined`, `null` or empty string), then an object
- * will be created for the default `uri` of this constructor (`location.href` for mw.Uri,
- * other values for other instances -- see mw.UriRelative for details).
- * @param {Object|boolean} [options] Object with options, or (backwards compatibility) a boolean
- * for strictMode
- * @param {boolean} [options.strictMode=false] Trigger strict mode parsing of the url.
- * @param {boolean} [options.overrideKeys=false] Whether to let duplicate query parameters
- * override each other (`true`) or automagically convert them to an array (`false`).
- */
- function Uri( uri, options ) {
- var prop, hrefCur,
- hasOptions = ( options !== undefined ),
- defaultUri = getDefaultUri();
-
- options = typeof options === 'object' ? options : { strictMode: !!options };
- options = $.extend( {
- strictMode: false,
- overrideKeys: false
- }, options );
-
- if ( uri !== undefined && uri !== null && uri !== '' ) {
- if ( typeof uri === 'string' ) {
- this.parse( uri, options );
- } else if ( typeof uri === 'object' ) {
- // Copy data over from existing URI object
- for ( prop in uri ) {
- // Only copy direct properties, not inherited ones
- if ( uri.hasOwnProperty( prop ) ) {
- // Deep copy object properties
- if ( Array.isArray( uri[ prop ] ) || $.isPlainObject( uri[ prop ] ) ) {
- this[ prop ] = $.extend( true, {}, uri[ prop ] );
- } else {
- this[ prop ] = uri[ prop ];
- }
- }
- }
- if ( !this.query ) {
- this.query = {};
- }
- }
- } else if ( hasOptions ) {
- // We didn't get a URI in the constructor, but we got options.
- hrefCur = typeof documentLocation === 'string' ? documentLocation : documentLocation();
- this.parse( hrefCur, options );
- } else {
- // We didn't get a URI or options in the constructor, use the default instance.
- return defaultUri.clone();
- }
-
- // protocol-relative URLs
- if ( !this.protocol ) {
- this.protocol = defaultUri.protocol;
- }
- // No host given:
- if ( !this.host ) {
- this.host = defaultUri.host;
- // port ?
- if ( !this.port ) {
- this.port = defaultUri.port;
- }
- }
- if ( this.path && this.path[ 0 ] !== '/' ) {
- // A real relative URL, relative to defaultUri.path. We can't really handle that since we cannot
- // figure out whether the last path component of defaultUri.path is a directory or a file.
- throw new Error( 'Bad constructor arguments' );
- }
- if ( !( this.protocol && this.host && this.path ) ) {
- throw new Error( 'Bad constructor arguments' );
- }
- }
-
- /**
- * Encode a value for inclusion in a url.
- *
- * Standard encodeURIComponent, with extra stuff to make all browsers work similarly and more
- * compliant with RFC 3986. Similar to rawurlencode from PHP and our JS library
- * mw.util.rawurlencode, except this also replaces spaces with `+`.
- *
- * @static
- * @param {string} s String to encode
- * @return {string} Encoded string for URI
- */
- Uri.encode = function ( s ) {
- return encodeURIComponent( s )
- .replace( /!/g, '%21' ).replace( /'/g, '%27' ).replace( /\(/g, '%28' )
- .replace( /\)/g, '%29' ).replace( /\*/g, '%2A' )
- .replace( /%20/g, '+' );
- };
-
- /**
- * Decode a url encoded value.
- *
- * Reversed #encode. Standard decodeURIComponent, with addition of replacing
- * `+` with a space.
- *
- * @static
- * @param {string} s String to decode
- * @return {string} Decoded string
- */
- Uri.decode = function ( s ) {
- return decodeURIComponent( s.replace( /\+/g, '%20' ) );
- };
-
- Uri.prototype = {
-
- /**
- * Parse a string and set our properties accordingly.
- *
- * @private
- * @param {string} str URI, see constructor.
- * @param {Object} options See constructor.
- */
- parse: function ( str, options ) {
- var q, matches,
- uri = this,
- hasOwn = Object.prototype.hasOwnProperty;
-
- // Apply parser regex and set all properties based on the result
- matches = parser[ options.strictMode ? 'strict' : 'loose' ].exec( str );
- properties.forEach( function ( property, i ) {
- uri[ property ] = matches[ i + 1 ];
- } );
-
- // uri.query starts out as the query string; we will parse it into key-val pairs then make
- // that object the "query" property.
- // we overwrite query in uri way to make cloning easier, it can use the same list of properties.
- q = {};
- // using replace to iterate over a string
- if ( uri.query ) {
- uri.query.replace( /(?:^|&)([^&=]*)(?:(=)([^&]*))?/g, function ( $0, $1, $2, $3 ) {
- var k, v;
- if ( $1 ) {
- k = Uri.decode( $1 );
- v = ( $2 === '' || $2 === undefined ) ? null : Uri.decode( $3 );
-
- // If overrideKeys, always (re)set top level value.
- // If not overrideKeys but this key wasn't set before, then we set it as well.
- if ( options.overrideKeys || !hasOwn.call( q, k ) ) {
- q[ k ] = v;
-
- // Use arrays if overrideKeys is false and key was already seen before
- } else {
- // Once before, still a string, turn into an array
- if ( typeof q[ k ] === 'string' ) {
- q[ k ] = [ q[ k ] ];
- }
- // Add to the array
- if ( Array.isArray( q[ k ] ) ) {
- q[ k ].push( v );
- }
- }
- }
- } );
- }
- uri.query = q;
-
- // Decode uri.fragment, otherwise it gets double-encoded when serializing
- if ( uri.fragment !== undefined ) {
- uri.fragment = Uri.decode( uri.fragment );
- }
- },
-
- /**
- * Get user and password section of a URI.
- *
- * @return {string}
- */
- getUserInfo: function () {
- return cat( '', this.user, cat( ':', this.password, '' ) );
- },
-
- /**
- * Get host and port section of a URI.
- *
- * @return {string}
- */
- getHostPort: function () {
- return this.host + cat( ':', this.port, '' );
- },
-
- /**
- * Get the userInfo, host and port section of the URI.
- *
- * In most real-world URLs this is simply the hostname, but the definition of 'authority' section is more general.
- *
- * @return {string}
- */
- getAuthority: function () {
- return cat( '', this.getUserInfo(), '@' ) + this.getHostPort();
- },
-
- /**
- * Get the query arguments of the URL, encoded into a string.
- *
- * Does not preserve the original order of arguments passed in the URI. Does handle escaping.
- *
- * @return {string}
- */
- getQueryString: function () {
- var args = [];
- $.each( this.query, function ( key, val ) {
- var k = Uri.encode( key ),
- vals = Array.isArray( val ) ? val : [ val ];
- vals.forEach( function ( v ) {
- if ( v === null ) {
- args.push( k );
- } else if ( k === 'title' ) {
- args.push( k + '=' + mw.util.wikiUrlencode( v ) );
- } else {
- args.push( k + '=' + Uri.encode( v ) );
- }
- } );
- } );
- return args.join( '&' );
- },
-
- /**
- * Get everything after the authority section of the URI.
- *
- * @return {string}
- */
- getRelativePath: function () {
- return this.path + cat( '?', this.getQueryString(), '', true ) + cat( '#', this.fragment, '' );
- },
-
- /**
- * Get the entire URI string.
- *
- * May not be precisely the same as input due to order of query arguments.
- *
- * @return {string} The URI string
- */
- toString: function () {
- return this.protocol + '://' + this.getAuthority() + this.getRelativePath();
- },
-
- /**
- * Clone this URI
- *
- * @return {Object} New URI object with same properties
- */
- clone: function () {
- return new Uri( this );
- },
-
- /**
- * Extend the query section of the URI with new parameters.
- *
- * @param {Object} parameters Query parameters to add to ours (or to override ours with) as an
- * object
- * @return {Object} This URI object
- */
- extend: function ( parameters ) {
- $.extend( this.query, parameters );
- return this;
- }
- };
-
- return Uri;
- };
-
- // Default to the current browsing location (for relative URLs).
- mw.Uri = mw.UriRelative( function () {
- return location.href;
- } );
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-^
-(?:
- (?![^:@]+:[^:@/]*@)
- (?<protocol>[^:/?#.]+):
-)?
-(?://)?
-(?:(?:
- (?<user>[^:@/?#]*)
- (?::(?<password>[^:@/?#]*))?
-)?@)?
-(?<host>[^:/?#]*)
-(?::(?<port>\d*))?
-(
- (?:/
- (?:[^?#]
- (?![^?#/]*\.[^?#/.]+(?:[?#]|$))
- )*/?
- )?
- [^?#/]*
-)
-(?:\?(?<query>[^#]*))?
-(?:\#(?<fragment>.*))?
+++ /dev/null
-^
-(?:(?<protocol>[^:/?#]+):)?
-(?://(?:
- (?:
- (?<user>[^:@/?#]*)
- (?::(?<password>[^:@/?#]*))?
- )?@)?
- (?<host>[^:/?#]*)
- (?::(?<port>\d*))?
-)?
-(?<path>(?:[^?#/]*/)*[^?#]*)
-(?:\?(?<query>[^#]*))?
-(?:\#(?<fragment>.*))?
+++ /dev/null
-.apihelp-header {
- clear: both;
- margin-bottom: 0.1em;
-}
-
-.apihelp-header.apihelp-module-name {
- /*
- * This element is explicitly set to dir="ltr" in HTML.
- * Set explicit alignment so that CSSJanus will flip it to "right";
- * otherwise the alignment will be automatically set to "left" according
- * to the element's direction, and this will have an inconsistent look.
- */
- text-align: left;
-}
-
-div.apihelp-linktrail {
- font-size: smaller;
-}
-
-.apihelp-block {
- margin-top: 0.5em;
-}
-
-.apihelp-block-head {
- font-weight: bold;
-}
-
-.apihelp-flags {
- font-size: smaller;
- float: right;
- border: 1px solid #000;
- padding: 0.25em;
- width: 20em;
-}
-
-.apihelp-deprecated,
-.apihelp-flag-deprecated,
-.apihelp-flag-internal strong {
- font-weight: bold;
- color: #d33;
-}
-
-.apihelp-deprecated-value {
- text-decoration: line-through;
-}
-
-.apihelp-unknown {
- color: #72777d;
-}
-
-.apihelp-empty {
- color: #72777d;
-}
-
-.apihelp-help-urls ul {
- list-style-image: none;
- list-style-type: none;
- margin-left: 0;
-}
-
-.apihelp-parameters dl,
-.apihelp-examples dl,
-.apihelp-permissions dl {
- margin-left: 2em;
-}
-
-.apihelp-parameters dt {
- float: left;
- clear: left;
- min-width: 10em;
- white-space: nowrap;
- line-height: 1.5em;
-}
-
-.apihelp-parameters dt:after {
- content: ':\A0';
-}
-
-.apihelp-parameters dd {
- margin: 0 0 0.5em 10em;
- line-height: 1.5em;
-}
-
-.apihelp-parameters dd p:first-child {
- margin-top: 0;
-}
-
-.apihelp-parameters dd.info {
- margin-left: 12em;
- text-indent: -2em;
-}
-
-.apihelp-examples dt {
- font-weight: normal;
-}
-
-.api-main-links {
- text-align: center;
-}
-.api-main-links ul:before {
- content: '[';
-}
-.api-main-links ul:after {
- content: ']';
-}
+++ /dev/null
-( function ( mw, $ ) {
- /**
- * Prevent the closing of a window with a confirm message (the onbeforeunload event seems to
- * work in most browsers.)
- *
- * This supersedes any previous onbeforeunload handler. If there was a handler before, it is
- * restored when you execute the returned release() function.
- *
- * var allowCloseWindow = mw.confirmCloseWindow();
- * // ... do stuff that can't be interrupted ...
- * allowCloseWindow.release();
- *
- * The second function returned is a trigger function to trigger the check and an alert
- * window manually, e.g.:
- *
- * var allowCloseWindow = mw.confirmCloseWindow();
- * // ... do stuff that can't be interrupted ...
- * if ( allowCloseWindow.trigger() ) {
- * // don't do anything (e.g. destroy the input field)
- * } else {
- * // do whatever you wanted to do
- * }
- *
- * @method confirmCloseWindow
- * @member mw
- * @param {Object} [options]
- * @param {string} [options.namespace] Namespace for the event registration
- * @param {string} [options.message]
- * @param {string} options.message.return The string message to show in the confirm dialog.
- * @param {Function} [options.test]
- * @param {boolean} [options.test.return=true] Whether to show the dialog to the user.
- * @return {Object} An object of functions to work with this module
- */
- mw.confirmCloseWindow = function ( options ) {
- var savedUnloadHandler,
- mainEventName = 'beforeunload',
- showEventName = 'pageshow',
- message;
-
- options = $.extend( {
- message: mw.message( 'mwe-prevent-close' ).text(),
- test: function () { return true; }
- }, options );
-
- if ( options.namespace ) {
- mainEventName += '.' + options.namespace;
- showEventName += '.' + options.namespace;
- }
-
- if ( $.isFunction( options.message ) ) {
- message = options.message();
- } else {
- message = options.message;
- }
-
- $( window ).on( mainEventName, function () {
- if ( options.test() ) {
- // remove the handler while the alert is showing - otherwise breaks caching in Firefox (3?).
- // but if they continue working on this page, immediately re-register this handler
- savedUnloadHandler = window.onbeforeunload;
- window.onbeforeunload = null;
- setTimeout( function () {
- window.onbeforeunload = savedUnloadHandler;
- }, 1 );
-
- // show an alert with this message
- return message;
- }
- } ).on( showEventName, function () {
- // Re-add onbeforeunload handler
- if ( !window.onbeforeunload && savedUnloadHandler ) {
- window.onbeforeunload = savedUnloadHandler;
- }
- } );
-
- /**
- * Return the object with functions to release and manually trigger the confirm alert
- *
- * @ignore
- */
- return {
- /**
- * Remove all event listeners and don't show an alert anymore, if the user wants to leave
- * the page.
- *
- * @ignore
- */
- release: function () {
- $( window ).off( mainEventName + ' ' + showEventName );
- },
- /**
- * Trigger the module's function manually: Check, if options.test() returns true and show
- * an alert to the user if he/she want to leave this page. Returns false, if options.test() returns
- * false or the user cancelled the alert window (~don't leave the page), true otherwise.
- *
- * @ignore
- * @return {boolean}
- */
- trigger: function () {
- // use confirm to show the message to the user (if options.text() is true)
- // eslint-disable-next-line no-alert
- if ( options.test() && !confirm( message ) ) {
- // the user want to keep the actual page
- return false;
- }
- // otherwise return true
- return true;
- }
- };
- };
-}( mediaWiki, jQuery ) );
+++ /dev/null
-/*!
- * CSS for styling HTML-formatted JSON Schema objects
- *
- * @file
- * @author Munaf Assaf <massaf@wikimedia.org>
- */
-
-.mw-json {
- border-collapse: collapse;
- border-spacing: 0;
- font-style: normal;
-}
-
-.mw-json th,
-.mw-json td {
- border: 1px solid #72777d;
- font-size: 16px;
- padding: 0.5em 1em;
-}
-
-.mw-json .value,
-.mw-json-single-value {
- background-color: #dcfae3;
- font-family: monospace, monospace;
- white-space: pre-wrap;
-}
-
-.mw-json-single-value {
- background-color: #eaecf0;
-}
-
-.mw-json-empty {
- background-color: #fff;
- font-style: italic;
-}
-
-.mw-json tr {
- background-color: #eaecf0;
- margin-bottom: 0.5em;
-}
-
-.mw-json th {
- background-color: #fff;
- font-weight: normal;
-}
-
-.mw-json caption {
- /* For stylistic reasons, suppress the caption of the outermost table */
- display: none;
-}
-
-.mw-json table caption {
- color: #72777d;
- display: inline-block;
- font-size: 10px;
- font-style: italic;
- margin-bottom: 0.5em;
- text-align: left;
-}
+++ /dev/null
-( function ( mw, $ ) {
- 'use strict';
-
- var debug,
- hovzer = $.getFootHovzer();
-
- OO.ui.getViewportSpacing = function () {
- return {
- top: 0,
- right: 0,
- bottom: hovzer.$.outerHeight(),
- left: 0
- };
- };
-
- /**
- * Debug toolbar.
- *
- * Enabled server-side through `$wgDebugToolbar`.
- *
- * @class mw.Debug
- * @singleton
- * @author John Du Hart
- * @since 1.19
- */
- debug = mw.Debug = {
- /**
- * Toolbar container element
- *
- * @property {jQuery}
- */
- $container: null,
-
- /**
- * Object containing data for the debug toolbar
- *
- * @property {Object}
- */
- data: {},
-
- /**
- * Initialize the debugging pane
- *
- * Shouldn't be called before the document is ready
- * (since it binds to elements on the page).
- *
- * @param {Object} [data] Defaults to 'debugInfo' from mw.config
- */
- init: function ( data ) {
-
- this.data = data || mw.config.get( 'debugInfo' );
- this.buildHtml();
-
- // Insert the container into the DOM
- hovzer.$.append( this.$container );
- hovzer.update();
-
- $( '.mw-debug-panelink' ).click( this.switchPane );
- },
-
- /**
- * Switch between panes
- *
- * Should be called with an HTMLElement as its thisArg,
- * because it's meant to be an event handler.
- *
- * TODO: Store cookie for last pane open.
- *
- * @param {jQuery.Event} e
- */
- switchPane: function ( e ) {
- var currentPaneId = debug.$container.data( 'currentPane' ),
- requestedPaneId = $( this ).prop( 'id' ).slice( 9 ),
- $currentPane = $( '#mw-debug-pane-' + currentPaneId ),
- $requestedPane = $( '#mw-debug-pane-' + requestedPaneId ),
- hovDone = false;
-
- function updateHov() {
- if ( !hovDone ) {
- hovzer.update();
- hovDone = true;
- }
- }
-
- // Skip hash fragment handling. Prevents screen from jumping.
- e.preventDefault();
-
- $( this ).addClass( 'current ' );
- $( '.mw-debug-panelink' ).not( this ).removeClass( 'current ' );
-
- // Hide the current pane
- if ( requestedPaneId === currentPaneId ) {
- $currentPane.slideUp( updateHov );
- debug.$container.data( 'currentPane', null );
- return;
- }
-
- debug.$container.data( 'currentPane', requestedPaneId );
-
- if ( currentPaneId === undefined || currentPaneId === null ) {
- $requestedPane.slideDown( updateHov );
- } else {
- $currentPane.hide();
- $requestedPane.show();
- updateHov();
- }
- },
-
- /**
- * Construct the HTML for the debugging toolbar
- */
- buildHtml: function () {
- var $container, $bits, panes, id, gitInfo;
-
- $container = $( '<div id="mw-debug-toolbar" class="mw-debug" lang="en" dir="ltr"></div>' );
-
- $bits = $( '<div class="mw-debug-bits"></div>' );
-
- /**
- * Returns a jQuery element for a debug-bit div
- *
- * @ignore
- * @param {string} id
- * @return {jQuery}
- */
- function bitDiv( id ) {
- return $( '<div>' ).prop( {
- id: 'mw-debug-' + id,
- className: 'mw-debug-bit'
- } ).appendTo( $bits );
- }
-
- /**
- * Returns a jQuery element for a pane link
- *
- * @ignore
- * @param {string} id
- * @param {string} text
- * @return {jQuery}
- */
- function paneLabel( id, text ) {
- return $( '<a>' )
- .prop( {
- className: 'mw-debug-panelabel',
- href: '#mw-debug-pane-' + id
- } )
- .text( text );
- }
-
- /**
- * Returns a jQuery element for a debug-bit div with a for a pane link
- *
- * @ignore
- * @param {string} id CSS id snippet. Will be prefixed with 'mw-debug-'
- * @param {string} text Text to show
- * @param {string} count Optional count to show
- * @return {jQuery}
- */
- function paneTriggerBitDiv( id, text, count ) {
- if ( count ) {
- text = text + ' (' + count + ')';
- }
- return $( '<div>' ).prop( {
- id: 'mw-debug-' + id,
- className: 'mw-debug-bit mw-debug-panelink'
- } )
- .append( paneLabel( id, text ) )
- .appendTo( $bits );
- }
-
- paneTriggerBitDiv( 'console', 'Console', this.data.log.length );
-
- paneTriggerBitDiv( 'querylist', 'Queries', this.data.queries.length );
-
- paneTriggerBitDiv( 'debuglog', 'Debug log', this.data.debugLog.length );
-
- paneTriggerBitDiv( 'request', 'Request' );
-
- paneTriggerBitDiv( 'includes', 'PHP includes', this.data.includes.length );
-
- gitInfo = '';
- if ( this.data.gitRevision !== false ) {
- gitInfo = '(' + this.data.gitRevision.slice( 0, 7 ) + ')';
- if ( this.data.gitViewUrl !== false ) {
- gitInfo = $( '<a>' )
- .attr( 'href', this.data.gitViewUrl )
- .text( gitInfo );
- }
- }
-
- bitDiv( 'mwversion' )
- .append( $( '<a href="//www.mediawiki.org/">MediaWiki</a>' ) )
- .append( document.createTextNode( ': ' + this.data.mwVersion + ' ' ) )
- .append( gitInfo );
-
- if ( this.data.gitBranch !== false ) {
- bitDiv( 'gitbranch' ).text( 'Git branch: ' + this.data.gitBranch );
- }
-
- bitDiv( 'phpversion' )
- .append( $( this.data.phpEngine === 'HHVM' ?
- '<a href="http://hhvm.com/">HHVM</a>' :
- '<a href="https://php.net/">PHP</a>'
- ) )
- .append( ': ' + this.data.phpVersion );
-
- bitDiv( 'time' )
- .text( 'Time: ' + this.data.time.toFixed( 5 ) );
-
- bitDiv( 'memory' )
- .text( 'Memory: ' + this.data.memory + ' (Peak: ' + this.data.memoryPeak + ')' );
-
- $bits.appendTo( $container );
-
- panes = {
- console: this.buildConsoleTable(),
- querylist: this.buildQueryTable(),
- debuglog: this.buildDebugLogTable(),
- request: this.buildRequestPane(),
- includes: this.buildIncludesPane()
- };
-
- for ( id in panes ) {
- if ( !panes.hasOwnProperty( id ) ) {
- continue;
- }
-
- $( '<div>' )
- .prop( {
- className: 'mw-debug-pane',
- id: 'mw-debug-pane-' + id
- } )
- .append( panes[ id ] )
- .appendTo( $container );
- }
-
- this.$container = $container;
- },
-
- /**
- * Build the console panel
- *
- * @return {jQuery} Console panel
- */
- buildConsoleTable: function () {
- var $table, entryTypeText, i, length, entry;
-
- $table = $( '<table id="mw-debug-console">' );
-
- $( '<colgroup>' ).css( 'width', /* padding = */ 20 + ( 10 * /* fontSize = */ 11 ) ).appendTo( $table );
- $( '<colgroup>' ).appendTo( $table );
- $( '<colgroup>' ).css( 'width', 350 ).appendTo( $table );
-
- entryTypeText = function ( entryType ) {
- switch ( entryType ) {
- case 'log':
- return 'Log';
- case 'warn':
- return 'Warning';
- case 'deprecated':
- return 'Deprecated';
- default:
- return 'Unknown';
- }
- };
-
- for ( i = 0, length = this.data.log.length; i < length; i += 1 ) {
- entry = this.data.log[ i ];
- entry.typeText = entryTypeText( entry.type );
-
- $( '<tr>' )
- .append( $( '<td>' )
- .text( entry.typeText )
- .addClass( 'mw-debug-console-' + entry.type )
- )
- .append( $( '<td>' ).html( entry.msg ) )
- .append( $( '<td>' ).text( entry.caller ) )
- .appendTo( $table );
- }
-
- return $table;
- },
-
- /**
- * Build query list pane
- *
- * @return {jQuery}
- */
- buildQueryTable: function () {
- var $table, i, length, query;
-
- $table = $( '<table id="mw-debug-querylist"></table>' );
-
- $( '<tr>' )
- .append( $( '<th>#</th>' ).css( 'width', '4em' ) )
- .append( $( '<th>SQL</th>' ) )
- .append( $( '<th>Time</th>' ).css( 'width', '8em' ) )
- .append( $( '<th>Call</th>' ).css( 'width', '18em' ) )
- .appendTo( $table );
-
- for ( i = 0, length = this.data.queries.length; i < length; i += 1 ) {
- query = this.data.queries[ i ];
-
- $( '<tr>' )
- .append( $( '<td>' ).text( i + 1 ) )
- .append( $( '<td>' ).text( query.sql ) )
- .append( $( '<td class="stats">' ).text( ( query.time * 1000 ).toFixed( 4 ) + 'ms' ) )
- .append( $( '<td>' ).text( query[ 'function' ] ) )
- .appendTo( $table );
- }
-
- return $table;
- },
-
- /**
- * Build legacy debug log pane
- *
- * @return {jQuery}
- */
- buildDebugLogTable: function () {
- var $list, i, length, line;
- $list = $( '<ul>' );
-
- for ( i = 0, length = this.data.debugLog.length; i < length; i += 1 ) {
- line = this.data.debugLog[ i ];
- $( '<li>' )
- .html( mw.html.escape( line ).replace( /\n/g, '<br />\n' ) )
- .appendTo( $list );
- }
-
- return $list;
- },
-
- /**
- * Build request information pane
- *
- * @return {jQuery}
- */
- buildRequestPane: function () {
-
- function buildTable( title, data ) {
- var $unit, $table, key;
-
- $unit = $( '<div>' ).append( $( '<h2>' ).text( title ) );
-
- $table = $( '<table>' ).appendTo( $unit );
-
- $( '<tr>' )
- .html( '<th>Key</th><th>Value</th>' )
- .appendTo( $table );
-
- for ( key in data ) {
- if ( !data.hasOwnProperty( key ) ) {
- continue;
- }
-
- $( '<tr>' )
- .append( $( '<th>' ).text( key ) )
- .append( $( '<td>' ).text( data[ key ] ) )
- .appendTo( $table );
- }
-
- return $unit;
- }
-
- return $( '<div>' )
- .text( this.data.request.method + ' ' + this.data.request.url )
- .append( buildTable( 'Headers', this.data.request.headers ) )
- .append( buildTable( 'Parameters', this.data.request.params ) );
- },
-
- /**
- * Build included files pane
- *
- * @return {jQuery}
- */
- buildIncludesPane: function () {
- var $table, i, length, file;
-
- $table = $( '<table>' );
-
- for ( i = 0, length = this.data.includes.length; i < length; i += 1 ) {
- file = this.data.includes[ i ];
- $( '<tr>' )
- .append( $( '<td>' ).text( file.name ) )
- .append( $( '<td class="nr">' ).text( file.size ) )
- .appendTo( $table );
- }
-
- return $table;
- }
- };
-
- $( function () {
- debug.init();
- } );
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-.mw-debug {
- width: 100%;
- background-color: #eee;
- border-top: 1px solid #aaa;
-
- pre {
- font-size: 11px;
- padding: 0;
- margin: 0;
- background: none;
- border: 0;
- }
-
- table {
- border-spacing: 0;
- width: 100%;
- table-layout: fixed;
-
- tr {
- background-color: #fff;
-
- &:nth-child( even ) {
- background-color: #f9f9f9;
- }
- }
-
- td,
- th {
- padding: 4px 10px;
- }
-
- td {
- border-bottom: 1px solid #eee;
- word-wrap: break-word;
-
- &.nr {
- text-align: right;
- }
-
- span.stats {
- color: #727272;
- }
- }
- }
-
- ul {
- margin: 0;
- list-style: none;
- }
-
- li {
- padding: 4px 0;
- width: 100%;
- }
-}
-
-.mw-debug-bits {
- text-align: center;
- border-bottom: 1px solid #aaa;
-}
-
-.mw-debug-bit {
- display: inline-block;
- padding: 10px 5px;
- font-size: 13px;
-}
-
-.mw-debug-panelink {
- background-color: #eee;
- border-right: 1px solid #ccc;
-
- &:first-child {
- border-left: 1px solid #ccc;
- }
-
- &:hover {
- background-color: #fefefe;
- cursor: pointer;
- }
-
- &.current {
- background-color: #dedede;
- }
-}
-
-a.mw-debug-panelabel,
-a.mw-debug-panelabel:visited {
- color: #000;
-}
-
-.mw-debug-pane {
- height: 300px;
- overflow: scroll;
- display: none;
- font-family: monospace, monospace;
- font-size: 11px;
- background-color: #e1eff2;
- box-sizing: border-box;
-}
-
-#mw-debug-pane-debuglog,
-#mw-debug-pane-request {
- padding: 20px;
-}
-
-#mw-debug-pane-request {
- table {
- width: 100%;
- margin: 10px 0 30px;
- }
-
- tr,
- th,
- td,
- table {
- border: 1px solid #d0dbb3;
- border-collapse: collapse;
- margin: 0;
- }
-
- th,
- td {
- font-size: 12px;
- padding: 8px 10px;
- }
-
- th {
- background-color: #f1f7e2;
- font-weight: bold;
- }
-
- td {
- background-color: #fff;
- }
-}
-
-#mw-debug-console tr td {
- &:first-child {
- font-weight: bold;
- vertical-align: top;
- }
-
- &:last-child {
- vertical-align: top;
- }
-}
-
-.mw-debug-backtrace {
- padding: 5px 10px;
- margin: 5px;
- background-color: #dedede;
-
- span {
- font-weight: bold;
- color: #111;
- }
-
- ul {
- padding-left: 10px;
- }
-
- li {
- width: auto;
- padding: 0;
- color: #333;
- font-size: 10px;
- margin-bottom: 0;
- line-height: 1em;
- }
-}
-
-.mw-debug-console-log {
- background-color: #add8e6;
-}
-
-.mw-debug-console-warn {
- background-color: #ffa07a;
-}
-
-.mw-debug-console-deprecated {
- background-color: #ffb6c1;
-}
-
-/* Cheapo hack to hide the first 3 lines of the backtrace */
-.mw-debug-backtrace li:nth-child( -n+3 ) {
- display: none;
-}
+++ /dev/null
-/*!
- * Diff rendering
- */
-
-.diff {
- border: 0;
- border-spacing: 4px;
- margin: 0;
- width: 100%;
- /* Ensure that colums are of equal width */
- table-layout: fixed;
-}
-
-.diff td {
- padding: 0.33em 0.5em;
-}
-
-.diff td.diff-marker {
- /* Compensate padding for increased font-size */
- padding: 0.25em;
-}
-
-.diff col.diff-marker {
- width: 2%;
-}
-
-.diff .diff-content {
- width: 48%;
-}
-
-.diff td div {
- /* Force-wrap very long lines such as URLs or page-widening char strings */
- word-wrap: break-word;
-}
-
-.diff-title {
- vertical-align: top;
-}
-
-.diff-notice,
-.diff-multi,
-.diff-otitle,
-.diff-ntitle {
- text-align: center;
-}
-
-.diff-lineno {
- font-weight: bold;
-}
-
-td.diff-marker {
- text-align: right;
- font-weight: bold;
- font-size: 1.25em;
- line-height: 1.2;
-}
-
-.diff-addedline,
-.diff-deletedline,
-.diff-context {
- font-size: 88%;
- line-height: 1.6;
- vertical-align: top;
- white-space: -moz-pre-wrap;
- white-space: pre-wrap;
- border-style: solid;
- border-width: 1px 1px 1px 4px;
- border-radius: 0.33em;
-}
-
-.diff-addedline {
- border-color: #a3d3ff;
-}
-
-.diff-deletedline {
- border-color: #ffe49c;
-}
-
-.diff-context {
- background: #f8f9fa;
- border-color: #eaecf0;
- color: #222;
-}
-
-.diffchange {
- font-weight: bold;
- text-decoration: none;
-}
-
-.diff-addedline .diffchange,
-.diff-deletedline .diffchange {
- border-radius: 0.33em;
- padding: 0.25em 0;
-}
-
-.diff-addedline .diffchange {
- background: #d8ecff;
-}
-
-.diff-deletedline .diffchange {
- background: #feeec8;
-}
-
-/* Correct user & content directionality when viewing a diff */
-.diff-currentversion-title,
-.diff {
- direction: ltr;
- unicode-bidi: embed;
-}
-
-/* @noflip */ .diff-contentalign-right td {
- direction: rtl;
- unicode-bidi: embed;
-}
-
-/* @noflip */ .diff-contentalign-left td {
- direction: ltr;
- unicode-bidi: embed;
-}
-
-.diff-multi,
-.diff-otitle,
-.diff-ntitle,
-.diff-lineno {
- direction: ltr !important; /* stylelint-disable-line declaration-no-important */
- unicode-bidi: embed;
-}
-
-/*!
- * Wikidiff2 rendering for moved paragraphs
- */
-
-.mw-diff-movedpara-left,
-.mw-diff-movedpara-right,
-.mw-diff-movedpara-left:visited,
-.mw-diff-movedpara-right:visited,
-.mw-diff-movedpara-left:active,
-.mw-diff-movedpara-right:active {
- display: block;
- color: transparent;
-}
-
-.mw-diff-movedpara-left:hover,
-.mw-diff-movedpara-right:hover {
- text-decoration: none;
- color: transparent;
-}
-
-.mw-diff-movedpara-left:after,
-.mw-diff-movedpara-right:after {
- display: block;
- color: #222;
- margin-top: -1.25em;
-}
-
-.mw-diff-movedpara-left:after,
-.rtl .mw-diff-movedpara-right:after {
- content: '↪';
-}
-
-.mw-diff-movedpara-right:after,
-.rtl .mw-diff-movedpara-left:after {
- content: '↩';
-}
+++ /dev/null
-/*!
- * Diff rendering
- */
-td.diff-context,
-td.diff-addedline .diffchange,
-td.diff-deletedline .diffchange {
- background-color: transparent;
-}
-
-td.diff-addedline .diffchange {
- text-decoration: underline;
-}
-
-td.diff-deletedline .diffchange {
- text-decoration: line-through;
-}
+++ /dev/null
-/* Edit font preference */
-.mw-editfont-monospace {
- font-family: monospace, monospace;
-}
-
-.mw-editfont-sans-serif {
- font-family: sans-serif;
-}
-
-.mw-editfont-serif {
- font-family: serif;
-}
-
-/* Standardize font size for edit areas using edit-fonts T182320 */
-.mw-editfont-monospace,
-.mw-editfont-sans-serif,
-.mw-editfont-serif {
- font-size: 13px;
-
- /* For OOUI TextInputWidget, the parent <div> element uses normal font size, and only
- the <textarea>/<input> inside of it has the adjusted font size. This allows the width
- of the widget and size of icons etc. (which are expressed in ems) to stay the same. */
- &.oo-ui-textInputWidget {
- font-size: inherit;
- }
-
- > .oo-ui-inputWidget-input {
- font-size: 13px;
- }
-}
+++ /dev/null
-.feedback-spinner {
- display: inline-block;
- zoom: 1;
- *display: inline; /* IE7 and below */ /* stylelint-disable declaration-block-no-duplicate-properties */
- /* @embed */
- background: url( mediawiki.feedback.spinner.gif );
- width: 18px;
- height: 18px;
-}
-
-.mw-feedbackDialog-welcome-message,
-.mw-feedbackDialog-feedback-terms {
- line-height: 1.4;
-}
-
-.mw-feedbackDialog-feedback-terms p:first-child {
- margin-top: 0;
-}
-
-.mw-feedbackDialog-welcome-message {
- margin-bottom: 1em;
-}
-
-/* Overwriting OOUI is no fun */
-.mw-feedbackDialog-feedback-form .oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header {
- min-width: 4.2em;
- width: 10%;
-}
-.mw-feedbackDialog-feedback-form .oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
- width: 80%;
-}
-
-.mw-feedbackDialog-feedback-termsofuse {
- margin-left: 2em;
-}
+++ /dev/null
-/*!
- * mediawiki.feedback
- *
- * @author Ryan Kaldari, 2010
- * @author Neil Kandalgaonkar, 2010-11
- * @author Moriel Schottlender, 2015
- * @since 1.19
- */
-( function ( mw, $ ) {
- /**
- * This is a way of getting simple feedback from users. It's useful
- * for testing new features -- users can give you feedback without
- * the difficulty of opening a whole new talk page. For this reason,
- * it also tends to collect a wider range of both positive and negative
- * comments. However you do need to tend to the feedback page. It will
- * get long relatively quickly, and you often get multiple messages
- * reporting the same issue.
- *
- * It takes the form of thing on your page which, when clicked, opens a small
- * dialog box. Submitting that dialog box appends its contents to a
- * wiki page that you specify, as a new section.
- *
- * This feature works with any content model that defines a
- * `mw.messagePoster.MessagePoster`.
- *
- * Minimal usage example:
- *
- * var feedback = new mw.Feedback();
- * $( '#myButton' ).click( function () { feedback.launch(); } );
- *
- * You can also launch the feedback form with a prefilled subject and body.
- * See the docs for the #launch() method.
- *
- * @class
- * @constructor
- * @param {Object} [config] Configuration object
- * @cfg {mw.Title} [title="Feedback"] The title of the page where you collect
- * feedback.
- * @cfg {string} [apiUrl] api.php URL if the feedback page is on another wiki
- * @cfg {string} [dialogTitleMessageKey="feedback-dialog-title"] Message key for the
- * title of the dialog box
- * @cfg {mw.Uri|string} [bugsLink="//phabricator.wikimedia.org/maniphest/task/edit/form/1/"] URL where
- * bugs can be posted
- * @cfg {mw.Uri|string} [bugsListLink="//phabricator.wikimedia.org/maniphest/query/advanced"] URL
- * where bugs can be listed
- * @cfg {boolean} [showUseragentCheckbox=false] Show a Useragent agreement checkbox as part of the form.
- * @cfg {boolean} [useragentCheckboxMandatory=false] Make the Useragent checkbox mandatory.
- * @cfg {string|jQuery} [useragentCheckboxMessage] Supply a custom message for the useragent checkbox.
- * defaults to the message 'feedback-terms'.
- */
- mw.Feedback = function MwFeedback( config ) {
- config = config || {};
-
- this.dialogTitleMessageKey = config.dialogTitleMessageKey || 'feedback-dialog-title';
-
- // Feedback page title
- this.feedbackPageTitle = config.title || new mw.Title( 'Feedback' );
-
- this.messagePosterPromise = mw.messagePoster.factory.create( this.feedbackPageTitle, config.apiUrl );
- this.foreignApi = config.apiUrl ? new mw.ForeignApi( config.apiUrl ) : null;
-
- // Links
- this.bugsTaskSubmissionLink = config.bugsLink || '//phabricator.wikimedia.org/maniphest/task/edit/form/1/';
- this.bugsTaskListLink = config.bugsListLink || '//phabricator.wikimedia.org/maniphest/query/advanced';
-
- // Terms of use
- this.useragentCheckboxShow = !!config.showUseragentCheckbox;
- this.useragentCheckboxMandatory = !!config.useragentCheckboxMandatory;
- this.useragentCheckboxMessage = config.useragentCheckboxMessage ||
- $( '<p>' ).append( mw.msg( 'feedback-terms' ) );
-
- // Message dialog
- this.thankYouDialog = new OO.ui.MessageDialog();
- };
-
- /* Initialize */
- OO.initClass( mw.Feedback );
-
- /* Static Properties */
- mw.Feedback.static.windowManager = null;
- mw.Feedback.static.dialog = null;
-
- /* Methods */
-
- /**
- * Respond to dialog submit event. If the information was
- * submitted successfully, open a MessageDialog to thank the user.
- *
- * @param {string} status A status of the end of operation
- * of the main feedback dialog. Empty if the dialog was
- * dismissed with no action or the user followed the button
- * to the external task reporting site.
- * @param {string} feedbackPageName
- * @param {string} feedbackPageUrl
- */
- mw.Feedback.prototype.onDialogSubmit = function ( status, feedbackPageName, feedbackPageUrl ) {
- var dialogConfig;
-
- if ( status !== 'submitted' ) {
- return;
- }
-
- dialogConfig = {
- title: mw.msg( 'feedback-thanks-title' ),
- message: $( '<span>' ).msg(
- 'feedback-thanks',
- feedbackPageName,
- $( '<a>' ).attr( {
- target: '_blank',
- href: feedbackPageUrl
- } )
- ),
- actions: [
- {
- action: 'accept',
- label: mw.msg( 'feedback-close' ),
- flags: 'primary'
- }
- ]
- };
-
- // Show the message dialog
- this.constructor.static.windowManager.openWindow(
- this.thankYouDialog,
- dialogConfig
- );
- };
-
- /**
- * Modify the display form, and then open it, focusing interface on the subject.
- *
- * @param {Object} [contents] Prefilled contents for the feedback form.
- * @param {string} [contents.subject] The subject of the feedback, as plaintext
- * @param {string} [contents.message] The content of the feedback, as wikitext
- */
- mw.Feedback.prototype.launch = function ( contents ) {
- // Dialog
- if ( !this.constructor.static.dialog ) {
- this.constructor.static.dialog = new mw.Feedback.Dialog();
- this.constructor.static.dialog.connect( this, { submit: 'onDialogSubmit' } );
- }
- if ( !this.constructor.static.windowManager ) {
- this.constructor.static.windowManager = new OO.ui.WindowManager();
- this.constructor.static.windowManager.addWindows( [
- this.constructor.static.dialog,
- this.thankYouDialog
- ] );
- $( 'body' )
- .append( this.constructor.static.windowManager.$element );
- }
- // Open the dialog
- this.constructor.static.windowManager.openWindow(
- this.constructor.static.dialog,
- {
- title: mw.msg( this.dialogTitleMessageKey ),
- foreignApi: this.foreignApi,
- settings: {
- messagePosterPromise: this.messagePosterPromise,
- title: this.feedbackPageTitle,
- dialogTitleMessageKey: this.dialogTitleMessageKey,
- bugsTaskSubmissionLink: this.bugsTaskSubmissionLink,
- bugsTaskListLink: this.bugsTaskListLink,
- useragentCheckbox: {
- show: this.useragentCheckboxShow,
- mandatory: this.useragentCheckboxMandatory,
- message: this.useragentCheckboxMessage
- }
- },
- contents: contents
- }
- );
- };
-
- /**
- * mw.Feedback Dialog
- *
- * @class
- * @extends OO.ui.ProcessDialog
- *
- * @constructor
- * @param {Object} config Configuration object
- */
- mw.Feedback.Dialog = function mwFeedbackDialog( config ) {
- // Parent constructor
- mw.Feedback.Dialog.parent.call( this, config );
-
- this.status = '';
- this.feedbackPageTitle = null;
- // Initialize
- this.$element.addClass( 'mwFeedback-Dialog' );
- };
-
- OO.inheritClass( mw.Feedback.Dialog, OO.ui.ProcessDialog );
-
- /* Static properties */
- mw.Feedback.Dialog.static.name = 'mwFeedbackDialog';
- mw.Feedback.Dialog.static.title = mw.msg( 'feedback-dialog-title' );
- mw.Feedback.Dialog.static.size = 'medium';
- mw.Feedback.Dialog.static.actions = [
- {
- action: 'submit',
- label: mw.msg( 'feedback-submit' ),
- flags: [ 'primary', 'progressive' ]
- },
- {
- action: 'external',
- label: mw.msg( 'feedback-external-bug-report-button' ),
- flags: 'progressive'
- },
- {
- action: 'cancel',
- label: mw.msg( 'feedback-cancel' ),
- flags: 'safe'
- }
- ];
-
- /**
- * @inheritdoc
- */
- mw.Feedback.Dialog.prototype.initialize = function () {
- var feedbackSubjectFieldLayout, feedbackMessageFieldLayout,
- feedbackFieldsetLayout, termsOfUseLabel;
-
- // Parent method
- mw.Feedback.Dialog.parent.prototype.initialize.call( this );
-
- this.feedbackPanel = new OO.ui.PanelLayout( {
- scrollable: false,
- expanded: false,
- padded: true
- } );
-
- this.$spinner = $( '<div>' )
- .addClass( 'feedback-spinner' );
-
- // Feedback form
- this.feedbackMessageLabel = new OO.ui.LabelWidget( {
- classes: [ 'mw-feedbackDialog-welcome-message' ]
- } );
- this.feedbackSubjectInput = new OO.ui.TextInputWidget( {
- indicator: 'required'
- } );
- this.feedbackMessageInput = new OO.ui.MultilineTextInputWidget( {
- autosize: true
- } );
- feedbackSubjectFieldLayout = new OO.ui.FieldLayout( this.feedbackSubjectInput, {
- label: mw.msg( 'feedback-subject' )
- } );
- feedbackMessageFieldLayout = new OO.ui.FieldLayout( this.feedbackMessageInput, {
- label: mw.msg( 'feedback-message' )
- } );
- feedbackFieldsetLayout = new OO.ui.FieldsetLayout( {
- items: [ feedbackSubjectFieldLayout, feedbackMessageFieldLayout ],
- classes: [ 'mw-feedbackDialog-feedback-form' ]
- } );
-
- // Useragent terms of use
- this.useragentCheckbox = new OO.ui.CheckboxInputWidget();
- this.useragentFieldLayout = new OO.ui.FieldLayout( this.useragentCheckbox, {
- classes: [ 'mw-feedbackDialog-feedback-terms' ],
- align: 'inline'
- } );
-
- termsOfUseLabel = new OO.ui.LabelWidget( {
- classes: [ 'mw-feedbackDialog-feedback-termsofuse' ],
- label: $( '<p>' ).append( mw.msg( 'feedback-termsofuse' ) )
- } );
-
- this.feedbackPanel.$element.append(
- this.feedbackMessageLabel.$element,
- feedbackFieldsetLayout.$element,
- this.useragentFieldLayout.$element,
- termsOfUseLabel.$element
- );
-
- // Events
- this.feedbackSubjectInput.connect( this, { change: 'validateFeedbackForm' } );
- this.feedbackMessageInput.connect( this, { change: 'validateFeedbackForm' } );
- this.feedbackMessageInput.connect( this, { change: 'updateSize' } );
- this.useragentCheckbox.connect( this, { change: 'validateFeedbackForm' } );
-
- this.$body.append( this.feedbackPanel.$element );
- };
-
- /**
- * Validate the feedback form
- */
- mw.Feedback.Dialog.prototype.validateFeedbackForm = function () {
- var isValid = (
- (
- !this.useragentMandatory ||
- this.useragentCheckbox.isSelected()
- ) &&
- this.feedbackSubjectInput.getValue()
- );
-
- this.actions.setAbilities( { submit: isValid } );
- };
-
- /**
- * @inheritdoc
- */
- mw.Feedback.Dialog.prototype.getBodyHeight = function () {
- return this.feedbackPanel.$element.outerHeight( true );
- };
-
- /**
- * @inheritdoc
- */
- mw.Feedback.Dialog.prototype.getSetupProcess = function ( data ) {
- return mw.Feedback.Dialog.parent.prototype.getSetupProcess.call( this, data )
- .next( function () {
- // Get the URL of the target page, we want to use that in links in the intro
- // and in the success dialog
- var dialog = this;
- if ( data.foreignApi ) {
- return data.foreignApi.get( {
- action: 'query',
- prop: 'info',
- inprop: 'url',
- formatversion: 2,
- titles: data.settings.title.getPrefixedText()
- } ).then( function ( data ) {
- dialog.feedbackPageUrl = OO.getProp( data, 'query', 'pages', 0, 'canonicalurl' );
- } );
- } else {
- this.feedbackPageUrl = data.settings.title.getUrl();
- }
- }, this )
- .next( function () {
- var plainMsg, parsedMsg,
- settings = data.settings;
- data.contents = data.contents || {};
-
- // Prefill subject/message
- this.feedbackSubjectInput.setValue( data.contents.subject );
- this.feedbackMessageInput.setValue( data.contents.message );
-
- this.status = '';
- this.messagePosterPromise = settings.messagePosterPromise;
- this.setBugReportLink( settings.bugsTaskSubmissionLink );
- this.feedbackPageTitle = settings.title;
- this.feedbackPageName = settings.title.getNameText();
-
- // Useragent checkbox
- if ( settings.useragentCheckbox.show ) {
- this.useragentFieldLayout.setLabel( settings.useragentCheckbox.message );
- }
-
- this.useragentMandatory = settings.useragentCheckbox.mandatory;
- this.useragentFieldLayout.toggle( settings.useragentCheckbox.show );
-
- // HACK: Setting a link in the messages doesn't work. There is already a report
- // about this, and the bug report offers a somewhat hacky work around that
- // includes setting a separate message to be parsed.
- // We want to make sure the user can configure both the title of the page and
- // a separate url, so this must be allowed to parse correctly.
- // See https://phabricator.wikimedia.org/T49395#490610
- mw.messages.set( {
- 'feedback-dialog-temporary-message':
- '<a href="' + this.feedbackPageUrl + '" target="_blank">' + this.feedbackPageName + '</a>'
- } );
- plainMsg = mw.message( 'feedback-dialog-temporary-message' ).plain();
- mw.messages.set( { 'feedback-dialog-temporary-message-parsed': plainMsg } );
- parsedMsg = mw.message( 'feedback-dialog-temporary-message-parsed' );
- this.feedbackMessageLabel.setLabel(
- // Double-parse
- $( '<span>' )
- .append( mw.message( 'feedback-dialog-intro', parsedMsg ).parse() )
- );
-
- this.validateFeedbackForm();
- }, this );
- };
-
- /**
- * @inheritdoc
- */
- mw.Feedback.Dialog.prototype.getReadyProcess = function ( data ) {
- return mw.Feedback.Dialog.parent.prototype.getReadyProcess.call( this, data )
- .next( function () {
- this.feedbackSubjectInput.focus();
- }, this );
- };
-
- /**
- * @inheritdoc
- */
- mw.Feedback.Dialog.prototype.getActionProcess = function ( action ) {
- if ( action === 'cancel' ) {
- return new OO.ui.Process( function () {
- this.close( { action: action } );
- }, this );
- } else if ( action === 'external' ) {
- return new OO.ui.Process( function () {
- // Open in a new window
- window.open( this.getBugReportLink(), '_blank' );
- // Close the dialog
- this.close();
- }, this );
- } else if ( action === 'submit' ) {
- return new OO.ui.Process( function () {
- var fb = this,
- userAgentMessage = ':' +
- '<small>' +
- mw.msg( 'feedback-useragent' ) +
- ' ' +
- mw.html.escape( navigator.userAgent ) +
- '</small>\n\n',
- subject = this.feedbackSubjectInput.getValue(),
- message = this.feedbackMessageInput.getValue();
-
- // Add user agent if checkbox is selected
- if ( this.useragentCheckbox.isSelected() ) {
- message = userAgentMessage + message;
- }
-
- // Post the message
- return this.messagePosterPromise.then( function ( poster ) {
- return fb.postMessage( poster, subject, message );
- }, function () {
- fb.status = 'error4';
- mw.log.warn( 'Feedback report failed because MessagePoster could not be fetched' );
- } ).then( function () {
- fb.close();
- }, function () {
- return fb.getErrorMessage();
- } );
- }, this );
- }
- // Fallback to parent handler
- return mw.Feedback.Dialog.parent.prototype.getActionProcess.call( this, action );
- };
-
- /**
- * Returns an error message for the current status.
- *
- * @private
- *
- * @return {OO.ui.Error}
- */
- mw.Feedback.Dialog.prototype.getErrorMessage = function () {
- // Messages: feedback-error1, feedback-error2, feedback-error3, feedback-error4
- return new OO.ui.Error( mw.msg( 'feedback-' + this.status ) );
- };
-
- /**
- * Posts the message
- *
- * @private
- *
- * @param {mw.messagePoster.MessagePoster} poster Poster implementation used to leave feedback
- * @param {string} subject Subject of message
- * @param {string} message Body of message
- * @return {jQuery.Promise} Promise representing success of message posting action
- */
- mw.Feedback.Dialog.prototype.postMessage = function ( poster, subject, message ) {
- var fb = this;
-
- return poster.post(
- subject,
- message
- ).then( function () {
- fb.status = 'submitted';
- }, function ( mainCode, secondaryCode, details ) {
- if ( mainCode === 'api-fail' ) {
- if ( secondaryCode === 'http' ) {
- fb.status = 'error3';
- // ajax request failed
- mw.log.warn( 'Feedback report failed with HTTP error: ' + details.textStatus );
- } else {
- fb.status = 'error2';
- mw.log.warn( 'Feedback report failed with API error: ' + secondaryCode );
- }
- } else {
- fb.status = 'error1';
- }
- } );
- };
-
- /**
- * @inheritdoc
- */
- mw.Feedback.Dialog.prototype.getTeardownProcess = function ( data ) {
- return mw.Feedback.Dialog.parent.prototype.getTeardownProcess.call( this, data )
- .first( function () {
- this.emit( 'submit', this.status, this.feedbackPageName, this.feedbackPageUrl );
- // Cleanup
- this.status = '';
- this.feedbackPageTitle = null;
- this.feedbackSubjectInput.setValue( '' );
- this.feedbackMessageInput.setValue( '' );
- this.useragentCheckbox.setSelected( false );
- }, this );
- };
-
- /**
- * Set the bug report link
- *
- * @param {string} link Link to the external bug report form
- */
- mw.Feedback.Dialog.prototype.setBugReportLink = function ( link ) {
- this.bugReportLink = link;
- };
-
- /**
- * Get the bug report link
- *
- * @return {string} Link to the external bug report form
- */
- mw.Feedback.Dialog.prototype.getBugReportLink = function () {
- return this.bugReportLink;
- };
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-/* Styles for links to RSS/Atom feeds in sidebar */
-
-a.feedlink {
- /* SVG support using a transparent gradient to guarantee cross-browser
- * compatibility (browsers able to understand gradient syntax support also SVG).
- * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique */
- background-image: url( images/feed-icon.png );
- /* @embed */
- background-image: linear-gradient( transparent, transparent ), url( images/feed-icon.svg );
- background-position: center left;
- background-repeat: no-repeat;
- background-size: 12px 12px;
- padding-left: 16px;
-}
+++ /dev/null
-/*!
- * mediawiki.filewarning
- *
- * @author Mark Holmquist, 2015
- * @since 1.25
- */
-( function ( mw, $, oo ) {
- var warningConfig = mw.config.get( 'wgFileWarning' ),
- warningMessages = warningConfig.messages,
- warningLink = warningConfig.link,
- $origMimetype = $( '.fullMedia .fileInfo .mime-type' ),
- $mimetype = $origMimetype.clone(),
- $header = $( '<h3>' )
- .addClass( 'mediawiki-filewarning-header empty' ),
- $main = $( '<p>' )
- .addClass( 'mediawiki-filewarning-main empty' ),
- $info = $( '<a>' )
- .addClass( 'mediawiki-filewarning-info empty' ),
- $footer = $( '<p>' )
- .addClass( 'mediawiki-filewarning-footer empty' ),
- dialog = new oo.ui.PopupButtonWidget( {
- classes: [ 'mediawiki-filewarning-anchor' ],
- label: $mimetype,
- flags: [ 'warning' ],
- icon: 'alert',
- framed: false,
- popup: {
- classes: [ 'mediawiki-filewarning' ],
- padded: true,
- width: 400,
- $content: $header.add( $main ).add( $info ).add( $footer )
- }
- } );
-
- function loadMessage( $target, message ) {
- if ( message ) {
- $target.removeClass( 'empty' )
- .text( mw.message( message ).text() );
- }
- }
-
- // The main message must be populated for the dialog to show.
- if ( warningConfig && warningConfig.messages && warningConfig.messages.main ) {
- $mimetype.addClass( 'has-warning' );
-
- $origMimetype.replaceWith( dialog.$element );
-
- if ( warningMessages ) {
- loadMessage( $main, warningMessages.main );
- loadMessage( $header, warningMessages.header );
- loadMessage( $footer, warningMessages.footer );
-
- if ( warningLink ) {
- loadMessage( $info, warningMessages.info );
- $info.attr( 'href', warningLink );
- }
- }
-
- // Make OOUI open the dialog, it won't appear until the user
- // hovers over the warning.
- dialog.getPopup().toggle( true );
-
- // Override toggle handler because we don't need it for this popup
- // object at all. Sort of nasty, but it gets the job done.
- dialog.getPopup().toggle = $.noop;
- }
-}( mediaWiki, jQuery, OO ) );
+++ /dev/null
-@import 'mediawiki.ui/variables';
-
-// Increase the area of the button, so that the user can move the mouse cursor
-// to the popup without the popup disappearing. (T157544)
-.mediawiki-filewarning-anchor {
- padding-bottom: 10px;
- margin-bottom: -10px;
-}
-
-.mediawiki-filewarning {
- visibility: hidden;
-
- .mediawiki-filewarning-header {
- padding: 0;
- font-weight: 600;
- }
-
- .mediawiki-filewarning-footer {
- color: #72777d;
- }
-
- .empty {
- display: none;
- }
-
- .mediawiki-filewarning-anchor:hover & {
- visibility: visible;
- }
-}
-
-.mime-type {
- &.has-warning {
- font-weight: bold;
- color: @colorMediumSevere;
- }
-}
+++ /dev/null
-@import 'mediawiki.mixins';
-
-#mw-indicator-mw-helplink a {
- .background-image-svg('images/help.svg', 'images/help.png');
- background-repeat: no-repeat;
- background-position: left center;
- padding-left: 28px;
- display: inline-block;
- height: 24px;
- line-height: 24px;
-}
+++ /dev/null
-jQuery( function ( $ ) {
- // Apply hidpi images on DOM-ready
- // Some may have already partly preloaded at low resolution.
- $( 'body' ).hidpi();
-} );
+++ /dev/null
-.hlist {
- dl,
- ol,
- ul {
- margin: 0;
- padding: 0;
-
- dl,
- ol,
- ul {
- display: inline;
- }
- }
-
- dd,
- dt,
- li {
- margin: 0;
- display: inline;
- }
-}
+++ /dev/null
-/*!
- * Stylesheet for mediawiki.hlist module
- * @author [[User:Edokter]]
- */
-/* Generate interpuncts */
-.hlist dt:after {
- content: ':';
-}
-.hlist dd:after,
-.hlist li:after {
- content: ' ·';
- font-weight: bold;
-}
-.hlist dd:last-child:after,
-.hlist dt:last-child:after,
-.hlist li:last-child:after {
- content: none;
-}
-/* For IE8 */
-.hlist dd.hlist-last-child:after,
-.hlist dt.hlist-last-child:after,
-.hlist li.hlist-last-child:after {
- content: none;
-}
-/* Add parentheses around nested lists */
-.hlist dd dd:first-child:before,
-.hlist dd dt:first-child:before,
-.hlist dd li:first-child:before,
-.hlist dt dd:first-child:before,
-.hlist dt dt:first-child:before,
-.hlist dt li:first-child:before,
-.hlist li dd:first-child:before,
-.hlist li dt:first-child:before,
-.hlist li li:first-child:before {
- content: '(';
- font-weight: normal;
-}
-.hlist dd dd:last-child:after,
-.hlist dd dt:last-child:after,
-.hlist dd li:last-child:after,
-.hlist dt dd:last-child:after,
-.hlist dt dt:last-child:after,
-.hlist dt li:last-child:after,
-.hlist li dd:last-child:after,
-.hlist li dt:last-child:after,
-.hlist li li:last-child:after {
- content: ')';
- font-weight: normal;
-}
-/* For IE8 */
-.hlist dd dd.hlist-last-child:after,
-.hlist dd dt.hlist-last-child:after,
-.hlist dd li.hlist-last-child:after,
-.hlist dt dd.hlist-last-child:after,
-.hlist dt dt.hlist-last-child:after,
-.hlist dt li.hlist-last-child:after,
-.hlist li dd.hlist-last-child:after,
-.hlist li dt.hlist-last-child:after,
-.hlist li li.hlist-last-child:after {
- content: ')';
- font-weight: normal;
-}
-/* Put ordinals in front of ordered list items */
-.hlist ol {
- counter-reset: list-item;
-}
-.hlist ol > li {
- counter-increment: list-item;
-}
-.hlist ol > li:before {
- content: counter( list-item ) ' ';
-}
-.hlist dd ol > li:first-child:before,
-.hlist dt ol > li:first-child:before,
-.hlist li ol > li:first-child:before {
- content: '(' counter( list-item ) ' ';
-}
-
-/* Support hlist styles inside *boxes */
-.errorbox .hlist,
-.successbox .hlist,
-.warningbox .hlist {
- margin-left: 0;
-}
-
-.errorbox .hlist li:after,
-.successbox .hlist li:after,
-.warningbox .hlist li:after {
- margin-right: 3px;
-}
+++ /dev/null
-/* General-purpose icons via CSS. Classes here should be named "mw-icon-*". */
-
-@import 'mediawiki.mixins';
-
-/* For the collapsed and expanded arrows, we also provide selectors to make it
- * easy to use them with jquery.makeCollapsible. */
-.mw-icon-arrow-collapsed,
-.mw-collapsible-arrow.mw-collapsible-toggle-collapsed {
- .background-image-svg('images/arrow-collapsed-ltr.svg', 'images/arrow-collapsed-ltr.png');
- background-repeat: no-repeat;
- background-position: left bottom;
-}
-
-.mw-icon-arrow-expanded,
-.mw-collapsible-arrow.mw-collapsible-toggle-expanded {
- .background-image-svg('images/arrow-expanded.svg', 'images/arrow-expanded.png');
- background-repeat: no-repeat;
- background-position: left bottom;
-}
+++ /dev/null
-/*!
-* Experimental advanced wikitext parser-emitter.
-* See: https://www.mediawiki.org/wiki/Extension:UploadWizard/MessageParser for docs
-*
-* @author neilk@wikimedia.org
-* @author mflaschen@wikimedia.org
-*/
-( function ( mw, $ ) {
- /**
- * @class mw.jqueryMsg
- * @singleton
- */
-
- var oldParser,
- slice = Array.prototype.slice,
- parserDefaults = {
- magic: {
- PAGENAME: mw.config.get( 'wgPageName' ),
- PAGENAMEE: mw.util.wikiUrlencode( mw.config.get( 'wgPageName' ) )
- },
- // Whitelist for allowed HTML elements in wikitext.
- // Self-closing tags are not currently supported.
- // Can be populated via setPrivateData().
- allowedHtmlElements: [],
- // Key tag name, value allowed attributes for that tag.
- // See Sanitizer::setupAttributeWhitelist
- allowedHtmlCommonAttributes: [
- // HTML
- 'id',
- 'class',
- 'style',
- 'lang',
- 'dir',
- 'title',
-
- // WAI-ARIA
- 'role'
- ],
-
- // Attributes allowed for specific elements.
- // Key is element name in lower case
- // Value is array of allowed attributes for that element
- allowedHtmlAttributesByElement: {},
- messages: mw.messages,
- language: mw.language,
-
- // Same meaning as in mediawiki.js.
- //
- // Only 'text', 'parse', and 'escaped' are supported, and the
- // actual escaping for 'escaped' is done by other code (generally
- // through mediawiki.js).
- //
- // However, note that this default only
- // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
- // is 'text', including when it uses jqueryMsg.
- format: 'parse'
- };
-
- /**
- * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
- * convert what it detects as an htmlString to an element.
- *
- * If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to
- * new parent.
- *
- * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
- *
- * @private
- * @param {jQuery} $parent Parent node wrapped by jQuery
- * @param {Object|string|Array} children What to append, with the same possible types as jQuery
- * @return {jQuery} $parent
- */
- function appendWithoutParsing( $parent, children ) {
- var i, len;
-
- if ( !Array.isArray( children ) ) {
- children = [ children ];
- }
-
- for ( i = 0, len = children.length; i < len; i++ ) {
- if ( typeof children[ i ] !== 'object' ) {
- children[ i ] = document.createTextNode( children[ i ] );
- }
- if ( children[ i ] instanceof jQuery && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
- children[ i ] = children[ i ].contents();
- }
- }
-
- return $parent.append( children );
- }
-
- /**
- * Decodes the main HTML entities, those encoded by mw.html.escape.
- *
- * @private
- * @param {string} encoded Encoded string
- * @return {string} String with those entities decoded
- */
- function decodePrimaryHtmlEntities( encoded ) {
- return encoded
- .replace( /'/g, '\'' )
- .replace( /"/g, '"' )
- .replace( /</g, '<' )
- .replace( />/g, '>' )
- .replace( /&/g, '&' );
- }
-
- /**
- * Turn input into a string.
- *
- * @private
- * @param {string|jQuery} input
- * @return {string} Textual value of input
- */
- function textify( input ) {
- if ( input instanceof jQuery ) {
- input = input.text();
- }
- return String( input );
- }
-
- /**
- * Given parser options, return a function that parses a key and replacements, returning jQuery object
- *
- * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
- * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
- * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
- *
- * @private
- * @param {Object} options Parser options
- * @return {Function}
- * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
- * @return {jQuery} return.return
- */
- function getFailableParserFn( options ) {
- return function ( args ) {
- var fallback,
- parser = new mw.jqueryMsg.Parser( options ),
- key = args[ 0 ],
- argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 );
- try {
- return parser.parse( key, argsArray );
- } catch ( e ) {
- fallback = parser.settings.messages.get( key );
- mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
- mw.track( 'mediawiki.jqueryMsg.error', {
- messageKey: key,
- errorMessage: e.message
- } );
- return $( '<span>' ).text( fallback );
- }
- };
- }
-
- mw.jqueryMsg = {};
-
- /**
- * Initialize parser defaults.
- *
- * ResourceLoaderJqueryMsgModule calls this to provide default values from
- * Sanitizer.php for allowed HTML elements. To override this data for individual
- * parsers, pass the relevant options to mw.jqueryMsg.Parser.
- *
- * @private
- * @param {Object} data New data to extend parser defaults with
- * @param {boolean} [deep=false] Whether the extend is done recursively (deep)
- */
- mw.jqueryMsg.setParserDefaults = function ( data, deep ) {
- if ( deep ) {
- $.extend( true, parserDefaults, data );
- } else {
- $.extend( parserDefaults, data );
- }
- };
-
- /**
- * Get current parser defaults.
- *
- * Primarily used for the unit test. Returns a copy.
- *
- * @private
- * @return {Object}
- */
- mw.jqueryMsg.getParserDefaults = function () {
- return $.extend( {}, parserDefaults );
- };
-
- /**
- * Returns a function suitable for static use, to construct strings from a message key (and optional replacements).
- *
- * Example:
- *
- * var format = mediaWiki.jqueryMsg.getMessageFunction( options );
- * $( '#example' ).text( format( 'hello-user', username ) );
- *
- * Tthis returns only strings, so it destroys any bindings. If you want to preserve bindings, use the
- * jQuery plugin version instead. This was originally created to ease migration from `window.gM()`,
- * from a time when the parser used by `mw.message` was not extendable.
- *
- * N.B. replacements are variadic arguments or an array in second parameter. In other words:
- * somefunction( a, b, c, d )
- * is equivalent to
- * somefunction( a, [b, c, d] )
- *
- * @param {Object} options parser options
- * @return {Function} Function The message formatter
- * @return {string} return.key Message key.
- * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
- * @return {string} return.return Rendered HTML.
- */
- mw.jqueryMsg.getMessageFunction = function ( options ) {
- var failableParserFn, format;
-
- if ( options && options.format !== undefined ) {
- format = options.format;
- } else {
- format = parserDefaults.format;
- }
-
- return function () {
- var failableResult;
- if ( !failableParserFn ) {
- failableParserFn = getFailableParserFn( options );
- }
- failableResult = failableParserFn( arguments );
- if ( format === 'text' || format === 'escaped' ) {
- return failableResult.text();
- } else {
- return failableResult.html();
- }
- };
- };
-
- /**
- * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
- * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
- * e.g.
- *
- * $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options );
- * var userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
- * $( 'p#headline' ).msg( 'hello-user', userlink );
- *
- * N.B. replacements are variadic arguments or an array in second parameter. In other words:
- * somefunction( a, b, c, d )
- * is equivalent to
- * somefunction( a, [b, c, d] )
- *
- * We append to 'this', which in a jQuery plugin context will be the selected elements.
- *
- * @param {Object} options Parser options
- * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
- * @return {string} return.key Message key.
- * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
- * @return {jQuery} return.return
- */
- mw.jqueryMsg.getPlugin = function ( options ) {
- var failableParserFn;
-
- return function () {
- var $target;
- if ( !failableParserFn ) {
- failableParserFn = getFailableParserFn( options );
- }
- $target = this.empty();
- appendWithoutParsing( $target, failableParserFn( arguments ) );
- return $target;
- };
- };
-
- /**
- * The parser itself.
- * Describes an object, whose primary duty is to .parse() message keys.
- *
- * @class
- * @private
- * @param {Object} options
- */
- mw.jqueryMsg.Parser = function ( options ) {
- this.settings = $.extend( {}, parserDefaults, options );
- this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
- this.astCache = {};
-
- this.emitter = new mw.jqueryMsg.HtmlEmitter( this.settings.language, this.settings.magic );
- };
- // Backwards-compatible alias
- // @deprecated since 1.31
- mw.jqueryMsg.parser = mw.jqueryMsg.Parser;
-
- mw.jqueryMsg.Parser.prototype = {
- /**
- * Where the magic happens.
- * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
- * If an error is thrown, returns original key, and logs the error
- *
- * @param {string} key Message key.
- * @param {Array} replacements Variable replacements for $1, $2... $n
- * @return {jQuery}
- */
- parse: function ( key, replacements ) {
- var ast = this.getAst( key );
- return this.emitter.emit( ast, replacements );
- },
-
- /**
- * Fetch the message string associated with a key, return parsed structure. Memoized.
- * Note that we pass '⧼' + key + '⧽' back for a missing message here.
- *
- * @param {string} key
- * @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing
- */
- getAst: function ( key ) {
- var wikiText;
-
- if ( !this.astCache.hasOwnProperty( key ) ) {
- wikiText = this.settings.messages.get( key );
- if ( typeof wikiText !== 'string' ) {
- wikiText = '⧼' + key + '⧽';
- }
- this.astCache[ key ] = this.wikiTextToAst( wikiText );
- }
- return this.astCache[ key ];
- },
-
- /**
- * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
- *
- * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
- * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
- *
- * @param {string} input Message string wikitext
- * @throws Error
- * @return {Mixed} abstract syntax tree
- */
- wikiTextToAst: function ( input ) {
- var pos,
- regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
- doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
- escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
- whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
- htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
- openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
- templateContents, openTemplate, closeTemplate,
- nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result,
- settings = this.settings,
- concat = Array.prototype.concat;
-
- // Indicates current position in input as we parse through it.
- // Shared among all parsing functions below.
- pos = 0;
-
- // =========================================================
- // parsing combinators - could be a library on its own
- // =========================================================
-
- /**
- * Try parsers until one works, if none work return null
- *
- * @private
- * @param {Function[]} ps
- * @return {string|null}
- */
- function choice( ps ) {
- return function () {
- var i, result;
- for ( i = 0; i < ps.length; i++ ) {
- result = ps[ i ]();
- if ( result !== null ) {
- return result;
- }
- }
- return null;
- };
- }
-
- /**
- * Try several ps in a row, all must succeed or return null.
- * This is the only eager one.
- *
- * @private
- * @param {Function[]} ps
- * @return {string|null}
- */
- function sequence( ps ) {
- var i, res,
- originalPos = pos,
- result = [];
- for ( i = 0; i < ps.length; i++ ) {
- res = ps[ i ]();
- if ( res === null ) {
- pos = originalPos;
- return null;
- }
- result.push( res );
- }
- return result;
- }
-
- /**
- * Run the same parser over and over until it fails.
- * Must succeed a minimum of n times or return null.
- *
- * @private
- * @param {number} n
- * @param {Function} p
- * @return {string|null}
- */
- function nOrMore( n, p ) {
- return function () {
- var originalPos = pos,
- result = [],
- parsed = p();
- while ( parsed !== null ) {
- result.push( parsed );
- parsed = p();
- }
- if ( result.length < n ) {
- pos = originalPos;
- return null;
- }
- return result;
- };
- }
-
- /**
- * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
- *
- * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
- * May be some scoping issue
- *
- * @private
- * @param {Function} p
- * @param {Function} fn
- * @return {string|null}
- */
- function transform( p, fn ) {
- return function () {
- var result = p();
- return result === null ? null : fn( result );
- };
- }
-
- /**
- * Just make parsers out of simpler JS builtin types
- *
- * @private
- * @param {string} s
- * @return {Function}
- * @return {string} return.return
- */
- function makeStringParser( s ) {
- var len = s.length;
- return function () {
- var result = null;
- if ( input.substr( pos, len ) === s ) {
- result = s;
- pos += len;
- }
- return result;
- };
- }
-
- /**
- * Makes a regex parser, given a RegExp object.
- * The regex being passed in should start with a ^ to anchor it to the start
- * of the string.
- *
- * @private
- * @param {RegExp} regex anchored regex
- * @return {Function} function to parse input based on the regex
- */
- function makeRegexParser( regex ) {
- return function () {
- var matches = input.slice( pos ).match( regex );
- if ( matches === null ) {
- return null;
- }
- pos += matches[ 0 ].length;
- return matches[ 0 ];
- };
- }
-
- // ===================================================================
- // General patterns above this line -- wikitext specific parsers below
- // ===================================================================
-
- // Parsing functions follow. All parsing functions work like this:
- // They don't accept any arguments.
- // Instead, they just operate non destructively on the string 'input'
- // As they can consume parts of the string, they advance the shared variable pos,
- // and return tokens (or whatever else they want to return).
- // some things are defined as closures and other things as ordinary functions
- // converting everything to a closure makes it a lot harder to debug... errors pop up
- // but some debuggers can't tell you exactly where they come from. Also the mutually
- // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
- // This may be because, to save code, memoization was removed
-
- /* eslint-disable no-useless-escape */
- regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
- regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
- regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
- regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
- /* eslint-enable no-useless-escape */
-
- backslash = makeStringParser( '\\' );
- doubleQuote = makeStringParser( '"' );
- singleQuote = makeStringParser( '\'' );
- anyCharacter = makeRegexParser( /^./ );
-
- openHtmlStartTag = makeStringParser( '<' );
- optionalForwardSlash = makeRegexParser( /^\/?/ );
- openHtmlEndTag = makeStringParser( '</' );
- htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
- closeHtmlTag = makeRegexParser( /^\s*>/ );
-
- function escapedLiteral() {
- var result = sequence( [
- backslash,
- anyCharacter
- ] );
- return result === null ? null : result[ 1 ];
- }
- escapedOrLiteralWithoutSpace = choice( [
- escapedLiteral,
- regularLiteralWithoutSpace
- ] );
- escapedOrLiteralWithoutBar = choice( [
- escapedLiteral,
- regularLiteralWithoutBar
- ] );
- escapedOrRegularLiteral = choice( [
- escapedLiteral,
- regularLiteral
- ] );
- // Used to define "literals" without spaces, in space-delimited situations
- function literalWithoutSpace() {
- var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
- return result === null ? null : result.join( '' );
- }
- // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
- // it is not a literal in the parameter
- function literalWithoutBar() {
- var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
- return result === null ? null : result.join( '' );
- }
-
- function literal() {
- var result = nOrMore( 1, escapedOrRegularLiteral )();
- return result === null ? null : result.join( '' );
- }
-
- function curlyBraceTransformExpressionLiteral() {
- var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
- return result === null ? null : result.join( '' );
- }
-
- asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
- htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
- htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
-
- whitespace = makeRegexParser( /^\s+/ );
- dollar = makeStringParser( '$' );
- digits = makeRegexParser( /^\d+/ );
-
- function replacement() {
- var result = sequence( [
- dollar,
- digits
- ] );
- if ( result === null ) {
- return null;
- }
- return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
- }
- openExtlink = makeStringParser( '[' );
- closeExtlink = makeStringParser( ']' );
- // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
- function extlink() {
- var result, parsedResult, target;
- result = null;
- parsedResult = sequence( [
- openExtlink,
- nOrMore( 1, nonWhitespaceExpression ),
- whitespace,
- nOrMore( 1, expression ),
- closeExtlink
- ] );
- if ( parsedResult !== null ) {
- // When the entire link target is a single parameter, we can't use CONCAT, as we allow
- // passing fancy parameters (like a whole jQuery object or a function) to use for the
- // link. Check only if it's a single match, since we can either do CONCAT or not for
- // singles with the same effect.
- target = parsedResult[ 1 ].length === 1 ?
- parsedResult[ 1 ][ 0 ] :
- [ 'CONCAT' ].concat( parsedResult[ 1 ] );
- result = [
- 'EXTLINK',
- target,
- [ 'CONCAT' ].concat( parsedResult[ 3 ] )
- ];
- }
- return result;
- }
- openWikilink = makeStringParser( '[[' );
- closeWikilink = makeStringParser( ']]' );
- pipe = makeStringParser( '|' );
-
- function template() {
- var result = sequence( [
- openTemplate,
- templateContents,
- closeTemplate
- ] );
- return result === null ? null : result[ 1 ];
- }
-
- function pipedWikilink() {
- var result = sequence( [
- nOrMore( 1, paramExpression ),
- pipe,
- nOrMore( 1, expression )
- ] );
- return result === null ? null : [
- [ 'CONCAT' ].concat( result[ 0 ] ),
- [ 'CONCAT' ].concat( result[ 2 ] )
- ];
- }
-
- function unpipedWikilink() {
- var result = sequence( [
- nOrMore( 1, paramExpression )
- ] );
- return result === null ? null : [
- [ 'CONCAT' ].concat( result[ 0 ] )
- ];
- }
-
- wikilinkContents = choice( [
- pipedWikilink,
- unpipedWikilink
- ] );
-
- function wikilink() {
- var result, parsedResult, parsedLinkContents;
- result = null;
-
- parsedResult = sequence( [
- openWikilink,
- wikilinkContents,
- closeWikilink
- ] );
- if ( parsedResult !== null ) {
- parsedLinkContents = parsedResult[ 1 ];
- result = [ 'WIKILINK' ].concat( parsedLinkContents );
- }
- return result;
- }
-
- // TODO: Support data- if appropriate
- function doubleQuotedHtmlAttributeValue() {
- var parsedResult = sequence( [
- doubleQuote,
- htmlDoubleQuoteAttributeValue,
- doubleQuote
- ] );
- return parsedResult === null ? null : parsedResult[ 1 ];
- }
-
- function singleQuotedHtmlAttributeValue() {
- var parsedResult = sequence( [
- singleQuote,
- htmlSingleQuoteAttributeValue,
- singleQuote
- ] );
- return parsedResult === null ? null : parsedResult[ 1 ];
- }
-
- function htmlAttribute() {
- var parsedResult = sequence( [
- whitespace,
- asciiAlphabetLiteral,
- htmlAttributeEquals,
- choice( [
- doubleQuotedHtmlAttributeValue,
- singleQuotedHtmlAttributeValue
- ] )
- ] );
- return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
- }
-
- /**
- * Checks if HTML is allowed
- *
- * @param {string} startTagName HTML start tag name
- * @param {string} endTagName HTML start tag name
- * @param {Object} attributes array of consecutive key value pairs,
- * with index 2 * n being a name and 2 * n + 1 the associated value
- * @return {boolean} true if this is HTML is allowed, false otherwise
- */
- function isAllowedHtml( startTagName, endTagName, attributes ) {
- var i, len, attributeName;
-
- startTagName = startTagName.toLowerCase();
- endTagName = endTagName.toLowerCase();
- if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) {
- return false;
- }
-
- for ( i = 0, len = attributes.length; i < len; i += 2 ) {
- attributeName = attributes[ i ];
- if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 &&
- ( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) {
- return false;
- }
- }
-
- return true;
- }
-
- function htmlAttributes() {
- var parsedResult = nOrMore( 0, htmlAttribute )();
- // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
- return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult );
- }
-
- // Subset of allowed HTML markup.
- // Most elements and many attributes allowed on the server are not supported yet.
- function html() {
- var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult,
- wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos,
- startCloseTagPos, endOpenTagPos, endCloseTagPos,
- result = null;
-
- // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
- // 1. open through closeHtmlTag
- // 2. expression
- // 3. openHtmlEnd through close
- // This will allow recording the positions to reconstruct if HTML is to be treated as text.
-
- startOpenTagPos = pos;
- parsedOpenTagResult = sequence( [
- openHtmlStartTag,
- asciiAlphabetLiteral,
- htmlAttributes,
- optionalForwardSlash,
- closeHtmlTag
- ] );
-
- if ( parsedOpenTagResult === null ) {
- return null;
- }
-
- endOpenTagPos = pos;
- startTagName = parsedOpenTagResult[ 1 ];
-
- parsedHtmlContents = nOrMore( 0, expression )();
-
- startCloseTagPos = pos;
- parsedCloseTagResult = sequence( [
- openHtmlEndTag,
- asciiAlphabetLiteral,
- closeHtmlTag
- ] );
-
- if ( parsedCloseTagResult === null ) {
- // Closing tag failed. Return the start tag and contents.
- return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
- .concat( parsedHtmlContents );
- }
-
- endCloseTagPos = pos;
- endTagName = parsedCloseTagResult[ 1 ];
- wrappedAttributes = parsedOpenTagResult[ 2 ];
- attributes = wrappedAttributes.slice( 1 );
- if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
- result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
- .concat( parsedHtmlContents );
- } else {
- // HTML is not allowed, so contents will remain how
- // it was, while HTML markup at this level will be
- // treated as text
- // E.g. assuming script tags are not allowed:
- //
- // <script>[[Foo|bar]]</script>
- //
- // results in '<script>' and '</script>'
- // (not treated as an HTML tag), surrounding a fully
- // parsed HTML link.
- //
- // Concatenate everything from the tag, flattening the contents.
- result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
- .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
- }
-
- return result;
- }
-
- // <nowiki>...</nowiki> tag. The tags are stripped and the contents are returned unparsed.
- function nowiki() {
- var parsedResult, plainText,
- result = null;
-
- parsedResult = sequence( [
- makeStringParser( '<nowiki>' ),
- // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much
- makeRegexParser( /^.*?(?=<\/nowiki>)/ ),
- makeStringParser( '</nowiki>' )
- ] );
- if ( parsedResult !== null ) {
- plainText = parsedResult[ 1 ];
- result = [ 'CONCAT' ].concat( plainText );
- }
-
- return result;
- }
-
- templateName = transform(
- // see $wgLegalTitleChars
- // not allowing : due to the need to catch "PLURAL:$1"
- makeRegexParser( /^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ ),
- function ( result ) { return result.toString(); }
- );
- function templateParam() {
- var expr, result;
- result = sequence( [
- pipe,
- nOrMore( 0, paramExpression )
- ] );
- if ( result === null ) {
- return null;
- }
- expr = result[ 1 ];
- // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
- return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ];
- }
-
- function templateWithReplacement() {
- var result = sequence( [
- templateName,
- colon,
- replacement
- ] );
- return result === null ? null : [ result[ 0 ], result[ 2 ] ];
- }
- function templateWithOutReplacement() {
- var result = sequence( [
- templateName,
- colon,
- paramExpression
- ] );
- return result === null ? null : [ result[ 0 ], result[ 2 ] ];
- }
- function templateWithOutFirstParameter() {
- var result = sequence( [
- templateName,
- colon
- ] );
- return result === null ? null : [ result[ 0 ], '' ];
- }
- colon = makeStringParser( ':' );
- templateContents = choice( [
- function () {
- var res = sequence( [
- // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
- // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
- choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
- nOrMore( 0, templateParam )
- ] );
- return res === null ? null : res[ 0 ].concat( res[ 1 ] );
- },
- function () {
- var res = sequence( [
- templateName,
- nOrMore( 0, templateParam )
- ] );
- if ( res === null ) {
- return null;
- }
- return [ res[ 0 ] ].concat( res[ 1 ] );
- }
- ] );
- openTemplate = makeStringParser( '{{' );
- closeTemplate = makeStringParser( '}}' );
- nonWhitespaceExpression = choice( [
- template,
- wikilink,
- extlink,
- replacement,
- literalWithoutSpace
- ] );
- paramExpression = choice( [
- template,
- wikilink,
- extlink,
- replacement,
- literalWithoutBar
- ] );
-
- expression = choice( [
- template,
- wikilink,
- extlink,
- replacement,
- nowiki,
- html,
- literal
- ] );
-
- // Used when only {{-transformation is wanted, for 'text'
- // or 'escaped' formats
- curlyBraceTransformExpression = choice( [
- template,
- replacement,
- curlyBraceTransformExpressionLiteral
- ] );
-
- /**
- * Starts the parse
- *
- * @param {Function} rootExpression Root parse function
- * @return {Array|null}
- */
- function start( rootExpression ) {
- var result = nOrMore( 0, rootExpression )();
- if ( result === null ) {
- return null;
- }
- return [ 'CONCAT' ].concat( result );
- }
- // everything above this point is supposed to be stateless/static, but
- // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
- // finally let's do some actual work...
-
- result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
-
- /*
- * For success, the p must have gotten to the end of the input
- * and returned a non-null.
- * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
- */
- if ( result === null || pos !== input.length ) {
- throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
- }
- return result;
- }
-
- };
-
- /**
- * Class that primarily exists to emit HTML from parser ASTs.
- *
- * @private
- * @class
- * @param {Object} language
- * @param {Object} magic
- */
- mw.jqueryMsg.HtmlEmitter = function ( language, magic ) {
- var jmsg = this;
- this.language = language;
- $.each( magic, function ( key, val ) {
- jmsg[ key.toLowerCase() ] = function () {
- return val;
- };
- } );
-
- /**
- * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
- * Walk entire node structure, applying replacements and template functions when appropriate
- *
- * @param {Mixed} node Abstract syntax tree (top node or subnode)
- * @param {Array} replacements for $1, $2, ... $n
- * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
- */
- this.emit = function ( node, replacements ) {
- var ret, subnodes, operation,
- jmsg = this;
- switch ( typeof node ) {
- case 'string':
- case 'number':
- ret = node;
- break;
- // typeof returns object for arrays
- case 'object':
- // node is an array of nodes
- subnodes = $.map( node.slice( 1 ), function ( n ) {
- return jmsg.emit( n, replacements );
- } );
- operation = node[ 0 ].toLowerCase();
- if ( typeof jmsg[ operation ] === 'function' ) {
- ret = jmsg[ operation ]( subnodes, replacements );
- } else {
- throw new Error( 'Unknown operation "' + operation + '"' );
- }
- break;
- case 'undefined':
- // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
- // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
- // The logical thing is probably to return the empty string here when we encounter undefined.
- ret = '';
- break;
- default:
- throw new Error( 'Unexpected type in AST: ' + typeof node );
- }
- return ret;
- };
- };
-
- // For everything in input that follows double-open-curly braces, there should be an equivalent parser
- // function. For instance {{PLURAL ... }} will be processed by 'plural'.
- // If you have 'magic words' then configure the parser to have them upon creation.
- //
- // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
- // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
- mw.jqueryMsg.HtmlEmitter.prototype = {
- /**
- * Parsing has been applied depth-first we can assume that all nodes here are single nodes
- * Must return a single node to parents -- a jQuery with synthetic span
- * However, unwrap any other synthetic spans in our children and pass them upwards
- *
- * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
- * @return {jQuery}
- */
- concat: function ( nodes ) {
- var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
- $.each( nodes, function ( i, node ) {
- // Let jQuery append nodes, arrays of nodes and jQuery objects
- // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
- appendWithoutParsing( $span, node );
- } );
- return $span;
- },
-
- /**
- * Return escaped replacement of correct index, or string if unavailable.
- * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
- * if the specified parameter is not found return the same string
- * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
- *
- * TODO: Throw error if nodes.length > 1 ?
- *
- * @param {Array} nodes List of one element, integer, n >= 0
- * @param {Array} replacements List of at least n strings
- * @return {string} replacement
- */
- replace: function ( nodes, replacements ) {
- var index = parseInt( nodes[ 0 ], 10 );
-
- if ( index < replacements.length ) {
- return replacements[ index ];
- } else {
- // index not found, fallback to displaying variable
- return '$' + ( index + 1 );
- }
- },
-
- /**
- * Transform wiki-link
- *
- * TODO:
- * It only handles basic cases, either no pipe, or a pipe with an explicit
- * anchor.
- *
- * It does not attempt to handle features like the pipe trick.
- * However, the pipe trick should usually not be present in wikitext retrieved
- * from the server, since the replacement is done at save time.
- * It may, though, if the wikitext appears in extension-controlled content.
- *
- * @param {string[]} nodes
- * @return {jQuery}
- */
- wikilink: function ( nodes ) {
- var page, anchor, url, $el;
-
- page = textify( nodes[ 0 ] );
- // Strip leading ':', which is used to suppress special behavior in wikitext links,
- // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
- if ( page.charAt( 0 ) === ':' ) {
- page = page.slice( 1 );
- }
- url = mw.util.getUrl( page );
-
- if ( nodes.length === 1 ) {
- // [[Some Page]] or [[Namespace:Some Page]]
- anchor = page;
- } else {
- // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
- anchor = nodes[ 1 ];
- }
-
- $el = $( '<a>' ).attr( {
- title: page,
- href: url
- } );
- return appendWithoutParsing( $el, anchor );
- },
-
- /**
- * Converts array of HTML element key value pairs to object
- *
- * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
- * name and 2 * n + 1 the associated value
- * @return {Object} Object mapping attribute name to attribute value
- */
- htmlattributes: function ( nodes ) {
- var i, len, mapping = {};
- for ( i = 0, len = nodes.length; i < len; i += 2 ) {
- mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
- }
- return mapping;
- },
-
- /**
- * Handles an (already-validated) HTML element.
- *
- * @param {Array} nodes Nodes to process when creating element
- * @return {jQuery|Array} jQuery node for valid HTML or array for disallowed element
- */
- htmlelement: function ( nodes ) {
- var tagName, attributes, contents, $element;
-
- tagName = nodes.shift();
- attributes = nodes.shift();
- contents = nodes;
- $element = $( document.createElement( tagName ) ).attr( attributes );
- return appendWithoutParsing( $element, contents );
- },
-
- /**
- * Transform parsed structure into external link.
- *
- * The "href" can be:
- * - a jQuery object, treat it as "enclosing" the link text.
- * - a function, treat it as the click handler.
- * - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying.
- *
- * TODO: throw an error if nodes.length > 2 ?
- *
- * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
- * @return {jQuery}
- */
- extlink: function ( nodes ) {
- var $el,
- arg = nodes[ 0 ],
- contents = nodes[ 1 ];
- if ( arg instanceof jQuery && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
- $el = arg;
- } else {
- $el = $( '<a>' );
- if ( typeof arg === 'function' ) {
- $el.attr( {
- role: 'button',
- tabindex: 0
- } ).on( 'click keypress', function ( e ) {
- if (
- e.type === 'click' ||
- e.type === 'keypress' && e.which === 13
- ) {
- arg.call( this, e );
- }
- } );
- } else {
- $el.attr( 'href', textify( arg ) );
- }
- }
- return appendWithoutParsing( $el.empty(), contents );
- },
-
- /**
- * Transform parsed structure into pluralization
- * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
- * So convert it back with the current language's convertNumber.
- *
- * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
- * @return {string} selected pluralized form according to current language
- */
- plural: function ( nodes ) {
- var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count,
- explicitPluralForms = {};
-
- count = parseFloat( this.language.convertNumber( nodes[ 0 ], true ) );
- forms = nodes.slice( 1 );
- for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
- form = forms[ formIndex ];
-
- if ( form instanceof jQuery && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
- // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
- firstChild = form.contents().get( 0 );
- if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
- firstChildText = firstChild.textContent;
- if ( /^\d+=/.test( firstChildText ) ) {
- explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
- // Use the digit part as key and rest of first text node and
- // rest of child nodes as value.
- firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
- explicitPluralForms[ explicitPluralFormNumber ] = form;
- forms[ formIndex ] = undefined;
- }
- }
- } else if ( /^\d+=/.test( form ) ) {
- // Simple explicit plural forms like 12=a dozen
- explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
- explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
- forms[ formIndex ] = undefined;
- }
- }
-
- // Remove explicit plural forms from the forms. They were set undefined in the above loop.
- forms = $.map( forms, function ( form ) {
- return form;
- } );
-
- return this.language.convertPlural( count, forms, explicitPluralForms );
- },
-
- /**
- * Transform parsed structure according to gender.
- *
- * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
- *
- * The first node must be one of:
- * - the mw.user object (or a compatible one)
- * - an empty string - indicating the current user, same effect as passing the mw.user object
- * - a gender string ('male', 'female' or 'unknown')
- *
- * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
- * @return {string} Selected gender form according to current language
- */
- gender: function ( nodes ) {
- var gender,
- maybeUser = nodes[ 0 ],
- forms = nodes.slice( 1 );
-
- if ( maybeUser === '' ) {
- maybeUser = mw.user;
- }
-
- // If we are passed a mw.user-like object, check their gender.
- // Otherwise, assume the gender string itself was passed .
- if ( maybeUser && maybeUser.options instanceof mw.Map ) {
- gender = maybeUser.options.get( 'gender' );
- } else {
- gender = maybeUser;
- }
-
- return this.language.gender( gender, forms );
- },
-
- /**
- * Transform parsed structure into grammar conversion.
- * Invoked by putting `{{grammar:form|word}}` in a message
- *
- * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
- * @return {string} selected grammatical form according to current language
- */
- grammar: function ( nodes ) {
- var form = nodes[ 0 ],
- word = nodes[ 1 ];
- return word && form && this.language.convertGrammar( word, form );
- },
-
- /**
- * Tranform parsed structure into a int: (interface language) message include
- * Invoked by putting `{{int:othermessage}}` into a message
- *
- * @param {Array} nodes List of nodes
- * @return {string} Other message
- */
- 'int': function ( nodes ) {
- var msg = nodes[ 0 ];
- return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) );
- },
-
- /**
- * Get localized namespace name from canonical name or namespace number.
- * Invoked by putting `{{ns:foo}}` into a message
- *
- * @param {Array} nodes List of nodes
- * @return {string} Localized namespace name
- */
- ns: function ( nodes ) {
- var ns = textify( nodes[ 0 ] ).trim();
- if ( !/^\d+$/.test( ns ) ) {
- ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
- }
- ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
- return ns || '';
- },
-
- /**
- * Takes an unformatted number (arab, no group separators and . as decimal separator)
- * and outputs it in the localized digit script and formatted with decimal
- * separator, according to the current language.
- *
- * @param {Array} nodes List of nodes
- * @return {number|string} Formatted number
- */
- formatnum: function ( nodes ) {
- var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R',
- number = nodes[ 0 ];
-
- return this.language.convertNumber( number, isInteger );
- },
-
- /**
- * Lowercase text
- *
- * @param {Array} nodes List of nodes
- * @return {string} The given text, all in lowercase
- */
- lc: function ( nodes ) {
- return textify( nodes[ 0 ] ).toLowerCase();
- },
-
- /**
- * Uppercase text
- *
- * @param {Array} nodes List of nodes
- * @return {string} The given text, all in uppercase
- */
- uc: function ( nodes ) {
- return textify( nodes[ 0 ] ).toUpperCase();
- },
-
- /**
- * Lowercase first letter of input, leaving the rest unchanged
- *
- * @param {Array} nodes List of nodes
- * @return {string} The given text, with the first character in lowercase
- */
- lcfirst: function ( nodes ) {
- var text = textify( nodes[ 0 ] );
- return text.charAt( 0 ).toLowerCase() + text.slice( 1 );
- },
-
- /**
- * Uppercase first letter of input, leaving the rest unchanged
- *
- * @param {Array} nodes List of nodes
- * @return {string} The given text, with the first character in uppercase
- */
- ucfirst: function ( nodes ) {
- var text = textify( nodes[ 0 ] );
- return text.charAt( 0 ).toUpperCase() + text.slice( 1 );
- }
- };
-
- /**
- * @method
- * @member jQuery
- * @see mw.jqueryMsg#getPlugin
- */
- $.fn.msg = mw.jqueryMsg.getPlugin();
-
- // Replace the default message parser with jqueryMsg
- oldParser = mw.Message.prototype.parser;
- mw.Message.prototype.parser = function () {
- if ( this.format === 'plain' || !/\{\{|[<>[&]/.test( this.map.get( this.key ) ) ) {
- // Fall back to mw.msg's simple parser
- return oldParser.apply( this );
- }
-
- if ( !this.map.hasOwnProperty( this.format ) ) {
- this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( {
- messages: this.map,
- // For format 'escaped', escaping part is handled by mediawiki.js
- format: this.format
- } );
- }
- return this.map[ this.format ]( this.key, this.parameters );
- };
-
- /**
- * Parse the message to DOM nodes, rather than HTML string like #parse.
- *
- * This method is only available when jqueryMsg is loaded.
- *
- * @since 1.27
- * @method parseDom
- * @member mw.Message
- * @return {jQuery}
- */
- mw.Message.prototype.parseDom = ( function () {
- var reusableParent = $( '<div>' );
- return function () {
- return reusableParent.msg( this.key, this.parameters ).contents().detach();
- };
- }() );
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-/* PEG grammar for a subset of wikitext, useful in the MediaWiki frontend */
-
-start
- = e:expression* { return e.length > 1 ? [ "CONCAT" ].concat(e) : e[0]; }
-
-expression
- = template
- / link
- / extlink
- / replacement
- / literal
-
-paramExpression
- = template
- / link
- / extlink
- / replacement
- / literalWithoutBar
-
-template
- = "{{" t:templateContents "}}" { return t; }
-
-templateContents
- = twr:templateWithReplacement p:templateParam* { return twr.concat(p) }
- / twr:templateWithOutReplacement p:templateParam* { return twr.concat(p) }
- / twr:templateWithOutFirstParameter p:templateParam* { return twr.concat(p) }
- / t:templateName p:templateParam* { return p.length ? [ t, p ] : [ t ] }
-
-templateWithReplacement
- = t:templateName ":" r:replacement { return [ t, r ] }
-
-templateWithOutReplacement
- = t:templateName ":" p:paramExpression { return [ t, p ] }
-
-templateWithOutFirstParameter
- = t:templateName ":" { return [ t, "" ] }
-
-templateParam
- = "|" e:paramExpression* { return e.length > 1 ? [ "CONCAT" ].concat(e) : e[0]; }
-
-templateName
- = tn:[A-Za-z_]+ { return tn.join('').toUpperCase() }
-
-/* TODO: Update to reflect separate piped and unpiped handling */
-link
- = "[[" w:expression "]]" { return [ 'WLINK', w ]; }
-
-extlink
- = "[" url:url whitespace text:expression "]" { return [ 'LINK', url, text ] }
-
-url
- = url:[^ ]+ { return url.join(''); }
-
-whitespace
- = [ ]+
-
-replacement
- = '$' digits:digits { return [ 'REPLACE', parseInt( digits, 10 ) - 1 ] }
-
-digits
- = [0-9]+
-
-literal
- = lit:escapedOrRegularLiteral+ { return lit.join(''); }
-
-literalWithoutBar
- = lit:escapedOrLiteralWithoutBar+ { return lit.join(''); }
-
-escapedOrRegularLiteral
- = escapedLiteral
- / regularLiteral
-
-escapedOrLiteralWithoutBar
- = escapedLiteral
- / regularLiteralWithoutBar
-
-escapedLiteral
- = "\\" escaped:. { return escaped; }
-
-regularLiteral
- = [^{}\[\]$\\]
-
-regularLiteralWithoutBar
- = [^{}\[\]$\\|]
-
+++ /dev/null
-/*!
- * Structures generated by the TablePager PHP class
- * in MediaWiki (used e.g. on Special:ListFiles).
- */
-
-@import 'mediawiki.mixins';
-
-.TablePager {
- min-width: 80%;
-}
-
-.TablePager .TablePager_sort-ascending a {
- padding-left: 15px;
- background: none left center no-repeat;
- .background-image-svg('images/arrow-sort-ascending.svg', 'images/arrow-sort-ascending.png');
-}
-
-.TablePager .TablePager_sort-descending a {
- padding-left: 15px;
- background: none left center no-repeat;
- .background-image-svg('images/arrow-sort-descending.svg', 'images/arrow-sort-descending.png');
-}
-
-.TablePager_nav.oo-ui-buttonGroupWidget {
- display: block;
- text-align: center;
- margin: 1em;
-}
+++ /dev/null
-/* Make sure the links are not underlined or colored, ever. */
-/* There is already a :focus / :hover indication on the <div>. */
-.suggestions a.mw-searchSuggest-link,
-.suggestions a.mw-searchSuggest-link:hover,
-.suggestions a.mw-searchSuggest-link:active,
-.suggestions a.mw-searchSuggest-link:focus {
- color: #000;
- text-decoration: none;
-}
-
-.suggestions-result-current a.mw-searchSuggest-link,
-.suggestions-result-current a.mw-searchSuggest-link:hover,
-.suggestions-result-current a.mw-searchSuggest-link:active,
-.suggestions-result-current a.mw-searchSuggest-link:focus {
- color: #fff;
-}
-
-.suggestions a.mw-searchSuggest-link .special-query {
- /* Apply ellipsis to suggestions */
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
+++ /dev/null
-/*!
- * Add search suggestions to the search form.
- */
-( function ( mw, $ ) {
- var searchNS = $.map( mw.config.get( 'wgFormattedNamespaces' ), function ( nsName, nsID ) {
- if ( nsID >= 0 && mw.user.options.get( 'searchNs' + nsID ) ) {
- // Cast string key to number
- return Number( nsID );
- }
- } );
- mw.searchSuggest = {
- // queries the wiki and calls response with the result
- request: function ( api, query, response, maxRows, namespace ) {
- return api.get( {
- formatversion: 2,
- action: 'opensearch',
- search: query,
- namespace: namespace || searchNS,
- limit: maxRows,
- suggest: true
- } ).done( function ( data, jqXHR ) {
- response( data[ 1 ], {
- type: jqXHR.getResponseHeader( 'X-OpenSearch-Type' ),
- query: query
- } );
- } );
- }
- };
-
- $( function () {
- var api, searchboxesSelectors,
- // Region where the suggestions box will appear directly below
- // (using the same width). Can be a container element or the input
- // itself, depending on what suits best in the environment.
- // For Vector the suggestion box should align with the simpleSearch
- // container's borders, in other skins it should align with the input
- // element (not the search form, as that would leave the buttons
- // vertically between the input and the suggestions).
- $searchRegion = $( '#simpleSearch, #searchInput' ).first(),
- $searchInput = $( '#searchInput' ),
- previousSearchText = $searchInput.val();
-
- // Compute form data for search suggestions functionality.
- function getFormData( context ) {
- var $form, baseHref, linkParams;
-
- if ( !context.formData ) {
- // Compute common parameters for links' hrefs
- $form = context.config.$region.closest( 'form' );
-
- baseHref = $form.attr( 'action' );
- baseHref += baseHref.indexOf( '?' ) > -1 ? '&' : '?';
-
- linkParams = $form.serializeObject();
-
- context.formData = {
- textParam: context.data.$textbox.attr( 'name' ),
- linkParams: linkParams,
- baseHref: baseHref
- };
- }
-
- return context.formData;
- }
-
- /**
- * Callback that's run when the user changes the search input text
- * 'this' is the search input box (jQuery object)
- *
- * @ignore
- */
- function onBeforeUpdate() {
- var searchText = this.val();
-
- if ( searchText && searchText !== previousSearchText ) {
- mw.track( 'mediawiki.searchSuggest', {
- action: 'session-start'
- } );
- }
- previousSearchText = searchText;
- }
-
- /**
- * Defines the location of autocomplete. Typically either
- * header, which is in the top right of vector (for example)
- * and content which identifies the main search bar on
- * Special:Search. Defaults to header for skins that don't set
- * explicitly.
- *
- * @ignore
- * @param {Object} context
- * @return {string}
- */
- function getInputLocation( context ) {
- return context.config.$region
- .closest( 'form' )
- .find( '[data-search-loc]' )
- .data( 'search-loc' ) || 'header';
- }
-
- /**
- * Callback that's run when suggestions have been updated either from the cache or the API
- * 'this' is the search input box (jQuery object)
- *
- * @ignore
- * @param {Object} metadata
- */
- function onAfterUpdate( metadata ) {
- var context = this.data( 'suggestionsContext' );
-
- mw.track( 'mediawiki.searchSuggest', {
- action: 'impression-results',
- numberOfResults: context.config.suggestions.length,
- resultSetType: metadata.type || 'unknown',
- query: metadata.query,
- inputLocation: getInputLocation( context )
- } );
- }
-
- // The function used to render the suggestions.
- function renderFunction( text, context ) {
- var formData = getFormData( context ),
- textboxConfig = context.data.$textbox.data( 'mw-searchsuggest' ) || {};
-
- // linkParams object is modified and reused
- formData.linkParams[ formData.textParam ] = text;
-
- // Allow trackers to attach tracking information, such
- // as wprov, to clicked links.
- mw.track( 'mediawiki.searchSuggest', {
- action: 'render-one',
- formData: formData,
- index: context.config.suggestions.indexOf( text )
- } );
-
- // this is the container <div>, jQueryfied
- this.text( text );
-
- // wrap only as link, if the config doesn't disallow it
- if ( textboxConfig.wrapAsLink !== false ) {
- this.wrap(
- $( '<a>' )
- .attr( 'href', formData.baseHref + $.param( formData.linkParams ) )
- .attr( 'title', text )
- .addClass( 'mw-searchSuggest-link' )
- );
- }
- }
-
- // The function used when the user makes a selection
- function selectFunction( $input, source ) {
- var context = $input.data( 'suggestionsContext' ),
- text = $input.val();
-
- // Selecting via keyboard triggers a form submission. That will fire
- // the submit-form event in addition to this click-result event.
- if ( source !== 'keyboard' ) {
- mw.track( 'mediawiki.searchSuggest', {
- action: 'click-result',
- numberOfResults: context.config.suggestions.length,
- index: context.config.suggestions.indexOf( text )
- } );
- }
-
- // allow the form to be submitted
- return true;
- }
-
- function specialRenderFunction( query, context ) {
- var $el = this,
- formData = getFormData( context );
-
- // linkParams object is modified and reused
- formData.linkParams[ formData.textParam ] = query;
-
- mw.track( 'mediawiki.searchSuggest', {
- action: 'render-one',
- formData: formData,
- index: context.config.suggestions.indexOf( query )
- } );
-
- if ( $el.children().length === 0 ) {
- $el
- .append(
- $( '<div>' )
- .addClass( 'special-label' )
- .text( mw.msg( 'searchsuggest-containing' ) ),
- $( '<div>' )
- .addClass( 'special-query' )
- .text( query )
- )
- .show();
- } else {
- $el.find( '.special-query' )
- .text( query );
- }
-
- if ( $el.parent().hasClass( 'mw-searchSuggest-link' ) ) {
- $el.parent().attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' );
- } else {
- $el.wrap(
- $( '<a>' )
- .attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' )
- .addClass( 'mw-searchSuggest-link' )
- );
- }
- }
-
- // Generic suggestions functionality for all search boxes
- searchboxesSelectors = [
- // Primary searchbox on every page in standard skins
- '#searchInput',
- // Generic selector for skins with multiple searchboxes (used by CologneBlue)
- // and for MediaWiki itself (special pages with page title inputs)
- '.mw-searchInput'
- ];
- $( searchboxesSelectors.join( ', ' ) )
- .suggestions( {
- fetch: function ( query, response, maxRows ) {
- var node = this[ 0 ];
-
- api = api || new mw.Api();
-
- $.data( node, 'request', mw.searchSuggest.request( api, query, response, maxRows ) );
- },
- cancel: function () {
- var node = this[ 0 ],
- request = $.data( node, 'request' );
-
- if ( request ) {
- request.abort();
- $.removeData( node, 'request' );
- }
- },
- result: {
- render: renderFunction,
- select: function () {
- // allow the form to be submitted
- return true;
- }
- },
- update: {
- before: onBeforeUpdate,
- after: onAfterUpdate
- },
- cache: true,
- highlightInput: true
- } )
- .on( 'paste cut drop', function () {
- // make sure paste and cut events from the mouse and drag&drop events
- // trigger the keypress handler and cause the suggestions to update
- $( this ).trigger( 'keypress' );
- } )
- // In most skins (at least Monobook and Vector), the font-size is messed up in <body>.
- // (they use 2 elements to get a sane font-height). So, instead of making exceptions for
- // each skin or adding more stylesheets, just copy it from the active element so auto-fit.
- .each( function () {
- var $this = $( this );
- $this
- .data( 'suggestions-context' )
- .data.$container.css( 'fontSize', $this.css( 'fontSize' ) );
- } );
-
- // Ensure that the thing is actually present!
- if ( $searchRegion.length === 0 ) {
- // Don't try to set anything up if simpleSearch is disabled sitewide.
- // The loader code loads us if the option is present, even if we're
- // not actually enabled (anymore).
- return;
- }
-
- // Special suggestions functionality and tracking for skin-provided search box
- $searchInput.suggestions( {
- update: {
- before: onBeforeUpdate,
- after: onAfterUpdate
- },
- result: {
- render: renderFunction,
- select: selectFunction
- },
- special: {
- render: specialRenderFunction,
- select: function ( $input, source ) {
- var context = $input.data( 'suggestionsContext' ),
- text = $input.val();
- if ( source === 'mouse' ) {
- // mouse click won't trigger form submission, so we need to send a click event
- mw.track( 'mediawiki.searchSuggest', {
- action: 'click-result',
- numberOfResults: context.config.suggestions.length,
- index: context.config.suggestions.indexOf( text )
- } );
- } else {
- $input.closest( 'form' )
- .append( $( '<input type="hidden" name="fulltext" value="1"/>' ) );
- }
- return true; // allow the form to be submitted
- }
- },
- $region: $searchRegion
- } );
-
- $searchInput.closest( 'form' )
- // track the form submit event
- .on( 'submit', function () {
- var context = $searchInput.data( 'suggestionsContext' );
- mw.track( 'mediawiki.searchSuggest', {
- action: 'submit-form',
- numberOfResults: context.config.suggestions.length,
- $form: context.config.$region.closest( 'form' ),
- inputLocation: getInputLocation( context ),
- index: context.config.suggestions.indexOf(
- context.data.$textbox.val()
- )
- } );
- } )
- // If the form includes any fallback fulltext search buttons, remove them
- .find( '.mw-fallbackSearchButton' ).remove();
- } );
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-/* global Mustache */
-( function ( mw, $ ) {
- // Register mustache compiler
- mw.template.registerCompiler( 'mustache', {
- compile: function ( src ) {
- return {
- /**
- * @ignore
- * @return {string} The raw source code of the template
- */
- getSource: function () {
- return src;
- },
- /**
- * @ignore
- * @param {Object} data Data to render
- * @param {Object} partialTemplates Map partial names to Mustache template objects
- * returned by mw.template.get()
- * @return {jQuery} Rendered HTML
- */
- render: function ( data, partialTemplates ) {
- var partials = {};
- if ( partialTemplates ) {
- $.each( partialTemplates, function ( name, template ) {
- partials[ name ] = template.getSource();
- } );
- }
- return $( $.parseHTML( Mustache.render( src, data, partials ) ) );
- }
- };
- }
- } );
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-.tochidden,
-.toctoggle {
- -moz-user-select: none;
- -webkit-user-select: none;
- -ms-user-select: none;
- user-select: none;
-}
-
-.toctoggle {
- font-size: 94%;
-}
+++ /dev/null
-( function ( mw, $ ) {
- 'use strict';
-
- // Table of contents toggle
- mw.hook( 'wikipage.content' ).add( function ( $content ) {
- $content.find( '.toc' ).addBack( '.toc' ).each( function () {
- var hideToc,
- $this = $( this ),
- $tocTitle = $this.find( '.toctitle' ),
- $tocToggleLink = $this.find( '.togglelink' ),
- $tocList = $this.find( 'ul' ).eq( 0 );
-
- // Hide/show the table of contents element
- function toggleToc() {
- if ( $tocList.is( ':hidden' ) ) {
- $tocList.slideDown( 'fast' );
- $tocToggleLink.text( mw.msg( 'hidetoc' ) );
- $this.removeClass( 'tochidden' );
- mw.cookie.set( 'hidetoc', null );
- } else {
- $tocList.slideUp( 'fast' );
- $tocToggleLink.text( mw.msg( 'showtoc' ) );
- $this.addClass( 'tochidden' );
- mw.cookie.set( 'hidetoc', '1' );
- }
- }
-
- // Only add it if there is a complete TOC and it doesn't
- // have a toggle added already
- if ( $tocTitle.length && $tocList.length && !$tocToggleLink.length ) {
- hideToc = mw.cookie.get( 'hidetoc' ) === '1';
-
- $tocToggleLink = $( '<a role="button" tabindex="0" class="togglelink"></a>' )
- .text( mw.msg( hideToc ? 'showtoc' : 'hidetoc' ) )
- .on( 'click keypress', function ( e ) {
- if (
- e.type === 'click' ||
- e.type === 'keypress' && e.which === 13
- ) {
- toggleToc();
- }
- } );
-
- $tocTitle.append(
- $tocToggleLink
- .wrap( '<span class="toctoggle"></span>' )
- .parent()
- .prepend( ' [' )
- .append( '] ' )
- );
-
- if ( hideToc ) {
- $tocList.hide();
- $this.addClass( 'tochidden' );
- }
- }
- } );
- } );
-
-}( mediaWiki, jQuery ) );
+++ /dev/null
-.toc.tochidden,
-.toctoggle {
- display: none;
-}