Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
41 changes: 33 additions & 8 deletions docs/rdf/rdf-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<link rel="alternate">` 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
Expand Down
12 changes: 11 additions & 1 deletion extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" ],
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 5 additions & 0 deletions i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,28 @@
{{ $i18n( 'neowiki-managesubjects-count', subjects.length ).text() }}
</span>
<span v-else class="ext-neowiki-subjects-manager__count" />
<CdxButton
v-if="canCreate && !isCompletelyEmpty"
weight="primary"
action="progressive"
@click="onAddClicked"
>
<CdxIcon :icon="cdxIconAdd" />
{{ $i18n( 'neowiki-managesubjects-add-button' ).text() }}
</CdxButton>
<div class="ext-neowiki-subjects-manager__controls-actions">
<CdxMenuButton
v-if="!loading && subjects.length > 0"
v-model:selected="exportMenuSelection"
class="ext-neowiki-subjects-manager__export-menu"
:menu-items="pageExportItems"
:aria-label="$i18n( 'neowiki-managesubjects-export-button' ).text()"
@update:selected="openExport"
>
<CdxIcon :icon="cdxIconDownload" />
{{ $i18n( 'neowiki-managesubjects-export-button' ).text() }}
</CdxMenuButton>
<CdxButton
v-if="canCreate && !isCompletelyEmpty"
weight="primary"
action="progressive"
@click="onAddClicked"
>
<CdxIcon :icon="cdxIconAdd" />
{{ $i18n( 'neowiki-managesubjects-add-button' ).text() }}
</CdxButton>
</div>
</div>

<p
Expand Down Expand Up @@ -174,6 +187,16 @@
</data>
</button>
</span>
<CdxMenuButton
v-model:selected="exportMenuSelection"
class="ext-neowiki-subjects-manager__export-menu"
:menu-items="subjectExportItems( mainSubject as Subject )"
:aria-label="$i18n( 'neowiki-managesubjects-export-button' ).text()"
@update:selected="openExport"
>
<CdxIcon :icon="cdxIconDownload" />
{{ $i18n( 'neowiki-managesubjects-export-button' ).text() }}
</CdxMenuButton>
</footer>
</div>
</details>
Expand Down Expand Up @@ -320,6 +343,16 @@
</data>
</button>
</span>
<CdxMenuButton
v-model:selected="exportMenuSelection"
class="ext-neowiki-subjects-manager__export-menu"
:menu-items="subjectExportItems( subject )"
:aria-label="$i18n( 'neowiki-managesubjects-export-button' ).text()"
@update:selected="openExport"
>
<CdxIcon :icon="cdxIconDownload" />
{{ $i18n( 'neowiki-managesubjects-export-button' ).text() }}
</CdxMenuButton>
</footer>
</div>
</details>
Expand Down Expand Up @@ -386,6 +419,7 @@ import type { MenuButtonItemData } from '@wikimedia/codex';
import {
cdxIconAdd,
cdxIconCollapse,
cdxIconDownload,
cdxIconDraggable,
cdxIconEdit,
cdxIconEllipsis,
Expand All @@ -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 {
Expand Down Expand Up @@ -553,6 +592,26 @@ function dispatchRowAction( value: string | number | null, subject: Subject ): v
}
}

const exportMenuSelection = ref<string | number | null>( null );

function subjectExportItems( subject: Subject ): MenuButtonItemData[] {
return subjectExportMenuItems( subject.getId().text, rdfProjections );
}

const pageExportItems = computed<MenuButtonItemData[]>(
() => 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 ) ) {
Expand Down Expand Up @@ -799,6 +858,12 @@ onUnmounted( () => {
margin-bottom: @spacing-150;
}

&__controls-actions {
display: flex;
align-items: center;
gap: @spacing-100;
}

&__count {
color: @color-subtle;
}
Expand Down Expand Up @@ -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;
Expand Down
67 changes: 67 additions & 0 deletions resources/ext.neowiki/src/presentation/DataExportMenu.ts
Original file line number Diff line number Diff line change
@@ -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 );
}
67 changes: 67 additions & 0 deletions resources/ext.neowiki/tests/presentation/DataExportMenu.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
] );
} );
} );
} );
Loading
Loading