jenkins-bot has submitted this change and it was merged.

Change subject: Specialized inspector for ISBN magic links
......................................................................


Specialized inspector for ISBN magic links

Implement a special node type, context item, and inspector for
ISBN/PMID/RFC magic links.  Add buttons to the link inspectors
to convert back and forth between "simple" links, and magic links.

Depends on I5d000d8b63dafdfe0a2753069d3f0ac5b03b8829 in Parsoid
for clean round-tripping of localized ISBN magic links.

Bug: T63558
Change-Id: Id5b7a2ae3c80b0e5eed598f0bd024d3e94f7e9aa
---
M VisualEditor.hooks.php
M extension.json
A modules/ve-mw/ce/nodes/ve.ce.MWMagicLinkNode.js
M modules/ve-mw/dm/annotations/ve.dm.MWInternalLinkAnnotation.js
A modules/ve-mw/dm/nodes/ve.dm.MWMagicLinkNode.js
M modules/ve-mw/i18n/en.json
M modules/ve-mw/i18n/qqq.json
M modules/ve-mw/tests/ui/actions/ve.ui.MWLinkAction.test.js
M 
modules/ve-mw/tests/ui/datatransferhandlers/ve.ui.MWWikitextStringTransferHandler.test.js
M modules/ve-mw/ui/actions/ve.ui.MWLinkAction.js
A modules/ve-mw/ui/contextitems/ve.ui.MWMagicLinkNodeContextItem.js
M modules/ve-mw/ui/datatransferhandlers/ve.ui.MWWikitextStringTransferHandler.js
M modules/ve-mw/ui/inspectors/ve.ui.MWLinkAnnotationInspector.js
A modules/ve-mw/ui/inspectors/ve.ui.MWMagicLinkNodeInspector.js
M modules/ve-mw/ui/tools/ve.ui.MWLinkInspectorTool.js
15 files changed, 926 insertions(+), 95 deletions(-)

Approvals:
  Esanders: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/VisualEditor.hooks.php b/VisualEditor.hooks.php
index 767b4d8..84b278f 100644
--- a/VisualEditor.hooks.php
+++ b/VisualEditor.hooks.php
@@ -434,6 +434,7 @@
                        'enableTocWidget' => $veConfig->get( 
'VisualEditorEnableTocWidget' ),
                        'svgMaxSize' => $coreConfig->get( 'SVGMaxSize' ),
                        'namespacesWithSubpages' => $coreConfig->get( 
'NamespacesWithSubpages' ),
+                       'specialBooksources' => urldecode( 
SpecialPage::getTitleFor( 'Booksources' )->getPrefixedURL() ),
                        'restbaseUrl' => $coreConfig->get( 
'VisualEditorRestbaseURL' ),
                );
 
diff --git a/extension.json b/extension.json
index ad1d4f4..80960ca 100644
--- a/extension.json
+++ b/extension.json
@@ -1190,9 +1190,11 @@
                },
                "ext.visualEditor.mwlink": {
                        "scripts": [
+                               
"modules/ve-mw/dm/nodes/ve.dm.MWMagicLinkNode.js",
                                
"modules/ve-mw/dm/nodes/ve.dm.MWNumberedExternalLinkNode.js",
                                
"modules/ve-mw/dm/annotations/ve.dm.MWExternalLinkAnnotation.js",
                                
"modules/ve-mw/dm/annotations/ve.dm.MWInternalLinkAnnotation.js",
+                               
"modules/ve-mw/ce/nodes/ve.ce.MWMagicLinkNode.js",
                                
"modules/ve-mw/ce/nodes/ve.ce.MWNumberedExternalLinkNode.js",
                                
"modules/ve-mw/ce/annotations/ve.ce.MWExternalLinkAnnotation.js",
                                
"modules/ve-mw/ce/annotations/ve.ce.MWInternalLinkAnnotation.js",
@@ -1200,8 +1202,10 @@
                                
"modules/ve-mw/ui/widgets/ve.ui.MWExternalLinkAnnotationWidget.js",
                                
"modules/ve-mw/ui/inspectors/ve.ui.MWLinkAnnotationInspector.js",
                                
"modules/ve-mw/ui/inspectors/ve.ui.MWLinkNodeInspector.js",
+                               
"modules/ve-mw/ui/inspectors/ve.ui.MWMagicLinkNodeInspector.js",
                                
"modules/ve-mw/ui/tools/ve.ui.MWLinkInspectorTool.js",
                                
"modules/ve-mw/ui/contextitems/ve.ui.MWInternalLinkContextItem.js",
+                               
"modules/ve-mw/ui/contextitems/ve.ui.MWMagicLinkNodeContextItem.js",
                                
"modules/ve-mw/ui/contextitems/ve.ui.MWNumberedExternalLinkNodeContextItem.js"
                        ],
                        "styles": [
@@ -1213,11 +1217,21 @@
                        ],
                        "messages": [
                                
"visualeditor-annotationbutton-linknode-tooltip",
+                               
"visualeditor-annotationbutton-magiclinknode-tooltip-isbn",
+                               
"visualeditor-annotationbutton-magiclinknode-tooltip-pmid",
+                               
"visualeditor-annotationbutton-magiclinknode-tooltip-rfc",
                                
"visualeditor-linkinspector-button-link-external",
                                
"visualeditor-linkinspector-button-link-internal",
+                               "visualeditor-linkinspector-convert-link-isbn",
+                               "visualeditor-linkinspector-convert-link-pmid",
+                               "visualeditor-linkinspector-convert-link-rfc",
                                "visualeditor-linkinspector-illegal-title",
                                "visualeditor-linknodeinspector-add-label",
-                               "visualeditor-linknodeinspector-title"
+                               "visualeditor-linknodeinspector-title",
+                               
"visualeditor-magiclinknodeinspector-convert-link",
+                               
"visualeditor-magiclinknodeinspector-title-isbn",
+                               
"visualeditor-magiclinknodeinspector-title-pmid",
+                               "visualeditor-magiclinknodeinspector-title-rfc"
                        ],
                        "targets": [
                                "desktop",
diff --git a/modules/ve-mw/ce/nodes/ve.ce.MWMagicLinkNode.js 
b/modules/ve-mw/ce/nodes/ve.ce.MWMagicLinkNode.js
new file mode 100644
index 0000000..6ce9750
--- /dev/null
+++ b/modules/ve-mw/ce/nodes/ve.ce.MWMagicLinkNode.js
@@ -0,0 +1,81 @@
+/*!
+ * VisualEditor ContentEditable MWMagicLinkNode class.
+ *
+ * @copyright 2011-2015 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+/**
+ * ContentEditable MediaWiki magic link node.
+ *
+ * @class
+ * @extends ve.ce.LeafNode
+ * @mixins ve.ce.FocusableNode
+ * @constructor
+ * @param {ve.dm.MWMagicLinkNode} model Model to observe
+ * @param {Object} [config] Configuration options
+ */
+ve.ce.MWMagicLinkNode = function VeCeMWMagicLinkNode( model, config ) {
+       // Parent constructor
+       ve.ce.LeafNode.call( this, model, config );
+
+       // Mixin constructors
+       ve.ce.FocusableNode.call( this );
+
+       // DOM changes
+       this.$element
+               .addClass( 've-ce-mwMagicLinkNode' )
+               // Need CE=false to prevent selection issues
+               .prop( 'contentEditable', 'false' );
+
+       // Add link
+       this.$link = $( '<a>' )
+               .appendTo( this.$element );
+
+       // Events
+       this.model.connect( this, { update: 'onUpdate' } );
+
+       // Initialization
+       this.onUpdate();
+};
+
+/* Inheritance */
+
+OO.inheritClass( ve.ce.MWMagicLinkNode, ve.ce.LeafNode );
+
+OO.mixinClass( ve.ce.MWMagicLinkNode, ve.ce.FocusableNode );
+
+/* Static Properties */
+
+ve.ce.MWMagicLinkNode.static.name = 'link/mwMagic';
+
+ve.ce.MWMagicLinkNode.static.tagName = 'span';
+
+ve.ce.MWMagicLinkNode.static.primaryCommandName = 'link';
+
+/* Static Methods */
+
+/**
+ * @inheritdoc
+ */
+ve.ce.MWMagicLinkNode.static.getDescription = function ( model ) {
+       return model.getAttribute( 'content' );
+};
+
+/* Methods */
+
+/**
+ * Handle model update events.
+ *
+ * @method
+ */
+ve.ce.MWMagicLinkNode.prototype.onUpdate = function () {
+       this.$link
+               .attr( 'href', this.model.getHref() )
+               .attr( 'rel', this.model.getRel() )
+               .text( this.model.getAttribute( 'content' ) );
+};
+
+/* Registration */
+
+ve.ce.nodeFactory.register( ve.ce.MWMagicLinkNode );
diff --git a/modules/ve-mw/dm/annotations/ve.dm.MWInternalLinkAnnotation.js 
b/modules/ve-mw/dm/annotations/ve.dm.MWInternalLinkAnnotation.js
index dbe22b0..13843ed 100644
--- a/modules/ve-mw/dm/annotations/ve.dm.MWInternalLinkAnnotation.js
+++ b/modules/ve-mw/dm/annotations/ve.dm.MWInternalLinkAnnotation.js
@@ -137,7 +137,8 @@
                        href = dataElement.attributes.hrefPrefix + href;
                }
        } else {
-               href = encodeURIComponent( title );
+               // Don't escape slashes in the title; they represent subpages.
+               href = title.split( '/' ).map( encodeURIComponent ).join( '/' );
        }
        return href;
 };
