diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md
index 8345ae99c..401137798 100644
--- a/docs/api/rest-api.md
+++ b/docs/api/rest-api.md
@@ -48,6 +48,7 @@ Read, change, and validate Subjects. New Subjects are created on a page — see
| Endpoint | Description |
|---|---|
| `GET /neowiki/v0/subject/{subjectId}` | Fetch a Subject. Optional `revisionId`; `expand` with `page` or `relations`. |
+| `GET /neowiki/v0/subject/{subjectId}/rdf` | Export one Subject as RDF. `format` is `trig` (default) or `turtle`; `projection` is `native` (default) or an ontology target. See [RDF export](../rdf/rdf-export.md). |
| `PUT /neowiki/v0/subject/{subjectId}` | Replace a Subject's label and statements. |
| `DELETE /neowiki/v0/subject/{subjectId}` | Delete a Subject. |
| `POST /neowiki/v0/subject/validate` | Check whether a new Subject is valid, without saving it. |
diff --git a/docs/rdf/rdf-export.md b/docs/rdf/rdf-export.md
index 6216c31b2..241c4792e 100644
--- a/docs/rdf/rdf-export.md
+++ b/docs/rdf/rdf-export.md
@@ -64,26 +64,51 @@ describe the same set of entities. A warning is logged for each omitted Subject.
## Endpoint
-`GET /rest.php/neowiki/v0/page/{pageId}/rdf`
-
-Returns the page's projection. The `projection` query parameter selects the vocabulary: `native` (the
-default, described here) or the name of a Mapping page — see
-[Ontology Mapping](ontology-mapping.md). An unknown projection returns `400`.
-
-The format is chosen by the `format` query parameter, falling back to the `Accept` header, then to
-TriG:
+RDF is served per page or per Subject. Both take the same `projection` and `format` query parameters.
+The `projection` selects the vocabulary: `native` (the default, described here) or the name of a
+Mapping page — see [Ontology Mapping](ontology-mapping.md); an unknown projection returns `400`. The
+`format` picks the serialization, falling back to the `Accept` header, then to TriG:
| `format` | `Accept` | Content-Type | Named graph |
|---|---|---|---|
| `trig` (default) | `application/trig` | `application/trig; charset=utf-8` | yes |
| `turtle` | `text/turtle` | `text/turtle; charset=utf-8` | no (same triples, no graph wrapper) |
+### Page
+
+`GET /rest.php/neowiki/v0/page/{pageId}/rdf`
+
+Returns the page's projection: its page metadata and every Subject on it, in the page's named graph.
Returns `404` when the page does not exist or has no NeoWiki Subject data.
```sh
curl 'https://wiki.example/rest.php/neowiki/v0/page/42/rdf?format=turtle'
```
+### Subject
+
+`GET /rest.php/neowiki/v0/subject/{subjectId}/rdf`
+
+Returns one Subject's projection: exactly the triples the page export emits for that Subject — its
+outbound description, including the native relation reification — with none of the page-metadata
+triples, in the hosting page's named graph. Inbound relations pointing at the Subject from elsewhere
+are not included.
+
+Returns `404` when the Subject does not exist or is on a page the caller may not read — the two are
+indistinguishable. A malformed Subject ID returns `400`. A readable Subject whose Schema has no mapping
+for the requested ontology target projects to an empty graph — a `200`, not a `404`.
+
+```sh
+curl 'https://wiki.example/rest.php/neowiki/v0/subject/s1demo8aaaaaab5/rdf?projection=EDM'
+```
+
+### Finding these exports
+
+These exports are surfaced in the UI: the Data tab (`?action=subjects`) links to each Subject's JSON and
+per-projection Turtle/TriG, and to the same for the whole page. Pages that carry NeoWiki data also
+advertise the page export through ` ` autodiscovery tags (Turtle and TriG, native
+projection) in the HTML head, so Linked Data tooling can locate the data without reading this reference.
+
## Bulk dump
`maintenance/DumpRdf.php` streams the projection of **every** subject page to stdout as TriG, one named
diff --git a/extension.json b/extension.json
index 1ed63587b..f4d844570 100644
--- a/extension.json
+++ b/extension.json
@@ -254,6 +254,11 @@
"method": [ "DELETE" ],
"factory": "ProfessionalWiki\\NeoWiki\\NeoWikiExtension::newDeleteSubjectApi"
},
+ {
+ "path": "/neowiki/v0/subject/{subjectId}/rdf",
+ "method": [ "GET" ],
+ "factory": "ProfessionalWiki\\NeoWiki\\NeoWikiExtension::newExportSubjectRdfApi"
+ },
{
"path": "/neowiki/v0/schema/{schemaName}",
"method": [ "GET" ],
@@ -586,7 +591,12 @@
"neowiki-managesubjects-id-label",
"neowiki-managesubjects-id-copy",
"neowiki-managesubjects-id-copied",
- "neowiki-managesubjects-id-copy-error"
+ "neowiki-managesubjects-id-copy-error",
+ "neowiki-managesubjects-export-button",
+ "neowiki-managesubjects-export-json",
+ "neowiki-managesubjects-export-native",
+ "neowiki-managesubjects-export-turtle",
+ "neowiki-managesubjects-export-trig"
],
"@group:": "TODO: Load code separately while in development. Remove later.",
"group": "ext.neowiki"
diff --git a/i18n/en.json b/i18n/en.json
index 4edfb6f3a..99e036004 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -305,5 +305,10 @@
"neowiki-managesubjects-id-copy": "Copy subject ID $1",
"neowiki-managesubjects-id-copied": "Copied \"$1\" to clipboard.",
"neowiki-managesubjects-id-copy-error": "Couldn't copy the subject ID.",
+ "neowiki-managesubjects-export-button": "Export",
+ "neowiki-managesubjects-export-json": "JSON",
+ "neowiki-managesubjects-export-native": "Native",
+ "neowiki-managesubjects-export-turtle": "$1 · Turtle",
+ "neowiki-managesubjects-export-trig": "$1 · TriG",
"action-subjects": "manage subjects on this page"
}
diff --git a/i18n/qqq.json b/i18n/qqq.json
index 8dcbe646f..2feccb177 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -236,5 +236,10 @@
"neowiki-managesubjects-id-copy": "Tooltip on the subject ID button. $1 is the Subject ID. Click copies the ID to the clipboard.",
"neowiki-managesubjects-id-copied": "Notification shown after the subject ID was copied. $1 is the Subject ID.",
"neowiki-managesubjects-id-copy-error": "Notification shown when copying the subject ID to the clipboard failed.",
+ "neowiki-managesubjects-export-button": "Label and accessible name of the button that opens the data export menu on the Data tab (per Subject, and for the whole page in the header).",
+ "neowiki-managesubjects-export-json": "Export menu item that downloads the raw JSON representation of the Subject or page.",
+ "neowiki-managesubjects-export-native": "Display name of the native (NeoWiki-vocabulary) RDF projection, used as $1 in {{msg-neowiki|neowiki-managesubjects-export-turtle}} and {{msg-neowiki|neowiki-managesubjects-export-trig}}.",
+ "neowiki-managesubjects-export-turtle": "Export menu item for the Turtle serialization of an RDF projection. $1 is the projection name (e.g. the message {{msg-neowiki|neowiki-managesubjects-export-native}}, or an ontology name such as \"EDM\"). \"Turtle\" is the name of the RDF serialization format and is not translated.",
+ "neowiki-managesubjects-export-trig": "Export menu item for the TriG serialization of an RDF projection. $1 is the projection name (e.g. the message {{msg-neowiki|neowiki-managesubjects-export-native}}, or an ontology name such as \"EDM\"). \"TriG\" is the name of the RDF serialization format and is not translated.",
"action-subjects": "Description of the Subject management action used by MediaWiki when generating action descriptions."
}
diff --git a/resources/ext.neowiki/src/components/SubjectsManager/SubjectsManagerPage.vue b/resources/ext.neowiki/src/components/SubjectsManager/SubjectsManagerPage.vue
index d45665606..dbdbb8839 100644
--- a/resources/ext.neowiki/src/components/SubjectsManager/SubjectsManagerPage.vue
+++ b/resources/ext.neowiki/src/components/SubjectsManager/SubjectsManagerPage.vue
@@ -8,15 +8,28 @@
{{ $i18n( 'neowiki-managesubjects-count', subjects.length ).text() }}
-
-
- {{ $i18n( 'neowiki-managesubjects-add-button' ).text() }}
-
+
+
+
+
+ {{ $i18n( 'neowiki-managesubjects-add-button' ).text() }}
+
+
+
@@ -320,6 +343,16 @@
+
@@ -386,6 +419,7 @@ import type { MenuButtonItemData } from '@wikimedia/codex';
import {
cdxIconAdd,
cdxIconCollapse,
+ cdxIconDownload,
cdxIconDraggable,
cdxIconEdit,
cdxIconEllipsis,
@@ -406,9 +440,14 @@ import EditSummary from '@/components/common/EditSummary.vue';
import I18nSlot from '@/components/common/I18nSlot.vue';
import SubjectStatementsView from '@/components/SubjectsManager/SubjectStatementsView.vue';
import { setPendingNotification } from '@/presentation/PendingNotification.ts';
+import { subjectExportMenuItems, pageExportMenuItems } from '@/presentation/DataExportMenu.ts';
const pageId = Number( mw.config.get( 'wgNeoWikiManageSubjectsPageId' ) );
+// RDF projections readable by the viewing user (native + ontology mappings), permission-filtered
+// server-side. Drives the export menus; native is always present, so this is never truly empty.
+const rdfProjections = ( mw.config.get( 'wgNeoWikiRdfProjections' ) as string[] | null ) ?? [];
+
const subjectStore = useSubjectStore();
const schemaStore = useSchemaStore();
const {
@@ -553,6 +592,26 @@ function dispatchRowAction( value: string | number | null, subject: Subject ): v
}
}
+const exportMenuSelection = ref( null );
+
+function subjectExportItems( subject: Subject ): MenuButtonItemData[] {
+ return subjectExportMenuItems( subject.getId().text, rdfProjections );
+}
+
+const pageExportItems = computed(
+ () => pageExportMenuItems( pageId, rdfProjections )
+);
+
+// The menu item value is the export endpoint URL; open it in a new tab so the Data tab UI state
+// (expanded rows, edits in progress) survives. Codex 1.14 menu-item anchors cannot set a target,
+// so we navigate on selection rather than rely on the item's `url`.
+function openExport( value: string | number | null ): void {
+ exportMenuSelection.value = null;
+ if ( typeof value === 'string' && value !== '' ) {
+ window.open( value, '_blank', 'noopener' );
+ }
+}
+
function toggleExpanded( id: string ): void {
const next = new Set( expandedIds.value );
if ( next.has( id ) ) {
@@ -799,6 +858,12 @@ onUnmounted( () => {
margin-bottom: @spacing-150;
}
+ &__controls-actions {
+ display: flex;
+ align-items: center;
+ gap: @spacing-100;
+ }
+
&__count {
color: @color-subtle;
}
@@ -1104,7 +1169,9 @@ onUnmounted( () => {
&__row-footer {
display: flex;
- justify-content: flex-start;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
gap: @spacing-100;
margin-top: @spacing-100;
padding-top: @spacing-75;
diff --git a/resources/ext.neowiki/src/presentation/DataExportMenu.ts b/resources/ext.neowiki/src/presentation/DataExportMenu.ts
new file mode 100644
index 000000000..73f8b15e4
--- /dev/null
+++ b/resources/ext.neowiki/src/presentation/DataExportMenu.ts
@@ -0,0 +1,67 @@
+import type { MenuButtonItemData } from '@wikimedia/codex';
+
+/**
+ * Builds the export menu items for the Data tab: a JSON entry, then Turtle and TriG entries for each
+ * RDF projection the viewing user may read (native first, then ontology mappings). Item values are the
+ * full endpoint URLs; the menu navigates to them on selection. Kept as pure functions so the URL and
+ * label derivation is unit-tested independently of the Vue component.
+ */
+
+interface RdfFormatOption {
+ format: string;
+ messageKey: string;
+}
+
+const RDF_FORMATS: readonly RdfFormatOption[] = [
+ { format: 'turtle', messageKey: 'neowiki-managesubjects-export-turtle' },
+ { format: 'trig', messageKey: 'neowiki-managesubjects-export-trig' },
+];
+
+const NATIVE_PROJECTION = 'native';
+
+function restApiBase(): string {
+ return mw.util.wikiScript( 'rest' );
+}
+
+function projectionLabel( projection: string ): string {
+ return projection === NATIVE_PROJECTION ?
+ mw.msg( 'neowiki-managesubjects-export-native' ) :
+ projection;
+}
+
+function exportMenuItems(
+ jsonUrl: string,
+ rdfEndpoint: string,
+ projections: readonly string[],
+): MenuButtonItemData[] {
+ const items: MenuButtonItemData[] = [
+ { value: jsonUrl, label: mw.msg( 'neowiki-managesubjects-export-json' ) },
+ ];
+
+ for ( const projection of projections ) {
+ for ( const { format, messageKey } of RDF_FORMATS ) {
+ items.push( {
+ value: `${ rdfEndpoint }?projection=${ encodeURIComponent( projection ) }&format=${ format }`,
+ label: mw.msg( messageKey, projectionLabel( projection ) ),
+ } );
+ }
+ }
+
+ return items;
+}
+
+export function subjectExportMenuItems(
+ subjectId: string,
+ projections: readonly string[],
+): MenuButtonItemData[] {
+ const base = `${ restApiBase() }/neowiki/v0/subject/${ encodeURIComponent( subjectId ) }`;
+ return exportMenuItems( base, `${ base }/rdf`, projections );
+}
+
+export function pageExportMenuItems(
+ pageId: number,
+ projections: readonly string[],
+): MenuButtonItemData[] {
+ const base = `${ restApiBase() }/neowiki/v0/page/${ pageId }`;
+ return exportMenuItems( `${ base }/subjects`, `${ base }/rdf`, projections );
+}
diff --git a/resources/ext.neowiki/tests/presentation/DataExportMenu.spec.ts b/resources/ext.neowiki/tests/presentation/DataExportMenu.spec.ts
new file mode 100644
index 000000000..1e5c5c6b1
--- /dev/null
+++ b/resources/ext.neowiki/tests/presentation/DataExportMenu.spec.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { subjectExportMenuItems, pageExportMenuItems } from '@/presentation/DataExportMenu';
+
+describe( 'DataExportMenu', () => {
+ beforeEach( () => {
+ vi.stubGlobal( 'mw', {
+ util: { wikiScript: vi.fn( () => '/w/rest.php' ) },
+ msg: vi.fn( ( key: string, ...params: string[] ) =>
+ params.length > 0 ? `[${ key }|${ params.join( ',' ) }]` : `[${ key }]` ),
+ } );
+ } );
+
+ describe( 'subjectExportMenuItems', () => {
+ it( 'lists JSON first, then Turtle and TriG for each projection in order', () => {
+ const items = subjectExportMenuItems( 's2picasso2aaaa2', [ 'native', 'EDM' ] );
+
+ expect( items.map( ( item ) => item.value ) ).toEqual( [
+ '/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2',
+ '/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=native&format=turtle',
+ '/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=native&format=trig',
+ '/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=EDM&format=turtle',
+ '/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=EDM&format=trig',
+ ] );
+ } );
+
+ it( 'labels JSON, the native projection, and mapping projections distinctly', () => {
+ const items = subjectExportMenuItems( 's2picasso2aaaa2', [ 'native', 'EDM' ] );
+
+ expect( items.map( ( item ) => item.label ) ).toEqual( [
+ '[neowiki-managesubjects-export-json]',
+ '[neowiki-managesubjects-export-turtle|[neowiki-managesubjects-export-native]]',
+ '[neowiki-managesubjects-export-trig|[neowiki-managesubjects-export-native]]',
+ '[neowiki-managesubjects-export-turtle|EDM]',
+ '[neowiki-managesubjects-export-trig|EDM]',
+ ] );
+ } );
+
+ it( 'percent-encodes the projection name in the RDF URLs', () => {
+ const items = subjectExportMenuItems( 's1aaaaaaaaaaaa1', [ 'Wikidata items' ] );
+
+ expect( items[ 1 ].value ).toBe(
+ '/w/rest.php/neowiki/v0/subject/s1aaaaaaaaaaaa1/rdf?projection=Wikidata%20items&format=turtle',
+ );
+ } );
+
+ it( 'returns only the JSON entry when there are no readable projections', () => {
+ const items = subjectExportMenuItems( 's1aaaaaaaaaaaa1', [] );
+
+ expect( items ).toHaveLength( 1 );
+ expect( items[ 0 ].value ).toBe( '/w/rest.php/neowiki/v0/subject/s1aaaaaaaaaaaa1' );
+ } );
+ } );
+
+ describe( 'pageExportMenuItems', () => {
+ it( 'targets the page subjects JSON and the page RDF endpoint', () => {
+ const items = pageExportMenuItems( 42, [ 'native', 'EDM' ] );
+
+ expect( items.map( ( item ) => item.value ) ).toEqual( [
+ '/w/rest.php/neowiki/v0/page/42/subjects',
+ '/w/rest.php/neowiki/v0/page/42/rdf?projection=native&format=turtle',
+ '/w/rest.php/neowiki/v0/page/42/rdf?projection=native&format=trig',
+ '/w/rest.php/neowiki/v0/page/42/rdf?projection=EDM&format=turtle',
+ '/w/rest.php/neowiki/v0/page/42/rdf?projection=EDM&format=trig',
+ ] );
+ } );
+ } );
+} );
diff --git a/src/Application/Rdf/OntologyMappingProjector.php b/src/Application/Rdf/OntologyMappingProjector.php
index a98f2605a..38eaf1e5c 100644
--- a/src/Application/Rdf/OntologyMappingProjector.php
+++ b/src/Application/Rdf/OntologyMappingProjector.php
@@ -19,6 +19,7 @@
use ProfessionalWiki\NeoWiki\Domain\Rdf\RdfValueMapperRegistry;
use ProfessionalWiki\NeoWiki\Domain\Statement;
use ProfessionalWiki\NeoWiki\Domain\Subject\Subject;
+use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
use ProfessionalWiki\NeoWiki\Domain\Value\RelationValue;
use Psr\Log\LoggerInterface;
@@ -60,17 +61,41 @@ public function projectPage( Page $page ): QuadList {
$schemaMapping = $this->mapping->forSchema( $subject->getSchemaName() );
if ( $schemaMapping !== null ) {
- $quads = array_merge( $quads, $this->projectSubject( $subject, $schemaMapping, $graph ) );
+ $quads = array_merge( $quads, $this->subjectQuads( $subject, $schemaMapping, $graph ) );
}
}
return QuadList::fromArray( $quads );
}
+ /**
+ * Projects a single Subject on the page into the target ontology — its per-Subject block from
+ * {@see projectPage()} (mapped type, label, mapped property values, relations as direct triples)
+ * placed in the target's named graph. A Subject that is not on the page, or whose Schema has no
+ * entry in this Mapping, yields an empty list.
+ */
+ public function projectSubject( Page $page, SubjectId $subjectId ): QuadList {
+ $subject = $page->getSubjects()->getAllSubjects()->getSubject( $subjectId );
+
+ if ( $subject === null ) {
+ return new QuadList();
+ }
+
+ $schemaMapping = $this->mapping->forSchema( $subject->getSchemaName() );
+
+ if ( $schemaMapping === null ) {
+ return new QuadList();
+ }
+
+ return QuadList::fromArray(
+ $this->subjectQuads( $subject, $schemaMapping, $this->namespaces->graph( $this->target, $page->getId() ) )
+ );
+ }
+
/**
* @return Quad[]
*/
- private function projectSubject( Subject $subject, SchemaMapping $schemaMapping, Iri $graph ): array {
+ private function subjectQuads( Subject $subject, SchemaMapping $schemaMapping, Iri $graph ): array {
$subjectIri = $this->namespaces->subject( $subject->id );
$quads = [
diff --git a/src/Application/Rdf/PageProjector.php b/src/Application/Rdf/PageProjector.php
index d0e2ed11c..1b5405ffc 100644
--- a/src/Application/Rdf/PageProjector.php
+++ b/src/Application/Rdf/PageProjector.php
@@ -6,6 +6,7 @@
use ProfessionalWiki\NeoWiki\Domain\Page\Page;
use ProfessionalWiki\NeoWiki\Domain\Rdf\QuadList;
+use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
/**
* Projects a {@see Page} to RDF quads in some target vocabulary. The native projection
@@ -17,4 +18,14 @@ interface PageProjector {
public function projectPage( Page $page ): QuadList;
+ /**
+ * Projects the single Subject $subjectId hosted on $page: exactly the quads {@see projectPage()}
+ * emits for that Subject (its outbound bounded description, including native relation reification),
+ * with none of the page-metadata quads. The page is passed for context — the graph IRI derives from
+ * its id and the Schema/Mapping is resolved as in a full-page projection. Returns an empty list when
+ * the Subject is absent from the page or its projection yields no quads (native: the Schema is
+ * unavailable; ontology: no Mapping targets it).
+ */
+ public function projectSubject( Page $page, SubjectId $subjectId ): QuadList;
+
}
diff --git a/src/Application/Rdf/RdfPageProjector.php b/src/Application/Rdf/RdfPageProjector.php
index 45a19530a..c295ec8b2 100644
--- a/src/Application/Rdf/RdfPageProjector.php
+++ b/src/Application/Rdf/RdfPageProjector.php
@@ -21,6 +21,7 @@
use ProfessionalWiki\NeoWiki\Domain\Schema\Schema;
use ProfessionalWiki\NeoWiki\Domain\Statement;
use ProfessionalWiki\NeoWiki\Domain\Subject\Subject;
+use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
use Psr\Log\LoggerInterface;
/**
@@ -68,12 +69,37 @@ public function projectPage( Page $page ): QuadList {
$quads = $this->projectPageMetadata( $page, $subjects, $graph );
foreach ( $subjects as [ $subject, $schema ] ) {
- $quads = array_merge( $quads, $this->projectSubject( $subject, $schema, $graph, $page->getId() ) );
+ $quads = array_merge( $quads, $this->subjectQuads( $subject, $schema, $graph, $page->getId() ) );
}
return QuadList::fromArray( $quads );
}
+ /**
+ * Projects a single Subject on the page — its per-Subject block from {@see projectPage()} (type,
+ * label, Statements, reified Relations) placed in the page's native named graph, without any
+ * page-metadata quads. A Subject that is not on the page, or whose Schema is unavailable (logged,
+ * as in the full-page projection), yields an empty list.
+ */
+ public function projectSubject( Page $page, SubjectId $subjectId ): QuadList {
+ $subject = $page->getSubjects()->getAllSubjects()->getSubject( $subjectId );
+
+ if ( $subject === null ) {
+ return new QuadList();
+ }
+
+ $schema = $this->schemaLookup->getSchema( $subject->getSchemaName() );
+
+ if ( $schema === null ) {
+ $this->logger->warning( 'Schema not found: ' . $subject->getSchemaName()->getText() );
+ return new QuadList();
+ }
+
+ return QuadList::fromArray(
+ $this->subjectQuads( $subject, $schema, $this->namespaces->graph( self::PROJECTION, $page->getId() ), $page->getId() )
+ );
+ }
+
/**
* Resolves each Subject's Schema up front. A Subject whose Schema is unavailable is dropped from
* the projection entirely — matching Neo4jSubjectUpdater, which skips the whole Subject — so page
@@ -168,7 +194,7 @@ private function isProjected( Subject $mainSubject, array $subjects ): bool {
/**
* @return Quad[]
*/
- private function projectSubject( Subject $subject, Schema $schema, Iri $graph, PageId $pageId ): array {
+ private function subjectQuads( Subject $subject, Schema $schema, Iri $graph, PageId $pageId ): array {
$subjectIri = $this->namespaces->subject( $subject->id );
$quads = [
diff --git a/src/Application/Rdf/RdfSubjectExporter.php b/src/Application/Rdf/RdfSubjectExporter.php
new file mode 100644
index 000000000..85184d1df
--- /dev/null
+++ b/src/Application/Rdf/RdfSubjectExporter.php
@@ -0,0 +1,57 @@
+pageIdentifiersLookup->getPageIdOfSubject( $subjectId );
+
+ if ( $pageIdentifiers === null ) {
+ return null;
+ }
+
+ if ( !$this->readAuthorizer->authorizeReadByPageId( $pageIdentifiers->getId() ) ) {
+ return null;
+ }
+
+ $page = $this->loader->loadByPageId( $pageIdentifiers->getId() );
+
+ if ( $page === null || $page->getSubjects()->getAllSubjects()->getSubject( $subjectId ) === null ) {
+ return null;
+ }
+
+ return $this->serializer->serialize( $this->projector->projectSubject( $page, $subjectId ), $format );
+ }
+
+}
diff --git a/src/EntryPoints/Actions/SubjectsAction.php b/src/EntryPoints/Actions/SubjectsAction.php
index 0e1cf03d8..b23277619 100644
--- a/src/EntryPoints/Actions/SubjectsAction.php
+++ b/src/EntryPoints/Actions/SubjectsAction.php
@@ -45,10 +45,17 @@ public function onView(): string {
);
}
- NeoWikiExtension::getInstance()->newFrontendModuleLoader()->load( $out, $this->getSkin() );
+ $extension = NeoWikiExtension::getInstance();
+ $extension->newFrontendModuleLoader()->load( $out, $this->getSkin() );
$out->addJsConfigVars( [
'wgNeoWikiManageSubjectsPageId' => $title->getArticleID(),
+ // The export UI (Data tab menus) is driven by this list. Filtered by the viewing user's
+ // read authority so restricted Mapping page titles never reach a reader who cannot see them.
+ 'wgNeoWikiRdfProjections' => $extension->filterReadableProjectionNames(
+ $extension->getRdfProjectionNames(),
+ $this->getAuthority()
+ ),
] );
return Html::element( 'div', [ 'id' => 'ext-neowiki-manage-subjects' ] );
diff --git a/src/EntryPoints/NeoWikiHooks.php b/src/EntryPoints/NeoWikiHooks.php
index 36a6b7037..9d8f19e7c 100644
--- a/src/EntryPoints/NeoWikiHooks.php
+++ b/src/EntryPoints/NeoWikiHooks.php
@@ -6,6 +6,7 @@
use MediaWiki\Html\Html;
use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Output\OutputPage;
use MediaWiki\Page\ProperPageIdentity;
@@ -16,6 +17,7 @@
use MediaWiki\Title\ForeignTitle;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
+use ProfessionalWiki\NeoWiki\Application\Rdf\RdfPageProjector;
use ProfessionalWiki\NeoWiki\Domain\Page\PageId;
use ProfessionalWiki\NeoWiki\EntryPoints\Content\SchemaContent;
use ProfessionalWiki\NeoWiki\EntryPoints\Content\SubjectContent;
@@ -61,6 +63,7 @@ private static function handleContentPage( OutputPage $out, Skin $skin ): void {
NeoWikiExtension::getInstance()->newFrontendModuleLoader()->load( $out, $skin );
$out->addHtml( self::getNeoWikiAppHtml( $out ) );
+ self::addRdfAutodiscoveryLinks( $out );
if ( !NeoWikiExtension::getInstance()->shouldAutoRenderMainSubject() ) {
return;
@@ -113,6 +116,34 @@ private static function pageIsLatestRevision( OutputPage $out ): bool {
return $out->getRevisionId() === $out->getTitle()->getLatestRevID();
}
+ /**
+ * Advertises the page's RDF export (native projection) via ` ` autodiscovery
+ * tags — one Turtle, one TriG — so Linked Data tooling finds the data without reading the API docs.
+ * Emitted only when the page has Subjects, so the advertised endpoint never 404s. Native only; the
+ * per-projection exports are reachable from the Data tab UI.
+ */
+ private static function addRdfAutodiscoveryLinks( OutputPage $out ): void {
+ $pageId = $out->getTitle()->getArticleID();
+
+ if ( !NeoWikiExtension::getInstance()->newPageSubjectsLookup()->pageHasSubjects( new PageId( $pageId ) ) ) {
+ return;
+ }
+
+ $services = MediaWikiServices::getInstance();
+ $endpoint = $services->getMainConfig()->get( MainConfigNames::RestPath )
+ . '/neowiki/v0/page/' . $pageId . '/rdf?projection=' . RdfPageProjector::PROJECTION;
+ $urlUtils = $services->getUrlUtils();
+
+ foreach ( [ 'turtle' => 'text/turtle', 'trig' => 'application/trig' ] as $format => $type ) {
+ $href = $endpoint . '&format=' . $format;
+ $out->addLink( [
+ 'rel' => 'alternate',
+ 'type' => $type,
+ 'href' => $urlUtils->expand( $href, PROTO_CANONICAL ) ?? $href,
+ ] );
+ }
+ }
+
private static function handleSchemaPage( OutputPage $out, Skin $skin ): void {
NeoWikiExtension::getInstance()->newFrontendModuleLoader()->load( $out, $skin );
diff --git a/src/EntryPoints/REST/ExportPageRdfApi.php b/src/EntryPoints/REST/ExportPageRdfApi.php
index d85009509..5cab2996e 100644
--- a/src/EntryPoints/REST/ExportPageRdfApi.php
+++ b/src/EntryPoints/REST/ExportPageRdfApi.php
@@ -6,10 +6,8 @@
use MediaWiki\Rest\Response;
use MediaWiki\Rest\SimpleHandler;
-use MediaWiki\Rest\StringStream;
use ProfessionalWiki\NeoWiki\Application\Rdf\RdfPageProjector;
use ProfessionalWiki\NeoWiki\Domain\Page\PageId;
-use ProfessionalWiki\NeoWiki\Domain\Rdf\RdfFormat;
use ProfessionalWiki\NeoWiki\NeoWikiExtension;
use Wikimedia\ParamValidator\ParamValidator;
@@ -22,11 +20,7 @@
*/
class ExportPageRdfApi extends SimpleHandler {
- private const string FORMAT_TRIG = 'trig';
- private const string FORMAT_TURTLE = 'turtle';
-
- private const string CONTENT_TYPE_TRIG = 'application/trig; charset=utf-8';
- private const string CONTENT_TYPE_TURTLE = 'text/turtle; charset=utf-8';
+ use RdfFormatNegotiation;
public function run( int $pageId ): Response {
$extension = NeoWikiExtension::getInstance();
@@ -76,42 +70,6 @@ private function noDataResponse( int $pageId ): Response {
] );
}
- private function resolveFormat(): RdfFormat {
- $requested = $this->getValidatedParams()['format'] ?? null;
-
- if ( $requested === self::FORMAT_TURTLE ) {
- return RdfFormat::Turtle;
- }
-
- if ( $requested === self::FORMAT_TRIG ) {
- return RdfFormat::TriG;
- }
-
- return $this->formatFromAcceptHeader();
- }
-
- private function formatFromAcceptHeader(): RdfFormat {
- $accept = $this->getRequest()->getHeaderLine( 'Accept' );
-
- // TriG is a superset of Turtle, so only pick Turtle when the client asks for it specifically.
- if ( str_contains( $accept, 'text/turtle' ) && !str_contains( $accept, 'application/trig' ) ) {
- return RdfFormat::Turtle;
- }
-
- return RdfFormat::TriG;
- }
-
- private function rdfResponse( string $document, RdfFormat $format ): Response {
- $response = $this->getResponseFactory()->create();
- $response->setHeader(
- 'Content-Type',
- $format === RdfFormat::Turtle ? self::CONTENT_TYPE_TURTLE : self::CONTENT_TYPE_TRIG
- );
- $response->setBody( new StringStream( $document ) );
-
- return $response;
- }
-
public function getParamSettings(): array {
return [
'pageId' => [
diff --git a/src/EntryPoints/REST/ExportSubjectRdfApi.php b/src/EntryPoints/REST/ExportSubjectRdfApi.php
new file mode 100644
index 000000000..5a09e5a9c
--- /dev/null
+++ b/src/EntryPoints/REST/ExportSubjectRdfApi.php
@@ -0,0 +1,105 @@
+getResponseFactory()->createHttpError( 400, [
+ 'message' => 'Invalid Subject ID: ' . $subjectId,
+ ] );
+ }
+
+ $extension = NeoWikiExtension::getInstance();
+ $projectionName = $this->getValidatedParams()['projection'] ?? RdfPageProjector::PROJECTION;
+ $resolution = $extension->resolveRdfProjection( $projectionName );
+
+ // The projection check runs before the Subject is resolved and before the read gate, so a
+ // caller who cannot read the Subject's page sees the same 400 as anyone else and cannot use it
+ // to probe existence or readability.
+ if ( $resolution->projection === null ) {
+ // Read-filter the known-projection list with the caller's own authority, so the 400 never
+ // leaks the titles of Mapping pages they may not read (#1046).
+ $knownProjections = $extension->filterReadableProjectionNames(
+ $resolution->knownProjectionNames,
+ $this->getAuthority()
+ );
+
+ return $this->getResponseFactory()->createHttpError( 400, [
+ 'message' => 'Unknown RDF projection: "' . $projectionName . '". Known projections: '
+ . implode( ', ', $knownProjections ) . '.',
+ ] );
+ }
+
+ $format = $this->resolveFormat();
+
+ $document = $extension
+ ->newRdfSubjectExporterForProjection( $resolution->projection, $this->getAuthority() )
+ ->exportBySubjectId( new SubjectId( $subjectId ), $format );
+
+ if ( $document === null ) {
+ return $this->noDataResponse( $subjectId );
+ }
+
+ return $this->rdfResponse( $document, $format );
+ }
+
+ private function noDataResponse( string $subjectId ): Response {
+ return $this->getResponseFactory()->createHttpError( 404, [
+ 'message' => 'No NeoWiki data found for subject: ' . $subjectId,
+ ] );
+ }
+
+ public function getParamSettings(): array {
+ return [
+ 'subjectId' => [
+ self::PARAM_SOURCE => 'path',
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => true,
+ self::PARAM_DESCRIPTION => 'Persistent identifier of the Subject. 15 characters, starting with "s".',
+ ],
+ 'format' => [
+ self::PARAM_SOURCE => 'query',
+ ParamValidator::PARAM_TYPE => [
+ self::FORMAT_TRIG,
+ self::FORMAT_TURTLE,
+ ],
+ ParamValidator::PARAM_REQUIRED => false,
+ self::PARAM_DESCRIPTION => 'RDF serialization to return: "trig" (default, includes the hosting page\'s named graph) or "turtle". Overrides the Accept header.',
+ ],
+ 'projection' => [
+ self::PARAM_SOURCE => 'query',
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => false,
+ self::PARAM_DESCRIPTION => 'RDF projection to produce: "native" (default) for NeoWiki-native vocabulary, or an ontology target declared by a Mapping page (e.g. "edm"). An unknown target returns 400.',
+ ],
+ ];
+ }
+
+}
diff --git a/src/EntryPoints/REST/RdfFormatNegotiation.php b/src/EntryPoints/REST/RdfFormatNegotiation.php
new file mode 100644
index 000000000..cf43a468f
--- /dev/null
+++ b/src/EntryPoints/REST/RdfFormatNegotiation.php
@@ -0,0 +1,64 @@
+getValidatedParams()['format'] ?? null;
+
+ if ( $requested === self::FORMAT_TURTLE ) {
+ return RdfFormat::Turtle;
+ }
+
+ if ( $requested === self::FORMAT_TRIG ) {
+ return RdfFormat::TriG;
+ }
+
+ return $this->formatFromAcceptHeader();
+ }
+
+ private function formatFromAcceptHeader(): RdfFormat {
+ $accept = $this->getRequest()->getHeaderLine( 'Accept' );
+
+ // TriG is a superset of Turtle, so only pick Turtle when the client asks for it specifically.
+ if ( str_contains( $accept, 'text/turtle' ) && !str_contains( $accept, 'application/trig' ) ) {
+ return RdfFormat::Turtle;
+ }
+
+ return RdfFormat::TriG;
+ }
+
+ private function rdfResponse( string $document, RdfFormat $format ): Response {
+ $response = $this->getResponseFactory()->create();
+ $response->setHeader(
+ 'Content-Type',
+ $format === RdfFormat::Turtle ? self::CONTENT_TYPE_TURTLE : self::CONTENT_TYPE_TRIG
+ );
+ $response->setBody( new StringStream( $document ) );
+
+ return $response;
+ }
+
+}
diff --git a/src/NeoWikiExtension.php b/src/NeoWikiExtension.php
index e035f60ea..0e37104d2 100644
--- a/src/NeoWikiExtension.php
+++ b/src/NeoWikiExtension.php
@@ -68,6 +68,7 @@
use ProfessionalWiki\NeoWiki\Application\Rdf\RdfPageProjector;
use ProfessionalWiki\NeoWiki\Application\Rdf\RdfProjection;
use ProfessionalWiki\NeoWiki\Application\Rdf\RdfProjectionResolution;
+use ProfessionalWiki\NeoWiki\Application\Rdf\RdfSubjectExporter;
use ProfessionalWiki\NeoWiki\Domain\Mapping\CurieExpander;
use ProfessionalWiki\NeoWiki\Domain\Mapping\Mapping;
use ProfessionalWiki\NeoWiki\Domain\Mapping\MappingName;
@@ -76,6 +77,7 @@
use ProfessionalWiki\NeoWiki\Domain\Rdf\RdfValueMapperRegistry;
use ProfessionalWiki\NeoWiki\Infrastructure\Rdf\HardfRdfSerializer;
use ProfessionalWiki\NeoWiki\EntryPoints\REST\ExportPageRdfApi;
+use ProfessionalWiki\NeoWiki\EntryPoints\REST\ExportSubjectRdfApi;
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Persistence\Neo4jWriteQueryEngine;
use ProfessionalWiki\NeoWiki\Domain\PropertyType\PropertyTypeLookup;
use ProfessionalWiki\NeoWiki\Domain\PropertyType\PropertyTypeRegistry;
@@ -330,6 +332,16 @@ public function newRdfPageExporterForProjection( RdfProjection $projection ): Rd
);
}
+ public function newRdfSubjectExporterForProjection( RdfProjection $projection, Authority $authority ): RdfSubjectExporter {
+ return new RdfSubjectExporter(
+ $this->getPageIdentifiersLookup(),
+ $this->newRdfPageLoader(),
+ $projection->projector,
+ $projection->serializer,
+ $this->newPageReadAuthorizer( $authority ),
+ );
+ }
+
/**
* The projection for a name, or null when the name is neither "native" nor a target any Mapping page
* declares. The seam the SPARQL store plugin (#586) consumes for its own store (it needs only
@@ -480,6 +492,10 @@ public static function newExportPageRdfApi(): ExportPageRdfApi {
return new ExportPageRdfApi();
}
+ public static function newExportSubjectRdfApi(): ExportSubjectRdfApi {
+ return new ExportSubjectRdfApi();
+ }
+
/**
* Propagating fan-out over every backend, used by the maintenance rebuild path so failures surface.
*/
diff --git a/tests/phpunit/Application/Rdf/OntologyMappingProjectorTest.php b/tests/phpunit/Application/Rdf/OntologyMappingProjectorTest.php
index a66ae4436..2721c5421 100644
--- a/tests/phpunit/Application/Rdf/OntologyMappingProjectorTest.php
+++ b/tests/phpunit/Application/Rdf/OntologyMappingProjectorTest.php
@@ -42,6 +42,7 @@ class OntologyMappingProjectorTest extends TestCase {
private const string PERSON_ID = 's1janeaaaaaaaa2';
private const string CITY_ID = 's1cityaaaaaaaa3';
+ private const string GHOST_ID = 's1ghostaaaaaaa9';
private const string EDM = 'http://www.europeana.eu/schemas/edm/';
private const string DC = 'http://purl.org/dc/elements/1.1/';
@@ -115,7 +116,11 @@ private function expectedTriG(): string {
}
private function examplePage(): Page {
- $person = TestSubject::build(
+ return TestPage::build( id: 42, mainSubject: $this->examplePerson(), childSubjects: new SubjectMap( $this->exampleCity() ) );
+ }
+
+ private function examplePerson(): Subject {
+ return TestSubject::build(
id: self::PERSON_ID,
label: 'Jane',
schemaName: new SchemaName( 'Person' ),
@@ -127,8 +132,10 @@ private function examplePage(): Page {
TestStatement::buildRelation( 'BornIn', [ TestRelation::build( targetId: self::CITY_ID ) ] ),
] )
);
+ }
- $city = TestSubject::build(
+ private function exampleCity(): Subject {
+ return TestSubject::build(
id: self::CITY_ID,
label: 'Berlin',
schemaName: new SchemaName( 'City' ),
@@ -136,8 +143,81 @@ private function examplePage(): Page {
TestStatement::build( 'Name', new StringValue( 'Berlin' ), 'text' ),
] )
);
+ }
+
+ /**
+ * The per-Subject export projects exactly the requested Subject's mapped block from a full-page
+ * projection, in the target's named graph, and nothing else. The target Person sits between two
+ * siblings; its BornIn relation points at the City sibling, whose IRI appears as the dc:spatial
+ * object but whose own mapped block (edm:Place, …) must not — so a "project every Subject"
+ * regression fails even though the City has an entry of its own.
+ */
+ public function testProjectSubjectEmitsOnlyTheTargetSubjectsMappedBlockInTheTargetGraph(): void {
+ $quads = $this->newProjector( [ 'Person' => $this->personMapping(), 'City' => $this->cityMapping() ] )
+ ->projectSubject( $this->pagePersonBetweenSiblings(), new SubjectId( self::PERSON_ID ) );
+
+ $output = ( new HardfRdfSerializer( $this->serializerPrefixes() ) )->serialize( $quads, RdfFormat::TriG );
+
+ $this->assertSame(
+ ParsedRdf::canonicalQuads( $this->personOnlyTriG() ),
+ ParsedRdf::canonicalQuads( $output )
+ );
+ $this->logger->assertNoLoggingCallsWhereMade();
+ }
+
+ public function testProjectSubjectForASubjectWhoseSchemaHasNoEntryReturnsNothing(): void {
+ // Only Person has an entry; the requested Subject uses the unmapped Ghost Schema.
+ $quads = $this->newProjector( [ 'Person' => $this->personMapping() ] )
+ ->projectSubject( $this->pagePersonBetweenSiblings(), new SubjectId( self::GHOST_ID ) );
+
+ $this->assertTrue( $quads->isEmpty() );
+ $this->logger->assertNoLoggingCallsWhereMade();
+ }
+
+ public function testProjectSubjectForASubjectNotOnThePageReturnsNothing(): void {
+ $quads = $this->newProjector( [ 'Person' => $this->personMapping() ] )
+ ->projectSubject( TestPage::build( id: 42, mainSubject: $this->examplePerson() ), new SubjectId( self::CITY_ID ) );
- return TestPage::build( id: 42, mainSubject: $person, childSubjects: new SubjectMap( $city ) );
+ $this->assertTrue( $quads->isEmpty() );
+ $this->logger->assertNoLoggingCallsWhereMade();
+ }
+
+ private function pagePersonBetweenSiblings(): Page {
+ return TestPage::build(
+ id: 42,
+ mainSubject: $this->exampleCity(),
+ childSubjects: new SubjectMap( $this->examplePerson(), $this->ghostSubject() )
+ );
+ }
+
+ private function ghostSubject(): Subject {
+ return TestSubject::build(
+ id: self::GHOST_ID,
+ label: 'Unmapped',
+ schemaName: new SchemaName( 'Ghost' ),
+ statements: new StatementList( [ TestStatement::build( 'Name', new StringValue( 'Unmapped' ), 'text' ) ] )
+ );
+ }
+
+ private function personOnlyTriG(): string {
+ return << .
+ @prefix neo-graph: .
+ @prefix rdf: .
+ @prefix rdfs: .
+ @prefix xsd: .
+ @prefix edm: .
+ @prefix dc: .
+
+ neo-graph:42 {
+ neo-subj:s1janeaaaaaaaa2 a edm:ProvidedCHO ;
+ rdfs:label "Jane" ;
+ dc:title "Jane"@en ;
+ edm:isShownAt "https://jane.example"^^xsd:anyURI ;
+ dc:date "1990"^^edm:year ;
+ dc:spatial neo-subj:s1cityaaaaaaaa3 .
+ }
+ TRIG;
}
private function personMapping(): SchemaMapping {
diff --git a/tests/phpunit/Application/Rdf/RdfPageProjectorTest.php b/tests/phpunit/Application/Rdf/RdfPageProjectorTest.php
index 88c9c29cf..c8221eac6 100644
--- a/tests/phpunit/Application/Rdf/RdfPageProjectorTest.php
+++ b/tests/phpunit/Application/Rdf/RdfPageProjectorTest.php
@@ -20,6 +20,7 @@
use ProfessionalWiki\NeoWiki\Domain\Schema\Schema;
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName;
use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList;
+use ProfessionalWiki\NeoWiki\Domain\Subject\Subject;
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectMap;
use ProfessionalWiki\NeoWiki\Domain\Value\NumberValue;
@@ -44,6 +45,7 @@ class RdfPageProjectorTest extends TestCase {
private const string ACME_ID = 's1acmeaaaaaaaa1';
private const string JANE_ID = 's1janeaaaaaaaa2';
private const string CEO_RELATION_ID = 'r1ceoaaaaaaaaa3';
+ private const string AFTER_ID = 's1afteraaaaaaa9';
private RdfNamespaces $ns;
private LegacyLoggerSpy $logger;
@@ -288,6 +290,147 @@ public function testDroppingAnInvalidDatePartLogsAWarning(): void {
);
}
+ /**
+ * The per-Subject export projects exactly the requested Subject's block from a full-page projection —
+ * its type, label, Statements and reified Relation — and nothing else: no page-metadata quads and
+ * none of the sibling Subjects' quads, even though a sibling (Jane) is the Relation target whose IRI
+ * does appear. The target sits between two siblings so a "project every Subject" regression fails.
+ */
+ public function testProjectSubjectEmitsOnlyTheTargetSubjectsBlockIncludingRelationReification(): void {
+ $schemaLookup = new InMemorySchemaLookup(
+ $this->companySchema(),
+ TestSchema::build( name: 'Person', properties: new PropertyDefinitions( [] ) ),
+ );
+
+ $quads = $this->newProjector( $schemaLookup )
+ ->projectSubject( $this->pageWithAcmeBetweenSiblings(), new SubjectId( self::ACME_ID ) );
+
+ $this->assertSame(
+ ParsedRdf::canonicalQuads( $this->acmeSubjectTriG() ),
+ ParsedRdf::canonicalQuads( $this->serialize( $quads ) )
+ );
+ $this->logger->assertNoLoggingCallsWhereMade();
+ }
+
+ public function testProjectSubjectDerivesTheNamedGraphFromTheHostingPageId(): void {
+ $quads = $this->newProjector( new InMemorySchemaLookup( $this->companySchema() ) )
+ ->projectSubject( TestPage::build( id: 77, mainSubject: $this->acmeWithCeo() ), new SubjectId( self::ACME_ID ) );
+
+ $this->assertSame(
+ [ 'https://wiki.example/graph/native/page/77' ],
+ $this->distinctGraphs( $quads ),
+ 'Every quad belongs to the hosting page\'s native named graph, derived from its id.'
+ );
+ }
+
+ public function testProjectSubjectForASubjectNotOnThePageReturnsNothing(): void {
+ $quads = $this->newProjector( new InMemorySchemaLookup( $this->companySchema() ) )
+ ->projectSubject( TestPage::build( id: 42, mainSubject: $this->acmeWithCeo() ), new SubjectId( self::AFTER_ID ) );
+
+ $this->assertTrue( $quads->isEmpty() );
+ $this->logger->assertNoLoggingCallsWhereMade();
+ }
+
+ public function testProjectSubjectForASubjectWithAnUnavailableSchemaReturnsNothingAndLogs(): void {
+ $page = TestPage::build( id: 42, mainSubject: $this->acmeWithCeo() );
+
+ // Only Person is registered, so the requested Company Subject's Schema is unavailable and it is
+ // skipped — the same graceful degradation as the full-page projection.
+ $quads = $this->newProjector( new InMemorySchemaLookup(
+ TestSchema::build( name: 'Person', properties: new PropertyDefinitions( [] ) )
+ ) )->projectSubject( $page, new SubjectId( self::ACME_ID ) );
+
+ $this->assertTrue( $quads->isEmpty() );
+ $this->assertSame(
+ [ 'Schema not found: Company' ],
+ $this->logger->getLogCalls()->getMessages()
+ );
+ }
+
+ private function acmeWithCeo(): Subject {
+ return TestSubject::build(
+ id: self::ACME_ID,
+ label: 'ACME Corp',
+ schemaName: new SchemaName( 'Company' ),
+ statements: new StatementList( [
+ TestStatement::build( 'Founded', new NumberValue( 2019 ), 'number' ),
+ TestStatement::buildRelation( 'CEO', [
+ TestRelation::build(
+ id: self::CEO_RELATION_ID,
+ targetId: self::JANE_ID,
+ properties: [ 'Since' => 2022 ],
+ ),
+ ] ),
+ ] )
+ );
+ }
+
+ private function pageWithAcmeBetweenSiblings(): Page {
+ $jane = TestSubject::build(
+ id: self::JANE_ID,
+ label: 'Jane Smith',
+ schemaName: new SchemaName( 'Person' ),
+ statements: new StatementList( [
+ TestStatement::build( 'Age', new NumberValue( 45 ), 'number' ),
+ ] )
+ );
+ $after = TestSubject::build(
+ id: self::AFTER_ID,
+ label: 'Later Co',
+ schemaName: new SchemaName( 'Company' ),
+ statements: new StatementList( [
+ TestStatement::build( 'Founded', new NumberValue( 2000 ), 'number' ),
+ ] )
+ );
+
+ return TestPage::build(
+ id: 42,
+ properties: TestPageProperties::build(
+ title: 'ACME Corp',
+ creationTime: '20240301090000',
+ modificationTime: '20251115164500',
+ lastEditor: 'Admin',
+ ),
+ mainSubject: $jane,
+ childSubjects: new SubjectMap( $this->acmeWithCeo(), $after ),
+ );
+ }
+
+ private function acmeSubjectTriG(): string {
+ return << .
+ @prefix neo-subj: .
+ @prefix neo-prop: .
+ @prefix neo-schema: .
+ @prefix neo-rel: .
+ @prefix neo-graph: .
+ @prefix rdfs: .
+
+ neo-graph:42 {
+ neo-subj:s1acmeaaaaaaaa1 a neo-schema:Company ;
+ rdfs:label "ACME Corp" ;
+ neo-prop:Founded 2019 ;
+ neo-prop:CEO neo-subj:s1janeaaaaaaaa2 .
+
+ neo-rel:r1ceoaaaaaaaaa3 a neo:Relation ;
+ neo:source neo-subj:s1acmeaaaaaaaa1 ;
+ neo:target neo-subj:s1janeaaaaaaaa2 ;
+ neo:relationType neo-prop:CEO ;
+ neo-prop:Since 2022 .
+ }
+ TRIG;
+ }
+
+ /**
+ * @return list
+ */
+ private function distinctGraphs( QuadList $quads ): array {
+ return array_values( array_unique( array_map(
+ static fn ( Quad $quad ): string => $quad->graph->value,
+ $quads->asArray()
+ ) ) );
+ }
+
/**
* A user-authored Property or Schema name must never break out of its IRI: illegal characters
* are escaped so the document still parses to exactly the intended quads. The confirmed repro
diff --git a/tests/phpunit/Application/Rdf/RdfSubjectExporterTest.php b/tests/phpunit/Application/Rdf/RdfSubjectExporterTest.php
new file mode 100644
index 000000000..633110c35
--- /dev/null
+++ b/tests/phpunit/Application/Rdf/RdfSubjectExporterTest.php
@@ -0,0 +1,138 @@
+exporter(
+ lookup: new InMemoryPageIdentifiersLookup(),
+ loadedPage: $this->pageWithTheSubject(),
+ authorized: true,
+ );
+
+ $this->assertNull( $exporter->exportBySubjectId( new SubjectId( self::SUBJECT_ID ), RdfFormat::TriG ) );
+ }
+
+ public function testReturnsNullWhenTheHostingPageIsNotReadable(): void {
+ $exporter = $this->exporter(
+ lookup: $this->lookupResolvingTheSubject(),
+ loadedPage: $this->pageWithTheSubject(),
+ authorized: false,
+ );
+
+ $this->assertNull( $exporter->exportBySubjectId( new SubjectId( self::SUBJECT_ID ), RdfFormat::TriG ) );
+ }
+
+ public function testReturnsNullWhenTheHostingPageCannotBeLoaded(): void {
+ $exporter = $this->exporter(
+ lookup: $this->lookupResolvingTheSubject(),
+ loadedPage: null,
+ authorized: true,
+ );
+
+ $this->assertNull( $exporter->exportBySubjectId( new SubjectId( self::SUBJECT_ID ), RdfFormat::TriG ) );
+ }
+
+ public function testReturnsNullWhenTheGraphPointsToAPageThatNoLongerHasTheSubject(): void {
+ // Stale graph: it resolves the Subject to a page whose current revision no longer carries it.
+ $exporter = $this->exporter(
+ lookup: $this->lookupResolvingTheSubject(),
+ loadedPage: TestPage::build( id: 42, mainSubject: TestSubject::build( id: self::OTHER_ID ) ),
+ authorized: true,
+ );
+
+ $this->assertNull( $exporter->exportBySubjectId( new SubjectId( self::SUBJECT_ID ), RdfFormat::TriG ) );
+ }
+
+ public function testSerializesTheProjectedSubjectWhenItIsPresentAndReadable(): void {
+ $quads = new QuadList(
+ new Quad(
+ new Iri( 'https://wiki.example/entity/' . self::SUBJECT_ID ),
+ new Iri( 'http://www.w3.org/2000/01/rdf-schema#label' ),
+ RdfLiteralFactory::typed( 'ACME Corp', 'string' ),
+ new Iri( 'https://wiki.example/graph/native/page/42' ),
+ )
+ );
+
+ $document = $this->exporter(
+ lookup: $this->lookupResolvingTheSubject(),
+ loadedPage: $this->pageWithTheSubject(),
+ authorized: true,
+ projector: new FixedPageProjector( $quads ),
+ )->exportBySubjectId( new SubjectId( self::SUBJECT_ID ), RdfFormat::TriG );
+
+ $this->assertSame(
+ ( new HardfRdfSerializer( [] ) )->serialize( $quads, RdfFormat::TriG ),
+ $document,
+ 'The document is the projected Subject serialized in the requested format.'
+ );
+ }
+
+ private function exporter(
+ InMemoryPageIdentifiersLookup $lookup,
+ ?Page $loadedPage,
+ bool $authorized,
+ ?PageProjector $projector = null
+ ): RdfSubjectExporter {
+ return new RdfSubjectExporter(
+ $lookup,
+ $this->fixedLoader( $loadedPage ),
+ $projector ?? new FixedPageProjector( new QuadList() ),
+ new HardfRdfSerializer( [] ),
+ new StubPageReadAuthorizer( $authorized ),
+ );
+ }
+
+ private function lookupResolvingTheSubject(): InMemoryPageIdentifiersLookup {
+ return new InMemoryPageIdentifiersLookup( [
+ [ new SubjectId( self::SUBJECT_ID ), new PageIdentifiers( new PageId( 42 ), 'ACME Corp', 0 ) ],
+ ] );
+ }
+
+ private function pageWithTheSubject(): Page {
+ return TestPage::build( id: 42, mainSubject: TestSubject::build( id: self::SUBJECT_ID ) );
+ }
+
+ private function fixedLoader( ?Page $page ): RdfPageLoader {
+ return new class( $page ) extends RdfPageLoader {
+
+ public function __construct( private readonly ?Page $page ) {
+ }
+
+ public function loadByPageId( PageId $pageId ): ?Page {
+ return $this->page;
+ }
+
+ };
+ }
+
+}
diff --git a/tests/phpunit/EntryPoints/Actions/SubjectsActionTest.php b/tests/phpunit/EntryPoints/Actions/SubjectsActionTest.php
index 4347d4621..b58a846ce 100644
--- a/tests/phpunit/EntryPoints/Actions/SubjectsActionTest.php
+++ b/tests/phpunit/EntryPoints/Actions/SubjectsActionTest.php
@@ -4,14 +4,22 @@
namespace ProfessionalWiki\NeoWiki\Tests\EntryPoints\Actions;
+use Article;
+use MediaWiki\Context\RequestContext;
+use MediaWiki\Output\OutputPage;
+use MediaWiki\Permissions\Authority;
use MediaWiki\Title\Title;
-use MediaWikiIntegrationTestCase;
use ProfessionalWiki\NeoWiki\EntryPoints\Actions\SubjectsAction;
+use ProfessionalWiki\NeoWiki\Tests\NeoWikiIntegrationTestCase;
+use ProfessionalWiki\NeoWiki\Tests\NeoWikiMockAuthorityTrait;
/**
* @covers \ProfessionalWiki\NeoWiki\EntryPoints\Actions\SubjectsAction
+ * @group Database
*/
-class SubjectsActionTest extends MediaWikiIntegrationTestCase {
+class SubjectsActionTest extends NeoWikiIntegrationTestCase {
+
+ use NeoWikiMockAuthorityTrait;
public function testNullTitleIsNotEligible(): void {
$this->assertFalse( SubjectsAction::isEligibleTitle( null ) );
@@ -43,4 +51,42 @@ public function testTitleInContentNamespaceIsEligible(): void {
$this->assertTrue( SubjectsAction::isEligibleTitle( $title ) );
}
+ public function testExposesReadableRdfProjectionsAsConfigVar(): void {
+ $this->createMapping( 'EDM', '{ "version": 1, "schemas": {} }' );
+
+ $out = $this->runOnView( 'SubjectsActionTest projections', $this->getTestSysop()->getAuthority() );
+
+ $this->assertSame(
+ [ 'native', 'EDM' ],
+ $out->getJsConfigVars()['wgNeoWikiRdfProjections']
+ );
+ }
+
+ public function testOmitsRdfProjectionsTheViewingUserCannotRead(): void {
+ $this->createMapping( 'EDM', '{ "version": 1, "schemas": {} }' );
+
+ $out = $this->runOnView(
+ 'SubjectsActionTest restricted',
+ $this->authorityWithGlobalReadButNoPageRead()
+ );
+
+ $this->assertSame(
+ [ 'native' ],
+ $out->getJsConfigVars()['wgNeoWikiRdfProjections'],
+ 'A read-restricted Mapping page name must not reach a reader who cannot see it.'
+ );
+ }
+
+ private function runOnView( string $pageName, Authority $authority ): OutputPage {
+ $title = $this->getExistingTestPage( $pageName )->getTitle();
+
+ $context = new RequestContext();
+ $context->setTitle( $title );
+ $context->setAuthority( $authority );
+
+ ( new SubjectsAction( Article::newFromTitle( $title, $context ), $context ) )->onView();
+
+ return $context->getOutput();
+ }
+
}
diff --git a/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php b/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php
new file mode 100644
index 000000000..f6014fa20
--- /dev/null
+++ b/tests/phpunit/EntryPoints/REST/ExportSubjectRdfApiTest.php
@@ -0,0 +1,310 @@
+setUpNeo4j();
+
+ $this->createSchema(
+ self::SCHEMA,
+ <<pageId = $this->createPageWithSubjects(
+ 'ExportSubjectRdfApiTest_Cities',
+ mainSubject: TestSubject::build(
+ id: self::BERLIN_ID,
+ label: new SubjectLabel( 'Berlin' ),
+ schemaName: new SchemaName( self::SCHEMA ),
+ statements: new StatementList( [
+ TestStatement::build( 'population', '3700000' ),
+ ] )
+ ),
+ childSubjects: new SubjectMap(
+ TestSubject::build(
+ id: self::PARIS_ID,
+ label: new SubjectLabel( 'Paris' ),
+ schemaName: new SchemaName( self::SCHEMA ),
+ statements: new StatementList( [
+ TestStatement::build( 'population', '2100000' ),
+ ] )
+ )
+ ),
+ )->getPage()->getId();
+ }
+
+ /**
+ * @param array $query
+ * @param array $headers
+ */
+ private function export( array $query = [], array $headers = [], ?string $subjectId = null, ?Authority $authority = null ): Response {
+ return $this->executeHandler(
+ new ExportSubjectRdfApi(),
+ new RequestData( [
+ 'method' => 'GET',
+ 'pathParams' => [ 'subjectId' => $subjectId ?? self::BERLIN_ID ],
+ 'queryParams' => $query,
+ 'headers' => $headers,
+ ] ),
+ authority: $authority
+ );
+ }
+
+ public function testDefaultsToTriGWithTheHostingPagesNamedGraph(): void {
+ $response = $this->export();
+ $body = $response->getBody()->getContents();
+
+ $this->assertSame( 200, $response->getStatusCode() );
+ $this->assertSame( 'application/trig; charset=utf-8', $response->getHeaderLine( 'Content-Type' ) );
+ $this->assertStringContainsString( 'Berlin', $body );
+ $this->assertStringEndsWith(
+ '/graph/native/page/' . $this->pageId,
+ $this->soleGraphIn( $body ),
+ 'Every TriG quad belongs to the hosting page\'s named graph.'
+ );
+ }
+
+ public function testFormatParameterSelectsTurtleWithoutANamedGraph(): void {
+ $response = $this->export( query: [ 'format' => 'turtle' ] );
+ $body = $response->getBody()->getContents();
+
+ $this->assertSame( 200, $response->getStatusCode() );
+ $this->assertSame( 'text/turtle; charset=utf-8', $response->getHeaderLine( 'Content-Type' ) );
+ $this->assertStringContainsString( 'Berlin', $body );
+ $this->assertSame( [ '' ], $this->graphsIn( $body ), 'Turtle drops the named graph.' );
+ }
+
+ public function testFormatParameterOverridesTheAcceptHeader(): void {
+ $response = $this->export( query: [ 'format' => 'trig' ], headers: [ 'Accept' => 'text/turtle' ] );
+
+ $this->assertSame( 200, $response->getStatusCode() );
+ $this->assertSame( 'application/trig; charset=utf-8', $response->getHeaderLine( 'Content-Type' ) );
+ }
+
+ public function testAcceptHeaderSelectsTurtle(): void {
+ $response = $this->export( headers: [ 'Accept' => 'text/turtle' ] );
+
+ $this->assertSame( 200, $response->getStatusCode() );
+ $this->assertSame( 'text/turtle; charset=utf-8', $response->getHeaderLine( 'Content-Type' ) );
+ $this->assertSame( [ '' ], $this->graphsIn( $response->getBody()->getContents() ) );
+ }
+
+ public function testAcceptHeaderSelectsTriG(): void {
+ $response = $this->export( headers: [ 'Accept' => 'application/trig' ] );
+
+ $this->assertSame( 200, $response->getStatusCode() );
+ $this->assertSame( 'application/trig; charset=utf-8', $response->getHeaderLine( 'Content-Type' ) );
+ }
+
+ public function testReturns404ForAnUnknownSubject(): void {
+ $response = $this->export( subjectId: self::ABSENT_ID );
+
+ $this->assertSame( 404, $response->getStatusCode() );
+ $this->assertStringContainsString(
+ 'No NeoWiki data found for subject: ' . self::ABSENT_ID,
+ $response->getBody()->getContents()
+ );
+ }
+
+ public function testSubjectOnAnUnreadablePageIsByteIdenticalToAnAbsentSubject(): void {
+ // An existing Subject whose hosting page the caller cannot read must answer exactly like an
+ // absent Subject, so the endpoint cannot confirm a harvested Subject id exists (#1046). Only the
+ // Subject id echoed in the message differs between the two.
+ $denied = $this->export( authority: $this->authorityWithGlobalReadButNoPageRead() );
+ $absent = $this->export( subjectId: self::ABSENT_ID );
+
+ $this->assertSame( 404, $denied->getStatusCode() );
+ $this->assertSame( $absent->getStatusCode(), $denied->getStatusCode() );
+ $this->assertSame(
+ $absent->getHeaderLine( 'Content-Type' ),
+ $denied->getHeaderLine( 'Content-Type' )
+ );
+ $this->assertSame(
+ str_replace( self::ABSENT_ID, self::BERLIN_ID, $absent->getBody()->getContents() ),
+ $denied->getBody()->getContents()
+ );
+ }
+
+ public function testUnknownProjectionReturns400EvenForAnAbsentSubject(): void {
+ // The projection check runs before the Subject is resolved, so a caller cannot use a valid vs
+ // invalid projection to probe whether a Subject exists.
+ $response = $this->export(
+ query: [ 'projection' => 'no-such-projection' ],
+ subjectId: self::ABSENT_ID
+ );
+
+ $this->assertSame( 400, $response->getStatusCode() );
+ }
+
+ public function testReturns400ForAMalformedSubjectId(): void {
+ $response = $this->export( subjectId: 'not-a-valid-id' );
+
+ $this->assertSame( 400, $response->getStatusCode() );
+ }
+
+ public function testReadRestrictedMappingNamesAreAbsentFromTheKnownProjectionsList(): void {
+ // The unknown-projection 400 list must not leak the titles of Mapping pages the caller cannot
+ // read (#1046). A caller who can read the page sees "EDM"; one who cannot must not — though
+ // "native" (not a page) stays listed for everyone.
+ $this->createEdmMapping();
+
+ $readable = $this->export( query: [ 'projection' => 'bogus' ] );
+ $restricted = $this->export(
+ query: [ 'projection' => 'bogus' ],
+ authority: $this->authorityWithGlobalReadButNoPageRead()
+ );
+
+ $this->assertStringContainsString( 'EDM', $readable->getBody()->getContents() );
+
+ $restrictedBody = $restricted->getBody()->getContents();
+ $this->assertStringNotContainsString( 'EDM', $restrictedBody, 'A read-restricted Mapping page name must not leak into the 400 list.' );
+ $this->assertStringContainsString( 'native', $restrictedBody, 'The always-available native projection stays listed.' );
+ }
+
+ public function testOmittedProjectionEqualsExplicitNative(): void {
+ $default = $this->export();
+ $explicit = $this->export( query: [ 'projection' => 'native' ] );
+
+ $this->assertSame( 200, $explicit->getStatusCode() );
+ $this->assertSame(
+ ParsedRdf::canonicalQuads( $default->getBody()->getContents() ),
+ ParsedRdf::canonicalQuads( $explicit->getBody()->getContents() )
+ );
+ }
+
+ public function testValidTargetProjectsTheMappedVocabularyWithNativeSubjectIris(): void {
+ $this->createEdmMapping();
+
+ $response = $this->export( query: [ 'projection' => 'EDM', 'format' => 'turtle' ] );
+ $body = $response->getBody()->getContents();
+
+ $this->assertSame( 200, $response->getStatusCode() );
+ $this->assertStringContainsString( 'europeana.eu/schemas/edm', $body, 'The mapped ontology vocabulary is used.' );
+ $this->assertStringContainsString( self::BERLIN_ID, $body, 'The Subject IRI stays native.' );
+ $this->assertStringNotContainsString( self::SCHEMA, $body, 'No native schema class is emitted.' );
+ }
+
+ public function testReadableSubjectWithoutAMappingForTheTargetReturnsAnEmptyGraph(): void {
+ // "EDM" is a known projection because Berlin's Schema has an entry on the Mapping page, but this
+ // Subject's Schema does not, so its ontology projection is empty. A readable Subject is never
+ // hidden behind a 404, so the response is a 200 empty graph rather than a not-found.
+ $this->createEdmMapping();
+
+ $this->createSchema( 'ExportSubjectRdfApiTestUnmappedSchema' );
+ $this->createPageWithSubjects(
+ 'ExportSubjectRdfApiTest_Unmapped',
+ mainSubject: TestSubject::build(
+ id: self::UNMAPPED_ID,
+ label: new SubjectLabel( 'Freetown' ),
+ schemaName: new SchemaName( 'ExportSubjectRdfApiTestUnmappedSchema' )
+ )
+ );
+
+ $response = $this->export( query: [ 'projection' => 'EDM', 'format' => 'turtle' ], subjectId: self::UNMAPPED_ID );
+
+ $this->assertSame( 200, $response->getStatusCode() );
+ $this->assertSame(
+ [],
+ ParsedRdf::canonicalQuads( $response->getBody()->getContents() ),
+ 'A readable Subject with no Mapping for the target projects to an empty graph, not a 404.'
+ );
+ }
+
+ public function testReturnsOnlyTheRequestedSubjectsTriples(): void {
+ $body = $this->export()->getBody()->getContents();
+
+ $this->assertStringContainsString( 'Berlin', $body, 'The requested Subject is present.' );
+ $this->assertStringNotContainsString( 'Paris', $body, 'A sibling Subject on the same page is not included.' );
+ $this->assertStringNotContainsString( self::PARIS_ID, $body, 'A sibling Subject IRI is not included.' );
+ }
+
+ private function createEdmMapping(): void {
+ $this->createMapping( 'EDM', <<schemaName()}": {
+ "subject": { "class": "edm:Place" },
+ "properties": {
+ "population": { "predicate": "dc:description" }
+ }
+ }
+ }
+ }
+ JSON );
+ }
+
+ private function schemaName(): string {
+ return self::SCHEMA;
+ }
+
+ /**
+ * The single named graph every quad in the document belongs to.
+ */
+ private function soleGraphIn( string $rdf ): string {
+ $graphs = $this->graphsIn( $rdf );
+
+ $this->assertCount( 1, $graphs, 'A per-Subject projection places every quad in exactly one named graph.' );
+
+ return $graphs[0];
+ }
+
+ /**
+ * The graph value (the fourth field of each canonical quad) of every quad in the parsed document.
+ * Distinguishes TriG (a non-empty page graph on every quad) from Turtle (the empty default graph).
+ *
+ * @return list
+ */
+ private function graphsIn( string $rdf ): array {
+ return array_values( array_unique( array_map(
+ static fn ( string $line ): string => explode( "\t", $line )[3],
+ ParsedRdf::canonicalQuads( $rdf )
+ ) ) );
+ }
+
+}
diff --git a/tests/phpunit/EntryPoints/RdfAutodiscoveryLinksTest.php b/tests/phpunit/EntryPoints/RdfAutodiscoveryLinksTest.php
new file mode 100644
index 000000000..4b03ec5eb
--- /dev/null
+++ b/tests/phpunit/EntryPoints/RdfAutodiscoveryLinksTest.php
@@ -0,0 +1,83 @@
+setUpNeo4j();
+ $this->createSchema( TestSubject::DEFAULT_SCHEMA_ID );
+ $this->markPageTableAsUsed();
+ }
+
+ public function testAdvertisesTurtleAndTrigExportsForPageWithSubjects(): void {
+ $revision = $this->createPageWithSubjects(
+ 'Autodiscovery with subjects',
+ TestSubject::build( id: self::SUBJECT_ID )
+ );
+
+ $out = $this->renderContentPage( 'Autodiscovery with subjects', $revision->getId() );
+ $pageId = $revision->getPageId();
+
+ $turtle = $this->alternateLinkHref( $out, 'text/turtle' );
+ $trig = $this->alternateLinkHref( $out, 'application/trig' );
+
+ $this->assertNotNull( $turtle, 'A text/turtle alternate link is expected on a page with Subjects.' );
+ $this->assertNotNull( $trig, 'An application/trig alternate link is expected on a page with Subjects.' );
+ $this->assertStringContainsString( "/neowiki/v0/page/{$pageId}/rdf?projection=native&format=turtle", $turtle );
+ $this->assertStringContainsString( "/neowiki/v0/page/{$pageId}/rdf?projection=native&format=trig", $trig );
+ // Absolute (canonical) URLs, not relative paths, so Linked Data tooling can dereference them.
+ $this->assertStringStartsWith( 'http', $turtle );
+ $this->assertStringStartsWith( 'http', $trig );
+ }
+
+ public function testDoesNotAdvertiseExportsForPageWithoutSubjects(): void {
+ $this->editPage( 'Autodiscovery without subjects', 'Plain page, no NeoWiki data.' );
+ $revisionId = Title::newFromText( 'Autodiscovery without subjects' )->getLatestRevID();
+
+ $out = $this->renderContentPage( 'Autodiscovery without subjects', $revisionId );
+
+ $this->assertNull( $this->alternateLinkHref( $out, 'text/turtle' ) );
+ $this->assertNull( $this->alternateLinkHref( $out, 'application/trig' ) );
+ }
+
+ private function renderContentPage( string $pageName, int $revisionId ): OutputPage {
+ $context = new RequestContext();
+ $context->setTitle( Title::newFromText( $pageName ) );
+
+ $out = $context->getOutput();
+ $out->setArticleFlag( true );
+ $out->setRevisionId( $revisionId );
+
+ NeoWikiHooks::onBeforePageDisplay( $out, $context->getSkin() );
+
+ return $out;
+ }
+
+ private function alternateLinkHref( OutputPage $out, string $type ): ?string {
+ foreach ( $out->getLinkTags() as $tag ) {
+ if ( ( $tag['rel'] ?? null ) === 'alternate' && ( $tag['type'] ?? null ) === $type ) {
+ return $tag['href'] ?? null;
+ }
+ }
+
+ return null;
+ }
+
+}
diff --git a/tests/phpunit/TestDoubles/FixedPageProjector.php b/tests/phpunit/TestDoubles/FixedPageProjector.php
index 090e30dc0..245d24da0 100644
--- a/tests/phpunit/TestDoubles/FixedPageProjector.php
+++ b/tests/phpunit/TestDoubles/FixedPageProjector.php
@@ -7,6 +7,7 @@
use ProfessionalWiki\NeoWiki\Application\Rdf\PageProjector;
use ProfessionalWiki\NeoWiki\Domain\Page\Page;
use ProfessionalWiki\NeoWiki\Domain\Rdf\QuadList;
+use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
/**
* A {@see PageProjector} that returns a preset {@see QuadList} regardless of the page, so tests can
@@ -23,4 +24,8 @@ public function projectPage( Page $page ): QuadList {
return $this->quads;
}
+ public function projectSubject( Page $page, SubjectId $subjectId ): QuadList {
+ return $this->quads;
+ }
+
}