Skip to content
Draft
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
6 changes: 4 additions & 2 deletions docs/api/graph-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,15 @@ their Schema (e.g., `:Subject:Person`, `:Subject:Company`). The Schema label cha

| Property | Neo4j Type | Description |
|----------|------------|-------------|
| `id` | string | Subject ID, 15 characters starting with `s` (unique) |
| `id` | string | Subject ID as stored: the bare local id, 15 characters starting with `s` (unique) |
| `name` | string | Subject label |
| `wiki_id` | string | [MediaWiki Wiki ID](https://www.mediawiki.org/wiki/Manual:Wiki_ID) of the wiki that owns the Subject |

Unlike page ids, Subject ids are globally unique nanoids ([ADR 14](../adr/014-improved-id-format.md)), so a Subject's
identity is its `id` alone. The `wiki_id` is carried only for per-wiki query filtering in a shared graph; Subject-id
namespacing is deferred ([ADR 22](../adr/022-multi-wiki-node-identity.md)).
namespacing is deferred ([ADR 22](../adr/022-multi-wiki-node-identity.md)). The source-qualified `(source, localId)`
reference form ([ADR 23](../adr/023-subject-sources.md)) is derived at the system boundaries; Subject nodes keep
storing the bare id.

### Dynamic properties

Expand Down
4 changes: 4 additions & 0 deletions docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ Read, change, and validate Subjects. New Subjects are created on a page — see
| `POST /neowiki/v0/subject/{subjectId}/validate` | Check whether a change to a Subject is valid, without saving it. |
| `GET /neowiki/v0/subject-labels` | Find Subjects of a Schema by label; returns `id`/`label` pairs. |

`{subjectId}` accepts a Subject id in either form described in
[Subject format](subject-format.md#id-formats): the bare local form or the source-qualified
`source:localId` form. An id that explicitly names the local wiki's source is treated as its bare form.

### Pages and Subjects

A page holds one optional main Subject and an ordered list of child Subjects. These endpoints create
Expand Down
22 changes: 18 additions & 4 deletions docs/api/subject-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Array of Relation objects, each pointing to another subject.
| Field | Type | Required | Description |
|-------|------|-------------|-------------|
| `id` | string | Yes | Unique identifier for this relation |
| `target` | string | Yes | Subject ID of the target subject |
| `target` | string | Yes | Subject ID of the target subject, in either id form (bare means local) |
| `properties` | object | No | Key-value pairs of relation properties. Only included when non-empty. |

Relation properties example:
Expand All @@ -172,10 +172,22 @@ Relation properties example:

### Subject IDs

Subject IDs are 15-character nanoid-style identifiers that are lexicographically sortable by creation time.
They start with `s`.
A Subject id has two textual forms ([ADR 23](../adr/023-subject-sources.md)):

Example: `s1demo5sssssss1`
- The **bare local form**: a 15-character nanoid-style identifier starting with `s`, lexicographically
sortable by creation time. Local Subjects are always stored in this form.

Example: `s1demo5sssssss1`

- The **source-qualified form** `source:localId`, referencing a Subject from a specific Source. The part
before the first `:` is the source key (`[A-Za-z0-9+_-]+`; for wikis, the
[MediaWiki Wiki ID](https://www.mediawiki.org/wiki/Manual:Wiki_ID)); the rest is the localId, whose
grammar is owned by its Source. Source keys are case-sensitive: keys that differ only in case denote
different Sources. Until Sources can define their own grammars, localIds are restricted to
URL-path-safe characters (RFC 3986 pchar, excluding percent-encoding). An id that explicitly names the
local source is equivalent to its bare form and is normalized to it.

Example: `otherwiki:s1demo5sssssss1`

### Relation IDs

Expand All @@ -191,6 +203,8 @@ See [ADR 014](../adr/014-improved-id-format.md) for details on the ID format.

`GET /rest.php/neowiki/v0/subject/{subjectId}`

`{subjectId}` accepts either id form; returned ids are canonical, so local Subjects always appear bare.

Returns the same statement format as storage, with additional fields:
- `requestedId`: The ID that was requested
- Each subject includes an `id` field. Passing `?expand=page` adds the page fields `pageId`,
Expand Down
4 changes: 3 additions & 1 deletion docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ Data about one thing. Similar to an Item in Wikibase or a Page/SubObject in SMW.

Subjects have

- An `id`: persistent identifier. Subject IDs start with `s` and are always 15 characters long ([ADR 14](adr/014-improved-id-format.md))
- An `id`: persistent identifier. Local Subject IDs are bare nanoids starting with `s`, always 15 characters
long ([ADR 14](adr/014-improved-id-format.md)). A Subject id may also be source-qualified as
`source:localId`, where the bare form means the local source ([ADR 23](adr/023-subject-sources.md))
- A `type`: reference to a Schema. Example: Person, Company, Product, etc.
- A `label`: the name of the subject. Example: "John Doe". This is a string, not a reference to a page.
- `statements`: a list of Statements
Expand Down
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ parameters:
path: src/Persistence/MediaWiki/Subject/SubjectContentDataDeserializer.php

-
message: "#^Parameter \\#1 \\$text of class ProfessionalWiki\\\\NeoWiki\\\\Domain\\\\Subject\\\\SubjectId constructor expects string, mixed given\\.$#"
message: "#^Parameter \\#1 \\$text of method ProfessionalWiki\\\\NeoWiki\\\\Domain\\\\Subject\\\\SubjectIdParser\\:\\:parse\\(\\) expects string, mixed given\\.$#"
count: 1
path: src/Persistence/MediaWiki/Subject/SubjectContentDataDeserializer.php

Expand Down
54 changes: 9 additions & 45 deletions resources/ext.neowiki/src/components/NeoWikiApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,14 @@
<script setup lang="ts">
import type { Component } from 'vue';
import { onMounted, ref } from 'vue';
import { SubjectId } from '@/domain/SubjectId';
import { SubjectIdParser } from '@/domain/SubjectIdParser';
import Infobox from '@/components/Views/Infobox.vue';
import SubjectCreatorDialog from '@/components/SubjectCreator/SubjectCreatorDialog.vue';
import { NeoWikiServices } from '@/NeoWikiServices.ts';
import { NeoWikiExtension } from '@/NeoWikiExtension.ts';
import { useLayoutStore } from '@/stores/LayoutStore.ts';

interface ViewData {
id: string;
element: HTMLElement;
subjectId: SubjectId;
canEditSubject: boolean;
viewType?: string;
layoutName?: string;
}
import { getViewsData } from '@/components/viewData';
import type { ViewData } from '@/components/viewData';

const props = defineProps<{
showSubjectCreator: boolean;
Expand Down Expand Up @@ -70,7 +63,12 @@ function isLatestRevision(): boolean {
}

onMounted( async (): Promise<void> => {
const localViewsData = await getViewsData( document.querySelectorAll( '.ext-neowiki-view' ) );
const subjectIdParser = new SubjectIdParser( String( mw.config.get( 'wgWikiID' ) ) );
const localViewsData = await getViewsData(
document.querySelectorAll( '.ext-neowiki-view' ),
subjectIdParser,
async ( subjectId ) => isLatestRevision() && await subjectPermissionHints.canEditSubject( subjectId )
);
const storeStateLoader = NeoWikiExtension.getInstance().getStoreStateLoader();

await Promise.all( [
Expand All @@ -85,38 +83,4 @@ onMounted( async (): Promise<void> => {
viewsData.value = localViewsData;
} );

// eslint-disable-next-line no-undef
async function getViewsData( elements: NodeListOf<HTMLElement> ): Promise<ViewData[]> {
const viewsData: ViewData[] = [];

for ( const element of elements ) {
const viewData = await getViewData( element );
if ( viewData ) {
viewsData.push( viewData );
}
}
return viewsData;
}

async function getViewData( element: HTMLElement ): Promise<ViewData|null> {
if ( !element.dataset.mwNeowikiSubjectId ) {
return null;
}

try {
const subjectId = new SubjectId( element.dataset.mwNeowikiSubjectId );
return {
id: subjectId.text,
element: element,
subjectId: subjectId,
canEditSubject: isLatestRevision() && await subjectPermissionHints.canEditSubject( subjectId ),
viewType: element.dataset.mwNeowikiViewType,
layoutName: element.dataset.mwNeowikiLayoutName
};
} catch ( error ) {
console.error( error );
return null;
}
}

</script>
52 changes: 52 additions & 0 deletions resources/ext.neowiki/src/components/viewData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { SubjectId } from '@/domain/SubjectId';
import { SubjectIdParser } from '@/domain/SubjectIdParser';

export interface ViewData {
id: string;
element: HTMLElement;
subjectId: SubjectId;
canEditSubject: boolean;
viewType?: string;
layoutName?: string;
}

export async function getViewsData(
elements: NodeListOf<HTMLElement>,
subjectIdParser: SubjectIdParser,
canEditSubject: ( subjectId: SubjectId ) => Promise<boolean>,
): Promise<ViewData[]> {
const viewsData: ViewData[] = [];

for ( const element of elements ) {
const viewData = await getViewData( element, subjectIdParser, canEditSubject );
if ( viewData ) {
viewsData.push( viewData );
}
}
return viewsData;
}

async function getViewData(
element: HTMLElement,
subjectIdParser: SubjectIdParser,
canEditSubject: ( subjectId: SubjectId ) => Promise<boolean>,
): Promise<ViewData|null> {
if ( !element.dataset.mwNeowikiSubjectId ) {
return null;
}

try {
const subjectId = subjectIdParser.parse( element.dataset.mwNeowikiSubjectId );
return {
id: subjectId.text,
element: element,
subjectId: subjectId,
canEditSubject: await canEditSubject( subjectId ),
viewType: element.dataset.mwNeowikiViewType,
layoutName: element.dataset.mwNeowikiLayoutName,
};
} catch ( error ) {
console.error( error );
return null;
}
}
26 changes: 23 additions & 3 deletions resources/ext.neowiki/src/domain/SubjectId.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
const BARE_PATTERN = /^s[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{14}$/;
const QUALIFIED_PATTERN = /^([A-Za-z0-9+_-]+):([A-Za-z0-9._~!$&'()*+,;=:@-]+)$/;

export class SubjectId {

public readonly text: string;

/**
* The source key, or null for a local Subject (bare id form).
*/
public readonly source: string | null;

public readonly localId: string;

public constructor( text: string ) {
if ( !SubjectId.isValid( text ) ) {
throw new Error( 'Subject ID has the wrong format. ID: ' + text );
if ( BARE_PATTERN.test( text ) ) {
this.source = null;
this.localId = text;
} else {
const match = QUALIFIED_PATTERN.exec( text );

if ( match === null ) {
throw new Error( 'Subject ID has the wrong format. ID: ' + text );
}

this.source = match[ 1 ];
this.localId = match[ 2 ];
}

this.text = text;
}

public static isValid( text: string ): boolean {
return /^s[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{14}$/.test( text );
return BARE_PATTERN.test( text ) || QUALIFIED_PATTERN.test( text );
}

}
33 changes: 33 additions & 0 deletions resources/ext.neowiki/src/domain/SubjectIdParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { SubjectId } from './SubjectId';

/**
* Parses Subject ID strings at system boundaries. An id that explicitly names
* the local source canonicalizes to the bare form, so one Subject never has
* two textual identities.
*/
export class SubjectIdParser {

public constructor( private readonly localSourceKey: string ) {
}

public parse( text: string ): SubjectId {
const id = new SubjectId( text );

if ( id.source === this.localSourceKey ) {
return this.newLocalId( id.localId, text );
}

return id;
}

private newLocalId( localId: string, originalText: string ): SubjectId {
const bareId = new SubjectId( localId );

if ( bareId.source !== null ) {
throw new Error( 'Local Subject IDs must be bare: ' + originalText );
}

return bareId;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export class RestSubjectRepository implements SubjectRepository {
}

private async fetchSubjectBundle( id: SubjectId ): Promise<SubjectBundleJson> {
let url = `${ this.mediaWikiRestApiUrl }/neowiki/v0/subject/${ id.text }?expand=page|relations`;
let url = `${ this.mediaWikiRestApiUrl }/neowiki/v0/subject/${ encodeURIComponent( id.text ) }?expand=page|relations`;

if ( this.revisionId !== undefined ) {
url += `&revisionId=${ this.revisionId }`;
Expand Down Expand Up @@ -249,7 +249,7 @@ export class RestSubjectRepository implements SubjectRepository {

public async updateSubject( id: SubjectId, label: string, statements: StatementList, comment?: string ): Promise<object> {
const response = await this.httpClient.put(
`${ this.mediaWikiRestApiUrl }/neowiki/v0/subject/${ id.text }`,
`${ this.mediaWikiRestApiUrl }/neowiki/v0/subject/${ encodeURIComponent( id.text ) }`,
{
label,
statements: statementsToJson( statements ),
Expand All @@ -273,7 +273,7 @@ export class RestSubjectRepository implements SubjectRepository {

public async deleteSubject( id: SubjectId, comment?: string ): Promise<boolean> {
const response = await this.httpClient.delete(
`${ this.mediaWikiRestApiUrl }/neowiki/v0/subject/${ id.text }`,
`${ this.mediaWikiRestApiUrl }/neowiki/v0/subject/${ encodeURIComponent( id.text ) }`,
{
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -306,7 +306,7 @@ export class RestSubjectRepository implements SubjectRepository {
statements: StatementList,
): Promise<SubjectViolation[]> {
return this.runValidation(
`${ this.mediaWikiRestApiUrl }/neowiki/v0/subject/${ id.text }/validate`,
`${ this.mediaWikiRestApiUrl }/neowiki/v0/subject/${ encodeURIComponent( id.text ) }/validate`,
{ label, statements: statementsToJson( statements ) },
);
}
Expand Down
Loading
Loading