diff --git a/components/annotorious-annotator/line-parser.js b/components/annotorious-annotator/line-parser.js index d816c78e..4bd19d2a 100644 --- a/components/annotorious-annotator/line-parser.js +++ b/components/annotorious-annotator/line-parser.js @@ -18,6 +18,7 @@ import { detectTextLinesCombined } from "./detect-lines.js" import { v4 as uuidv4 } from "https://cdn.skypack.dev/uuid@9.0.1" import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' import { onProjectReady } from '../../utilities/projectReady.js' +import vault from '../../js/vault.js' import '../page-selector/index.js' class AnnotoriousAnnotator extends HTMLElement { @@ -553,7 +554,7 @@ class AnnotoriousAnnotator extends HTMLElement { /** * Fetch a Canvas URI and check that it is a Canvas object. Pass it forward to render the Image into the interface. - * Be prepared to recieve presentation api 2+ + * Be prepared to receive presentation api 2+ * * FIXME * Give users a path when Canvas URIs do not resolve or resolve to something unexpected. @@ -562,20 +563,15 @@ class AnnotoriousAnnotator extends HTMLElement { */ async processCanvas(uri) { if (!uri) return - // TODO Vault me? - const resolvedCanvas = await fetch(uri) - .then(r => { - if (!r.ok) throw r - return r.json() - }) - .catch(e => { - this.shadowRoot.innerHTML = ` -

Canvas Error

-

The Canvas within this Page could not be loaded.

-

${e.status ?? e.code}: ${e.statusText ?? e.message}

- ` - throw e - }) + let resolvedCanvas = await vault.getWithFallback(uri, 'canvas', TPEN.activeProject?.manifest) + if (!resolvedCanvas) { + this.shadowRoot.innerHTML = ` +

Canvas Error

+

The Canvas within this Page could not be loaded.

+

The Canvas could not be resolved or is invalid.

