diff --git a/__tests__/presentation-4-parser/traverse.test.ts b/__tests__/presentation-4-parser/traverse.test.ts index 0feba56..dff4d36 100644 --- a/__tests__/presentation-4-parser/traverse.test.ts +++ b/__tests__/presentation-4-parser/traverse.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { cwd } from "node:process"; import { describe, expect, test } from "vitest"; import { Traverse } from "../../src/presentation-4"; +import type { Annotation } from "../../src/presentation-4/types"; describe("presentation-4 traverse", () => { test("dispatches callbacks across mixed resource types", () => { @@ -88,7 +89,7 @@ describe("presentation-4 traverse", () => { const annotation = { id: "https://example.org/anno/1", - type: "Annotation", + type: "Annotation" as const, motivation: ["painting"], target: [ { @@ -104,11 +105,11 @@ describe("presentation-4 traverse", () => { ], }; - const traversed = traverse.traverseAnnotation(annotation, undefined, "$.annotation"); - const target = traversed.target; - - expect(target.type).toBe("SpecificResource"); - expect(target.selector[0].type).toBe("FragmentSelector"); + const traversed = traverse.traverseAnnotation(annotation as unknown as Annotation, undefined, "$.annotation"); + expect(traversed.target).toMatchObject({ + type: "SpecificResource", + selector: [{ type: "FragmentSelector" }], + }); expect(selectorCount).toBe(1); }); @@ -116,7 +117,7 @@ describe("presentation-4 traverse", () => { const traverse = new Traverse(); const annotation = { id: "https://example.org/anno/list-wrapper", - type: "Annotation", + type: "Annotation" as const, motivation: ["painting"], body: { type: "List", @@ -135,26 +136,27 @@ describe("presentation-4 traverse", () => { }; const traversed = traverse.traverseAnnotation(annotation, undefined, "$.annotation"); - expect(Array.isArray(traversed.body)).toBe(false); - expect(Array.isArray(traversed.target)).toBe(false); - expect(traversed.body.type).toBe("List"); - expect(traversed.target.type).toBe("List"); - expect(traversed.target.items[0].type).toBe("SpecificResource"); - expect(traversed.target.items[0].selector[0].type).toBe("FragmentSelector"); + expect(traversed).toMatchObject({ + body: { type: "List" }, + target: { + type: "List", + items: [{ type: "SpecificResource", selector: [{ type: "FragmentSelector" }] }], + }, + }); }); test("coerces PointSelector.t to PointSelector.instant by default", () => { const traverse = new Traverse(); const selector = { - type: "PointSelector", + type: "PointSelector" as const, x: 1, y: 2, t: 3.5, }; const traversed = traverse.traverseSelector(selector, undefined, "$.selector"); - expect(traversed.instant).toBe(3.5); - expect(Object.hasOwn(traversed, "t")).toBe(false); + expect(traversed).toMatchObject({ instant: 3.5 }); + expect(traversed).not.toHaveProperty("t"); }); test("can disable PointSelector.t coercion via traverse option", () => { @@ -165,22 +167,23 @@ describe("presentation-4 traverse", () => { } ); const selector = { - type: "PointSelector", + type: "PointSelector" as const, x: 1, y: 2, t: 3.5, }; const traversed = traverse.traverseSelector(selector, undefined, "$.selector"); - expect(traversed.t).toBe(3.5); - expect(Object.hasOwn(traversed, "instant")).toBe(false); + expect(traversed).toMatchObject({ t: 3.5 }); + expect(traversed).not.toHaveProperty("instant"); }); test("normalizes paging first/last string references to typed objects", () => { const traverse = new Traverse(); const annotationCollection = { id: "https://example.org/annotation-collection/1", - type: "AnnotationCollection", + type: "AnnotationCollection" as const, + label: null, first: "https://example.org/annotation-collection/1/page/1", last: "https://example.org/annotation-collection/1/page/2", items: [], @@ -203,10 +206,10 @@ describe("presentation-4 traverse", () => { const collection = { id: "https://example.org/collection/1", - type: "Collection", + type: "Collection" as const, + label: { en: ["Collection"] }, first: "https://example.org/collection/1/page/1", last: "https://example.org/collection/1/page/2", - items: [], }; const traversedCollection = traverse.traverseCollection(collection, undefined, "$.collection"); diff --git a/src/presentation-3/traverse.ts b/src/presentation-3/traverse.ts index 5502877..c3038f7 100644 --- a/src/presentation-3/traverse.ts +++ b/src/presentation-3/traverse.ts @@ -38,9 +38,30 @@ export const types = [ "Agent", ]; -export type TraversalContext = { parent?: any }; - -export type Traversal = (jsonLd: T, context: TraversalContext) => Partial | any; +export type Presentation3Resource = + | Collection + | Manifest + | Canvas + | AnnotationCollection + | AnnotationPage + | Annotation + | ContentResource + | ChoiceTarget + | ChoiceBody + | Range + | Service + | ResourceProvider + | SpecificResource + | GeoJSON; + +export type TraversalContext = { parent?: unknown }; + +export type Traversal = (jsonLd: T, context: TraversalContext) => unknown; + +export type AllTraversal = ( + resource: Resource, + context: TraversalContext +) => unknown; export type TraversalMap = { collection?: Array>; @@ -62,7 +83,7 @@ export type TraverseOptions = { allowUndefinedReturn: boolean; }; -export function identifyResource(resource: any, typeHint?: string): string { +export function identifyResource(resource: unknown, typeHint?: string): string { if (typeof resource === "undefined" || resource === null) { throw new Error("Null or undefined is not a valid entity."); } @@ -76,14 +97,14 @@ export function identifyResource(resource: any, typeHint?: string): string { throw new Error(`${typeof resource} is not a valid entity`); } - if (typeof resource!.type === "string") { + if ("type" in resource && typeof resource.type === "string") { const hasType = types.indexOf(resource.type); if (hasType !== -1) { return types[hasType]!; } } - if (resource!.profile) { + if ("profile" in resource && resource.profile) { return "Service"; } @@ -118,7 +139,7 @@ export class Traverse { }; } - static all(traversal: (resource: any) => any) { + static all(traversal: AllTraversal) { return new Traverse({ collection: [traversal], manifest: [traversal], @@ -136,7 +157,7 @@ export class Traverse { }); } - traverseDescriptive>(resource: T): T { + traverseDescriptive>(resource: T): T { if (resource.thumbnail) { resource.thumbnail = ensureArray(resource.thumbnail).map((thumbnail) => this.traverseType(thumbnail, { parent: resource }, this.traversals.contentResource) @@ -148,7 +169,7 @@ export class Traverse { return resource; } - traverseLinking>(resource: T): T { + traverseLinking>(resource: T): T { if (resource.seeAlso) { resource.seeAlso = ensureArray(resource.seeAlso).map((content) => this.traverseType(content, { parent: resource }, this.traversals.contentResource) @@ -227,6 +248,7 @@ export class Traverse { return collection; } + traverseCollection(collection: Collection, parent?: Presentation3Resource): Collection; traverseCollection(collection: Collection, parent?: any): Collection { return this.traverseType( this.traverseDescriptive( @@ -241,6 +263,7 @@ export class Traverse { ); } + traverseGeoJson(geoJson: GeoJSON, parent?: Presentation3Resource): GeoJSON; traverseGeoJson(geoJson: GeoJSON, parent?: any): GeoJSON { return this.traverseType(geoJson, { parent }, this.traversals.geoJson); } @@ -276,6 +299,7 @@ export class Traverse { this.traverseInlineAnnotationPages.bind(this) ); + traverseManifest(manifest: Manifest, parent?: Presentation3Resource): Manifest; traverseManifest(manifest: Manifest, parent?: any): Manifest { return this.traverseType(this._traverseManifest(manifest), { parent }, this.traversals.manifest); } @@ -309,6 +333,7 @@ export class Traverse { this.traverseInlineAnnotationPages.bind(this) ); + traverseCanvas(canvas: Canvas, parent?: Presentation3Resource): Canvas; traverseCanvas(canvas: Canvas, parent?: any): Canvas { return this.traverseType(this._traverseCanvas(canvas), { parent }, this.traversals.canvas); } @@ -328,6 +353,7 @@ export class Traverse { this.traverseDescriptive.bind(this) ); + traverseAnnotationPage(annotationPageJson: AnnotationPage, parent?: Presentation3Resource): AnnotationPage; traverseAnnotationPage(annotationPageJson: AnnotationPage, parent?: any): AnnotationPage { return this.traverseType( this._traverseAnnotationPage(annotationPageJson), @@ -363,6 +389,7 @@ export class Traverse { } // @todo traverseAnnotationSelector + traverseAnnotation(annotationJson: Annotation, parent?: Presentation3Resource): Annotation; traverseAnnotation(annotationJson: Annotation, parent?: any): Annotation { return this.traverseType( this.traverseLinking(this.traverseAnnotationBody(this.traverseDescriptive(annotationJson as any))), @@ -384,6 +411,7 @@ export class Traverse { return contentResourceJson; } + traverseContentResource(contentResourceJson: ContentResource, parent?: Presentation3Resource): ContentResource; traverseContentResource(contentResourceJson: ContentResource, parent?: any): ContentResource { if ((contentResourceJson as any).type === "Choice") { (contentResourceJson as any).items = (contentResourceJson as any).items.map((choiceItem: ContentResource) => { @@ -407,6 +435,11 @@ export class Traverse { ); } + traverseSpecificResource( + specificResource: SpecificResource, + typeHint?: string, + parent?: Presentation3Resource + ): SpecificResource; traverseSpecificResource(specificResource: SpecificResource, typeHint?: string, parent?: any): SpecificResource { let source = specificResource.source; if (typeof specificResource.source === "string") { @@ -420,7 +453,7 @@ export class Traverse { typeHint === "Canvas" || source.type === "Canvas" ? this.traverseType(source, { parent }, this.traversals.canvas) : typeHint === "ContentResource" - ? this.traverseContentResource(source, { parent }) + ? this.traverseContentResource(source, parent) : this.traverseUnknown(source, { parent, typeHint }), }, { parent }, @@ -455,10 +488,12 @@ export class Traverse { this.traverseLinkedCanvases.bind(this) ); + traverseRange(range: Range, parent?: Presentation3Resource): Range; traverseRange(range: Range, parent?: any): Range { return this.traverseType(this._traverseRange(range), { parent }, this.traversals.range); } + traverseAgent(agent: ResourceProvider, parent?: Presentation3Resource): ResourceProvider; traverseAgent(agent: ResourceProvider, parent?: any) { return this.traverseType( this.traverseDescriptive(this.traverseLinking(agent)), @@ -473,10 +508,11 @@ export class Traverse { if (typeof returnValue === "undefined" && !this.options.allowUndefinedReturn) { return acc; } - return returnValue; + return returnValue as T; }, object); } + traverseService(service: Service, parent?: Presentation3Resource): Service; traverseService(service: Service, parent?: any): Service { const _service: any = Object.assign({}, service); if (_service && _service.service) { @@ -485,6 +521,10 @@ export class Traverse { return this.traverseType(_service, { parent }, this.traversals.service); } + traverseUnknown( + resource: unknown, + options?: { typeHint?: string; parent?: Presentation3Resource } + ): Presentation3Resource; traverseUnknown( resource: any, { parent, typeHint }: { typeHint?: string; parent?: any } = {} diff --git a/src/presentation-4/traverse.ts b/src/presentation-4/traverse.ts index e0b46fd..30080d4 100644 --- a/src/presentation-4/traverse.ts +++ b/src/presentation-4/traverse.ts @@ -1,6 +1,26 @@ import { splitCanvasFragment } from "../shared/canvas-fragments"; import { compose } from "../shared/compose"; import { ensureArray } from "../shared/ensure-array"; +import type { + Agent, + Annotation, + AnnotationCollection, + AnnotationPage, + Canvas, + Collection, + CollectionPage, + ContentResourceLike, + ListResource, + Manifest, + Quantity, + Range, + Scene, + Selector, + Service, + SpecificResource, + Timeline, + Transform, +} from "./types"; import { annotationTypes, containerTypes, @@ -15,32 +35,56 @@ import { structuralTypes, } from "./utilities"; +export type Presentation4Resource = + | Collection + | CollectionPage + | Manifest + | Timeline + | Canvas + | Scene + | AnnotationPage + | AnnotationCollection + | Annotation + | ContentResourceLike + | Range + | Service + | Agent + | SpecificResource + | Selector + | Quantity + | Transform; + export type TraversalContext = { - parent?: any; + parent?: Presentation4Resource; path: string; typeHint?: string; }; -export type Traversal = (resource: T, context: TraversalContext) => T | void; +export type Traversal = (resource: T, context: TraversalContext) => unknown; + +export type AllTraversal = ( + resource: Resource, + context: TraversalContext +) => unknown; export type TraversalMap = { - collection?: Array; - collectionPage?: Array; - manifest?: Array; - timeline?: Array; - canvas?: Array; - scene?: Array; - annotationCollection?: Array; - annotationPage?: Array; - annotation?: Array; - contentResource?: Array; - range?: Array; - service?: Array; - agent?: Array; - specificResource?: Array; - selector?: Array; - quantity?: Array; - transform?: Array; + collection?: Array>; + collectionPage?: Array>; + manifest?: Array>; + timeline?: Array>; + canvas?: Array>; + scene?: Array>; + annotationCollection?: Array>; + annotationPage?: Array>; + annotation?: Array>; + contentResource?: Array>; + range?: Array>; + service?: Array>; + agent?: Array>; + specificResource?: Array>; + selector?: Array>; + quantity?: Array>; + transform?: Array>; }; export type TraverseOptions = { @@ -51,7 +95,7 @@ export type TraverseOptions = { }; type UnknownTraversalArgs = { - parent?: any; + parent?: Presentation4Resource; path: string; typeHint?: string; }; @@ -109,7 +153,7 @@ export class Traverse { }; } - static all(traversal: Traversal) { + static all(traversal: AllTraversal) { return new Traverse({ collection: [traversal], collectionPage: [traversal], @@ -288,6 +332,7 @@ export class Traverse { return collection; } + traverseCollection(collection: Collection, parent?: Presentation4Resource, path?: string): Collection; traverseCollection(collection: any, parent?: any, path = "$"): any { const withCollectionItems = this.traverseCollectionItems(collection, path); const withContainerItems = this.options.legacyPresentation3Behavior @@ -302,6 +347,7 @@ export class Traverse { ); } + traverseCollectionPage(collectionPage: CollectionPage, parent?: Presentation4Resource, path?: string): CollectionPage; traverseCollectionPage(collectionPage: any, parent?: any, path = "$"): any { const withItems = this.traverseCollectionItems(collectionPage, path); const withLinks = this.traversePageReferences(withItems, path, "CollectionPage"); @@ -312,6 +358,7 @@ export class Traverse { ); } + traverseManifest(manifest: Manifest, parent?: Presentation4Resource, path?: string): Manifest; traverseManifest(manifest: any, parent?: any, path = "$"): any { const pipeline = compose( (value: any) => this.traverseManifestItems(value, path), @@ -321,6 +368,7 @@ export class Traverse { return this.traverseType(pipeline(manifest), { parent, path }, this.traversals.manifest); } + traverseTimeline(timeline: Timeline, parent?: Presentation4Resource, path?: string): Timeline; traverseTimeline(timeline: any, parent?: any, path = "$"): any { return this.traverseType( this.traverseLinkedResources(this.traverseContainerItems(timeline, path), path), @@ -329,6 +377,7 @@ export class Traverse { ); } + traverseCanvas(canvas: Canvas, parent?: Presentation4Resource, path?: string): Canvas; traverseCanvas(canvas: any, parent?: any, path = "$"): any { return this.traverseType( this.traverseLinkedResources(this.traverseContainerItems(canvas, path), path), @@ -337,6 +386,7 @@ export class Traverse { ); } + traverseScene(scene: Scene, parent?: Presentation4Resource, path?: string): Scene; traverseScene(scene: any, parent?: any, path = "$"): any { return this.traverseType( this.traverseLinkedResources(this.traverseContainerItems(scene, path), path), @@ -407,6 +457,7 @@ export class Traverse { return resource; } + traverseAnnotationPage(annotationPage: AnnotationPage, parent?: Presentation4Resource, path?: string): AnnotationPage; traverseAnnotationPage(annotationPage: any, parent?: any, path = "$"): any { return this.traverseType( this.traverseLinkedResources(this.traverseAnnotationItems(annotationPage, path), path), @@ -415,6 +466,11 @@ export class Traverse { ); } + traverseAnnotationCollection( + annotationCollection: AnnotationCollection, + parent?: Presentation4Resource, + path?: string + ): AnnotationCollection; traverseAnnotationCollection(annotationCollection: any, parent?: any, path = "$"): any { return this.traverseType( this.traverseLinkedResources( @@ -439,7 +495,7 @@ export class Traverse { { id: body, type: "ContentResource", - }, + } as ContentResourceLike, annotation, `${path}.body[${index}]` ); @@ -545,6 +601,7 @@ export class Traverse { }); } + traverseAnnotation(annotation: Annotation, parent?: Presentation4Resource, path?: string): Annotation; traverseAnnotation(annotation: any, parent?: any, path = "$"): any { if (annotation.position && typeof annotation.position === "object") { annotation.position = this.traversePosition(annotation.position, annotation, `${path}.position`); @@ -559,6 +616,7 @@ export class Traverse { ); } + traverseSelector(selector: Selector, parent?: Presentation4Resource, path?: string): Selector; traverseSelector(selector: any, parent?: any, path = "$"): any { if ( this.options.coerceLegacyPointSelectorTime && @@ -580,10 +638,12 @@ export class Traverse { return this.traverseType(selector, { parent, path }, this.traversals.selector); } + traverseQuantity(quantity: Quantity, parent?: Presentation4Resource, path?: string): Quantity; traverseQuantity(quantity: any, parent?: any, path = "$"): any { return this.traverseType(quantity, { parent, path }, this.traversals.quantity); } + traverseTransform(transform: Transform, parent?: Presentation4Resource, path?: string): Transform; traverseTransform(transform: any, parent?: any, path = "$"): any { return this.traverseType(transform, { parent, path }, this.traversals.transform); } @@ -598,6 +658,12 @@ export class Traverse { return position; } + traverseSpecificResource( + specificResource: SpecificResource, + typeHint?: string, + parent?: Presentation4Resource, + path?: string + ): SpecificResource; traverseSpecificResource(specificResource: any, typeHint?: string, parent?: any, path = "$"): any { const normalizedSpecificResource = this.toSpecificResource(specificResource, typeHint || "Canvas"); if (normalizedSpecificResource) { @@ -659,6 +725,11 @@ export class Traverse { return this.traverseType(specificResource, { parent, path }, this.traversals.specificResource); } + traverseContentResource( + contentResource: ContentResourceLike, + parent?: Presentation4Resource, + path?: string + ): ContentResourceLike; traverseContentResource(contentResource: any, parent?: any, path = "$"): any { if (!contentResource || typeof contentResource !== "object") { if (this.options.legacyPresentation3Behavior && typeof contentResource === "string") { @@ -757,6 +828,7 @@ export class Traverse { ); } + traverseRange(range: Range, parent?: Presentation4Resource, path?: string): Range; traverseRange(range: any, parent?: any, path = "$"): any { if (range.items) { range.items = ensureArray(range.items).map((item: any, index: number) => { @@ -779,10 +851,12 @@ export class Traverse { return this.traverseType(this.traverseLinkedResources(range, path), { parent, path }, this.traversals.range); } + traverseAgent(agent: Agent, parent?: Presentation4Resource, path?: string): Agent; traverseAgent(agent: any, parent?: any, path = "$"): any { return this.traverseType(this.traverseLinkedResources(agent, path), { parent, path }, this.traversals.agent); } + traverseService(service: Service, parent?: Presentation4Resource, path?: string): Service; traverseService(service: any, parent?: any, path = "$"): any { if (service && typeof service === "object" && service.service) { service.service = ensureArray(service.service).map((innerService: any, index: number) => @@ -797,7 +871,7 @@ export class Traverse { return values[0]; } - const listResource = { + const listResource: ListResource = { id: mintDeterministicId( { type: "List", @@ -962,6 +1036,7 @@ export class Traverse { return this.toSpecificResource(target, typeHint); } + traverseUnknown(resource: unknown, options: UnknownTraversalArgs): Presentation4Resource; traverseUnknown(resource: any, { parent, path, typeHint }: UnknownTraversalArgs): any { const type = identifyResourceType(resource, typeHint); diff --git a/src/presentation-4/upgrade.ts b/src/presentation-4/upgrade.ts index 080d2ba..870018f 100644 --- a/src/presentation-4/upgrade.ts +++ b/src/presentation-4/upgrade.ts @@ -1,4 +1,7 @@ import { convertPresentation2 } from "../presentation-2"; +import type { Collection as Collection2, Manifest as Manifest2 } from "../presentation-2/types"; +import type { Collection as Collection3, Manifest as Manifest3 } from "../presentation-3/types"; +import type { Collection, Manifest } from "./types"; import { deepClone, ensureArray, @@ -426,12 +429,22 @@ function coerceV4Shape( return resource; } +export function upgradePresentation3To4(entity: Manifest3): Manifest; +export function upgradePresentation3To4(entity: Collection3): Collection; +export function upgradePresentation3To4(entity: unknown): Manifest | Collection; export function upgradePresentation3To4(entity: any): any { const clone = prepareContainerIds(deepClone(entity)); const typeLookup = collectKnownTypes(clone); return coerceV4Shape(clone, typeLookup, true); } +export function upgradeToPresentation4(entity: Manifest): Manifest; +export function upgradeToPresentation4(entity: Collection): Collection; +export function upgradeToPresentation4(entity: Manifest3): Manifest; +export function upgradeToPresentation4(entity: Collection3): Collection; +export function upgradeToPresentation4(entity: Manifest2): Manifest; +export function upgradeToPresentation4(entity: Collection2): Collection; +export function upgradeToPresentation4(entity: unknown): Manifest | Collection; export function upgradeToPresentation4(entity: any): any { const upgraded = convertPresentation2(deepClone(entity)); if (hasPresentation4Context(upgraded)) { diff --git a/src/presentation-4/validator.ts b/src/presentation-4/validator.ts index 4e49f2c..d783771 100644 --- a/src/presentation-4/validator.ts +++ b/src/presentation-4/validator.ts @@ -1509,7 +1509,8 @@ export function runRawValidation(resource: any, options: { skipAnnotationShape?: ], selector: [ (selector, context) => { - if (!selector.type || !selector.type.endsWith("Selector")) { + const selectorType = typeof selector === "string" ? undefined : selector.type; + if (!selectorType || !selectorType.endsWith("Selector")) { issue(issues, { code: "selector-type-invalid", message: 'Selector.type must end with "Selector"', @@ -1544,7 +1545,13 @@ export function runRawValidation(resource: any, options: { skipAnnotationShape?: ], contentResource: [ (resource, context) => { - if (resource.spatialScale && resource.spatialScale.type !== "Quantity") { + if ( + typeof resource === "object" && + resource && + "spatialScale" in resource && + resource.spatialScale && + getType(resource.spatialScale) !== "Quantity" + ) { issue(issues, { code: "spatial-scale-quantity", message: "spatialScale must be a Quantity object", @@ -1553,7 +1560,13 @@ export function runRawValidation(resource: any, options: { skipAnnotationShape?: specRef: "#spatialScale", }); } - if (resource.temporalScale && resource.temporalScale.type !== "Quantity") { + if ( + typeof resource === "object" && + resource && + "temporalScale" in resource && + resource.temporalScale && + getType(resource.temporalScale) !== "Quantity" + ) { issue(issues, { code: "temporal-scale-quantity", message: "temporalScale must be a Quantity object",