From 6d316298a3ae4ed826907ba3953acccdd9469380 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:24:19 +0000 Subject: [PATCH 1/4] Initial plan From bde38ece83b998d4d80a00d771638abdfa2d8c64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:29:49 +0000 Subject: [PATCH 2/4] Add dynamic OOUI namespace management interface --- extension.json | 14 ++ includes/SpecialManageNamespaces.php | 18 +- modules/ext.namespaceManager.css | 17 ++ modules/ext.namespaceManager.js | 295 +++++++++++++++++++++++++++ 4 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 modules/ext.namespaceManager.css create mode 100644 modules/ext.namespaceManager.js 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..98ae918 --- /dev/null +++ b/modules/ext.namespaceManager.js @@ -0,0 +1,295 @@ +/*! + * 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, $jsonField, $rawField; + + 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 next available even namespace id within the allowed range. + * + * @return {number} + */ + function nextId() { + var max = 2998; + namespaces.forEach( function ( ns ) { + if ( typeof ns.id === 'number' && ns.id > max ) { + max = ns.id; + } + } ); + var candidate = max + 2; + if ( candidate < 3000 ) { + candidate = 3000; + } + if ( candidate > 4998 ) { + candidate = 4998; + } + return candidate; + } + + /** + * Create a blank namespace definition based on the schema already in use by + * the existing namespaces (or the default schema when there are none). + * + * @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 ] = ''; + } + } ); + ns.id = nextId(); + 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( + $( '
' ) + .addClass( 'namespacemanager-actions' ) + .append( addButton.$element, rawToggle.$element ) + ); + + sync(); + } + + function addNamespace() { + collect(); + namespaces.push( makeNamespace() ); + render(); + } + + function removeNamespace( index ) { + collect(); + namespaces.splice( index, 1 ); + render(); + } + + $( function () { + $app = $( '#namespacemanager-app' ); + if ( !$app.length ) { + return; + } + + $jsonField = $( '[name="namespaceJsonContents"]' ); + $rawField = $( '.namespacemanager-rawjson' ); + + var data = mw.config.get( 'wgNamespaceManagerData' ); + if ( Array.isArray( data ) ) { + namespaces = data; + } else if ( data && typeof data === 'object' ) { + // Tolerate an object map by taking its values. + namespaces = Object.keys( data ).map( function ( key ) { + return data[ key ]; + } ); + } else { + namespaces = []; + } + + render(); + } ); +}() ); From 187e9bd58b5d10016826265ef7c2766215b367a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:31:19 +0000 Subject: [PATCH 3/4] Pick first free namespace id and handle exhausted range --- modules/ext.namespaceManager.js | 38 +++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/modules/ext.namespaceManager.js b/modules/ext.namespaceManager.js index 98ae918..065c719 100644 --- a/modules/ext.namespaceManager.js +++ b/modules/ext.namespaceManager.js @@ -53,7 +53,7 @@ var namespaces = []; // Parallel array of { key: widget } maps, one entry per rendered namespace. var panelWidgets = []; - var $app, $jsonField, $rawField; + var $app = null, $jsonField = null, $rawField = null; function labelFor( key ) { return Object.prototype.hasOwnProperty.call( propertyLabels, key ) ? @@ -123,30 +123,30 @@ } /** - * Compute the next available even namespace id within the allowed range. + * Compute the lowest available even namespace id within the allowed range + * (3000-4998). Returns null when every valid id is already in use. * - * @return {number} + * @return {number|null} */ function nextId() { - var max = 2998; + var used = {}; namespaces.forEach( function ( ns ) { - if ( typeof ns.id === 'number' && ns.id > max ) { - max = ns.id; + if ( typeof ns.id === 'number' ) { + used[ ns.id ] = true; } } ); - var candidate = max + 2; - if ( candidate < 3000 ) { - candidate = 3000; - } - if ( candidate > 4998 ) { - candidate = 4998; + for ( var candidate = 3000; candidate <= 4998; candidate += 2 ) { + if ( !used[ candidate ] ) { + return 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 existing namespaces (or the default schema when there are none). The + * caller is responsible for assigning a valid id. * * @return {Object} */ @@ -165,7 +165,6 @@ ns[ key ] = ''; } } ); - ns.id = nextId(); return ns; } @@ -259,7 +258,14 @@ function addNamespace() { collect(); - namespaces.push( makeNamespace() ); + var id = nextId(); + if ( id === null ) { + OO.ui.alert( 'All namespace ids in the range 3000-4998 are already in use.' ); + return; + } + var ns = makeNamespace(); + ns.id = id; + namespaces.push( ns ); render(); } From 0c1d9560d0a8a44b4d1c418558279293c1f02f91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:32:28 +0000 Subject: [PATCH 4/4] Extract namespace id range into named constants --- modules/ext.namespaceManager.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/modules/ext.namespaceManager.js b/modules/ext.namespaceManager.js index 065c719..74b5799 100644 --- a/modules/ext.namespaceManager.js +++ b/modules/ext.namespaceManager.js @@ -55,6 +55,11 @@ 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; @@ -123,8 +128,8 @@ } /** - * Compute the lowest available even namespace id within the allowed range - * (3000-4998). Returns null when every valid id is already in use. + * Compute the lowest available even namespace id within the allowed range. + * Returns null when every valid id is already in use. * * @return {number|null} */ @@ -135,7 +140,7 @@ used[ ns.id ] = true; } } ); - for ( var candidate = 3000; candidate <= 4998; candidate += 2 ) { + for ( var candidate = MIN_NAMESPACE_ID; candidate <= MAX_NAMESPACE_ID; candidate += 2 ) { if ( !used[ candidate ] ) { return candidate; } @@ -260,7 +265,8 @@ collect(); var id = nextId(); if ( id === null ) { - OO.ui.alert( 'All namespace ids in the range 3000-4998 are already in use.' ); + OO.ui.alert( 'All namespace ids in the range ' + MIN_NAMESPACE_ID + + '-' + MAX_NAMESPACE_ID + ' are already in use.' ); return; } var ns = makeNamespace();