+ ` + return + } const context = resolvedCanvas["@context"] if (!context?.includes("iiif.io/api/presentation/3/context.json")) { console.warn("The Canvas object did not have the IIIF Presentation API 3 context and may not be parseable.") @@ -619,7 +615,7 @@ class AnnotoriousAnnotator extends HTMLElement { throw new Error("Cannot Resolve Canvas Image", { "cause": "The Image is 404 or unresolvable." }) } let imgx = resolvedCanvas?.items?.[0]?.items?.[0]?.body?.width - if (!imgx) imgx = resolvedCanvas?.images[0]?.resource?.width + if (!imgx) imgx = resolvedCanvas?.images?.[0]?.resource?.width let imgy = resolvedCanvas?.items?.[0]?.items?.[0]?.body?.height if (!imgy) imgy = resolvedCanvas?.images?.[0]?.resource?.height this.#imageDims = [imgx, imgy] diff --git a/components/column-selector/index.js b/components/column-selector/index.js index ba7d5fe8..29dd4595 100644 --- a/components/column-selector/index.js +++ b/components/column-selector/index.js @@ -68,7 +68,8 @@ export default class ColumnSelector extends HTMLElement { return { ...col, label: isAuto ? `Unnamed ${i + 1}` : col.label } }) - this.#page = await vault.get(pageId, 'annotationpage', true) + this.#page = await vault.getWithFallback(pageId, 'annotationpage', TPEN.activeProject?.manifest, true) + if (!this.#page) return const { orderedItems, columnsInPage, allColumnLines } = orderPageItemsByColumns( { columns: this.columns, items: page?.items }, this.#page diff --git a/components/continue-working/index.js b/components/continue-working/index.js index 7dc5ef73..4719fc3c 100644 --- a/components/continue-working/index.js +++ b/components/continue-working/index.js @@ -1,4 +1,5 @@ import TPEN from '../../api/TPEN.js' +import vault from '../../js/vault.js' import { stringFromDate } from '/js/utils.js' import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' @@ -177,26 +178,12 @@ class ContinueWorking extends HTMLElement { if (!canvasId) return this.generateProjectPlaceholder(project) let canvas, isV3 - try { - canvas = await fetch(canvasId).then(r => r.json()) - const context = canvas['@context'] - isV3 = Array.isArray(context) - ? context.some(ctx => typeof ctx === 'string' && ctx.includes('iiif.io/api/presentation/3')) - : typeof context === 'string' && context.includes('iiif.io/api/presentation/3') - } catch { - // Fetch manifest - const manifestUrl = project.manifest?.[0] - if (!manifestUrl) return this.generateProjectPlaceholder(project) - - const manifest = await fetch(manifestUrl).then(r => r.json()) - const context = manifest['@context'] - isV3 = Array.isArray(context) - ? context.some(ctx => typeof ctx === 'string' && ctx.includes('iiif.io/api/presentation/3')) - : typeof context === 'string' && context.includes('iiif.io/api/presentation/3') - const canvases = isV3 ? manifest.items : manifest.sequences?.[0]?.canvases - canvas = canvases?.find(c => (isV3 ? c.id : c['@id']) === canvasId) - if (!canvas) return this.generateProjectPlaceholder(project) - } + canvas = await vault.getWithFallback(canvasId, 'canvas', project.manifest) + + if (!canvas) return this.generateProjectPlaceholder(project) + + // Structure-based detection + isV3 = Array.isArray(canvas.items) || canvas.type === "Canvas" // Get thumbnail from canvas let thumbnailUrl = canvas.thumbnail?.id ?? canvas.thumbnail?.['@id'] ?? canvas.thumbnail diff --git a/components/default-transcribe/index.js b/components/default-transcribe/index.js index fa128ce6..3daf8977 100644 --- a/components/default-transcribe/index.js +++ b/components/default-transcribe/index.js @@ -2,13 +2,13 @@ * TpenTranscriptionElement - Default transcription view component. * Displays line text and images for an annotation page. * @element tpen-transcription + * + * @deprecated in favor of tpen-simple-transcription. */ import { userMessage, encodeContentState } from "../iiif-tools/index.js" +import vault from "../../js/vault.js" import "../line-image/index.js" import "../line-text/index.js" -import { Vault } from 'https://cdn.jsdelivr.net/npm/@iiif/helpers/+esm' - -const vault = new Vault() class TpenTranscriptionElement extends HTMLElement { #transcriptionContainer @@ -71,26 +71,18 @@ class TpenTranscriptionElement extends HTMLElement { } async #loadPage(annotationPageID) { - let page = { id: annotationPageID } - try { - page = vault.get({id:annotationPageID,type:"AnnotationPage"}) ?? await vault.load(annotationPageID) - } catch (err) { - switch (err.status ?? err.code) { - case 401: - return userMessage('Unauthorized') - case 403: - return userMessage('Forbidden') - case 404: - return userMessage('Project not found') - default: - return userMessage(err.message ?? err.statusText ?? err.text ?? 'Unknown error') - } + let page = await vault.getWithFallback(annotationPageID, 'annotationpage', TPEN.activeProject?.manifest) + if (!page) { + return userMessage('Failed to load page. Please try again.') } let lines = await Promise.all(page.items.flatMap(async l => { const lineElem = document.createElement('tpen-line-text') const lineImg = document.createElement('tpen-line-image') - lineElem.line = vault.get({id:l.id,type:"Annotation"}) ?? await vault.load(l.id) - lineElem.line.body[0] = vault.get({id:lineElem.line.body[0].id,type:"ContentResource"}) + lineElem.line = await vault.get(l.id, 'annotation') + if (!lineElem.line) { + lineElem.line = await vault.get(l.id, 'annotation', true) + } + lineElem.line.body[0] = await vault.get(lineElem.line.body[0].id, 'contentresource') lineElem.setAttribute('tpen-line-id', l.id) lineImg.setAttribute('tpen-line-id', l.id) lineImg.setAttribute('iiif-canvas', lineElem.line.target.source.id) diff --git a/components/legacy-annotator/plain.js b/components/legacy-annotator/plain.js index e147e437..739bb0a3 100644 --- a/components/legacy-annotator/plain.js +++ b/components/legacy-annotator/plain.js @@ -6,11 +6,14 @@ * * It is exposed to the user through /interfaces/annotator/legacy.html * @element tpen-legacy-annotator + * + * @deprecated in favor of tpen-plain-annotator. */ import { eventDispatcher } from '../../api/events.js' import TPEN from '../../api/TPEN.js' import User from '../../api/User.js' +import vault from '../../js/vault.js' import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' class LegacyAnnotator extends HTMLElement { @@ -311,14 +314,10 @@ class LegacyAnnotator extends HTMLElement { async processAnnotationPage(page) { if(!page) return - const resolvedPage = await fetch(page) - .then(r => { - if(!r.ok) throw r - return r.json() - }) - .catch(e => { - throw e - }) + let resolvedPage = await vault.getWithFallback(page, 'annotationpage', TPEN.activeProject?.manifest) + if (!resolvedPage) { + throw new Error("Cannot Resolve AnnotationPage", {cause: "The AnnotationPage is 404 or unresolvable."}) + } const context = resolvedPage["@context"] if(!(context.includes("iiif.io/api/presentation/3/context.json") || context.includes("w3.org/ns/anno.jsonld"))){ console.warn("The AnnotationPage object did not have the IIIF Presentation API 3 context and may not be parseable.") @@ -396,14 +395,10 @@ class LegacyAnnotator extends HTMLElement { const ctx = imageCanvas.getContext("2d") let err if(!canvas) return - const resolvedCanvas = await fetch(canvas) - .then(r => { - if(!r.ok) throw r - return r.json() - }) - .catch(e => { - throw e - }) + let resolvedCanvas = await vault.getWithFallback(canvas, 'canvas', TPEN.activeProject?.manifest) + if (!resolvedCanvas) { + throw new Error("Canvas Error", {cause: "The Canvas could not be resolved"}) + } const context = resolvedCanvas["@context"] if(!context.includes("iiif.io/api/presentation/3/context.json")){ console.warn("The Canvas object did not have the IIIF Presentation API 3 context and may not be parseable.") diff --git a/components/line-image/index.js b/components/line-image/index.js index ba192ccb..c067a479 100644 --- a/components/line-image/index.js +++ b/components/line-image/index.js @@ -204,18 +204,21 @@ class TpenImageFragment extends HTMLElement { } connectedCallback() { - this.cleanup.onDocument('canvas-change', (event) => { - fetch(event.detail.canvasId) - .then(res => res.json()) - .then(canvas => { - this.#canvas = canvas - this.setContainerStyle() - const imageResource = canvas?.items?.[0]?.items?.[0]?.body?.id ?? canvas?.images?.[0]?.resource?.id - if (imageResource) { - this.#lineImage.src = imageResource - } - }) - .catch(console.error) + this.cleanup.onDocument('canvas-change', async (event) => { + const canvasId = event.detail.canvasId + if (!canvasId) return + + this.#canvas = { id: canvasId } + this.setContainerStyle() + + // If canvas data is provided, extract image resource + if (event.detail.canvas) { + this.#canvas = event.detail.canvas + const imageResource = event.detail.canvas?.items?.[0]?.items?.[0]?.body?.id ?? event.detail.canvas?.images?.[0]?.resource?.["@id"] + if (imageResource) { + this.#lineImage.src = imageResource + } + } }) } diff --git a/components/read-only-transcribe/index.js b/components/read-only-transcribe/index.js index 6ded6e03..bbf61d1a 100644 --- a/components/read-only-transcribe/index.js +++ b/components/read-only-transcribe/index.js @@ -1,4 +1,5 @@ import { checkIfUrlExists } from '../../utilities/checkIfUrlExists.js' +import vault from '../../js/vault.js' import { CleanupRegistry } from '../../utilities/CleanupRegistry.js' /** @@ -324,12 +325,10 @@ class ReadOnlyViewTranscribe extends HTMLElement { return } - const response = await fetch(manifestUrl) - if (!response.ok) { - const errText = await response.text() - throw new Error(`GitHub read failed: ${response.status} - ${errText}`) + const manifest = await vault.get(manifestUrl, 'manifest') + if (!manifest) { + throw new Error(`Manifest could not be resolved for URL: ${manifestUrl}`) } - const manifest = await response.json() this.#staticManifest = manifest this.shadowRoot.querySelector(".transcribe-title").textContent = `Transcription for ${manifest.label.none?.[0]}` @@ -411,9 +410,9 @@ class ReadOnlyViewTranscribe extends HTMLElement { async processCanvas(uri) { if (!uri) return - let embeddedCanvas = this.#staticManifest?.items.find(c => (c.id ?? c['@id']) === uri) + let embeddedCanvas = this.#staticManifest?.items?.find(c => (c.id ?? c['@id']) === uri) // Handle both Presentation API v3 (items) and v2 (images) formats - let fullImage = embeddedCanvas?.items?.[0]?.items?.[0]?.body?.id ?? embeddedCanvas?.images?.[0]?.resource?.id + let fullImage = embeddedCanvas?.items?.[0]?.items?.[0]?.body?.id ?? embeddedCanvas?.images?.[0]?.resource?.["@id"] let imageService = embeddedCanvas?.items?.[0]?.items?.[0]?.body?.service?.id let imgx = embeddedCanvas?.items?.[0]?.items?.[0]?.body?.width let imgy = embeddedCanvas?.items?.[0]?.items?.[0]?.body?.height diff --git a/components/simple-transcription/index.js b/components/simple-transcription/index.js index 16224e50..7b5bac31 100644 --- a/components/simple-transcription/index.js +++ b/components/simple-transcription/index.js @@ -481,7 +481,7 @@ export default class SimpleTranscriptionInterface extends HTMLElement { } // Use vault.get to fetch the page properly - const fetchedPage = await vault.get(pageID, 'annotationpage', true) + let fetchedPage = await vault.getWithFallback(pageID, 'annotationpage', TPEN.activeProject?.manifest, true) if (!fetchedPage) { TPEN.eventDispatcher.dispatch("tpen-toast", { message: "Failed to load page. Please try again.", @@ -515,7 +515,7 @@ export default class SimpleTranscriptionInterface extends HTMLElement { canvasID = target.source } - const fetchedCanvas = await vault.get(canvasID, 'canvas') + let fetchedCanvas = await vault.getWithFallback(canvasID, 'canvas', TPEN.activeProject?.manifest) if (!fetchedCanvas) { TPEN.eventDispatcher.dispatch("tpen-toast", { message: "Could not load canvas. Please try again.", @@ -523,16 +523,13 @@ export default class SimpleTranscriptionInterface extends HTMLElement { }) return } - this.#canvas = fetchedCanvas - - // Get canvas dimensions (these are the authoritative dimensions for XYWH calculations) - this.#imgTopOriginalHeight = fetchedCanvas.height ?? 1000 - this.#imgTopOriginalWidth = fetchedCanvas.width ?? 1000 + this.#imgTopOriginalHeight = this.#canvas.height ?? 1000 + this.#imgTopOriginalWidth = this.#canvas.width ?? 1000 // Get the image resource from the canvas // Handle both Presentation API v3 (items) and v2 (images) formats - const imageResource = fetchedCanvas.items?.[0]?.items?.[0]?.body?.id ?? fetchedCanvas.images?.[0]?.resource?.id + const imageResource = fetchedCanvas.items?.[0]?.items?.[0]?.body?.id ?? fetchedCanvas.images?.[0]?.resource?.["@id"] if (!imageResource) { TPEN.eventDispatcher.dispatch("tpen-toast", { diff --git a/components/transcription-block/index.js b/components/transcription-block/index.js index 4d333027..7f4fe2dd 100644 --- a/components/transcription-block/index.js +++ b/components/transcription-block/index.js @@ -92,7 +92,7 @@ export default class TranscriptionBlock extends HTMLElement { */ async initializeAsync() { const pageID = TPEN.screen?.pageInQuery - this.#page = await vault.get(pageID, 'annotationpage', true) + this.#page = await vault.getWithFallback(pageID, 'annotationpage', TPEN.activeProject?.manifest, true) const projectPage = TPEN.activeProject.layers.flatMap(layer => layer.pages || []).find(p => p.id.split('/').pop() === pageID.split('/').pop()) if (!this.#page || !projectPage) return @@ -508,6 +508,7 @@ export default class TranscriptionBlock extends HTMLElement { } updateTranscriptionUI() { + if (!this.#transcriptions) return const previousLineText = this.#transcriptions[TPEN.activeLineIndex - 1] || 'No previous line' const currentLineText = this.#transcriptions[TPEN.activeLineIndex] || '' const prevLineElem = this.shadowRoot?.querySelector('.transcription-line') diff --git a/interfaces/transcription/index.js b/interfaces/transcription/index.js index 7ffa514a..f25a3d5c 100644 --- a/interfaces/transcription/index.js +++ b/interfaces/transcription/index.js @@ -611,10 +611,9 @@ export default class TranscriptionInterface extends HTMLElement { if (bottomImage) bottomImage.moveUnder(x, y, width, height, topImage) } - getImage(project) { + async getImage(project) { const imageCanvas = this.shadowRoot.querySelector('.canvas-image') let canvasID - let err = {} const allPages = project.layers.flatMap(layer => layer.pages) if (TPEN?.screen?.pageInQuery) { const matchingPage = allPages.find( @@ -625,33 +624,18 @@ export default class TranscriptionInterface extends HTMLElement { canvasID = allPages[0]?.target } - fetch(canvasID) - .then(response => { - if (response.status === 404) { - err = { "status": 404, "statusText": "Canvas not found" } - throw err - } - return response.json() - }) - .then(canvas => { - // Handle both Presentation API v3 (items) and v2 (images) formats - const imageId = canvas.items?.[0]?.items?.[0]?.body?.id ?? canvas.images?.[0]?.resource?.id - if (imageId) { - imageCanvas.src = imageId - } - else { - err = { "status": 500, "statusText": "Image could not be found in Canvas" } - throw err - } - }) - .catch(error => { - if (error?.status === 404) { - imageCanvas.src = "../../assets/images/404_PageNotFound.jpeg" - } - else { - imageCanvas.src = "../../assets/images/noimage.jpg" - } - }) + let canvas = await vault.getWithFallback(canvasID, 'canvas', TPEN.activeProject?.manifest) + if (!canvas) { + imageCanvas.src = "../../assets/images/404_PageNotFound.jpeg" + return + } + // Handle both Presentation API v3 (items) and v2 (images) formats + const imageId = canvas.items?.[0]?.items?.[0]?.body?.id ?? canvas.images?.[0]?.resource?.["@id"] + if (imageId) { + imageCanvas.src = imageId + } else { + imageCanvas.src = "../../assets/images/noimage.jpg" + } } setCanvasAndSelector(thisLine, page) { @@ -667,8 +651,8 @@ export default class TranscriptionInterface extends HTMLElement { async updateTranscriptionImages(pageID) { const topImage = this.shadowRoot.querySelector('#topImage') const bottomImage = this.shadowRoot.querySelector('#bottomImage') - topImage.manifest = bottomImage.manifest = TPEN.activeProject?.manifest[0] - this.#page = await vault.get(pageID, 'annotationpage', true) + topImage.manifest = bottomImage.manifest = TPEN.activeProject?.manifest?.[0] + this.#page = await vault.getWithFallback(pageID, 'annotationpage', TPEN.activeProject?.manifest, true) const projectPage = TPEN.activeProject.layers.flatMap(layer => layer.pages || []).find(p => p.id.split('/').pop() === pageID.split('/').pop()) if (!this.#page || !projectPage) return const { orderedItems, columnsInPage } = orderPageItemsByColumns(projectPage, this.#page) diff --git a/js/utils.js b/js/utils.js index 4f28008e..15d7b3b6 100644 --- a/js/utils.js +++ b/js/utils.js @@ -31,6 +31,13 @@ export function urlFromIdAndType(id, type, { projectId, pageId, layerId}) { case 'annotationcollection': if (!projectId) return '' return `${TPEN.servicesURL}/project/${projectId}/layer/${id}` + case 'canvas': + case 'manifest': + case 'collection': + // These should come from external IIIF manifests or be full URLs already + // If they're hex strings without a URL, they're embedded and shouldn't be fetched + // Return null to indicate no URL exists (semantically clearer than empty string) + return null default: return `${TPEN.RERUMURL}/id/${id}` } diff --git a/js/vault.js b/js/vault.js index 431bf77d..2b6b5669 100644 --- a/js/vault.js +++ b/js/vault.js @@ -1,17 +1,53 @@ // Local simulacrum vault for use in client without something like webpack import TPEN from "../api/TPEN.js" import { urlFromIdAndType } from "../js/utils.js" + +// Module-level constants to avoid recreating on every fetch +const SKIP_PROPERTIES = new Set([ + 'id', '@id', 'type', '@type', '@context', 'context', + 'metadata', 'label', 'summary', 'requiredStatement', + 'rights', 'navDate', 'language', 'format', + 'duration', 'width', 'height', + 'viewingDirection', 'behavior', 'motivation', + 'timeMode', 'thumbnail', 'placeholderCanvas', + 'accompanyingCanvas', 'provider', 'homepage', + 'logo', 'rendering', 'partOf', 'seeAlso', 'service', + 'prev', 'next', + 'selector', 'conformsTo', 'value', 'purpose', 'profile' +]) + +// IIIF resource types for both Presentation API v2 (prefixed) and v3 (unprefixed) +// v2 types use prefixes: sc: (Shared Canvas), oa: (Open Annotation) +// v3 types are unprefixed +const IIIF_RESOURCE_TYPES = new Set([ + // IIIF Presentation API v3 (unprefixed) + 'manifest', 'collection', 'canvas', 'annotation', + 'annotationpage', 'annotationcollection', 'range', + 'agent', // v3 metadata type for providers/creators + // IIIF Presentation API v2 (sc: prefix for Shared Canvas types) + 'sc:manifest', 'sc:collection', 'sc:canvas', 'sc:sequence', + 'sc:range', 'sc:layer', + // Open Annotation (oa: prefix) - v2 annotation types + 'oa:annotation', 'oa:annotationlist' // annotationlist is v2; becomes annotationpage in v3 +]) + class Vault { constructor() { this.store = new Map() + this.inFlightPromises = new Map() } _normalizeType(type) { return (type ?? '').toString().toLowerCase() || 'none' } + _normalizeId(id) { + if (typeof id !== 'string') return id + return id.split('#')[0] + } + _getId(item) { - return item?._id ?? item?.id ?? item?.['@id'] ?? item + return this._normalizeId(item?._id ?? item?.id ?? item?.['@id'] ?? item) } _cacheKey(itemType, id) { @@ -21,9 +57,19 @@ class Vault { async get(item, itemType, noCache = false) { const type = this._normalizeType(itemType ?? item?.type ?? item?.['@type']) const id = this._getId(item) - const typeStore = this.store.get(type) - let result = typeStore?.get(id) - if (result) return result + + const promiseKey = `${type}:${id}` + + if (this.inFlightPromises.has(promiseKey)) { + return this.inFlightPromises.get(promiseKey) + } + + // Skip in-memory store when noCache is true + if (!noCache) { + const typeStore = this.store.get(type) + let result = typeStore?.get(id) + if (result) return result + } const cacheKey = this._cacheKey(type, id) const cached = localStorage.getItem(cacheKey) @@ -35,16 +81,66 @@ class Vault { } catch {} } + const fetchPromise = this._fetchAndHydrate(item, type, id, cacheKey, itemType) + this.inFlightPromises.set(promiseKey, fetchPromise) + try { - const uri = urlFromIdAndType(id, type, { - projectId: TPEN.screen?.projectInQuery, - pageId: TPEN.screen?.pageInQuery, - layerId: TPEN.screen?.layerInQuery - }) - const response = await fetch(uri) - if (!response.ok) return null + return await fetchPromise + } finally { + this.inFlightPromises.delete(promiseKey) + } + } - const data = await response.json() + async _processIIIFResource(resource, visited, iiifResourceTypes) { + const resourceId = this._normalizeId(resource?.['@id'] ?? resource?.id) + const resourceType = resource?.['@type'] ?? resource?.type + const normalizedType = this._normalizeType(resourceType) + + if (resourceId && resourceType && iiifResourceTypes.has(normalizedType) && !visited.has(resourceId)) { + visited.add(resourceId) + + // Check if resource is a full embedded object vs a minimal stub/reference. + // A stub with just {id, type, label} should be fetched to get full content. + // An embedded object has properties that indicate substantial content: + // - items/annotations: arrays containing child resources (Canvas, Manifest, AnnotationPage, Collection) + // - body/target: core annotation properties (at least one present) + // - height+width: Canvas dimensions (both must be present) + // Stubs may have other metadata (label, summary, thumbnail) but lack content properties. + const hasItems = Array.isArray(resource?.items) && resource.items.length > 0 + const hasAnnotations = Array.isArray(resource?.annotations) && resource.annotations.length > 0 + const hasBody = resource?.body !== undefined + const hasTarget = resource?.target !== undefined + const hasCanvasDimensions = resource?.height !== undefined && resource?.width !== undefined + + const isEmbeddedObject = typeof resource === 'object' && resource !== null && + (hasItems || hasAnnotations || hasBody || hasTarget || hasCanvasDimensions) + + if (isEmbeddedObject) { + // For embedded objects, cache directly without fetching + this.set(resource, normalizedType) + } else { + // For ID strings or minimal references, fetch the full resource + await this.get(resource, resourceType) + } + + return { id: resourceId, type: resourceType, label: resource?.label ?? resource?.title } + } + return null + } + + async _fetchAndHydrate(item, type, id, cacheKey, itemType) { + const seed = item && typeof item === 'object' ? item : null + const hydrateFromObject = async (data) => { + if (!data || typeof data !== 'object') { + if (seed) this.set(seed, type) + return seed + } + + // Clone data before mutating to avoid corrupting caller's object + data = structuredClone(data) + + const dataType = this._normalizeType(data?.['@type'] ?? data?.type ?? type) + const hasKnownType = dataType && dataType !== 'none' const queue = [{ obj: data, depth: 0 }] const visited = new Set() @@ -53,33 +149,63 @@ class Vault { if (depth >= 4 || !obj || typeof obj !== 'object') continue for (const key of Object.keys(obj)) { + // Skip known non-resource properties + if (SKIP_PROPERTIES.has(key)) continue + const value = obj[key] + + // Skip if we've already processed this value + const valueId = this._normalizeId(value?.['@id'] ?? value?.id) + if ((valueId && visited.has(valueId)) || (typeof value === 'string' && visited.has(value))) { + continue + } + if (Array.isArray(value)) { for (const item of value) { - if (item && typeof item === 'object') queue.push({ obj: item, depth: depth + 1 }) + if (item && typeof item === 'object') { + queue.push({ obj: item, depth: depth + 1 }) + // Process IIIF resources in arrays (e.g., canvases in manifest.items) + await this._processIIIFResource(item, visited, IIIF_RESOURCE_TYPES) + } } } else if (value && typeof value === 'object') { queue.push({ obj: value, depth: depth + 1 }) } - const id = value?.['@id'] ?? value?.id - const type = value?.['@type'] ?? value?.type - if (id && type && !visited.has(id)) { - visited.add(id) - this.get(id, type) - // Project embedded object to minimal form - const label = value?.label ?? value?.title - obj[key] = { id, type, ...(label && { label }) } + // Handle objects with id and type properties (non-array values) + const processed = await this._processIIIFResource(value, visited, IIIF_RESOURCE_TYPES) + if (processed) { + obj[key] = processed } } } - this.set(data, itemType) - try { - localStorage.setItem(cacheKey, JSON.stringify(data)) - } catch {} + const storageType = hasKnownType ? dataType : type + this.set(data, storageType) return data - } catch { - return + } + + try { + const uri = urlFromIdAndType(id, type, { + projectId: TPEN.screen?.projectInQuery, + pageId: TPEN.screen?.pageInQuery, + layerId: TPEN.screen?.layerInQuery + }) + // Guard against null/falsy URIs (e.g., for IIIF resources without URLs) + if (!uri) { + if (seed) return hydrateFromObject(seed) + return null + } + const response = await fetch(uri) + if (!response.ok) { + if (seed) return hydrateFromObject(seed) + return null + } + + const data = await response.json() + return hydrateFromObject(data) + } catch (err) { + if (seed) return hydrateFromObject(seed) + return null } } @@ -118,7 +244,49 @@ class Vault { } all() { - return Object.values(this.store) + return [...this.store.values()] + } + + /** + * Get a resource with fallback to prefetch manifests if not found. + * This consolidates the common pattern of retrying after prefetching manifests. + * @param {*} item - Resource ID or object to fetch + * @param {string} itemType - Resource type (e.g., 'canvas', 'annotationpage') + * @param {string|string[]} manifestUrls - Manifest URL(s) to prefetch if resource not found + * @param {boolean} noCache - Force fresh fetch (bypasses all caches) + * @returns {Promise<*>} The resolved resource or null + */ + async getWithFallback(item, itemType, manifestUrls, noCache = false) { + let result = await this.get(item, itemType, noCache) + if (!result && manifestUrls) { + const urls = Array.isArray(manifestUrls) ? manifestUrls : [manifestUrls] + await this.prefetchManifests(urls) + result = await this.get(item, itemType, noCache) + } + return result + } + + async prefetchDocuments(items, docType) { + if (!Array.isArray(items)) items = [items] + const errors = [] + const promises = items.map(item => { + const type = docType ?? item?.['@type'] ?? item?.type + return this.get(item, type) + .catch(err => { + errors.push({ item, error: err?.message || String(err) }) + return null + }) + }) + await Promise.all(promises) + return errors + } + + async prefetchManifests(items) { + return this.prefetchDocuments(items, 'manifest') + } + + async prefetchCollections(items) { + return this.prefetchDocuments(items, 'collection') } }