From 2ec837da259c10c9b39365cec9a96a58dd71b625 Mon Sep 17 00:00:00 2001 From: Morne Alberts Date: Thu, 16 Jul 2026 18:48:27 +0200 Subject: [PATCH 1/3] Document internal surfaces and the PHP Cypher query path Extensions have started building on two surfaces that are reachable but internal: the rendered .ext-neowiki-view placeholders and the raw Neo4j client. NeoWiki is pre-1.0 and cannot yet declare a stable interface, so the extending docs gain an "Internal surfaces" section: the rendered HTML is marked as not an integration surface, while the internal client gets a comparison of what the documented query interfaces provide over it (read-only enforcement, resource limits, result handling, churn exposure) rather than a prohibition, plus a warning that direct graph writes do not survive re-projection or rebuilds. Since the alternative for PHP-side querying was undocumented, add a "Running Cypher queries" subsection covering newCypherQueryService(), backed by a new RedHerb example (SpecialRedHerbContentPageCount) with an integration test against real Neo4j, so the documented call shape stays working like the page's other examples. Co-Authored-By: Claude Fable 5 --- docs/extending/extending.md | 73 +++++++++++++++++++ tests/RedHerb/README.md | 2 + tests/RedHerb/extension.json | 3 + tests/RedHerb/i18n/_Aliases.php | 1 + tests/RedHerb/i18n/en.json | 3 + tests/RedHerb/i18n/qqq.json | 3 + .../SpecialRedHerbContentPageCount.php | 46 ++++++++++++ .../SpecialRedHerbContentPageCountTest.php | 52 +++++++++++++ 8 files changed, 183 insertions(+) create mode 100644 tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php create mode 100644 tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php diff --git a/docs/extending/extending.md b/docs/extending/extending.md index 692ad4110..24d04b1ea 100644 --- a/docs/extending/extending.md +++ b/docs/extending/extending.md @@ -190,6 +190,31 @@ you to remove pages that are already gone from your store. Examples: [`src/RedHerbSidebarHook.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/RedHerbSidebarHook.php) and [`src/Specials/SpecialRedHerbSubjectFinder.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/Specials/SpecialRedHerbSubjectFinder.php). +### Running Cypher queries + +To run a read-only Cypher query from PHP, use `NeoWikiExtension::getInstance()->newCypherQueryService()`. +It rejects write queries and enforces the timeout and row cap you pass as limits; resolve the values +configured in [`$wgNeoWikiQueryLimits`](../api/query-api.md) with `Neo4jQueryLimits::forUser()` (users +with `apihighlimits` get the higher tier): + +```php +$result = NeoWikiExtension::getInstance()->newCypherQueryService()->execute( new Neo4jQueryRequest( + cypher: 'MATCH (s:Subject:Person) WHERE s.`Birth year` > $minYear RETURN s.name AS name', + parameters: [ 'minYear' => 2000 ], + limits: Neo4jQueryLimits::forUser( $this->getUser() ), +) ); +``` + +`execute()` returns a `Neo4jQueryResult` (columns, rows, truncation flag) and throws a `QueryException` +subclass on failure; `newCypherQueryService()` itself throws a `LogicException` on a wiki with no graph +backend configured. + +The `User` in `forUser()` only sizes the limits: how heavy a single query may be, not whether the user may +query at all or how often. When running user-supplied queries, check the `neowiki-query` right and rate +limit yourself, as the [Query API](../api/query-api.md) endpoint does. + +Example: [`src/Specials/SpecialRedHerbContentPageCount.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php). + ## Frontend extension points (JS/Vue) NeoWiki's frontend is built with TypeScript and Vue. Extensions consume it as plain JavaScript and need no build step. @@ -377,3 +402,51 @@ These extension points are designed or partially present but not yet open to ext - **A published TypeScript types package.** TypeScript authors get types today by pointing their `tsconfig` at NeoWiki's source (see "Authoring in TypeScript" and [ADR 24](../adr/024-frontend-extension-mechanism.md)). A published, versioned package is deferred until a consumer needs types without a NeoWiki checkout. + +## Internal surfaces + +Everything on this page is alpha, but the surfaces below are internal even by that standard: they are +implementation details that happen to be reachable, and they can change in any release without notice. + +### NeoWiki's rendered HTML + +The `.ext-neowiki-view` placeholder elements and `data-mw-neowiki-*` attributes are the private contract +between NeoWiki's backend and its frontend for mounting Views. They are not an integration surface: do not +select these elements, read Subject IDs out of them, restyle their internals, or remove and replace them +with your own rendering. Their structure can change in any release. + +To control where and how a Subject renders: + +- To place a Subject rendering in page content, use the + [`{{#view}}` parser function](../authoring/parser-functions.md), optionally with a Layout to control which + properties are shown. +- To render Subjects in your own visual format, register a custom View Type + (see [Registering a View Type frontend](#registering-a-view-type-frontend)). +- For fully custom UI outside the View system, fetch the data through the [REST API](../api/rest-api.md) or + the [public JS API](#using-neowikis-public-js-api) and render your own components, mounted as described in + [Mounting standalone Vue features](#mounting-standalone-vue-features). + +### The internal Neo4j client + +`NeoWikiExtension::getInstance()->getNeo4jClient()` and `getReadOnlyNeo4jClient()` return the Laudis client +NeoWiki itself uses. Neo4j access is treated as an implementation detail of NeoWiki's persistence layer +([ADR 13](../adr/013-restrict-neo4j-access.md)). Nothing stops an extension from querying through the raw +client, but compare what the documented query interfaces +([`{{#cypher_raw}}`](../authoring/parser-functions.md), [`nw.query`](../authoring/lua-api.md), the +[Query API](../api/query-api.md), and the [PHP query service](#running-cypher-queries)) provide over it: + +- **Read-only enforcement.** The query interfaces reject write queries (keyword check plus `EXPLAIN`); + with the raw client, a bug in calling code can corrupt the graph projection, which is what ADR 13 + exists to prevent. +- **Resource limits.** The query interfaces apply a configured timeout and row cap; the raw client runs + unbounded queries. +- **Result handling.** The query interfaces return normalized rows and columns; the raw client returns + Laudis driver types that you convert yourself. +- **Churn exposure.** The query interfaces are documented contracts (alpha, like everything on this page); + the client accessors are internal wiring that can change or disappear in any release. + +Writing to the graph directly deserves particular caution: the graph is a projection of wiki content that +NeoWiki rewrites at will. Saving a page re-projects that page's nodes, and the `RebuildGraphDatabases` +maintenance script rebuilds the projection from scratch, so anything a third party writes into the graph +can be overwritten, orphaned, or deleted at any time. To contribute data to the graph durably, use +[Page Property Providers](#page-property-providers), which NeoWiki re-runs on every save and rebuild. diff --git a/tests/RedHerb/README.md b/tests/RedHerb/README.md index 0bf456fda..f66bb54ef 100644 --- a/tests/RedHerb/README.md +++ b/tests/RedHerb/README.md @@ -24,6 +24,8 @@ extension point to the file that demonstrates it. [`src/RedHerbFrontendModulesHook.php`](src/RedHerbFrontendModulesHook.php). - **Reading NeoWiki data / authorization** — [`src/RedHerbSidebarHook.php`](src/RedHerbSidebarHook.php) and [`src/Specials/SpecialRedHerbSubjectFinder.php`](src/Specials/SpecialRedHerbSubjectFinder.php). +- **Running Cypher queries (PHP query service)** — + [`src/Specials/SpecialRedHerbContentPageCount.php`](src/Specials/SpecialRedHerbContentPageCount.php). ### Frontend (JS/Vue) diff --git a/tests/RedHerb/extension.json b/tests/RedHerb/extension.json index 380838db0..b99275852 100644 --- a/tests/RedHerb/extension.json +++ b/tests/RedHerb/extension.json @@ -47,6 +47,9 @@ "SpecialPages": { "RedHerbSubjectFinder": { "class": "ProfessionalWiki\\RedHerb\\Specials\\SpecialRedHerbSubjectFinder" + }, + "RedHerbContentPageCount": { + "class": "ProfessionalWiki\\RedHerb\\Specials\\SpecialRedHerbContentPageCount" } }, diff --git a/tests/RedHerb/i18n/_Aliases.php b/tests/RedHerb/i18n/_Aliases.php index 5c8563a32..a5e299398 100644 --- a/tests/RedHerb/i18n/_Aliases.php +++ b/tests/RedHerb/i18n/_Aliases.php @@ -4,4 +4,5 @@ $specialPageAliases['en'] = [ 'RedHerbSubjectFinder' => [ 'RedHerbSubjectFinder' ], + 'RedHerbContentPageCount' => [ 'RedHerbContentPageCount' ], ]; diff --git a/tests/RedHerb/i18n/en.json b/tests/RedHerb/i18n/en.json index 669f2d0c7..2657e8b82 100644 --- a/tests/RedHerb/i18n/en.json +++ b/tests/RedHerb/i18n/en.json @@ -6,6 +6,9 @@ "redherb-sidebar-create-child-company": "Create child Company", "redherb-sidebar-subject-finder": "Find a subject", "redherb-special-subject-finder": "RedHerb subject finder", + "redherb-special-content-page-count": "RedHerb content page count", + "redherb-content-page-count": "Content namespace pages tracked in the graph: $1", + "redherb-content-page-count-error": "Could not count content namespace pages tracked in the graph.", "redherb-subject-finder-schema-label": "Schema", "redherb-subject-finder-schema-placeholder": "Enter a schema name", "redherb-subject-finder-pick-subject": "Pick a subject", diff --git a/tests/RedHerb/i18n/qqq.json b/tests/RedHerb/i18n/qqq.json index 728c2d84a..3f0991b39 100644 --- a/tests/RedHerb/i18n/qqq.json +++ b/tests/RedHerb/i18n/qqq.json @@ -6,6 +6,9 @@ "redherb-sidebar-create-child-company": "Text of the sidebar link that opens a dialog for creating a child Subject with the Company schema.", "redherb-sidebar-subject-finder": "Text of the sidebar link that opens the RedHerb subject finder.", "redherb-special-subject-finder": "Title shown on Special:RedHerbSubjectFinder.", + "redherb-special-content-page-count": "Title shown on Special:RedHerbContentPageCount.", + "redherb-content-page-count": "Result shown on Special:RedHerbContentPageCount, reporting how many content-namespace pages NeoWiki tracks in the graph. $1 is the count.", + "redherb-content-page-count-error": "Error shown on Special:RedHerbContentPageCount when the Cypher query fails.", "redherb-subject-finder-schema-label": "Label of the schema name input on Special:RedHerbSubjectFinder.\n{{Identical|Schema}}", "redherb-subject-finder-schema-placeholder": "Placeholder text inside the schema name input on Special:RedHerbSubjectFinder.", "redherb-subject-finder-pick-subject": "Label of the subject lookup on Special:RedHerbSubjectFinder.", diff --git a/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php b/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php new file mode 100644 index 000000000..c13d1f571 --- /dev/null +++ b/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php @@ -0,0 +1,46 @@ +getOutput(); + + try { + $result = NeoWikiExtension::getInstance()->newCypherQueryService()->execute( new Neo4jQueryRequest( + cypher: 'MATCH (page:Page) WHERE page.namespaceId = $namespaceId RETURN count(page) AS pageCount', + parameters: [ 'namespaceId' => NS_MAIN ], + limits: Neo4jQueryLimits::forUser( $this->getUser() ), + ) ); + } catch ( Exception ) { + $out->addWikiMsg( 'redherb-content-page-count-error' ); + return; + } + + $out->addWikiMsg( 'redherb-content-page-count', $result->rows[0]['pageCount'] ); + } + + public function getDescription(): Message { + return $this->msg( 'redherb-special-content-page-count' ); + } + +} diff --git a/tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php b/tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php new file mode 100644 index 000000000..479e9b83b --- /dev/null +++ b/tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php @@ -0,0 +1,52 @@ +setUpNeo4j(); + $this->createSchema( TestSubject::DEFAULT_SCHEMA_ID ); + $this->markPageTableAsUsed(); + } + + public function testCountsOnlyContentNamespacePagesTrackedInTheGraph(): void { + $this->createPageWithSubjects( 'RedHerb content page one', TestSubject::build( id: 'sRedHerbCP11111' ) ); + $this->createPageWithSubjects( 'RedHerb content page two', TestSubject::build( id: 'sRedHerbCP22222' ) ); + $this->createPageWithSubjects( 'Help:RedHerb help page', TestSubject::build( id: 'sRedHerbHP33333' ) ); + + $this->assertStringContainsString( + 'Content namespace pages tracked in the graph: 2', + $this->executeContentPageCount() + ); + } + + private function executeContentPageCount(): string { + $context = new DerivativeContext( RequestContext::getMain() ); + $context->setTitle( Title::makeTitle( NS_SPECIAL, 'RedHerbContentPageCount' ) ); + $output = new OutputPage( $context ); + $context->setOutput( $output ); + + $page = new SpecialRedHerbContentPageCount(); + $page->setContext( $context ); + $page->execute( null ); + + return $output->getHTML(); + } + +} From f4f41091fb3182d6e687b0c671190a5d2fde1a0b Mon Sep 17 00:00:00 2001 From: Morne Alberts Date: Thu, 16 Jul 2026 19:42:53 +0200 Subject: [PATCH 2/3] Drop the apihighlimits mention from the query limits sentence The tier mechanics are documented on the linked Query API page; naming the right here duplicated that and it is default MediaWiki behavior. Co-Authored-By: Claude Fable 5 --- docs/extending/extending.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/extending/extending.md b/docs/extending/extending.md index 24d04b1ea..e48471fbf 100644 --- a/docs/extending/extending.md +++ b/docs/extending/extending.md @@ -194,8 +194,7 @@ and [`src/Specials/SpecialRedHerbSubjectFinder.php`](https://github.com/Professi To run a read-only Cypher query from PHP, use `NeoWikiExtension::getInstance()->newCypherQueryService()`. It rejects write queries and enforces the timeout and row cap you pass as limits; resolve the values -configured in [`$wgNeoWikiQueryLimits`](../api/query-api.md) with `Neo4jQueryLimits::forUser()` (users -with `apihighlimits` get the higher tier): +configured in [`$wgNeoWikiQueryLimits`](../api/query-api.md) with `Neo4jQueryLimits::forUser()`: ```php $result = NeoWikiExtension::getInstance()->newCypherQueryService()->execute( new Neo4jQueryRequest( From c4d144e0cea2ba041384bf6a14e35a49bb3d2676 Mon Sep 17 00:00:00 2001 From: Morne Alberts Date: Fri, 17 Jul 2026 12:24:16 +0200 Subject: [PATCH 3/3] Address review precision findings on the query docs and example Docs: name the keyword validator's CALL and SHOW rejection, state that the row cap truncates only after the query has run (bound work with LIMIT in the Cypher), scope the durable-write pointer to page-level key/value metadata and state plainly that arbitrary graph structure has no durable third-party write path, and qualify projection on save and rebuild with the Subject-slot requirement. Example: bind the caught exception and surface its message like the Scribunto library does, cover the no-backend error branch via runWithoutGraphBackend(), and rework editMainSubject to resolve the Main Subject through getSubjectRepository()->getPageSubjects() instead of scraping the .ext-neowiki-view placeholder the docs forbid; the docs now cite it as the worked example of that path. Also exclude tests/RedHerb/node_modules from phpcs: following the RedHerb README's npm install instructions previously made make phpcs scan thousands of dependency files and run out of memory. Co-Authored-By: Claude Fable 5 --- docs/extending/extending.md | 39 ++++++++++++------ phpcs.xml | 2 + tests/RedHerb/i18n/en.json | 2 +- tests/RedHerb/i18n/qqq.json | 2 +- .../RedHerb/resources/editMainSubject/init.js | 40 +++++++++++++------ .../SpecialRedHerbContentPageCount.php | 4 +- .../SpecialRedHerbContentPageCountTest.php | 8 ++++ 7 files changed, 67 insertions(+), 30 deletions(-) diff --git a/docs/extending/extending.md b/docs/extending/extending.md index e48471fbf..7178e7c59 100644 --- a/docs/extending/extending.md +++ b/docs/extending/extending.md @@ -106,7 +106,8 @@ Neo4j projection. ### Page Property Providers Page Property Providers contribute key/value metadata to the Page node in the graph (queryable via Cypher; -Neo4j is currently the only graph backend). Implement `PagePropertyProvider`: +Neo4j is currently the only graph backend). Providers run when a page carrying the NeoWiki subject slot is +saved or rebuilt; pages without Subjects are not projected. Implement `PagePropertyProvider`: ```php class StaticPagePropertyProvider implements PagePropertyProvider { @@ -193,8 +194,9 @@ and [`src/Specials/SpecialRedHerbSubjectFinder.php`](https://github.com/Professi ### Running Cypher queries To run a read-only Cypher query from PHP, use `NeoWikiExtension::getInstance()->newCypherQueryService()`. -It rejects write queries and enforces the timeout and row cap you pass as limits; resolve the values -configured in [`$wgNeoWikiQueryLimits`](../api/query-api.md) with `Neo4jQueryLimits::forUser()`: +It rejects write queries, enforces the timeout against the backend, and truncates results to the row cap; +resolve the limits configured in [`$wgNeoWikiQueryLimits`](../api/query-api.md) with +`Neo4jQueryLimits::forUser()`: ```php $result = NeoWikiExtension::getInstance()->newCypherQueryService()->execute( new Neo4jQueryRequest( @@ -212,6 +214,11 @@ The `User` in `forUser()` only sizes the limits: how heavy a single query may be query at all or how often. When running user-supplied queries, check the `neowiki-query` right and rate limit yourself, as the [Query API](../api/query-api.md) endpoint does. +Two sharp edges: the write check is keyword-based and also rejects `CALL` and `SHOW`, even for read-only +procedures (see the [parser function notes](../authoring/parser-functions.md)). And the row cap truncates +the result only after the query has run in full, so bound expensive queries with `LIMIT` in the Cypher +itself. + Example: [`src/Specials/SpecialRedHerbContentPageCount.php`](https://github.com/ProfessionalWiki/NeoWiki/blob/master/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php). ## Frontend extension points (JS/Vue) @@ -423,7 +430,9 @@ To control where and how a Subject renders: (see [Registering a View Type frontend](#registering-a-view-type-frontend)). - For fully custom UI outside the View system, fetch the data through the [REST API](../api/rest-api.md) or the [public JS API](#using-neowikis-public-js-api) and render your own components, mounted as described in - [Mounting standalone Vue features](#mounting-standalone-vue-features). + [Mounting standalone Vue features](#mounting-standalone-vue-features). RedHerb's + [`editMainSubject`](https://github.com/ProfessionalWiki/NeoWiki/tree/master/tests/RedHerb/resources/editMainSubject) + resolves the page's Main Subject this way. ### The internal Neo4j client @@ -434,18 +443,22 @@ client, but compare what the documented query interfaces ([`{{#cypher_raw}}`](../authoring/parser-functions.md), [`nw.query`](../authoring/lua-api.md), the [Query API](../api/query-api.md), and the [PHP query service](#running-cypher-queries)) provide over it: -- **Read-only enforcement.** The query interfaces reject write queries (keyword check plus `EXPLAIN`); - with the raw client, a bug in calling code can corrupt the graph projection, which is what ADR 13 - exists to prevent. -- **Resource limits.** The query interfaces apply a configured timeout and row cap; the raw client runs - unbounded queries. +- **Read-only enforcement.** The query interfaces reject write queries (a keyword check plus `EXPLAIN`; + the keyword check also rejects read-only `CALL` and `SHOW`); with the raw client, a bug in calling code + can corrupt the graph projection, which is what ADR 13 exists to prevent. +- **Resource limits.** The query interfaces enforce a configured timeout against the backend and cap the + rows returned; the raw client has neither. The row cap does not bound the work a query does, so use + `LIMIT` in the Cypher either way. - **Result handling.** The query interfaces return normalized rows and columns; the raw client returns Laudis driver types that you convert yourself. - **Churn exposure.** The query interfaces are documented contracts (alpha, like everything on this page); the client accessors are internal wiring that can change or disappear in any release. Writing to the graph directly deserves particular caution: the graph is a projection of wiki content that -NeoWiki rewrites at will. Saving a page re-projects that page's nodes, and the `RebuildGraphDatabases` -maintenance script rebuilds the projection from scratch, so anything a third party writes into the graph -can be overwritten, orphaned, or deleted at any time. To contribute data to the graph durably, use -[Page Property Providers](#page-property-providers), which NeoWiki re-runs on every save and rebuild. +NeoWiki rewrites at will. Saving a page that carries the Subject slot re-projects that page's nodes, and +the `RebuildGraphDatabases` maintenance script rebuilds the projection from scratch, so anything a third +party writes into the graph can be overwritten, orphaned, or deleted at any time. For page-level key/value +metadata there is a durable path: [Page Property Providers](#page-property-providers), which NeoWiki +re-runs whenever a Subject-slot page is saved or rebuilt. For arbitrary nodes and relationships there is +currently no durable third-party write path into NeoWiki's graph; a +[Graph Database Backend](#graph-database-backends) projects durably, but into its own store. diff --git a/phpcs.xml b/phpcs.xml index ef81a3ef1..4806ecede 100755 --- a/phpcs.xml +++ b/phpcs.xml @@ -3,6 +3,8 @@ src/ tests/ + tests/RedHerb/node_modules/ + diff --git a/tests/RedHerb/i18n/en.json b/tests/RedHerb/i18n/en.json index 2657e8b82..c0f270730 100644 --- a/tests/RedHerb/i18n/en.json +++ b/tests/RedHerb/i18n/en.json @@ -8,7 +8,7 @@ "redherb-special-subject-finder": "RedHerb subject finder", "redherb-special-content-page-count": "RedHerb content page count", "redherb-content-page-count": "Content namespace pages tracked in the graph: $1", - "redherb-content-page-count-error": "Could not count content namespace pages tracked in the graph.", + "redherb-content-page-count-error": "Could not count content namespace pages tracked in the graph: $1", "redherb-subject-finder-schema-label": "Schema", "redherb-subject-finder-schema-placeholder": "Enter a schema name", "redherb-subject-finder-pick-subject": "Pick a subject", diff --git a/tests/RedHerb/i18n/qqq.json b/tests/RedHerb/i18n/qqq.json index 3f0991b39..11c066d05 100644 --- a/tests/RedHerb/i18n/qqq.json +++ b/tests/RedHerb/i18n/qqq.json @@ -8,7 +8,7 @@ "redherb-special-subject-finder": "Title shown on Special:RedHerbSubjectFinder.", "redherb-special-content-page-count": "Title shown on Special:RedHerbContentPageCount.", "redherb-content-page-count": "Result shown on Special:RedHerbContentPageCount, reporting how many content-namespace pages NeoWiki tracks in the graph. $1 is the count.", - "redherb-content-page-count-error": "Error shown on Special:RedHerbContentPageCount when the Cypher query fails.", + "redherb-content-page-count-error": "Error shown on Special:RedHerbContentPageCount when the Cypher query fails. $1 is the underlying error message.", "redherb-subject-finder-schema-label": "Label of the schema name input on Special:RedHerbSubjectFinder.\n{{Identical|Schema}}", "redherb-subject-finder-schema-placeholder": "Placeholder text inside the schema name input on Special:RedHerbSubjectFinder.", "redherb-subject-finder-pick-subject": "Label of the subject lookup on Special:RedHerbSubjectFinder.", diff --git a/tests/RedHerb/resources/editMainSubject/init.js b/tests/RedHerb/resources/editMainSubject/init.js index d9bf0ce77..cdd14ec69 100644 --- a/tests/RedHerb/resources/editMainSubject/init.js +++ b/tests/RedHerb/resources/editMainSubject/init.js @@ -8,7 +8,6 @@ const DIALOG_STATE_KEY = require( './constants.js' ).DIALOG_STATE_KEY; const TRIGGER_SELECTOR = '.ext-redherb-edit-main-subject-trigger'; - const MAIN_SUBJECT_SELECTOR = '.ext-neowiki-view[data-mw-neowiki-subject-id]'; const dialogState = Vue.reactive( { open: false, subjectId: null } ); let mounted = false; @@ -31,21 +30,18 @@ } function resolveMainSubjectId() { - const el = document.querySelector( MAIN_SUBJECT_SELECTOR ); - if ( el === null ) { - return null; + const pageId = mw.config.get( 'wgArticleId' ); + if ( !pageId ) { + return Promise.resolve( null ); } - return el.dataset.mwNeowikiSubjectId || null; + return nw.NeoWikiExtension.getInstance().getSubjectRepository().getPageSubjects( pageId ) + .then( ( result ) => { + const mainSubjectId = result.pageSubjects.getMainSubjectId(); + return mainSubjectId === null ? null : mainSubjectId.text; + } ); } - function handleClick( ev ) { - const trigger = ev.target.closest( TRIGGER_SELECTOR ); - if ( trigger === null ) { - return; - } - ev.preventDefault(); - - const subjectId = resolveMainSubjectId(); + function openDialog( subjectId ) { if ( subjectId === null ) { mw.notify( mw.message( 'redherb-edit-main-subject-no-main' ).text(), @@ -59,6 +55,24 @@ dialogState.open = true; } + function handleClick( ev ) { + const trigger = ev.target.closest( TRIGGER_SELECTOR ); + if ( trigger === null ) { + return; + } + ev.preventDefault(); + + resolveMainSubjectId() + .then( openDialog ) + .catch( ( err ) => { + mw.log.error( err ); + mw.notify( + err instanceof Error ? err.message : String( err ), + { type: 'error' } + ); + } ); + } + queueMicrotask( () => { document.body.addEventListener( 'click', handleClick ); } ); diff --git a/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php b/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php index c13d1f571..57c0b9820 100644 --- a/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php +++ b/tests/RedHerb/src/Specials/SpecialRedHerbContentPageCount.php @@ -31,8 +31,8 @@ public function execute( $subPage ): void { parameters: [ 'namespaceId' => NS_MAIN ], limits: Neo4jQueryLimits::forUser( $this->getUser() ), ) ); - } catch ( Exception ) { - $out->addWikiMsg( 'redherb-content-page-count-error' ); + } catch ( Exception $e ) { + $out->addWikiMsg( 'redherb-content-page-count-error', $e->getMessage() ); return; } diff --git a/tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php b/tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php index 479e9b83b..abfdaf8fc 100644 --- a/tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php +++ b/tests/phpunit/RedHerb/SpecialRedHerbContentPageCountTest.php @@ -36,6 +36,14 @@ public function testCountsOnlyContentNamespacePagesTrackedInTheGraph(): void { ); } + public function testErrorOutputIncludesUnderlyingCauseWhenGraphBackendMissing(): void { + $html = $this->runWithoutGraphBackend( + fn() => $this->executeContentPageCount() + ); + + $this->assertStringContainsString( 'A configured Neo4j backend is required', $html ); + } + private function executeContentPageCount(): string { $context = new DerivativeContext( RequestContext::getMain() ); $context->setTitle( Title::makeTitle( NS_SPECIAL, 'RedHerbContentPageCount' ) );