mediawiki-extensions-Multim.../resources/mmv/ui/mmv.ui.metadataPanel.js
Gergő Tisza b9b1529e9b Add more information to embed HTML
Adds site link, license link and long name, replaces internal license name
(we don't have it for most licenses) with short name.

Also fixes some problems in previous changesets that I stumbled on,
renames things to be more consistent/less misleading, and makes
EmbedFileInfo a thin container for existing classes. (That results
in a lot of Demeter's law violations, but it means one less model
to remember, which is a good thing since our property names are
often not very informative (e.g. EmbedFileInfo.url and
Image.url which had completely different meanings))

We always have the site information for embed texts (comes from the repo
API); that part of the tests was pointless, but now that EmbedFileInfo
depends on Repo they became impossible to maintain, hence the
deletion of half the test cases in getThumbnailHtml().

Change-Id: I94e1d0aca14e2a7d5fad983412090add8ad6bfa3
Mingle:  https://wikimedia.mingle.thoughtworks.com/projects/multimedia/cards/369
2014-03-27 19:14:06 +00:00

839 lines
23 KiB
JavaScript

/*
* This file is part of the MediaWiki extension MultimediaViewer.
*
* MultimediaViewer is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* MultimediaViewer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with MultimediaViewer. If not, see <http://www.gnu.org/licenses/>.
*/
( function ( mw, $, oo, moment ) {
// Shortcut for prototype later
var MPP;
/**
* Represents the metadata panel in the viewer
* @class mw.mmv.ui.MetadataPanel
* @extends mw.mmv.ui.Element
* @constructor
* @param {jQuery} $container The container for the panel.
* @param {jQuery} $controlBar The control bar element.
*/
function MetadataPanel( $container, $controlBar ) {
mw.mmv.ui.Element.call( this, $container );
this.$controlBar = $controlBar;
/**
* Whether we've fired an animation for the metadata div.
* @property {boolean}
* @private
*/
this.hasAnimatedMetadata = window.localStorage !== undefined &&
localStorage.getItem( 'mmv.hasOpenedMetadata' );
/** @property {mw.mmv.HtmlUtils} htmlUtils - */
this.htmlUtils = new mw.mmv.HtmlUtils();
this.initializeHeader();
this.initializeImageMetadata();
this.initializeAboutLinks();
}
oo.inheritClass( MetadataPanel, mw.mmv.ui.Element );
MPP = MetadataPanel.prototype;
MPP.attach = function() {
var panel = this;
this.handleEvent( 'keydown', function ( e ) {
panel.keydown( e );
} );
$.scrollTo().on( 'scroll.mmvp', $.throttle( 250, function() {
panel.scroll();
} ) );
this.fileReuse.attach();
};
MPP.unattach = function() {
this.fileReuse.unattach();
this.fileReuse.closeDialog();
this.clearEvents();
$.scrollTo().off( 'scroll.mmvp' );
};
MPP.empty = function () {
this.$license.empty().addClass( 'empty' );
this.$permissionLink.hide();
this.description.empty();
this.categories.empty();
this.fileUsage.empty();
this.permission.empty();
this.$title.empty().removeClass( 'error' );
this.$credit.empty().addClass( 'empty' );
this.$username.empty();
this.$usernameLi.addClass( 'empty' );
this.$repo.empty();
this.$repoLi.addClass( 'empty' );
this.$datetime.empty();
this.$datetimeLi.addClass( 'empty' );
this.$location.empty();
this.$locationLi.addClass( 'empty' );
this.$dragIcon.removeClass( 'pointing-down' );
this.$progress.addClass( 'empty' );
// need to remove this to avoid animating again when reopening lightbox on same page
this.$container.removeClass( 'invite' );
this.fileReuse.empty();
};
// **********************************************
// *********** Initialization methods ***********
// **********************************************
/**
* Initializes the header, which contains the title, credit, and license elements.
*/
MPP.initializeHeader = function () {
var panel = this;
this.initializeProgress();
this.$dragBar = $( '<div>' )
.addClass( 'mw-mlb-drag-affordance' )
.appendTo( this.$controlBar )
.click( function () {
panel.toggle();
} );
this.$dragIcon = $( '<div>' )
.addClass( 'mw-mlb-drag-icon' )
.appendTo( this.$dragBar );
this.$titleDiv = $( '<div>' )
.addClass( 'mw-mlb-title-contain' )
.appendTo( this.$controlBar );
this.$container.append( this.$controlBar );
this.initializeTitleAndCredit();
this.initializeLicense();
};
/**
* Initializes the title and credit elements.
*/
MPP.initializeTitleAndCredit = function () {
this.$titleAndCredit = $( '<div>' )
.addClass( 'mw-mlb-title-credit' )
.appendTo( this.$titleDiv );
this.initializeTitle();
this.initializeCredit();
};
/**
* Initializes the title elements.
*/
MPP.initializeTitle = function () {
this.$titlePara = $( '<p>' )
.addClass( 'mw-mlb-title-para' )
.appendTo( this.$titleAndCredit );
this.$title = $( '<span>' )
.addClass( 'mw-mlb-title' )
.appendTo( this.$titlePara );
};
/**
* Initializes the credit elements.
*/
MPP.initializeCredit = function () {
this.$source = $( '<span>' )
.addClass( 'mw-mlb-source' );
this.$author = $( '<span>' )
.addClass( 'mw-mlb-author' );
this.$credit = $( '<p>' )
.addClass( 'mw-mlb-credit empty' )
.html(
mw.message(
'multimediaviewer-credit',
this.$author.get( 0 ).outerHTML,
this.$source.get( 0 ).outerHTML
).plain()
)
.appendTo( this.$titleAndCredit );
};
/**
* Initializes the license elements.
*/
MPP.initializeLicense = function () {
var panel = this;
this.$license = $( '<a>' )
.addClass( 'mw-mlb-license empty' )
.prop( 'href', '#' )
.appendTo( this.$titlePara );
this.$permissionLink = $( '<span>' )
.addClass( 'mw-mlb-permission-link mw-mlb-label' )
.text( mw.message( 'multimediaviewer-permission-link' ).text() )
.appendTo( this.$titlePara )
.hide()
.on( 'click', function() {
panel.permission.grow();
panel.scrollIntoView( panel.permission.$box, 500 );
} );
};
/**
* Initializes the main body of metadata elements.
*/
MPP.initializeImageMetadata = function () {
this.$imageMetadata = $( '<div>' )
.addClass( 'mw-mlb-image-metadata' )
.appendTo( this.$container );
this.$imageMetadataLeft = $( '<div>' )
.addClass( 'mw-mlb-image-metadata-column mw-mlb-image-metadata-desc-column' )
.appendTo( this.$imageMetadata );
this.$imageMetadataRight = $( '<div>' )
.addClass( 'mw-mlb-image-metadata-column mw-mlb-image-metadata-links-column' )
.appendTo( this.$imageMetadata );
this.description = new mw.mmv.ui.Description( this.$imageMetadataLeft );
this.permission = new mw.mmv.ui.Permission( this.$imageMetadataLeft );
this.initializeImageLinks();
};
/**
* Initializes the list of image metadata on the right side of the panel.
*/
MPP.initializeImageLinks = function () {
this.$imageLinkDiv = $( '<div>' )
.addClass( 'mw-mlb-image-links-div' )
.appendTo( this.$imageMetadataRight );
this.$imageLinks = $( '<ul>' )
.addClass( 'mw-mlb-image-links' )
.appendTo( this.$imageLinkDiv );
this.initializeRepoLink();
this.initializeUploader();
this.initializeDatetime();
this.initializeLocation();
this.fileReuse = new mw.mmv.ui.reuse.Dialog( this.$container, this.$titleDiv );
this.categories = new mw.mmv.ui.Categories( this.$imageLinks );
this.fileUsage = new mw.mmv.ui.FileUsage(
$( '<div>' ).appendTo( this.$imageMetadataRight )
);
this.fileUsage.init();
};
/**
* Initializes the link to the file page on the (maybe remote) repository.
*/
MPP.initializeRepoLink = function () {
this.$repoLi = $( '<li>' )
.addClass( 'mw-mlb-repo-li empty' )
.appendTo( this.$imageLinks );
this.$repo = $( '<a>' )
.addClass( 'mw-mlb-repo' )
.prop( 'href', '#' )
.click( function ( e ) {
var $link = $( this ),
redirect;
if ( e.altKey || e.shiftKey || e.ctrlKey || e.metaKey || e.button === 1 ) {
// They are likely opening the link in a new window or tab
mw.mmv.logger.log( 'site-link-click' );
return;
}
// If it's a plain click, we need to wait for the logging to
// be done before navigating to the desired page
e.preventDefault();
redirect = function () {
window.location.href = $link.prop( 'href' );
};
// We want to redirect anyway, whether logging worked or not
mw.mmv.logger.log( 'site-link-click' ).then( redirect, redirect );
} )
.appendTo( this.$repoLi );
};
/**
* Initializes the upload date/time element.
*/
MPP.initializeDatetime = function () {
this.$datetimeLi = $( '<li>' )
.addClass( 'mw-mlb-datetime-li empty' )
.appendTo( this.$imageLinks );
this.$datetime = $( '<span>' )
.addClass( 'mw-mlb-datetime' )
.appendTo( this.$datetimeLi );
};
/**
* Initializes the link to the uploader's file page.
*/
MPP.initializeUploader = function () {
this.$usernameLi = $( '<li>' )
.addClass( 'mw-mlb-username-li empty' )
.appendTo( this.$imageLinks );
this.$username = $( '<a>' )
.addClass( 'mw-mlb-username' )
.prop( 'href', '#' )
.appendTo( this.$usernameLi );
};
/**
* Initializes the geolocation element.
*/
MPP.initializeLocation = function () {
this.$locationLi = $( '<li>' )
.addClass( 'mw-mlb-location-li empty' )
.appendTo( this.$imageLinks );
this.$location = $( '<a>' )
.addClass( 'mw-mlb-location' )
.appendTo( this.$locationLi );
};
/**
* Initializes two about links at the bottom of the panel.
*/
MPP.initializeAboutLinks = function () {
var separator = ' | ';
this.$mmvAboutLink = $( '<a>' )
.prop( 'href', mw.config.get( 'wgMultimediaViewer' ).infoLink )
.text( mw.message( 'multimediaviewer-about-mmv' ).text() )
.addClass( 'mw-mlb-mmv-about-link' );
this.$mmvDiscussLink = $( '<a>' )
.prop( 'href', mw.config.get( 'wgMultimediaViewer' ).discussionLink )
.text( mw.message( 'multimediaviewer-discuss-mmv' ).text() )
.addClass( 'mw-mlb-mmv-discuss-link' );
this.$mmvHelpLink = $( '<a>' )
.prop( 'href', mw.config.get( 'wgMultimediaViewer' ).helpLink )
.text( mw.message( 'multimediaviewer-help-mmv' ).text() )
.addClass( 'mw-mlb-mmv-help-link' );
this.$mmvAboutLinks = $( '<div>' )
.addClass( 'mw-mlb-mmv-about-links' )
.append(
this.$mmvAboutLink,
separator,
this.$mmvDiscussLink,
separator,
this.$mmvHelpLink
)
.appendTo( this.$imageMetadata );
};
/**
* Initializes the progress display at the top of the panel.
*/
MPP.initializeProgress = function () {
this.$progress = $( '<div>' )
.addClass( 'mw-mlb-progress empty' )
.appendTo( this.$controlBar );
this.$percent = $( '<div>' )
.addClass( 'mw-mlb-progress-percent' )
.appendTo( this.$progress );
};
// *********************************
// ******** Setting methods ********
// *********************************
/**
* Sets the URL for the File: page of the image
* @param {string} url
*/
MPP.setFilePageLink = function ( url ) {
this.$repo.prop( 'href', url );
this.$license.prop( 'href', url );
};
/**
* Sets the display name of the repository
* @param {string} displayname
* @param {string} favIcon
* @param {boolean} isLocal true if this is the local repo ( the file has been uploaded locally)
*/
MPP.setRepoDisplay = function ( displayname, favIcon, isLocal ) {
if ( isLocal ) {
this.$repo.text(
mw.message( 'multimediaviewer-repository-local' ).text()
);
} else {
displayname = displayname || mw.config.get( 'wgSiteName' );
this.$repo.text(
mw.message( 'multimediaviewer-repository', displayname ).text()
);
}
// This horror exists because the CSS uses a :before pseudo-class to
// define the repo icon. This is the only way to override it.
if ( favIcon ) {
if ( !this.$repoLiInlineStyle ) {
this.$repoLiInlineStyle = $( '<style type="text/css" />' ).appendTo( 'head' );
}
this.$repoLiInlineStyle.html( '.mw-mlb-image-links li.mw-mlb-repo-li:before '
+ '{ background-image: url("'
+ favIcon
+ '"); }'
);
} else if ( this.$repoLiInlineStyle ) {
this.$repoLiInlineStyle.html( '' );
}
this.$repoLi.removeClass( 'empty' );
};
/**
* Sets the link to the user page where possible
* @param {mw.mmv.model.Repo} repoData
* @param {string} username
* @param {string} gender
*/
MPP.setUserPageLink = function ( repoData, username, gender ) {
var userpage = 'User:' + username,
articlePath = repoData.getArticlePath(),
userlink = articlePath.replace( '$1', userpage );
this.$username
.text(
mw.message( 'multimediaviewer-userpage-link', username, gender ).text()
)
.prop( 'href', userlink );
this.$usernameLi.toggleClass( 'empty', !username );
};
/**
* Sets the file title in the panel
* @param {string} title
*/
MPP.setFileTitle = function ( title ) {
this.$title.text( title );
};
/**
* Sets up the file reuse data in the DOM
* @param {mw.mmv.model.Image} image
* @param {mw.mmv.model.Repo} repo
* @param {string} caption
*/
MPP.setFileReuseData = function ( image, repo, caption ) {
this.fileReuse.set( image, repo, caption );
};
/**
* Sets the upload or creation date and time in the panel
* @param {string} date The formatted date to set.
* @param {boolean} created Whether this is the creation date
*/
MPP.setDateTime = function ( date, created ) {
this.$datetime.text(
mw.message(
'multimediaviewer-datetime-' + ( created ? 'created' : 'uploaded' ),
date
).text()
);
this.$datetimeLi.removeClass( 'empty' );
};
/**
* Sets the source in the panel
* @param {string} source Warning - unsafe HTML sometimes goes here
*/
MPP.setSource = function ( source ) {
this.$source.html( this.htmlUtils.htmlToTextWithLinks( source ) );
this.$credit.removeClass( 'empty' );
};
/**
* Sets the author in the panel
* @param {string} author Warning - unsafe HTML sometimes goes here
*/
MPP.setAuthor = function ( author ) {
this.$author.html( this.htmlUtils.htmlToTextWithLinks( author ) );
this.$credit.removeClass( 'empty' );
};
/**
* Consolidate the source and author fields into a credit field
* @param {boolean} source Do we have the source field?
* @param {boolean} author Do we have the author field?
*/
MPP.consolidateCredit = function ( source, author ) {
if ( source && author ) {
this.$credit.html(
mw.message(
'multimediaviewer-credit',
this.$author.get( 0 ).outerHTML,
this.$source.get( 0 ).outerHTML
).plain()
);
} else {
// Clobber the contents and only have one of the fields
if ( source ) {
this.$credit.empty().append( this.$source );
} else if ( author ) {
this.$credit.empty().append( this.$author );
}
}
};
/**
* Sets the license data in the DOM
* @param {string} license The license this file has.
*/
MPP.setLicenseData = function ( license ) {
this.$license.data( 'license', license );
};
/**
* Sets the license display in the panel
* @param {string} license The human-readable name of the license
* @param {boolean} isCc Whether this is a CC license
*/
MPP.setLicense = function ( license, isCc ) {
this.$license
.text( license )
.toggleClass( 'cc-license', isCc );
this.$license.removeClass( 'empty' );
};
/**
* Set an extra permission text which should be displayed.
* @param {string} permission
*/
MPP.setPermission = function ( permission ) {
this.$permissionLink.show();
this.permission.set( permission );
};
/**
* Sets location data in the interface.
* @param {mw.mmv.model.Image} imageData
*/
MPP.setLocationData = function ( imageData ) {
var latsec, latitude, latmsg, latdeg, latremain, latmin,
longsec, longitude, longmsg, longdeg, longremain, longmin,
language;
if ( !imageData.hasCoords() ) {
return;
}
latitude = imageData.latitude >= 0 ? imageData.latitude : imageData.latitude * -1;
latmsg = 'multimediaviewer-geoloc-' + ( imageData.latitude >= 0 ? 'north' : 'south' );
latdeg = Math.floor( latitude );
latremain = latitude - latdeg;
latmin = Math.floor( ( latremain ) * 60 );
longitude = imageData.longitude >= 0 ? imageData.longitude : imageData.longitude * -1;
longmsg = 'multimediaviewer-geoloc-' + ( imageData.longitude >= 0 ? 'east' : 'west' );
longdeg = Math.floor( longitude );
longremain = longitude - longdeg;
longmin = Math.floor( ( longremain ) * 60 );
longremain -= longmin / 60;
latremain -= latmin / 60;
latsec = Math.round( latremain * 100 * 60 * 60 ) / 100;
longsec = Math.round( longremain * 100 * 60 * 60 ) / 100;
this.$location.text(
mw.message( 'multimediaviewer-geolocation',
mw.message(
'multimediaviewer-geoloc-coords',
mw.message(
'multimediaviewer-geoloc-coord',
mw.language.convertNumber( latdeg ),
mw.language.convertNumber( latmin ),
mw.language.convertNumber( latsec ),
mw.message( latmsg ).text()
).text(),
mw.message(
'multimediaviewer-geoloc-coord',
mw.language.convertNumber( longdeg ),
mw.language.convertNumber( longmin ),
mw.language.convertNumber( longsec ),
mw.message( longmsg ).text()
).text()
).text()
).text()
);
$.each( mw.language.data, function( key, value ) {
value = 'go away jshint';
language = key;
return false;
} );
this.$location.prop( 'href', (
'//tools.wmflabs.org/geohack/geohack.php?pagename=' +
'File:' + imageData.title.getMain() +
'&params=' +
Math.abs( imageData.latitude ) + ( imageData.latitude >= 0 ? '_N_' : '_S_' ) +
Math.abs( imageData.longitude ) + ( imageData.longitude >= 0 ? '_E_' : '_W_' ) +
'&language=' + language
) );
this.$locationLi.removeClass( 'empty' );
};
/**
* Set all the image information in the panel
* @param {mw.mmv.LightboxImage} image
* @param {mw.mmv.model.Image} imageData
* @param {mw.mmv.model.Repo} repoData
* @param {mw.mmv.model.FileUsage} localUsage
* @param {mw.mmv.model.FileUsage} globalUsage
* @param {mw.mmv.model.User} user
*/
MPP.setImageInfo = function ( image, imageData, repoData, localUsage, globalUsage, user ) {
var msgname,
fileTitle = image.filePageTitle;
this.setFileTitle( fileTitle.getNameText() );
this.setRepoDisplay( repoData.displayName, repoData.favIcon, repoData.isLocal );
this.setFilePageLink( imageData.descriptionUrl );
if ( imageData.creationDateTime ) {
this.setDateTime( this.formatDate( imageData.creationDateTime ), true );
} else if ( imageData.uploadDateTime ) {
this.setDateTime( this.formatDate( imageData.uploadDateTime ) );
}
if ( imageData.source ) {
this.setSource( imageData.source );
}
if ( imageData.author ) {
this.setAuthor( imageData.author );
}
this.consolidateCredit( !!imageData.source, !!imageData.author );
this.description.set( imageData.description, image.caption );
this.categories.set( repoData.getArticlePath(), imageData.categories );
msgname = 'multimediaviewer-license-' + ( imageData.license && imageData.license.internalName || '' );
if ( !mw.messages.exists( msgname ) ) {
// Cannot display, fallback or fail
msgname = 'multimediaviewer-license-default';
} else {
// License found, store the license data
this.setLicenseData( mw.message( msgname ).text() );
}
if ( imageData.license ) {
this.setLicense( mw.message( msgname ).text(), imageData.isCcLicensed() );
}
if ( imageData.permission ) {
this.setPermission( imageData.permission );
}
this.fileUsage.set( localUsage, globalUsage );
this.setLocationData( imageData );
if ( user ) {
this.setUserPageLink( repoData, imageData.lastUploader, user.gender );
}
// File reuse steals a bunch of information from the DOM, so do it last
this.setFileReuseData( imageData, repoData, image.caption );
};
/**
* Show an error message, in case the data could not be loaded
* @param {string} error
*/
MPP.showError = function ( error ) {
this.$title.addClass( 'error' )
.text( mw.message( 'multimediaviewer-metadata-error', error ).text() );
};
/**
* Transforms a date string into localized, human-readable format.
* Unrecognized strings are returned unchanged.
* @param {string} dateString
* @return {string}
*/
MPP.formatDate = function ( dateString ) {
var date = moment( dateString );
if ( !date.isValid() ) {
return dateString;
}
return date.format( 'LL' );
};
/**
* Animates the metadata area when the viewer is first opened.
*/
MPP.animateMetadataOnce = function () {
if ( !this.hasAnimatedMetadata ) {
this.hasAnimatedMetadata = true;
this.$container.addClass( 'invite' );
} else {
this.$container.addClass( 'invited' );
}
};
// ********************************
// ******** Action methods ********
// ********************************
/**
* Toggles the metadata div being totally visible.
*/
MPP.toggle = function ( forceDirection ) {
var scrollTopWhenOpen = this.$container.outerHeight() - this.$controlBar.outerHeight(),
scrollTopTarget = $.scrollTo().scrollTop() > 0 ? 0 : scrollTopWhenOpen;
if ( forceDirection ) {
scrollTopTarget = forceDirection === 'down' ? 0 : scrollTopWhenOpen;
}
$.scrollTo( scrollTopTarget, 400 );
};
/**
* Handles keydown events for this element.
*/
MPP.keydown = function ( e ) {
switch ( e.which ) {
case 40:
// Down arrow
this.toggle( 'down' );
e.preventDefault();
break;
case 38:
// Up arrow
this.toggle( 'up' );
e.preventDefault();
break;
}
};
/**
* Makes sure that the given element (which must be a descendant of the metadata panel) is
* in view. If it isn't, scrolls the panel smoothly to reveal it.
* @param {HTMLElement|jQuery|string} target
* @param {number} [duration] animation length
* @param {Object} [settings] see jQuery.scrollTo
*/
MPP.scrollIntoView = function( target, duration, settings ) {
var $target = $( target ),
targetHeight = $target.height(),
targetTop = $target.offset().top,
targetBottom = targetTop + targetHeight,
viewportHeight = $(window).height(),
viewportTop = $.scrollTo().scrollTop(),
viewportBottom = viewportTop + viewportHeight;
// we omit here a bunch of cases which are logically possible but unlikely given the size
// of the panel, and only care about the one which will actually happen
if ( targetHeight <= viewportHeight ) { // target fits into screen
if (targetBottom > viewportBottom ) {
$.scrollTo( viewportTop + ( targetBottom - viewportBottom ), duration, settings );
}
}
};
/**
* Handles the progress display when a percentage of progress is received
* @param {number} percent
*/
MPP.percent = function ( percent ) {
var panel = this;
if ( percent === 0 ) {
// When a 0% update comes in, we jump without animation to 0 and we hide the bar
this.$progress.addClass( 'empty' );
this.$percent.stop().css( { width : 0 } );
} else if ( percent === 100 ) {
// When a 100% update comes in, we make sure that the bar is visible, we animate
// fast to 100 and we hide the bar when the animation is done
this.$progress.removeClass( 'empty' );
this.$percent.stop().animate( { width : percent + '%' }, 50, 'swing',
function () {
// Reset the position for good measure
panel.$percent.stop().css( { width : 0 } );
panel.$progress.addClass( 'empty' );
} );
} else {
// When any other % update comes in, we make sure the bar is visible
// and we animate to the right position
this.$progress.removeClass( 'empty' );
this.$percent.stop().animate( { width : percent + '%' } );
}
};
/**
* Receives the window's scroll events and flips the chevron if necessary.
*/
MPP.scroll = function () {
var scrolled = !!$.scrollTo().scrollTop();
this.$dragIcon.toggleClass( 'pointing-down', scrolled );
if (
!this.savedHasOpenedMetadata &&
scrolled &&
window.localStorage !== undefined
) {
localStorage.setItem( 'mmv.hasOpenedMetadata', true );
this.savedHasOpenedMetadata = true;
}
};
mw.mmv.ui.MetadataPanel = MetadataPanel;
}( mediaWiki, jQuery, OO, moment ) );