diff --git a/modules/ve-mw/dm/nodes/ve.dm.MWMagicLinkNode.js 
b/modules/ve-mw/dm/nodes/ve.dm.MWMagicLinkNode.js
new file mode 100644
index 0000000..d09b9b9
--- /dev/null
+++ b/modules/ve-mw/dm/nodes/ve.dm.MWMagicLinkNode.js
@@ -0,0 +1,415 @@
+/*!
+ * VisualEditor DataModel MWMagicLinkNode class.
+ *
+ * @copyright 2011-2015 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+/**
+ * DataModel MediaWiki magic link node.
+ *
+ * @class
+ * @extends ve.dm.LeafNode
+ * @mixins ve.dm.FocusableNode
+ *
+ * @constructor
+ * @param {Object} [element] Reference to element in linear model
+ */
+ve.dm.MWMagicLinkNode = function VeDmMWMagicLinkNode() {
+       // Parent constructor
+       ve.dm.LeafNode.apply( this, arguments );
+
+       // Mixin constructors
+       ve.dm.FocusableNode.call( this );
+};
+
+/* Inheritance */
+
+OO.inheritClass( ve.dm.MWMagicLinkNode, ve.dm.LeafNode );
+
+OO.mixinClass( ve.dm.MWMagicLinkNode, ve.dm.FocusableNode );
+
+/* Static Properties */
+
+ve.dm.MWMagicLinkNode.static.name = 'link/mwMagic';
+
+ve.dm.MWMagicLinkNode.static.isContent = true;
+
+ve.dm.MWMagicLinkNode.static.matchTagNames = [ 'a' ];
+
+ve.dm.MWMagicLinkNode.static.matchRdfaTypes = [ 'mw:WikiLink', 'mw:ExtLink' ];
+
+ve.dm.MWMagicLinkNode.static.blacklistedAnnotationTypes = [ 'link' ];
+
+/**
+ * Determine whether the given `element` is a magic link.
+ *
+ * @return {boolean} True if the element is a magic link
+ */
+ve.dm.MWMagicLinkNode.static.matchFunction = function ( element ) {
+       var i,
+               children = element.childNodes,
+               href = element.getAttribute( 'href' );
+       // All children must be text nodes, or a <span> representing an entity.
+       for ( i = 0; i < children.length; i++ ) {
+               if ( children[ i ].nodeType === Node.TEXT_NODE ) {
+                       continue;
+               }
+               // <span typeof='mw:Entity'>...</span> (for &nbsp;)
+               if ( children[ i ].nodeType === Node.ELEMENT_NODE &&
+                       children[ i ].tagName === 'SPAN' &&
+                       children[ i ].getAttribute( 'typeof' ) === 'mw:Entity' 
) {
+                       continue;
+               }
+               return false;
+       }
+
+       // Check that text content matches one of the magic link types and that
+       // the href matches that expected for the magic link type.
+       return ve.dm.MWMagicLinkNode.static.validateHref( element.textContent, 
href );
+};
+
+/**
+ * Test that a proposed content string is valid for a magic link.
+ * If `optType` is given, additionally verify that the content string is
+ * valid for the particular type of magic link (RFC/ISBN/PMID).
+ *
+ * @param {string} content
+ *   The content string to test.
+ * @param {string} [optType]
+ *   The desired type of magic link, one of "RFC", "ISBN", or "PMID".
+ *   If not supplied, returns true if the content is valid for any one
+ *   of these.
+ * @return {boolean}
+ *   True if the content string is valid for a magic link of the appropriate
+ *   type (or any type).
+ */
+ve.dm.MWMagicLinkNode.static.validateContent = function ( content, optType ) {
+       var type = ve.dm.MWMagicLinkType.static.fromContent( content );
+       if ( type === null || ( optType !== undefined && type.type !== optType 
) ) {
+               // Not a valid magic link, or a magic link of the wrong type.
+               return false;
+       }
+       return true;
+};
+
+/**
+ * Test that a proposed content string and href is valid for a magic link.
+ *
+ * @param {string} content
+ *   The content string to test.
+ * @param {string} href
+ *   The URL target of the magic link.
+ * @return {boolean}
+ *   True if the content string and href are valid for a magic link.
+ */
+ve.dm.MWMagicLinkNode.static.validateHref = function ( content, href ) {
+       var type = ve.dm.MWMagicLinkType.static.fromContent( content );
+       return type && type.matchHref( href );
+};
+
+/**
+ * Return a link annotation appropriate for converting a magic link
+ * with the given content into a simple link, or `null` if the given
+ * content is not a valid magic link.
+ *
+ * @return {ve.dm.MWExternalLinkAnnotation|ve.dm.MWInternalLinkAnnotation|null}
+ */
+ve.dm.MWMagicLinkNode.static.annotationFromContent = function ( content ) {
+       var type = ve.dm.MWMagicLinkType.static.fromContent( content );
+       return type !== null ? type.getAnnotation() : null;
+};
+
+/**
+ * @inheritdoc
+ */
+ve.dm.MWMagicLinkNode.static.toDataElement = function ( domElements ) {
+       var textContent = domElements[ 0 ].textContent,
+               htmlContent = domElements[ 0 ].innerHTML;
+       return {
+               type: this.name,
+               attributes: {
+                       content: textContent,
+                       // These next two attributes allow lossless 
round-tripping
+                       // if the original wikitext contained html entities like
+                       // &nbsp;
+                       origText: textContent,
+                       origHtml: htmlContent
+               }
+       };
+};
+
+/**
+ * @inheritdoc
+ */
+ve.dm.MWMagicLinkNode.static.toDomElements = function ( dataElement, doc ) {
+       var content = dataElement.attributes.content,
+               type = ve.dm.MWMagicLinkType.static.fromContent( content ),
+               href = type.getHref(),
+               domElement = doc.createElement( 'a' );
+       domElement.setAttribute( 'href', href );
+       domElement.setAttribute( 'rel', type.rel );
+       if ( content === dataElement.attributes.origText ) {
+               // Preserve <span typeof="mw:Entity"> elements from the 
original.
+               domElement.innerHTML = dataElement.attributes.origHtml;
+       } else {
+               domElement.textContent = content;
+       }
+       return [ domElement ];
+};
+
+/* Methods */
+
+/**
+ * Return the link target appropriate for this magic link node.
+ *
+ * @return {string} Link href
+ */
+ve.dm.MWMagicLinkNode.prototype.getHref = function () {
+       var content = this.element.attributes.content,
+               type = ve.dm.MWMagicLinkType.static.fromContent( content );
+       return type.getHref();
+};
+
+/**
+ * Return the rel attribute appropriate for this magic link node.
+ *
+ * @return {string} Either "mw:ExtLink" or "mw:WikiLink"
+ */
+ve.dm.MWMagicLinkNode.prototype.getRel = function () {
+       var content = this.element.attributes.content,
+               type = ve.dm.MWMagicLinkType.static.fromContent( content );
+       return type.rel;
+};
+
+/**
+ * Return the type of this magic link node: one of "RFC", "PMID", or "ISBN".
+ *
+ * @return {string} Magic link type
+ */
+ve.dm.MWMagicLinkNode.prototype.getMagicType = function () {
+       var content = this.element.attributes.content,
+               type = ve.dm.MWMagicLinkType.static.fromContent( content );
+       return type.type;
+};
+
+/**
+ * Return the numeric code associated with this magic link node.
+ *
+ * @return {string}
+ */
+ve.dm.MWMagicLinkNode.prototype.getCode = function () {
+       var content = this.element.attributes.content,
+               type = ve.dm.MWMagicLinkType.static.fromContent( content );
+       return type.num;
+};
+
+/**
+ * Encapsulation of a particular magic link type.
+ *
+ * @class
+ * @private
+ *
+ * @constructor
+ * @param {string} type
+ *   The type of magic link; one of `'ISBN'`, `'PMID'`, or `'RFC'`.
+ * @param {string} rel
+ *   The value of the link's "rel" attribute.
+ *   Either `'mw:ExtLink'` or `'mw:WikiLink'`.
+ * @param {string} content
+ *   The content of the magic link.
+ */
+ve.dm.MWMagicLinkType = function VeDmMWMagicLinkType( type, rel, content ) {
+       this.type = type;
+       this.rel = rel;
+       this.content = content;
+       // Make the code available as a property; this is also used for
+       // validity checking.
+       this.code = this.getCode();
+};
+
+OO.initClass( ve.dm.MWMagicLinkType );
+
+/**
+ * @inheritdoc ve.dm.MWMagicLinkNode#annotationFromContent
+ */
+ve.dm.MWMagicLinkType.prototype.getAnnotation = function () {
+       return new ve.dm.MWExternalLinkAnnotation( {
+               type: 'link/mwExternal',
+               attributes: { href: this.getHref() }
+       } );
+};
+
+/**
+ * @inheritdoc ve.dm.MWMagicLinkNode#getCode
+ * @protected
+ */
+ve.dm.MWMagicLinkType.prototype.getCode = function () {
+       var m = /^([A-Z]+)[\t 
\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]+(\d+)$/.exec( this.content );
+       if ( !m || m[ 1 ] !== this.type ) {
+               return null;
+       }
+       return m[ 2 ];
+};
+
+/**
+ * @method getHref
+ * @inheritdoc ve.dm.MWMagicLinkNode#getHref
+ */
+
+/**
+ * Return true if the given href is appropriate for this magic link.
+ *
+ * @param {string} href
+ * @return {boolean}
+ */
+ve.dm.MWMagicLinkType.prototype.matchHref = function ( href ) {
+       return href.replace( /^https?:/i, '' ) === this.getHref();
+};
+
+/**
+ * Return the subclass of {@link ve.dm.MWMagicLinkType}
+ * appropriate for the given content, or `null` if the content
+ * is not appropriate for a magic link.
+ *
+ * @param {string} content
+ * @return {ve.dm.MWMagicLinkType|null}
+ */
+ve.dm.MWMagicLinkType.static.fromContent = function ( content ) {
+       var m = /^(ISBN|PMID|RFC)/.exec( content ),
+               typeStr = m && m[ 1 ],
+               type = null;
+       if ( typeStr === 'ISBN' ) {
+               type = new ve.dm.MWMagicLinkIsbnType( content );
+       } else if ( typeStr === 'PMID' ) {
+               type = new ve.dm.MWMagicLinkPmidType( content );
+       } else if ( typeStr === 'RFC' ) {
+               type = new ve.dm.MWMagicLinkRfcType( content );
+       }
+       // validate parsed number
+       return type && type.code !== null ? type : null;
+};
+
+/**
+ * An ISBN magic link.
+ *
+ * @class
+ * @extends ve.dm.MWMagicLinkType
+ * @private
+ *
+ * @constructor
+ * @param {string} content
+ */
+ve.dm.MWMagicLinkIsbnType = function VeDmMWMagicLinkIsbnType( content ) {
+       // Parent constructor
+       ve.dm.MWMagicLinkIsbnType.super.call( this, 'ISBN', 'mw:WikiLink', 
content );
+};
+
+OO.inheritClass( ve.dm.MWMagicLinkIsbnType, ve.dm.MWMagicLinkType );
+
+/**
+ * @inheritdoc
+ */
+ve.dm.MWMagicLinkIsbnType.prototype.getAnnotation = function () {
+       var conf = mw.config.get( 'wgVisualEditorConfig' ),
+               title = mw.Title.newFromText( conf.specialBooksources + '/' + 
this.code );
+       return ve.dm.MWInternalLinkAnnotation.static.newFromTitle( title );
+};
+
+/**
+ * @inheritdoc
+ */
+ve.dm.MWMagicLinkIsbnType.prototype.getCode = function () {
+       var spaceOrDash, isbnCode,
+               content = this.content;
+
+       if ( !/^ISBN[^-0-9][\s\S]+[0-9Xx]$/.test( content ) ) {
+               return null;
+       }
+
+       // Remove unicode whitespace and dashes
+       spaceOrDash = /[-\t \u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]+/g;
+       isbnCode = content.replace( spaceOrDash, '' ).replace( /^ISBN/, '' );
+
+       // Verify format of ISBN
+       if ( !/^(97[89])?\d{9}[0-9Xx]$/.test( isbnCode ) ) {
+               return null;
+       }
+       return isbnCode.toUpperCase();
+};
+
+/**
+ * @inheritdoc
+ */
+ve.dm.MWMagicLinkIsbnType.prototype.getHref = function () {
+       var conf = mw.config.get( 'wgVisualEditorConfig' );
+       return './' + conf.specialBooksources + '/' + this.code;
+};
+
+/**
+ * @inheritdoc
+ */
+ve.dm.MWMagicLinkIsbnType.prototype.matchHref = function ( href ) {
+       var conf, m, normalized;
+
+       conf = mw.config.get( 'wgVisualEditorConfig' );
+       m = /^(?:[.]+\/)*([^\/]+)\/(\d+[Xx]?)$/.exec( href );
+       if ( !m ) {
+               return false;
+       }
+       // conf.specialBooksources has localized name for Special:Booksources
+       normalized = ve.safeDecodeURIComponent( m[ 1 ] ).replace( ' ', '_' );
+       if ( normalized !== 'Special:BookSources' && normalized !== 
conf.specialBooksources ) {
+               return false;
+       }
+       if ( m[ 2 ] !== this.code ) {
+               return false;
+       }
+       return true;
+};
+
+/**
+ * A PMID magic link.
+ *
+ * @class
+ * @extends ve.dm.MWMagicLinkType
+ * @private
+ *
+ * @constructor
+ * @param {string} content
+ */
+ve.dm.MWMagicLinkPmidType = function VeDmMWMagicLinkPmidType( content ) {
+       // Parent constructor
+       ve.dm.MWMagicLinkPmidType.super.call( this, 'PMID', 'mw:ExtLink', 
content );
+};
+
+OO.inheritClass( ve.dm.MWMagicLinkPmidType, ve.dm.MWMagicLinkType );
+
+ve.dm.MWMagicLinkPmidType.prototype.getHref = function () {
+       return '//www.ncbi.nlm.nih.gov/pubmed/' + this.code + '?dopt=Abstract';
+};
+
+/**
+ * An RFC magic link.
+ *
+ * @class
+ * @extends ve.dm.MWMagicLinkType
+ * @private
+ *
+ * @constructor
+ * @param {string} content
+ */
+ve.dm.MWMagicLinkRfcType = function VeDmMWMagicLinkRfcType( content ) {
+       // Parent constructor
+       ve.dm.MWMagicLinkRfcType.super.call( this, 'RFC', 'mw:ExtLink', content 
);
+};
+
+OO.inheritClass( ve.dm.MWMagicLinkRfcType, ve.dm.MWMagicLinkType );
+
+ve.dm.MWMagicLinkRfcType.prototype.getHref = function () {
+       return '//tools.ietf.org/html/rfc' + this.code;
+};
+
+/* Registration */
+
+ve.dm.modelRegistry.register( ve.dm.MWMagicLinkNode );
diff --git a/modules/ve-mw/i18n/en.json b/modules/ve-mw/i18n/en.json
index 0e74169..43de2bb 100644
--- a/modules/ve-mw/i18n/en.json
+++ b/modules/ve-mw/i18n/en.json
@@ -49,6 +49,9 @@
        "tooltip-ca-ve-edit": "Edit this page",
        "visualeditor-advancedsettings-tool": "Advanced settings",
        "visualeditor-annotationbutton-linknode-tooltip": "Simple link",
