Skip to content

Commit ae98bfe

Browse files
authored
Merge pull request #1090 from ProfessionalWiki/feature/subject-rdf-export
Add per-subject RDF export endpoint
2 parents 471bd58 + f5c5edb commit ae98bfe

25 files changed

Lines changed: 1424 additions & 72 deletions

docs/api/rest-api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Read, change, and validate Subjects. New Subjects are created on a page — see
4848
| Endpoint | Description |
4949
|---|---|
5050
| `GET /neowiki/v0/subject/{subjectId}` | Fetch a Subject. Optional `revisionId`; `expand` with `page` or `relations`. |
51+
| `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). |
5152
| `PUT /neowiki/v0/subject/{subjectId}` | Replace a Subject's label and statements. |
5253
| `DELETE /neowiki/v0/subject/{subjectId}` | Delete a Subject. |
5354
| `POST /neowiki/v0/subject/validate` | Check whether a new Subject is valid, without saving it. |

docs/rdf/rdf-export.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,26 +64,51 @@ describe the same set of entities. A warning is logged for each omitted Subject.
6464

6565
## Endpoint
6666

67-
`GET /rest.php/neowiki/v0/page/{pageId}/rdf`
68-
69-
Returns the page's projection. The `projection` query parameter selects the vocabulary: `native` (the
70-
default, described here) or the name of a Mapping page — see
71-
[Ontology Mapping](ontology-mapping.md). An unknown projection returns `400`.
72-
73-
The format is chosen by the `format` query parameter, falling back to the `Accept` header, then to
74-
TriG:
67+
RDF is served per page or per Subject. Both take the same `projection` and `format` query parameters.
68+
The `projection` selects the vocabulary: `native` (the default, described here) or the name of a
69+
Mapping page — see [Ontology Mapping](ontology-mapping.md); an unknown projection returns `400`. The
70+
`format` picks the serialization, falling back to the `Accept` header, then to TriG:
7571

7672
| `format` | `Accept` | Content-Type | Named graph |
7773
|---|---|---|---|
7874
| `trig` (default) | `application/trig` | `application/trig; charset=utf-8` | yes |
7975
| `turtle` | `text/turtle` | `text/turtle; charset=utf-8` | no (same triples, no graph wrapper) |
8076

77+
### Page
78+
79+
`GET /rest.php/neowiki/v0/page/{pageId}/rdf`
80+
81+
Returns the page's projection: its page metadata and every Subject on it, in the page's named graph.
8182
Returns `404` when the page does not exist or has no NeoWiki Subject data.
8283

8384
```sh
8485
curl 'https://wiki.example/rest.php/neowiki/v0/page/42/rdf?format=turtle'
8586
```
8687

88+
### Subject
89+
90+
`GET /rest.php/neowiki/v0/subject/{subjectId}/rdf`
91+
92+
Returns one Subject's projection: exactly the triples the page export emits for that Subject — its
93+
outbound description, including the native relation reification — with none of the page-metadata
94+
triples, in the hosting page's named graph. Inbound relations pointing at the Subject from elsewhere
95+
are not included.
96+
97+
Returns `404` when the Subject does not exist or is on a page the caller may not read — the two are
98+
indistinguishable. A malformed Subject ID returns `400`. A readable Subject whose Schema has no mapping
99+
for the requested ontology target projects to an empty graph — a `200`, not a `404`.
100+
101+
```sh
102+
curl 'https://wiki.example/rest.php/neowiki/v0/subject/s1demo8aaaaaab5/rdf?projection=EDM'
103+
```
104+
105+
### Finding these exports
106+
107+
These exports are surfaced in the UI: the Data tab (`?action=subjects`) links to each Subject's JSON and
108+
per-projection Turtle/TriG, and to the same for the whole page. Pages that carry NeoWiki data also
109+
advertise the page export through `<link rel="alternate">` autodiscovery tags (Turtle and TriG, native
110+
projection) in the HTML head, so Linked Data tooling can locate the data without reading this reference.
111+
87112
## Bulk dump
88113

89114
`maintenance/DumpRdf.php` streams the projection of **every** subject page to stdout as TriG, one named

extension.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,11 @@
254254
"method": [ "DELETE" ],
255255
"factory": "ProfessionalWiki\\NeoWiki\\NeoWikiExtension::newDeleteSubjectApi"
256256
},
257+
{
258+
"path": "/neowiki/v0/subject/{subjectId}/rdf",
259+
"method": [ "GET" ],
260+
"factory": "ProfessionalWiki\\NeoWiki\\NeoWikiExtension::newExportSubjectRdfApi"
261+
},
257262
{
258263
"path": "/neowiki/v0/schema/{schemaName}",
259264
"method": [ "GET" ],
@@ -586,7 +591,12 @@
586591
"neowiki-managesubjects-id-label",
587592
"neowiki-managesubjects-id-copy",
588593
"neowiki-managesubjects-id-copied",
589-
"neowiki-managesubjects-id-copy-error"
594+
"neowiki-managesubjects-id-copy-error",
595+
"neowiki-managesubjects-export-button",
596+
"neowiki-managesubjects-export-json",
597+
"neowiki-managesubjects-export-native",
598+
"neowiki-managesubjects-export-turtle",
599+
"neowiki-managesubjects-export-trig"
590600
],
591601
"@group:": "TODO: Load code separately while in development. Remove later.",
592602
"group": "ext.neowiki"

i18n/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,5 +305,10 @@
305305
"neowiki-managesubjects-id-copy": "Copy subject ID $1",
306306
"neowiki-managesubjects-id-copied": "Copied \"$1\" to clipboard.",
307307
"neowiki-managesubjects-id-copy-error": "Couldn't copy the subject ID.",
308+
"neowiki-managesubjects-export-button": "Export",
309+
"neowiki-managesubjects-export-json": "JSON",
310+
"neowiki-managesubjects-export-native": "Native",
311+
"neowiki-managesubjects-export-turtle": "$1 · Turtle",
312+
"neowiki-managesubjects-export-trig": "$1 · TriG",
308313
"action-subjects": "manage subjects on this page"
309314
}

i18n/qqq.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,5 +236,10 @@
236236
"neowiki-managesubjects-id-copy": "Tooltip on the subject ID button. $1 is the Subject ID. Click copies the ID to the clipboard.",
237237
"neowiki-managesubjects-id-copied": "Notification shown after the subject ID was copied. $1 is the Subject ID.",
238238
"neowiki-managesubjects-id-copy-error": "Notification shown when copying the subject ID to the clipboard failed.",
239+
"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).",
240+
"neowiki-managesubjects-export-json": "Export menu item that downloads the raw JSON representation of the Subject or page.",
241+
"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}}.",
242+
"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.",
243+
"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.",
239244
"action-subjects": "Description of the Subject management action used by MediaWiki when generating action descriptions."
240245
}

resources/ext.neowiki/src/components/SubjectsManager/SubjectsManagerPage.vue

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,28 @@
88
{{ $i18n( 'neowiki-managesubjects-count', subjects.length ).text() }}
99
</span>
1010
<span v-else class="ext-neowiki-subjects-manager__count" />
11-
<CdxButton
12-
v-if="canCreate && !isCompletelyEmpty"
13-
weight="primary"
14-
action="progressive"
15-
@click="onAddClicked"
16-
>
17-
<CdxIcon :icon="cdxIconAdd" />
18-
{{ $i18n( 'neowiki-managesubjects-add-button' ).text() }}
19-
</CdxButton>
11+
<div class="ext-neowiki-subjects-manager__controls-actions">
12+
<CdxMenuButton
13+
v-if="!loading && subjects.length > 0"
14+
v-model:selected="exportMenuSelection"
15+
class="ext-neowiki-subjects-manager__export-menu"
16+
:menu-items="pageExportItems"
17+
:aria-label="$i18n( 'neowiki-managesubjects-export-button' ).text()"
18+
@update:selected="openExport"
19+
>
20+
<CdxIcon :icon="cdxIconDownload" />
21+
{{ $i18n( 'neowiki-managesubjects-export-button' ).text() }}
22+
</CdxMenuButton>
23+
<CdxButton
24+
v-if="canCreate && !isCompletelyEmpty"
25+
weight="primary"
26+
action="progressive"
27+
@click="onAddClicked"
28+
>
29+
<CdxIcon :icon="cdxIconAdd" />
30+
{{ $i18n( 'neowiki-managesubjects-add-button' ).text() }}
31+
</CdxButton>
32+
</div>
2033
</div>
2134

2235
<p
@@ -174,6 +187,16 @@
174187
</data>
175188
</button>
176189
</span>
190+
<CdxMenuButton
191+
v-model:selected="exportMenuSelection"
192+
class="ext-neowiki-subjects-manager__export-menu"
193+
:menu-items="subjectExportItems( mainSubject as Subject )"
194+
:aria-label="$i18n( 'neowiki-managesubjects-export-button' ).text()"
195+
@update:selected="openExport"
196+
>
197+
<CdxIcon :icon="cdxIconDownload" />
198+
{{ $i18n( 'neowiki-managesubjects-export-button' ).text() }}
199+
</CdxMenuButton>
177200
</footer>
178201
</div>
179202
</details>
@@ -320,6 +343,16 @@
320343
</data>
321344
</button>
322345
</span>
346+
<CdxMenuButton
347+
v-model:selected="exportMenuSelection"
348+
class="ext-neowiki-subjects-manager__export-menu"
349+
:menu-items="subjectExportItems( subject )"
350+
:aria-label="$i18n( 'neowiki-managesubjects-export-button' ).text()"
351+
@update:selected="openExport"
352+
>
353+
<CdxIcon :icon="cdxIconDownload" />
354+
{{ $i18n( 'neowiki-managesubjects-export-button' ).text() }}
355+
</CdxMenuButton>
323356
</footer>
324357
</div>
325358
</details>
@@ -386,6 +419,7 @@ import type { MenuButtonItemData } from '@wikimedia/codex';
386419
import {
387420
cdxIconAdd,
388421
cdxIconCollapse,
422+
cdxIconDownload,
389423
cdxIconDraggable,
390424
cdxIconEdit,
391425
cdxIconEllipsis,
@@ -406,9 +440,14 @@ import EditSummary from '@/components/common/EditSummary.vue';
406440
import I18nSlot from '@/components/common/I18nSlot.vue';
407441
import SubjectStatementsView from '@/components/SubjectsManager/SubjectStatementsView.vue';
408442
import { setPendingNotification } from '@/presentation/PendingNotification.ts';
443+
import { subjectExportMenuItems, pageExportMenuItems } from '@/presentation/DataExportMenu.ts';
409444
410445
const pageId = Number( mw.config.get( 'wgNeoWikiManageSubjectsPageId' ) );
411446
447+
// RDF projections readable by the viewing user (native + ontology mappings), permission-filtered
448+
// server-side. Drives the export menus; native is always present, so this is never truly empty.
449+
const rdfProjections = ( mw.config.get( 'wgNeoWikiRdfProjections' ) as string[] | null ) ?? [];
450+
412451
const subjectStore = useSubjectStore();
413452
const schemaStore = useSchemaStore();
414453
const {
@@ -553,6 +592,26 @@ function dispatchRowAction( value: string | number | null, subject: Subject ): v
553592
}
554593
}
555594
595+
const exportMenuSelection = ref<string | number | null>( null );
596+
597+
function subjectExportItems( subject: Subject ): MenuButtonItemData[] {
598+
return subjectExportMenuItems( subject.getId().text, rdfProjections );
599+
}
600+
601+
const pageExportItems = computed<MenuButtonItemData[]>(
602+
() => pageExportMenuItems( pageId, rdfProjections )
603+
);
604+
605+
// The menu item value is the export endpoint URL; open it in a new tab so the Data tab UI state
606+
// (expanded rows, edits in progress) survives. Codex 1.14 menu-item anchors cannot set a target,
607+
// so we navigate on selection rather than rely on the item's `url`.
608+
function openExport( value: string | number | null ): void {
609+
exportMenuSelection.value = null;
610+
if ( typeof value === 'string' && value !== '' ) {
611+
window.open( value, '_blank', 'noopener' );
612+
}
613+
}
614+
556615
function toggleExpanded( id: string ): void {
557616
const next = new Set( expandedIds.value );
558617
if ( next.has( id ) ) {
@@ -799,6 +858,12 @@ onUnmounted( () => {
799858
margin-bottom: @spacing-150;
800859
}
801860
861+
&__controls-actions {
862+
display: flex;
863+
align-items: center;
864+
gap: @spacing-100;
865+
}
866+
802867
&__count {
803868
color: @color-subtle;
804869
}
@@ -1104,7 +1169,9 @@ onUnmounted( () => {
11041169
11051170
&__row-footer {
11061171
display: flex;
1107-
justify-content: flex-start;
1172+
flex-wrap: wrap;
1173+
align-items: center;
1174+
justify-content: space-between;
11081175
gap: @spacing-100;
11091176
margin-top: @spacing-100;
11101177
padding-top: @spacing-75;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { MenuButtonItemData } from '@wikimedia/codex';
2+
3+
/**
4+
* Builds the export menu items for the Data tab: a JSON entry, then Turtle and TriG entries for each
5+
* RDF projection the viewing user may read (native first, then ontology mappings). Item values are the
6+
* full endpoint URLs; the menu navigates to them on selection. Kept as pure functions so the URL and
7+
* label derivation is unit-tested independently of the Vue component.
8+
*/
9+
10+
interface RdfFormatOption {
11+
format: string;
12+
messageKey: string;
13+
}
14+
15+
const RDF_FORMATS: readonly RdfFormatOption[] = [
16+
{ format: 'turtle', messageKey: 'neowiki-managesubjects-export-turtle' },
17+
{ format: 'trig', messageKey: 'neowiki-managesubjects-export-trig' },
18+
];
19+
20+
const NATIVE_PROJECTION = 'native';
21+
22+
function restApiBase(): string {
23+
return mw.util.wikiScript( 'rest' );
24+
}
25+
26+
function projectionLabel( projection: string ): string {
27+
return projection === NATIVE_PROJECTION ?
28+
mw.msg( 'neowiki-managesubjects-export-native' ) :
29+
projection;
30+
}
31+
32+
function exportMenuItems(
33+
jsonUrl: string,
34+
rdfEndpoint: string,
35+
projections: readonly string[],
36+
): MenuButtonItemData[] {
37+
const items: MenuButtonItemData[] = [
38+
{ value: jsonUrl, label: mw.msg( 'neowiki-managesubjects-export-json' ) },
39+
];
40+
41+
for ( const projection of projections ) {
42+
for ( const { format, messageKey } of RDF_FORMATS ) {
43+
items.push( {
44+
value: `${ rdfEndpoint }?projection=${ encodeURIComponent( projection ) }&format=${ format }`,
45+
label: mw.msg( messageKey, projectionLabel( projection ) ),
46+
} );
47+
}
48+
}
49+
50+
return items;
51+
}
52+
53+
export function subjectExportMenuItems(
54+
subjectId: string,
55+
projections: readonly string[],
56+
): MenuButtonItemData[] {
57+
const base = `${ restApiBase() }/neowiki/v0/subject/${ encodeURIComponent( subjectId ) }`;
58+
return exportMenuItems( base, `${ base }/rdf`, projections );
59+
}
60+
61+
export function pageExportMenuItems(
62+
pageId: number,
63+
projections: readonly string[],
64+
): MenuButtonItemData[] {
65+
const base = `${ restApiBase() }/neowiki/v0/page/${ pageId }`;
66+
return exportMenuItems( `${ base }/subjects`, `${ base }/rdf`, projections );
67+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { subjectExportMenuItems, pageExportMenuItems } from '@/presentation/DataExportMenu';
3+
4+
describe( 'DataExportMenu', () => {
5+
beforeEach( () => {
6+
vi.stubGlobal( 'mw', {
7+
util: { wikiScript: vi.fn( () => '/w/rest.php' ) },
8+
msg: vi.fn( ( key: string, ...params: string[] ) =>
9+
params.length > 0 ? `[${ key }|${ params.join( ',' ) }]` : `[${ key }]` ),
10+
} );
11+
} );
12+
13+
describe( 'subjectExportMenuItems', () => {
14+
it( 'lists JSON first, then Turtle and TriG for each projection in order', () => {
15+
const items = subjectExportMenuItems( 's2picasso2aaaa2', [ 'native', 'EDM' ] );
16+
17+
expect( items.map( ( item ) => item.value ) ).toEqual( [
18+
'/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2',
19+
'/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=native&format=turtle',
20+
'/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=native&format=trig',
21+
'/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=EDM&format=turtle',
22+
'/w/rest.php/neowiki/v0/subject/s2picasso2aaaa2/rdf?projection=EDM&format=trig',
23+
] );
24+
} );
25+
26+
it( 'labels JSON, the native projection, and mapping projections distinctly', () => {
27+
const items = subjectExportMenuItems( 's2picasso2aaaa2', [ 'native', 'EDM' ] );
28+
29+
expect( items.map( ( item ) => item.label ) ).toEqual( [
30+
'[neowiki-managesubjects-export-json]',
31+
'[neowiki-managesubjects-export-turtle|[neowiki-managesubjects-export-native]]',
32+
'[neowiki-managesubjects-export-trig|[neowiki-managesubjects-export-native]]',
33+
'[neowiki-managesubjects-export-turtle|EDM]',
34+
'[neowiki-managesubjects-export-trig|EDM]',
35+
] );
36+
} );
37+
38+
it( 'percent-encodes the projection name in the RDF URLs', () => {
39+
const items = subjectExportMenuItems( 's1aaaaaaaaaaaa1', [ 'Wikidata items' ] );
40+
41+
expect( items[ 1 ].value ).toBe(
42+
'/w/rest.php/neowiki/v0/subject/s1aaaaaaaaaaaa1/rdf?projection=Wikidata%20items&format=turtle',
43+
);
44+
} );
45+
46+
it( 'returns only the JSON entry when there are no readable projections', () => {
47+
const items = subjectExportMenuItems( 's1aaaaaaaaaaaa1', [] );
48+
49+
expect( items ).toHaveLength( 1 );
50+
expect( items[ 0 ].value ).toBe( '/w/rest.php/neowiki/v0/subject/s1aaaaaaaaaaaa1' );
51+
} );
52+
} );
53+
54+
describe( 'pageExportMenuItems', () => {
55+
it( 'targets the page subjects JSON and the page RDF endpoint', () => {
56+
const items = pageExportMenuItems( 42, [ 'native', 'EDM' ] );
57+
58+
expect( items.map( ( item ) => item.value ) ).toEqual( [
59+
'/w/rest.php/neowiki/v0/page/42/subjects',
60+
'/w/rest.php/neowiki/v0/page/42/rdf?projection=native&format=turtle',
61+
'/w/rest.php/neowiki/v0/page/42/rdf?projection=native&format=trig',
62+
'/w/rest.php/neowiki/v0/page/42/rdf?projection=EDM&format=turtle',
63+
'/w/rest.php/neowiki/v0/page/42/rdf?projection=EDM&format=trig',
64+
] );
65+
} );
66+
} );
67+
} );

0 commit comments

Comments
 (0)