diff --git a/extension.json b/extension.json index 905f0f2..6e5e71a 100644 --- a/extension.json +++ b/extension.json @@ -33,6 +33,20 @@ "SpecialPages": { "ManageNamespaces": "SpecialManageNamespaces" }, + "ResourceFileModulePaths": { + "localBasePath": "modules", + "remoteExtPath": "NamespaceManager/modules" + }, + "ResourceModules": { + "ext.namespaceManager": { + "scripts": "ext.namespaceManager.js", + "styles": "ext.namespaceManager.css", + "dependencies": [ + "oojs-ui-core", + "oojs-ui-widgets" + ] + } + }, "GroupPermissions": { "sysop": { "managenamespaces": true diff --git a/includes/SpecialManageNamespaces.php b/includes/SpecialManageNamespaces.php index 7279110..9f6821c 100644 --- a/includes/SpecialManageNamespaces.php +++ b/includes/SpecialManageNamespaces.php @@ -22,6 +22,7 @@ public function execute( $sub ) { $out = $this->getOutput(); $out->enableOOUI(); + $out->addModules( 'ext.namespaceManager' ); $out->setPageTitle( $this->msg( 'managenamespaces-title' ) ); $out->addWikiMsg( 'managenamespaces-intro' ); @@ -46,9 +47,23 @@ public function execute( $sub ) { $textWidgetContents = NamespaceManager::loadNamespaceDataRaw(); } + // Provide the current namespace definitions to the frontend interface so + // it can render the dynamic management UI. Falls back to an empty list if + // the stored file is missing or contains invalid JSON. + $namespaceData = json_decode($textWidgetContents !== false ? $textWidgetContents : '', true); + if (!is_array($namespaceData)) { + $namespaceData = []; + } + $out->addJsConfigVars('wgNamespaceManagerData', $namespaceData); + + // Container the JavaScript interface renders into. When JavaScript is + // unavailable, this stays empty and the raw JSON editor below is used. + $out->addHTML(Html::element('div', ['id' => 'namespacemanager-app'])); + $out->addHTML(new OOUI\FormLayout([ 'method' => 'POST', 'action' => 'Special:ManageNamespaces', + 'id' => 'namespacemanager-form', 'items' => [ new OOUI\FieldsetLayout([ 'label' => 'Namespaces definition', @@ -62,12 +77,13 @@ public function execute( $sub ) { [ 'label' => 'JSON file contents', 'align' => 'top', + 'classes' => ['namespacemanager-rawjson'], ] ), new OOUI\FieldLayout( new OOUI\ButtonInputWidget([ 'name' => 'save', - 'label' => 'Save JSON', + 'label' => 'Save namespaces', 'type' => 'submit', 'flags' => ['primary', 'progressive'], 'icon' => 'check', diff --git a/modules/ext.namespaceManager.css b/modules/ext.namespaceManager.css new file mode 100644 index 0000000..8d1bc3f --- /dev/null +++ b/modules/ext.namespaceManager.css @@ -0,0 +1,17 @@ +.namespacemanager-namespace { + margin-bottom: 1em; +} + +.namespacemanager-actions { + margin: 1em 0; +} + +.namespacemanager-actions .oo-ui-buttonWidget { + margin-right: 0.5em; +} + +/* Hidden by default; revealed via the "Toggle raw JSON editor" button when + * JavaScript is enabled. Without JavaScript it remains visible as a fallback. */ +.client-js .namespacemanager-rawjson { + display: none; +} diff --git a/modules/ext.namespaceManager.js b/modules/ext.namespaceManager.js new file mode 100644 index 0000000..74b5799 --- /dev/null +++ b/modules/ext.namespaceManager.js @@ -0,0 +1,307 @@ +/*! + * NamespaceManager dynamic management interface. + * + * Renders the namespace definitions provided via mw.config as an OOUI-based + * editor, replacing the raw JSON textarea on Special:ManageNamespaces. The + * raw JSON field is kept as an advanced fallback and is used as the field that + * is actually submitted with the form: every change in the dynamic interface + * is serialized back into it. + */ +( function () { + 'use strict'; + + // Human-readable labels for the known namespace properties. Unknown keys + // fall back to the raw key name so the interface still renders for them. + var propertyLabels = { + id: 'Namespace ID', + name: 'Name', + talkname: 'Talk page name', + content: 'Content namespace', + visualeditor: 'Enable VisualEditor', + searchdefault: 'Searched by default', + talksearchdefault: 'Talk searched by default', + subpages: 'Subpages enabled', + talksubpages: 'Talk subpages enabled', + includable: 'Includable', + talkincludable: 'Talk includable', + aliases: 'Aliases', + talkaliases: 'Talk aliases', + editpermissions: 'Edit permissions', + talkeditpermissions: 'Talk edit permissions' + }; + + // Default schema used when there are no existing namespaces to derive the + // shape from. Mirrors the documented sample configuration. + var defaultSchema = { + id: 3000, + name: '', + talkname: '', + content: false, + visualeditor: false, + searchdefault: false, + talksearchdefault: false, + subpages: false, + talksubpages: false, + includable: true, + talkincludable: true, + aliases: [], + talkaliases: [], + editpermissions: [], + talkeditpermissions: [] + }; + + var namespaces = []; + // Parallel array of { key: widget } maps, one entry per rendered namespace. + var panelWidgets = []; + var $app = null, $jsonField = null, $rawField = null; + + // Custom namespace ids must be even and within this inclusive range, matching + // the constraints enforced server-side in NamespaceManagerHooks. + var MIN_NAMESPACE_ID = 3000; + var MAX_NAMESPACE_ID = 4998; + + function labelFor( key ) { + return Object.prototype.hasOwnProperty.call( propertyLabels, key ) ? + propertyLabels[ key ] : key; + } + + /** + * Build an OOUI widget appropriate for the type of the given value. + * + * @param {Mixed} value + * @return {OO.ui.Widget} + */ + function buildWidget( value ) { + if ( typeof value === 'boolean' ) { + return new OO.ui.ToggleSwitchWidget( { value: value } ); + } + if ( typeof value === 'number' ) { + return new OO.ui.NumberInputWidget( { value: value } ); + } + if ( Array.isArray( value ) ) { + return new OO.ui.TagMultiselectWidget( { + allowArbitrary: true, + selected: value.map( String ) + } ); + } + return new OO.ui.TextInputWidget( { + value: value === null || value === undefined ? '' : String( value ) + } ); + } + + /** + * Read the current value out of a widget, coercing it back to the type that + * matches how it was originally rendered. + * + * @param {OO.ui.Widget} widget + * @param {Mixed} original Original value, used to infer the target type + * @return {Mixed} + */ + function readWidget( widget, original ) { + if ( typeof original === 'number' ) { + return Number( widget.getValue() ); + } + // Booleans (ToggleSwitch), arrays (TagMultiselect) and strings + // (TextInput) are all returned directly by getValue(). + return widget.getValue(); + } + + /** + * Copy the current widget values back into the in-memory namespace model. + */ + function collect() { + panelWidgets.forEach( function ( widgets, index ) { + var ns = namespaces[ index ]; + Object.keys( widgets ).forEach( function ( key ) { + ns[ key ] = readWidget( widgets[ key ], ns[ key ] ); + } ); + } ); + } + + /** + * Serialize the model into the raw JSON field that is submitted with the + * form. + */ + function sync() { + collect(); + $jsonField.val( JSON.stringify( namespaces, null, 4 ) ); + } + + /** + * Compute the lowest available even namespace id within the allowed range. + * Returns null when every valid id is already in use. + * + * @return {number|null} + */ + function nextId() { + var used = {}; + namespaces.forEach( function ( ns ) { + if ( typeof ns.id === 'number' ) { + used[ ns.id ] = true; + } + } ); + for ( var candidate = MIN_NAMESPACE_ID; candidate <= MAX_NAMESPACE_ID; candidate += 2 ) { + if ( !used[ candidate ] ) { + return candidate; + } + } + return null; + } + + /** + * Create a blank namespace definition based on the schema already in use by + * the existing namespaces (or the default schema when there are none). The + * caller is responsible for assigning a valid id. + * + * @return {Object} + */ + function makeNamespace() { + var template = namespaces.length ? namespaces[ 0 ] : defaultSchema; + var ns = {}; + Object.keys( template ).forEach( function ( key ) { + var value = template[ key ]; + if ( typeof value === 'boolean' ) { + ns[ key ] = false; + } else if ( typeof value === 'number' ) { + ns[ key ] = 0; + } else if ( Array.isArray( value ) ) { + ns[ key ] = []; + } else { + ns[ key ] = ''; + } + } ); + return ns; + } + + /** + * Render a single namespace definition as an OOUI panel. + * + * @param {Object} ns + * @param {number} index + * @return {jQuery} + */ + function renderNamespace( ns, index ) { + var widgets = {}; + + var deleteButton = new OO.ui.ButtonWidget( { + label: 'Delete namespace', + icon: 'trash', + flags: [ 'destructive' ] + } ); + deleteButton.on( 'click', function () { + removeNamespace( index ); + } ); + + var titleText = String( ns.name || '' ).trim(); + var fieldset = new OO.ui.FieldsetLayout( { + label: titleText !== '' ? + titleText + ' (' + ns.id + ')' : + 'New namespace (' + ns.id + ')' + } ); + + Object.keys( ns ).forEach( function ( key ) { + var widget = buildWidget( ns[ key ] ); + widgets[ key ] = widget; + widget.connect( null, { change: sync } ); + fieldset.addItems( [ + new OO.ui.FieldLayout( widget, { + label: labelFor( key ), + align: 'left' + } ) + ] ); + } ); + + fieldset.addItems( [ + new OO.ui.FieldLayout( deleteButton, { align: 'top' } ) + ] ); + + panelWidgets[ index ] = widgets; + + return new OO.ui.PanelLayout( { + expanded: false, + framed: true, + padded: true, + classes: [ 'namespacemanager-namespace' ], + content: [ fieldset ] + } ).$element; + } + + /** + * Rebuild the whole interface from the current model. + */ + function render() { + panelWidgets = []; + $app.empty(); + + namespaces.forEach( function ( ns, index ) { + $app.append( renderNamespace( ns, index ) ); + } ); + + var addButton = new OO.ui.ButtonWidget( { + label: 'Add namespace', + icon: 'add', + flags: [ 'progressive' ] + } ); + addButton.on( 'click', addNamespace ); + + var rawToggle = new OO.ui.ButtonWidget( { + label: 'Toggle raw JSON editor', + icon: 'code' + } ); + rawToggle.on( 'click', function () { + $rawField.toggle(); + } ); + + $app.append( + $( '