+       "visualeditor-annotationbutton-magiclinknode-tooltip-isbn": "ISBN link",
+       "visualeditor-annotationbutton-magiclinknode-tooltip-pmid": "PMID link",
+       "visualeditor-annotationbutton-magiclinknode-tooltip-rfc": "RFC link",
        "visualeditor-backbutton-tooltip": "Go back",
        "visualeditor-beta-appendix": "beta",
        "visualeditor-beta-label": "beta",
@@ -245,6 +248,9 @@
        "visualeditor-languages-tool": "Languages",
        "visualeditor-linkinspector-button-link-external": "External link",
        "visualeditor-linkinspector-button-link-internal": "Search pages",
+       "visualeditor-linkinspector-convert-link-isbn": "Convert to ISBN link",
+       "visualeditor-linkinspector-convert-link-pmid": "Convert to PMID link",
+       "visualeditor-linkinspector-convert-link-rfc": "Convert to RFC link",
        "visualeditor-linkinspector-illegal-title": "Invalid page title",
        "visualeditor-linknodeinspector-add-label": "Add label",
        "visualeditor-linknodeinspector-title": "Simple link",
@@ -253,6 +259,10 @@
        "visualeditor-loadwarning": "Error loading data from server: $1. Would 
you like to retry?",
        "visualeditor-loadwarning-token": "Error loading edit token from 
server: $1. Would you like to retry?",
        "visualeditor-mainnamespacepagelink": "Project:Main namespace",
+       "visualeditor-magiclinknodeinspector-convert-link": "Convert to simple 
link",
+       "visualeditor-magiclinknodeinspector-title-isbn": "ISBN link",
+       "visualeditor-magiclinknodeinspector-title-pmid": "PMID link",
+       "visualeditor-magiclinknodeinspector-title-rfc": "RFC link",
        "visualeditor-media-input-placeholder": "Search for media",
        "visualeditor-meta-tool": "Options",
        "visualeditor-mweditmodesource-title": "Switch to source editing?",
diff --git a/modules/ve-mw/i18n/qqq.json b/modules/ve-mw/i18n/qqq.json
index c4ab098..abd9d5f 100644
--- a/modules/ve-mw/i18n/qqq.json
+++ b/modules/ve-mw/i18n/qqq.json
@@ -58,6 +58,9 @@
        "tooltip-ca-ve-edit": "Tooltip of the dedicated VisualEditor \"Edit\" 
tab.\n{{Identical|Edit this page}}",
        "visualeditor-advancedsettings-tool": "Tool for opening the advanced 
settings section of the meta dialog.\n{{Identical|Advanced settings}}",
        "visualeditor-annotationbutton-linknode-tooltip": "Tooltip text for 
link button for auto-numbered, labelless, external links.\n\nSee also:\n* 
{{msg-mw|Visualeditor-linknodeinspector-title}}\n{{Related|Visualeditor-annotationbutton}}",
+       "visualeditor-annotationbutton-magiclinknode-tooltip-isbn": "Tooltip 
text for link button for ISBN magic links.\n\nSee also:\n* 
{{msg-mw|Visualeditor-magiclinknodeinspector-title-isbn}}\n{{Related|Visualeditor-annotationbutton}}",
+       "visualeditor-annotationbutton-magiclinknode-tooltip-pmid": "Tooltip 
text for link button for PMID magic links.\n\nSee also:\n* 
{{msg-mw|Visualeditor-magiclinknodeinspector-title-pmid}}\n{{Related|Visualeditor-annotationbutton}}",
+       "visualeditor-annotationbutton-magiclinknode-tooltip-rfc": "Tooltip 
text for link button for RFC magic links.\n\nSee also:\n* 
{{msg-mw|Visualeditor-magiclinknodeinspector-title-rfc}}\n{{Related|Visualeditor-annotationbutton}}",
        "visualeditor-backbutton-tooltip": "Tooltip text for back button taking 
user to reading mode and closing the editor.\n{{Identical|Go back}}",
        "visualeditor-beta-appendix": "Used in 
{{msg-mw|Guidedtour-tour-firsteditve-edit-page-description}}.\n{{Identical|Beta}}",
        "visualeditor-beta-label": "Text of tool in the toolbar that highlights 
that VisualEditor is still in beta.\n{{Identical|Beta}}",
@@ -254,6 +257,9 @@
        "visualeditor-languages-tool": "Tool for opening the languages links 
section of the meta dialog.\n{{Identical|Language}}",
        "visualeditor-linkinspector-button-link-external": "Button label for 
entering an external link.\n{{Identical|External link}}",
        "visualeditor-linkinspector-button-link-internal": "Button label for 
entering an internal link.",
+       "visualeditor-linkinspector-convert-link-isbn": "Button label for 
converting a simple link to a ISBN magic link.",
+       "visualeditor-linkinspector-convert-link-pmid": "Button label for 
converting a simple link to a PMID magic link.",
+       "visualeditor-linkinspector-convert-link-rfc": "Button label for 
converting a simple link to a RFC magic link.",
        "visualeditor-linkinspector-illegal-title": "Warning that the entered 
text is not a valid page title.",
        "visualeditor-linknodeinspector-add-label": "Label of button that 
converts an auto-numbered, external, labelless link into a labeled external 
link",
        "visualeditor-linknodeinspector-title": "Title of inspector for editing 
auto-numbered, external, labelless links.\n\nSee also:\n* 
{{msg-mw|Visualeditor-annotationbutton-linknode-tooltip}}",
@@ -262,6 +268,10 @@
        "visualeditor-loadwarning": "Text (JavaScript confirm()) shown when the 
editor fails to load properly.\n\nParameters:\n* $1 - the error message from 
the server, in English. e.g. \"parsoidserver-http-err\"",
        "visualeditor-loadwarning-token": "Text (JavaScript confirm()) shown 
when the editor fails to load properly.\n\nParameters:\n* $1 - the error 
message from the server.",
        "visualeditor-mainnamespacepagelink": "Name of a page describing the 
main namespace (NS0) in this project.\n{{doc-important|Do not translate 
\"Project\"; it is automatically converted to the wiki's project namespace.}}",
+       "visualeditor-magiclinknodeinspector-convert-link": "Label of button 
that converts a magic link into a normal labeled link",
+       "visualeditor-magiclinknodeinspector-title-isbn": "Title of inspector 
for editing ISBN magic links.\n\nSee also:\n* 
{{msg-mw|Visualeditor-annotationbutton-magiclinknode-tooltip-isbn}}",
+       "visualeditor-magiclinknodeinspector-title-pmid": "Title of inspector 
for editing PMID magic links.\n\nSee also:\n* 
{{msg-mw|Visualeditor-annotationbutton-magiclinknode-tooltip-pmid}}",
+       "visualeditor-magiclinknodeinspector-title-rfc": "Title of inspector 
for editing RFC magic links.\n\nSee also:\n* 
{{msg-mw|Visualeditor-annotationbutton-magiclinknode-tooltip-rfc}}",
        "visualeditor-media-input-placeholder": "Place holder text for media 
search input",
        "visualeditor-meta-tool": "Text of tool in the toolbar the lets users 
set categories, language links and other page settings.\n{{Identical|Options}}",
        "visualeditor-mweditmodesource-title": "Title of dialog to confirm 
switching to source mode.",
diff --git a/modules/ve-mw/tests/ui/actions/ve.ui.MWLinkAction.test.js 
b/modules/ve-mw/tests/ui/actions/ve.ui.MWLinkAction.test.js
index ba60cc3..f85a8ea 100644
--- a/modules/ve-mw/tests/ui/actions/ve.ui.MWLinkAction.test.js
+++ b/modules/ve-mw/tests/ui/actions/ve.ui.MWLinkAction.test.js
@@ -114,7 +114,8 @@
                                expectedRange: new ve.Range( 21, 21 ),
                                expectedData: function ( data, makeAnnotation ) 
{
                                        var i,
-                                               a = makeAnnotation( 
'./Special:BookSources/9780596517748' );
+                                               conf = mw.config.get( 
'wgVisualEditorConfig' ),
+                                               a = makeAnnotation( './' + 
conf.specialBooksources + '/9780596517748' );
                                        for ( i = 1; i < 20; i++ ) {
                                                data[ i ] = [ data[ i ], [ a ] 
];
                                        }
diff --git 
a/modules/ve-mw/tests/ui/datatransferhandlers/ve.ui.MWWikitextStringTransferHandler.test.js
 
b/modules/ve-mw/tests/ui/datatransferhandlers/ve.ui.MWWikitextStringTransferHandler.test.js
index 73844b4..73dee72 100644
--- 
a/modules/ve-mw/tests/ui/datatransferhandlers/ve.ui.MWWikitextStringTransferHandler.test.js
+++ 
b/modules/ve-mw/tests/ui/datatransferhandlers/ve.ui.MWWikitextStringTransferHandler.test.js
@@ -166,22 +166,19 @@
                                pasteString: 'RFC 1234',
                                pasteType: 'text/plain',
                                parsoidResponse: '<body 
data-parsoid=\'{"dsr":[0,8,0,0]}\' lang="en" class="mw-content-ltr sitedir-ltr 
ltr mw-body mw-body-content mediawiki" dir="ltr"><p 
data-parsoid=\'{"dsr":[0,8,0,0]}\'><a href="//tools.ietf.org/html/rfc1234" 
rel="mw:ExtLink" data-parsoid=\'{"stx":"magiclink","dsr":[0,8,0,0]}\'>RFC 
1234</a></p></body>',
-                               annotations: [ {
-                                       type: 'link/mwExternal',
-                                       attributes: {
-                                               href: 
'//tools.ietf.org/html/rfc1234',
-                                               rel: 'mw:ExtLink'
-                                       }
-                               } ],
+                               annotations: [],
                                expectedData: [
-                                       [ 'R', [ 0 ] ],
-                                       [ 'F', [ 0 ] ],
-                                       [ 'C', [ 0 ] ],
-                                       [ ' ', [ 0 ] ],
-                                       [ '1', [ 0 ] ],
-                                       [ '2', [ 0 ] ],
-                                       [ '3', [ 0 ] ],
-                                       [ '4', [ 0 ] ],
+                                       {
+                                               type: 'link/mwMagic',
+                                               attributes: {
+                                                       content: 'RFC 1234',
+                                                       origText: 'RFC 1234',
+                                                       origHtml: 'RFC 1234'
+                                               }
+                                       },
+                                       {
+                                               type: '/link/mwMagic'
+                                       },
                                        { type: 'internalList' },
                                        { type: '/internalList' }
                                ]
@@ -191,23 +188,19 @@
                                pasteString: 'PMID 1234',
                                pasteType: 'text/plain',
                                parsoidResponse: '<body 
data-parsoid=\'{"dsr":[0,9,0,0]}\' lang="en" class="mw-content-ltr sitedir-ltr 
ltr mw-body mw-body-content mediawiki" dir="ltr"><p 
data-parsoid=\'{"dsr":[0,9,0,0]}\'><a 
href="//www.ncbi.nlm.nih.gov/pubmed/1234?dopt=Abstract" rel="mw:ExtLink" 
data-parsoid=\'{"stx":"magiclink","dsr":[0,9,0,0]}\'>PMID 1234</a></p></body>',
-                               annotations: [ {
-                                       type: 'link/mwExternal',
-                                       attributes: {
-                                               href: 
'//www.ncbi.nlm.nih.gov/pubmed/1234?dopt=Abstract',
-                                               rel: 'mw:ExtLink'
-                                       }
-                               } ],
+                               annotations: [],
                                expectedData: [
-                                       [ 'P', [ 0 ] ],
-                                       [ 'M', [ 0 ] ],
-                                       [ 'I', [ 0 ] ],
-                                       [ 'D', [ 0 ] ],
-                                       [ ' ', [ 0 ] ],
-                                       [ '1', [ 0 ] ],
-                                       [ '2', [ 0 ] ],
-                                       [ '3', [ 0 ] ],
-                                       [ '4', [ 0 ] ],
+                                       {
+                                               type: 'link/mwMagic',
+                                               attributes: {
+                                                       content: 'PMID 1234',
+                                                       origText: 'PMID 1234',
+                                                       origHtml: 'PMID 1234'
+                                               }
+                                       },
+                                       {
+                                               type: '/link/mwMagic'
+                                       },
                                        { type: 'internalList' },
                                        { type: '/internalList' }
                                ]
@@ -217,29 +210,19 @@
                                pasteString: 'ISBN 123456789X',
                                pasteType: 'text/plain',
                                parsoidResponse: '<body 
data-parsoid=\'{"dsr":[0,15,0,0]}\' lang="en" class="mw-content-ltr sitedir-ltr 
ltr mw-body mw-body-content mediawiki" dir="ltr"><p 
data-parsoid=\'{"dsr":[0,15,0,0]}\'><a href="./Special:BookSources/123456789X" 
rel="mw:ExtLink" data-parsoid=\'{"stx":"magiclink","dsr":[0,15,0,0]}\'>ISBN 
123456789X</a></p></body>',
-                               annotations: [ {
-                                       type: 'link/mwExternal',
-                                       attributes: {
-                                               href: 
'./Special:BookSources/123456789X',
-                                               rel: 'mw:ExtLink'
-                                       }
-                               } ],
+                               annotations: [],
                                expectedData: [
-                                       [ 'I', [ 0 ] ],
-                                       [ 'S', [ 0 ] ],
-                                       [ 'B', [ 0 ] ],
-                                       [ 'N', [ 0 ] ],
-                                       [ ' ', [ 0 ] ],
-                                       [ '1', [ 0 ] ],
-                                       [ '2', [ 0 ] ],
-                                       [ '3', [ 0 ] ],
-                                       [ '4', [ 0 ] ],
-                                       [ '5', [ 0 ] ],
-                                       [ '6', [ 0 ] ],
-                                       [ '7', [ 0 ] ],
-                                       [ '8', [ 0 ] ],
-                                       [ '9', [ 0 ] ],
-                                       [ 'X', [ 0 ] ],
+                                       {
+                                               type: 'link/mwMagic',
+                                               attributes: {
+                                                       content: 'ISBN 
123456789X',
+                                                       origText: 'ISBN 
123456789X',
+                                                       origHtml: 'ISBN 
123456789X'
+                                               }
+                                       },
+                                       {
+                                               type: '/link/mwMagic'
+                                       },
                                        { type: 'internalList' },
                                        { type: '/internalList' }
                                ]
diff --git a/modules/ve-mw/ui/actions/ve.ui.MWLinkAction.js 
b/modules/ve-mw/ui/actions/ve.ui.MWLinkAction.js
index a9f44de..9595a01 100644
--- a/modules/ve-mw/ui/actions/ve.ui.MWLinkAction.js
+++ b/modules/ve-mw/ui/actions/ve.ui.MWLinkAction.js
@@ -62,33 +62,27 @@
  * @return {ve.dm.MWExternalLinkAnnotation} The annotation to use.
  */
 ve.ui.MWLinkAction.prototype.getLinkAnnotation = function ( linktext ) {
-       var title, targetData, m,
+       var title, targetData,
                href = linktext;
-       // The link has been validated in #autolinkMagicLink and/or
-       // #autolinkUrl, so we can use a quick and dirty regexp here to pull
-       // apart the magic link.
-       m = /^(RFC|PMID|ISBN)\s+(\S.*)$/.exec( linktext );
-       if ( m && m[ 1 ] === 'RFC' ) {
-               href = '//tools.ietf.org/html/rfc' + m[ 2 ];
-       } else if ( m && m[ 1 ] === 'PMID' ) {
-               href = '//www.ncbi.nlm.nih.gov/pubmed/' + m[ 2 ] + 
'?dopt=Abstract';
-       } else if ( m && m[ 1 ] === 'ISBN' ) {
-               title = mw.Title.newFromText( 'Special:BookSources/' + m[ 2 
].replace( /[^0-9Xx]/g, '' ) );
-       } else {
-               targetData = 
ve.dm.MWInternalLinkAnnotation.static.getTargetDataFromHref(
-                       href,
-                       this.surface.getModel().getDocument().getHtmlDocument()
-               );
-               if ( targetData.isInternal ) {
-                       title = mw.Title.newFromText( targetData.title );
-               }
+
+       // Is this a "magic link"?
+       if ( ve.dm.MWMagicLinkNode.static.validateContent( linktext ) ) {
+               return ve.dm.MWMagicLinkNode.static.annotationFromContent( 
linktext );
        }
-       return title ?
-               ve.dm.MWInternalLinkAnnotation.static.newFromTitle( title ) :
-               new ve.dm.MWExternalLinkAnnotation( {
-                       type: 'link/mwExternal',
-                       attributes: { href: href }
-               } );
+       // Is this an internal link?
+       targetData = 
ve.dm.MWInternalLinkAnnotation.static.getTargetDataFromHref(
+               href,
+               this.surface.getModel().getDocument().getHtmlDocument()
+       );
+       if ( targetData.isInternal ) {
+               title = mw.Title.newFromText( targetData.title );
+               return ve.dm.MWInternalLinkAnnotation.static.newFromTitle( 
title );
+       }
+       // It's an external link.
+       return new ve.dm.MWExternalLinkAnnotation( {
+               type: 'link/mwExternal',
+               attributes: { href: href }
+       } );
 };
 
 /**
@@ -102,13 +96,7 @@
  */
 ve.ui.MWLinkAction.prototype.autolinkMagicLink = function () {
        return this.autolink( function ( linktext ) {
-               if ( /^(RFC|PMID) [0-9]+$/.test( linktext ) ) {
-                       return true; // Valid RFC/PMID
-               }
-               if ( /^ISBN (97[89][- ]?)?([0-9][- ]?){9}[0-9Xx]$/.test( 
linktext ) ) {
-                       return true; // Valid ISBN
-               }
-               return false;
+               return ve.dm.MWMagicLinkNode.static.validateContent( linktext );
        } );
 };
 
@@ -120,10 +108,13 @@
  */
 ve.ui.MWLinkAction.prototype.open = function () {
        var fragment = this.surface.getModel().getFragment(),
+               selectedNode = fragment.getSelectedNode(),
                windowName = 'link';
 
-       if ( fragment.getSelectedNode() instanceof 
ve.dm.MWNumberedExternalLinkNode ) {
+       if ( selectedNode instanceof ve.dm.MWNumberedExternalLinkNode ) {
                windowName = 'linkNode';
+       } else if ( selectedNode instanceof ve.dm.MWMagicLinkNode ) {
+               windowName = 'linkMagicNode';
        }
        this.surface.execute( 'window', 'open', windowName );
        return true;
diff --git a/modules/ve-mw/ui/contextitems/ve.ui.MWMagicLinkNodeContextItem.js 
b/modules/ve-mw/ui/contextitems/ve.ui.MWMagicLinkNodeContextItem.js
new file mode 100644
index 0000000..0ecacde
--- /dev/null
+++ b/modules/ve-mw/ui/contextitems/ve.ui.MWMagicLinkNodeContextItem.js
@@ -0,0 +1,55 @@
+/*!
+ * VisualEditor MWMagicLinkNodeContextItem class.
+ *
+ * @copyright 2011-2015 VisualEditor Team and others; see 
http://ve.mit-license.org
+ */
+
+/**
+ * Context item for a MWMagicLinkNode.
+ *
+ * @class
+ * @extends ve.ui.LinkContextItem
+ *
+ * @constructor
+ * @param {ve.ui.Context} context Context item is in
+ * @param {ve.dm.MWMagicLinkNode} model Model item is related to
+ * @param {Object} config Configuration options
+ */
+ve.ui.MWMagicLinkNodeContextItem = function VeUiMWMagicLinkNodeContextItem() {
+       // Parent constructor
+       ve.ui.MWMagicLinkNodeContextItem.super.apply( this, arguments );
+
+       // Initialization
+       this.$element.addClass( 've-ui-mwMagicLinkNodeContextItem' );
+};
+
+/* Inheritance */
+
+OO.inheritClass( ve.ui.MWMagicLinkNodeContextItem, ve.ui.LinkContextItem );
+
+/* Static Properties */
+
+ve.ui.MWMagicLinkNodeContextItem.static.name = 'link/mwMagic';
+
+ve.ui.MWMagicLinkNodeContextItem.static.label = null; // see #setup()
+
+ve.ui.MWMagicLinkNodeContextItem.static.modelClasses = [ ve.dm.MWMagicLinkNode 
];
+
+/* Methods */
+
+ve.ui.MWMagicLinkNodeContextItem.prototype.setup = function () {
+       // Set up label
+       var msg = 'visualeditor-magiclinknodeinspector-title-' +
+               this.model.getMagicType().toLowerCase();
+       this.setLabel( OO.ui.deferMsg( msg ) );
+       // Invoke superclass method.
+       return ve.ui.MWMagicLinkNodeContextItem.super.prototype.setup.call( 
this );
+};
+
+ve.ui.MWMagicLinkNodeContextItem.prototype.getDescription = function () {
+       return this.model.getAttribute( 'content' );
+};
+
+/* Registration */
+
+ve.ui.contextItemFactory.register( ve.ui.MWMagicLinkNodeContextItem );
diff --git 
a/modules/ve-mw/ui/datatransferhandlers/ve.ui.MWWikitextStringTransferHandler.js
 
b/modules/ve-mw/ui/datatransferhandlers/ve.ui.MWWikitextStringTransferHandler.js
index 2b227ee..79b5c6d 100644
--- 
a/modules/ve-mw/ui/datatransferhandlers/ve.ui.MWWikitextStringTransferHandler.js
+++ 
b/modules/ve-mw/ui/datatransferhandlers/ve.ui.MWWikitextStringTransferHandler.js
@@ -59,7 +59,7 @@
 
        // Detect autolink opportunities for magic words.
        // (The link should be the only contents of paste to match this 
heuristic)
-       if ( /^\s*(RFC|ISBN|PMID)[-\s0-9]+[Xx]?\s*$/.test( text ) ) {
+       if ( ve.dm.MWMagicLinkNode.static.validateContent( text.trim() ) ) {
                return true;
        }
 
diff --git a/modules/ve-mw/ui/inspectors/ve.ui.MWLinkAnnotationInspector.js 
b/modules/ve-mw/ui/inspectors/ve.ui.MWLinkAnnotationInspector.js
index 5bb2f99..372effa 100644
--- a/modules/ve-mw/ui/inspectors/ve.ui.MWLinkAnnotationInspector.js
+++ b/modules/ve-mw/ui/inspectors/ve.ui.MWLinkAnnotationInspector.js
@@ -32,6 +32,14 @@
        ve.dm.MWInternalLinkAnnotation
 ];
 
+ve.ui.MWLinkAnnotationInspector.static.actions = 
ve.ui.MWLinkAnnotationInspector.static.actions.concat( [
+       {
+               action: 'convert',
+               label: null, // see #updateActions
+               modes: [ 'edit', 'insert' ]
+       }
+] );
+
 /* Methods */
 
 /**
@@ -132,6 +140,38 @@
 /**
  * @inheritdoc
  */
+ve.ui.MWLinkAnnotationInspector.prototype.updateActions = function () {
+       var content, annotation, href, type,
+               msg = null;
+
+       ve.ui.MWLinkAnnotationInspector.super.prototype.updateActions.call( 
this );
+
+       // show/hide convert action
+       content = this.fragment ? this.fragment.getText() : '';
+       annotation = this.annotationInput.getAnnotation();
+       href = annotation && annotation.getHref();
+       if ( href && ve.dm.MWMagicLinkNode.static.validateHref( content, href ) 
) {
+               type = ve.dm.MWMagicLinkType.static.fromContent( content ).type;
+               msg = 'visualeditor-linkinspector-convert-link-' + 
type.toLowerCase();
+       }
+
+       // Once we toggle the visibility of the ActionWidget, we can't filter
+       // it with `get` any more.  So we have to use `forEach`:
+       this.actions.forEach( null, function ( action ) {
+               if ( action.getAction() === 'convert' ) {
+                       if ( msg ) {
+                               action.setLabel( OO.ui.deferMsg( msg ) );
+                               action.toggle( true );
+                       } else {
+                               action.toggle( false );
+                       }
+               }
+       } );
+};
+
+/**
+ * @inheritdoc
+ */
 ve.ui.MWLinkAnnotationInspector.prototype.createAnnotationInput = function () {
        return this.isExternal() ? this.externalAnnotationInput : 
this.internalAnnotationInput;
 };
@@ -162,9 +202,44 @@
 /**
  * @inheritdoc
  */
+ve.ui.MWLinkAnnotationInspector.prototype.getActionProcess = function ( action 
) {
+       if ( action === 'convert' ) {
+               return new OO.ui.Process( function () {
+                       this.close( { action: 'done', convert: true } );
+               }, this );
+       }
+       return 
ve.ui.MWLinkAnnotationInspector.super.prototype.getActionProcess.call( this, 
action );
+};
+
+/**
+ * @inheritdoc
+ */
 ve.ui.MWLinkAnnotationInspector.prototype.getTeardownProcess = function ( data 
) {
+       var fragment;
        return 
ve.ui.MWLinkAnnotationInspector.super.prototype.getTeardownProcess.call( this, 
data )
+               .first( function () {
+                       // Save the original fragment for later.
+                       fragment = this.getFragment();
+               }, this )
                .next( function () {
+                       var selection = fragment && fragment.getSelection();
+
+                       // Handle conversion to magic link.
+                       if ( data.convert && selection instanceof 
ve.dm.LinearSelection ) {
+                               fragment.insertContent( [
+                                       {
+                                               type: 'link/mwMagic',
+                                               attributes: {
+                                                       content: 
fragment.getText()
+                                               }
+                                       },
+                                       {
+                                               type: '/link/mwMagic'
+                                       }
+                               ], true );
+                       }
+
+                       // Clear dialog state.
                        this.allowProtocolInInternal = false;
                        // Make sure both inputs are cleared
                        this.internalAnnotationInput.setAnnotation( null );
diff --git a/modules/ve-mw/ui/inspectors/ve.ui.MWMagicLinkNodeInspector.js 
b/modules/ve-mw/ui/inspectors/ve.ui.MWMagicLinkNodeInspector.js
new file mode 100644
index 0000000..053ee05
--- /dev/null
+++ b/modules/ve-mw/ui/inspectors/ve.ui.MWMagicLinkNodeInspector.js
@@ -0,0 +1,182 @@
+/*!
+ * VisualEditor UserInterface MWMagicLinkNodeInspector class.
+ *
+ * @copyright 2011-2015 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+/**
+ * Inspector for editing MediaWiki magic links (RFC/ISBN/PMID).
+ *
+ * @class
+ * @extends ve.ui.NodeInspector
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+ve.ui.MWMagicLinkNodeInspector = function VeUiMWMagicLinkNodeInspector( config 
) {
+       // Parent constructor
+       ve.ui.NodeInspector.call( this, config );
+};
+
+/* Inheritance */
+
+OO.inheritClass( ve.ui.MWMagicLinkNodeInspector, ve.ui.NodeInspector );
+
+/* Static properties */
+
+ve.ui.MWMagicLinkNodeInspector.static.name = 'linkMagicNode';
+
+ve.ui.MWMagicLinkNodeInspector.static.icon = 'link';
+
+ve.ui.MWMagicLinkNodeInspector.static.title = null; // see #getSetupProcess
+
+ve.ui.MWMagicLinkNodeInspector.static.modelClasses = [ ve.dm.MWMagicLinkNode ];
+
+ve.ui.MWMagicLinkNodeInspector.static.actions = 
ve.ui.MWMagicLinkNodeInspector.super.static.actions.concat( [
+       {
+               action: 'convert',
+               label: OO.ui.deferMsg( 
'visualeditor-magiclinknodeinspector-convert-link' ),
+               modes: [ 'edit' ]
+       }
+] );
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ */
+ve.ui.MWMagicLinkNodeInspector.prototype.initialize = function () {
+       // Parent method
+       ve.ui.MWMagicLinkNodeInspector.super.prototype.initialize.call( this );
+
+       // Properties
+       this.targetInput = new OO.ui.TextInputWidget( {
+               validate: this.validate.bind( this )
+       } );
+       this.targetInput.on( 'change', this.onChange.bind( this ) );
+
+       // Initialization
+       this.form.$element.append( this.targetInput.$element );
+};
+
+/**
+ * Return true if the given string is a valid magic link of the
+ * appropriate type.
+ *
+ * @private
+ */
+ve.ui.MWMagicLinkNodeInspector.prototype.validate = function ( str ) {
+       var node = this.getFragment().getSelectedNode();
+       return node.constructor.static.validateContent( str, 
node.getMagicType() );
+};
+
+ve.ui.MWMagicLinkNodeInspector.prototype.onChange = function ( value ) {
+       // Disable the unsafe action buttons if the input isn't valid
+       var isValid = this.validate( value );
+       this.actions.forEach( null, function ( action ) {
+               if ( !action.hasFlag( 'safe' ) ) {
+                       action.setDisabled( !isValid );
+               }
+       } );
+};
+
+/**
+ * @inheritdoc
+ */
+ve.ui.MWMagicLinkNodeInspector.prototype.getActionProcess = function ( action 
) {
+       if ( ( action === 'done' || action === 'convert' ) &&
+               !this.validate( this.targetInput.getValue() ) ) {
+               // Don't close dialog: input isn't valid.
+               return new OO.ui.Process( 0 );
+       }
+       if ( action === 'convert' ) {
+               return new OO.ui.Process( function () {
+                       this.close( { action: action } );
+               }, this );
+       }
+       return 
ve.ui.MWMagicLinkNodeInspector.super.prototype.getActionProcess.call( this, 
action );
+};
+
+/**
+ * @inheritdoc
+ */
+ve.ui.MWMagicLinkNodeInspector.prototype.getSetupProcess = function ( data ) {
+       // Set the title based on the node type
+       var fragment = data.fragment,
+               node = fragment instanceof ve.dm.SurfaceFragment ?
+                       fragment.getSelectedNode() : null,
+               type = node instanceof ve.dm.MWMagicLinkNode ?
+                       node.getMagicType() : null,
+               msg = type ?
+                       'visualeditor-magiclinknodeinspector-title-' + 
type.toLowerCase() :
+                       null;
+
+       data = $.extend( {
+               title: msg ? OO.ui.deferMsg( msg ) : null
+       }, data );
+       return 
ve.ui.MWMagicLinkNodeInspector.super.prototype.getSetupProcess.call( this, data 
)
+               .next( function () {
+                       // Initialization
+                       this.targetInput.setValue(
+                               this.selectedNode ? 
this.selectedNode.getAttribute( 'content' ) : ''
+                       );
+               }, this );
+};
+
+/**
+ * @inheritdoc
+ */
+ve.ui.MWMagicLinkNodeInspector.prototype.getReadyProcess = function ( data ) {
+       return 
ve.ui.MWMagicLinkNodeInspector.super.prototype.getReadyProcess.call( this, data 
)
+               .next( function () {
+                       this.targetInput.focus().select();
+               }, this );
+};
+
+/**
+ * @inheritdoc
+ */
+ve.ui.MWMagicLinkNodeInspector.prototype.getTeardownProcess = function ( data 
) {
+       data = data || {};
+       return 
ve.ui.MWMagicLinkNodeInspector.super.prototype.getTeardownProcess.call( this, 
data )
+               .first( function () {
+                       var content, annotation, annotations,
+                               surfaceModel = this.getFragment().getSurface(),
+                               doc = surfaceModel.getDocument(),
+                               nodeRange = this.selectedNode.getOuterRange(),
+                               value = this.targetInput.getValue(),
+                               done = data.action === 'done',
+                               convert = data.action === 'convert',
+                               remove = data.action === 'remove' || ( done && 
!value );
+
+                       if ( remove ) {
+                               surfaceModel.change(
+                                       ve.dm.Transaction.newFromRemoval( doc, 
nodeRange )
+                               );
+                       } else if ( convert ) {
+                               annotation = 
ve.dm.MWMagicLinkNode.static.annotationFromContent(
+                                       value
+                               );
+                               if ( annotation ) {
+                                       annotations = 
doc.data.getAnnotationsFromOffset( nodeRange.start ).clone();
+                                       annotations.push( annotation );
+                                       content = value.split( '' );
+                                       
ve.dm.Document.static.addAnnotationsToData( content, annotations );
+                                       surfaceModel.change(
+                                               
ve.dm.Transaction.newFromReplacement( doc, nodeRange, content )
+                                       );
+                               }
+                       } else if ( done && this.validate( value ) ) {
+                               surfaceModel.change(
+                                       
ve.dm.Transaction.newFromAttributeChanges(
+                                               doc, nodeRange.start, { 
content: value }
+                                       )
+                               );
+                       }
+               }, this );
+};
+
+/* Registration */
+
+ve.ui.windowFactory.register( ve.ui.MWMagicLinkNodeInspector );
diff --git a/modules/ve-mw/ui/tools/ve.ui.MWLinkInspectorTool.js 
b/modules/ve-mw/ui/tools/ve.ui.MWLinkInspectorTool.js
index e41f1d3..c988d92 100644
--- a/modules/ve-mw/ui/tools/ve.ui.MWLinkInspectorTool.js
+++ b/modules/ve-mw/ui/tools/ve.ui.MWLinkInspectorTool.js
@@ -24,15 +24,27 @@
 
 OO.inheritClass( ve.ui.MWLinkInspectorTool, ve.ui.LinkInspectorTool );
 
-// FIXME should eventually vary title based on link type
-// Use message visualeditor-annotationbutton-linknode-tooltip
-
 ve.ui.MWLinkInspectorTool.static.modelClasses =
        ve.ui.MWLinkInspectorTool.super.static.modelClasses.concat( [
-               ve.dm.MWNumberedExternalLinkNode
+               ve.dm.MWNumberedExternalLinkNode,
+               ve.dm.MWMagicLinkNode
        ] );
 
-ve.ui.MWLinkInspectorTool.static.associatedWindows = [ 'link', 'linkNode' ];
+ve.ui.MWLinkInspectorTool.static.associatedWindows = [ 'link', 'linkNode', 
'linkMagicNode' ];
+
+ve.ui.MWLinkInspectorTool.prototype.onUpdateState = function ( fragment ) {
+       // Vary title based on link type.
+       var node = fragment instanceof ve.dm.SurfaceFragment ?
+                       fragment.getSelectedNode() : null,
+               type = node instanceof ve.dm.MWMagicLinkNode ?
+                       'magiclinknode-tooltip-' + 
node.getMagicType().toLowerCase() :
+                       node instanceof ve.dm.MWNumberedExternalLinkNode ?
+                       'linknode-tooltip' : null,
+               title = type ?
+                       OO.ui.deferMsg( 'visualeditor-annotationbutton-' + type 
) :
+                       ve.ui.MWLinkInspectorTool.static.title;
+       this.setTitle( title  );
+};
 
 ve.ui.toolFactory.register( ve.ui.MWLinkInspectorTool );
 

-- 
To view, visit https://gerrit.wikimedia.org/r/233669
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Id5b7a2ae3c80b0e5eed598f0bd024d3e94f7e9aa
Gerrit-PatchSet: 11
Gerrit-Project: mediawiki/extensions/VisualEditor
Gerrit-Branch: master
Gerrit-Owner: Cscott <canan...@wikimedia.org>
Gerrit-Reviewer: Catrope <roan.katt...@gmail.com>
Gerrit-Reviewer: Cscott <canan...@wikimedia.org>
Gerrit-Reviewer: Esanders <esand...@wikimedia.org>
Gerrit-Reviewer: Jforrester <jforres...@wikimedia.org>
Gerrit-Reviewer: Krinkle <krinklem...@gmail.com>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to