UNPKG

@digitalculture/ochre-sdk

Version:

Node.js library for working with OCHRE (Online Cultural and Historical Research Environment) data

1,163 lines (1,156 loc) 189 kB
import * as z from "zod"; import { UTCDate } from "@date-fns/utc"; import { parseISO, set } from "date-fns"; //#region src/utils/internal.ts /** * Get the category of an item from the OCHRE API response * @param keys - The keys of the OCHRE API response * @returns The category of the item * @internal */ function getItemCategory(keys) { const categoryFound = keys.find((key) => categorySchema.safeParse(key).success); if (!categoryFound) { const unknownKey = keys.find((key) => ![ "uuid", "uuidBelongsTo", "belongsTo", "publicationDateTime", "metadata", "languages" ].includes(key)); throw new Error(`Invalid OCHRE data; found unexpected "${unknownKey}" key`); } return categorySchema.parse(categoryFound); } /** * Get the categories of items from the OCHRE API response * @param keys - The keys of the OCHRE API response * @returns The categories of the items * @internal */ function getItemCategories(keys) { const categories = keys.map((key) => categorySchema.safeParse(key)); if (categories.some((result) => !result.success)) throw new Error(`Invalid OCHRE data; found unexpected keys: ${categories.filter((result) => !result.success).map((result) => result.error.message).join(", ")}`); return categories.filter((result) => result.success).map((result) => result.data); } /** * Validates a pseudo-UUID string * @param value - The string to validate * @returns True if the string is a valid pseudo-UUID, false otherwise * @internal */ function isPseudoUuid(value) { return /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(value); } /** * Flatten a properties array * @param properties - The properties to flatten * @returns The flattened properties * @internal */ function flattenProperties(properties) { const result = []; const queue = [...properties]; while (queue.length > 0) { const { properties: nestedProperties, ...rest } = queue.shift(); result.push({ ...rest, properties: [] }); if (nestedProperties.length > 0) queue.push(...nestedProperties); } return result; } //#endregion //#region src/schemas.ts /** * Schema for validating UUIDs * @internal */ const uuidSchema = z.string().refine(isPseudoUuid, { error: "Invalid pseudo-UUID" }); const richTextStringContentSchema = z.object({ content: z.union([ z.string(), z.number(), z.boolean() ]).optional(), rend: z.string().optional(), whitespace: z.string().optional() }); /** * Schema for validating rich text string content * @internal */ const richTextStringSchema = z.object({ string: z.union([ z.string(), z.number(), z.boolean(), richTextStringContentSchema, z.array(richTextStringContentSchema) ]), lang: z.string().optional() }); /** * Schema for validating identification * @internal */ const identificationSchema = z.object({ label: z.object({ content: z.union([richTextStringSchema, z.array(richTextStringSchema)]) }), abbreviation: z.object({ content: z.union([richTextStringSchema, z.array(richTextStringSchema)]).optional() }), code: z.string().optional() }); /** * Schema for validating filters * @internal */ const filterSchema = z.string().optional(); /** * Schema for validating data options * @internal */ const dataOptionsSchema = z.object({ filter: z.string().optional().default(""), start: z.number().positive({ error: "Start must be positive" }).optional().default(1), limit: z.number().positive({ error: "Limit must be positive" }).optional().default(40) }).optional().default({ filter: "", start: 1, limit: 40 }); const apiVersionSuffixSchema = z.enum(["-v1", "-v2"]).transform((suffix) => Number.parseInt(suffix.replace("-v", ""), 10)); /** * Schema for validating website properties * @internal */ const websiteSchema = z.object({ type: z.enum([ "traditional", "digital-collection", "plum", "cedar", "elm", "maple", "oak", "palm" ], { error: "Invalid website type" }), status: z.enum([ "development", "preview", "production" ], { error: "Invalid website status" }), privacy: z.enum([ "public", "password", "private" ], { error: "Invalid website privacy" }) }); /** * Valid component types for web elements * @internal */ const componentSchema = z.enum([ "3d-viewer", "advanced-search", "annotated-document", "annotated-image", "audio-player", "bibliography", "button", "collection", "empty-space", "entries", "iframe", "iiif-viewer", "image", "image-gallery", "map", "network-graph", "query", "search-bar", "table", "text", "timeline", "video" ], { error: "Invalid component" }); /** * Schema for validating data categories * @internal */ const categorySchema = z.enum([ "resource", "spatialUnit", "concept", "period", "bibliography", "person", "propertyVariable", "propertyValue", "text", "tree", "set" ]); /** * Schema for validating property value content types * @internal */ const propertyValueContentTypeSchema = z.enum([ "string", "integer", "decimal", "boolean", "date", "dateTime", "time", "coordinate", "IDREF" ]); /** * Schema for validating gallery parameters * @internal */ const gallerySchema = z.object({ uuid: z.string().refine(isPseudoUuid, { error: "Invalid UUID" }), filter: z.string().optional(), page: z.number().positive({ error: "Page must be positive" }), pageSize: z.number().positive({ error: "Page size must be positive" }) }).strict(); /** * Schema for validating and parsing render options * @internal */ const renderOptionsSchema = z.string().transform((str) => str.split(" ")).pipe(z.array(z.enum([ "bold", "italic", "underline" ]))); /** * Schema for validating and parsing whitespace options * @internal */ const whitespaceSchema = z.string().transform((str) => str.split(" ")).pipe(z.array(z.enum([ "newline", "trailing", "leading" ]))); /** * Schema for validating email addresses * @internal */ const emailSchema = z.email({ error: "Invalid email" }); /** * Schema for parsing and validating a string in the format "[[number, number], [number, number]]" * into an array with exactly two bounds * @internal */ const boundsSchema = z.string().transform((str, ctx) => { const trimmed = str.trim(); if (!trimmed.startsWith("[[") || !trimmed.endsWith("]]")) { ctx.addIssue({ code: "invalid_format", format: "string", message: "String must start with '[[' and end with ']]'" }); return z.NEVER; } try { return JSON.parse(trimmed); } catch { ctx.addIssue({ code: "invalid_format", format: "string", message: "Invalid JSON format" }); return z.NEVER; } }).pipe(z.tuple([z.tuple([z.number(), z.number()]), z.tuple([z.number(), z.number()])], { message: "Must contain exactly 2 coordinate pairs" })); //#endregion //#region src/utils/getters.ts const DEFAULT_OPTIONS = { includeNestedProperties: false }; /** * Finds a property by its UUID in an array of properties * * @param properties - Array of properties to search through * @param uuid - The UUID to search for * @param options - Search options, including whether to include nested properties * @returns The matching Property object, or null if not found */ function getPropertyByUuid(properties, uuid, options = DEFAULT_OPTIONS) { const { includeNestedProperties } = options; const property = properties.find((property) => property.uuid === uuid); if (property) return property; if (includeNestedProperties) { for (const property of properties) if (property.properties.length > 0) { const nestedResult = getPropertyByUuid(property.properties, uuid, { includeNestedProperties }); if (nestedResult) return nestedResult; } } return null; } /** * Retrieves all values for a property with the given UUID * * @param properties - Array of properties to search through * @param uuid - The UUID to search for * @param options - Search options, including whether to include nested properties * @returns Array of property values as strings, or null if property not found */ function getPropertyValuesByUuid(properties, uuid, options = DEFAULT_OPTIONS) { const { includeNestedProperties } = options; const property = properties.find((property) => property.uuid === uuid); if (property) return property.values.map((value) => value.content); if (includeNestedProperties) { for (const property of properties) if (property.properties.length > 0) { const nestedResult = getPropertyValuesByUuid(property.properties, uuid, { includeNestedProperties }); if (nestedResult) return nestedResult; } } return null; } /** * Gets the first value of a property with the given UUID * * @param properties - Array of properties to search through * @param uuid - The UUID to search for * @param options - Search options, including whether to include nested properties * @returns The first property value as string, or null if property not found */ function getPropertyValueByUuid(properties, uuid, options = DEFAULT_OPTIONS) { const { includeNestedProperties } = options; const values = getPropertyValuesByUuid(properties, uuid, { includeNestedProperties }); if (values !== null && values.length > 0) return values[0]; if (includeNestedProperties) { for (const property of properties) if (property.properties.length > 0) { const nestedResult = getPropertyValueByUuid(property.properties, uuid, { includeNestedProperties }); if (nestedResult !== null) return nestedResult; } } return null; } /** * Finds a property by its label in an array of properties * * @param properties - Array of properties to search through * @param label - The label to search for * @param options - Search options, including whether to include nested properties * @returns The matching Property object, or null if not found */ function getPropertyByLabel(properties, label, options = DEFAULT_OPTIONS) { const { includeNestedProperties } = options; const property = properties.find((property) => property.label === label); if (property) return property; if (includeNestedProperties) { for (const property of properties) if (property.properties.length > 0) { const nestedResult = getPropertyByLabel(property.properties, label, { includeNestedProperties }); if (nestedResult) return nestedResult; } } return null; } /** * Retrieves all values for a property with the given label * * @param properties - Array of properties to search through * @param label - The label to search for * @param options - Search options, including whether to include nested properties * @returns Array of property values as strings, or null if property not found */ function getPropertyValuesByLabel(properties, label, options = DEFAULT_OPTIONS) { const { includeNestedProperties } = options; const property = properties.find((property) => property.label === label); if (property) return property.values.map((value) => value.content); if (includeNestedProperties) { for (const property of properties) if (property.properties.length > 0) { const nestedResult = getPropertyValuesByLabel(property.properties, label, { includeNestedProperties }); if (nestedResult) return nestedResult; } } return null; } /** * Gets the first value of a property with the given label * * @param properties - Array of properties to search through * @param label - The label to search for * @param options - Search options, including whether to include nested properties * @returns The first property value as string, or null if property not found */ function getPropertyValueByLabel(properties, label, options = DEFAULT_OPTIONS) { const { includeNestedProperties } = options; const values = getPropertyValuesByLabel(properties, label, { includeNestedProperties }); if (values !== null && values.length > 0) return values[0]; if (includeNestedProperties) { for (const property of properties) if (property.properties.length > 0) { const nestedResult = getPropertyValueByLabel(property.properties, label, { includeNestedProperties }); if (nestedResult !== null) return nestedResult; } } return null; } /** * Gets all unique properties from an array of properties * * @param properties - Array of properties to get unique properties from * @param options - Search options, including whether to include nested properties * @returns Array of unique properties */ function getUniqueProperties(properties, options = DEFAULT_OPTIONS) { const { includeNestedProperties } = options; const uniqueProperties = new Array(); for (const property of properties) { if (uniqueProperties.some((p) => p.uuid === property.uuid)) continue; uniqueProperties.push(property); if (property.properties.length > 0 && includeNestedProperties) { const nestedProperties = getUniqueProperties(property.properties, { includeNestedProperties: true }); for (const property of nestedProperties) { if (uniqueProperties.some((p) => p.uuid === property.uuid)) continue; uniqueProperties.push(property); } } } return uniqueProperties; } /** * Gets all unique property labels from an array of properties * * @param properties - Array of properties to get unique property labels from * @param options - Search options, including whether to include nested properties * @returns Array of unique property labels */ function getUniquePropertyLabels(properties, options = DEFAULT_OPTIONS) { const { includeNestedProperties } = options; const uniquePropertyLabels = new Array(); for (const property of properties) { if (uniquePropertyLabels.includes(property.label)) continue; uniquePropertyLabels.push(property.label); if (property.properties.length > 0 && includeNestedProperties) { const nestedProperties = getUniquePropertyLabels(property.properties, { includeNestedProperties: true }); for (const property of nestedProperties) { if (uniquePropertyLabels.includes(property)) continue; uniquePropertyLabels.push(property); } } } return uniquePropertyLabels; } /** * Filters a property based on a label and value criteria * * @param property - The property to filter * @param filter - Filter criteria containing label and value to match * @param filter.label - The label to filter by * @param filter.value - The value to filter by * @param options - Search options, including whether to include nested properties * @returns True if the property matches the filter criteria, false otherwise */ function filterProperties(property, filter, options = DEFAULT_OPTIONS) { const { includeNestedProperties } = options; if (filter.label.toLocaleLowerCase("en-US") === "all fields" || property.label.toLocaleLowerCase("en-US") === filter.label.toLocaleLowerCase("en-US")) { let isFound = property.values.some((value) => { if (value.content === null) return false; if (typeof value.content === "string") { if (typeof filter.value !== "string") return false; return value.content.toLocaleLowerCase("en-US").includes(filter.value.toLocaleLowerCase("en-US")); } if (typeof value.content === "number") { if (typeof filter.value !== "number") return false; return value.content === filter.value; } if (typeof value.content === "boolean") { if (typeof filter.value !== "boolean") return false; return value.content === filter.value; } return false; }); if (!isFound && includeNestedProperties) isFound = property.properties.some((property) => filterProperties(property, filter, { includeNestedProperties: true })); return isFound; } return false; } //#endregion //#region src/constants.ts const BELONGS_TO_COLLECTION_UUID = "30054cb2-909a-4f34-8db9-8fe7369d691d"; const PRESENTATION_ITEM_UUID = "f1c131b6-1498-48a4-95bf-a9edae9fd518"; const TEXT_ANNOTATION_UUID = "b9ca2732-78f4-416e-b77f-dae7647e68a9"; const TEXT_ANNOTATION_HOVER_CARD_UUID = "c7f6a08a-f07b-49b6-bcb1-af485da3c58f"; const TEXT_ANNOTATION_ITEM_PAGE_VARIANT_UUID = "bf4476ab-6bc8-40d0-a001-1446213c72ce"; const TEXT_ANNOTATION_ENTRY_PAGE_VARIANT_UUID = "9d52db95-a9cf-45f7-a0bf-fc9ba9f0aae0"; const TEXT_ANNOTATION_TEXT_STYLING_UUID = "3e6f86ab-df81-45ae-8257-e2867357df56"; const TEXT_ANNOTATION_TEXT_STYLING_VARIANT_UUID = "e1647bef-d801-4100-bdde-d081c422f763"; const TEXT_ANNOTATION_TEXT_STYLING_HEADING_LEVEL_UUID = "d4266f0b-3f8d-4b32-8c15-4b229c8bb11e"; //#endregion //#region src/utils/string.ts /** * Finds a string item in an array by language code * * @param content - Array of string items to search * @param language - Language code to search for * @returns Matching string item or null if not found * @internal */ function getStringItemByLanguage(content, language) { return content.find((item) => item.lang === language) ?? null; } /** * Parses email addresses in a string into HTML links * * @param string - Input string to parse * @returns String with emails converted to HTML links */ function parseEmail(string) { const splitString = string.split(" "); const returnSplitString = []; for (const string of splitString) { const cleanString = string.replaceAll(/(?<=\s|^)[([{]+|[)\]}]+(?=\s|$)/g, "").replaceAll(/[!),:;?\]]/g, "").replace(/\.$/, ""); const index = string.indexOf(cleanString); const before = string.slice(0, index); const after = string.slice(index + cleanString.length); if (emailSchema.safeParse(cleanString).success) { returnSplitString.push(`${before}<ExternalLink href="mailto:${cleanString}">${cleanString}</ExternalLink>${after}`); continue; } returnSplitString.push(string); } return returnSplitString.join(" "); } /** * Applies text rendering options (bold, italic, underline) to a string * * @param contentString - The string content to render * @param renderString - Space-separated string of render options * @returns String with markdown formatting applied * @internal */ function parseRenderOptions(contentString, renderString) { let returnString = contentString; const result = renderOptionsSchema.safeParse(renderString); if (!result.success) { console.warn(`Invalid render options string provided: “${renderString}”`); return contentString; } for (const option of result.data) switch (option) { case "bold": returnString = `**${returnString}**`; break; case "italic": returnString = `*${returnString}*`; break; case "underline": returnString = `_${returnString}_`; break; } return returnString.replaceAll("&#39;", "'"); } /** * Applies whitespace options to a string (newline, trailing, leading) * * @param contentString - The string content to modify * @param whitespace - Space-separated string of whitespace options * @returns String with whitespace modifications applied * @internal */ function parseWhitespace(contentString, whitespace) { let returnString = contentString; const result = whitespaceSchema.safeParse(whitespace); if (!result.success) { console.warn(`Invalid whitespace string provided: “${whitespace}”`); return contentString; } for (const option of result.data) switch (option) { case "newline": if (returnString.trim() === "***") returnString = `${returnString}\n`; else returnString = `<br />\n${returnString}`; break; case "trailing": returnString = `${returnString} `; break; case "leading": returnString = ` ${returnString}`; break; } return returnString.replaceAll("&#39;", "'"); } /** * Converts a FakeString (string|number|boolean) to a proper string * * @param string - FakeString value to convert * @returns Converted string value */ function parseFakeString(string) { return String(string).replaceAll("&#39;", "'").replaceAll("{", String.raw`\{`).replaceAll("}", String.raw`\}`); } /** * Parses a rich text item's string field, applying rend styling and preserving whitespace * * @param stringField - The string field from a rich text item (FakeString or OchreStringRichTextItemContent) * @returns Object containing styled content and optional whitespace to apply after MDX wrapping * @internal */ function parseRichTextItemString(stringField) { if (stringField == null) return { content: "", whitespace: null }; if (typeof stringField === "string" || typeof stringField === "number" || typeof stringField === "boolean") return { content: parseFakeString(stringField).replaceAll("<", String.raw`\<`).replaceAll("{", String.raw`\{`), whitespace: null }; let content = parseFakeString(stringField.content).replaceAll("<", String.raw`\<`).replaceAll("{", String.raw`\{`); if (stringField.rend != null) content = parseRenderOptions(content, stringField.rend); return { content, whitespace: stringField.whitespace ?? null }; } /** * Applies whitespace to a result string if whitespace is provided * * @param result - The string to apply whitespace to * @param whitespace - Optional whitespace string to apply * @returns String with whitespace applied, or original string if no whitespace * @internal */ function applyWhitespaceToResult(result, whitespace) { if (whitespace != null) return parseWhitespace(result, whitespace); return result; } /** * Extracts annotation metadata from item properties (link variants and text styling) * * @param item - Rich text item that may contain properties * @returns Annotation metadata including link variant and text styling info * @internal */ function extractAnnotationMetadata(item) { const result = { linkVariant: null, textStyling: null }; if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") return result; if (!("properties" in item) || item.properties == null) return result; const itemProperty = Array.isArray(item.properties.property) ? item.properties.property[0] : item.properties.property; if (itemProperty == null) return result; const itemPropertyLabelUuid = itemProperty.label.uuid; const itemPropertyValueUuid = typeof itemProperty.value === "object" && "uuid" in itemProperty.value && itemProperty.value.uuid != null ? itemProperty.value.uuid : null; if (itemPropertyLabelUuid !== PRESENTATION_ITEM_UUID || itemPropertyValueUuid !== TEXT_ANNOTATION_UUID) return result; const textAnnotationProperties = itemProperty.property != null ? Array.isArray(itemProperty.property) ? itemProperty.property : [itemProperty.property] : []; for (const textAnnotationProperty of textAnnotationProperties) { const textAnnotationPropertyValueUuid = typeof textAnnotationProperty.value === "object" && "uuid" in textAnnotationProperty.value && textAnnotationProperty.value.uuid != null ? textAnnotationProperty.value.uuid : null; switch (textAnnotationPropertyValueUuid) { case TEXT_ANNOTATION_HOVER_CARD_UUID: result.linkVariant = "hover-card"; break; case TEXT_ANNOTATION_ITEM_PAGE_VARIANT_UUID: result.linkVariant = "item-page"; break; case TEXT_ANNOTATION_ENTRY_PAGE_VARIANT_UUID: result.linkVariant = "entry-page"; break; default: if (textAnnotationPropertyValueUuid === TEXT_ANNOTATION_TEXT_STYLING_UUID && textAnnotationProperty.property != null) { let textStylingVariant = "block"; let textStylingSize = "md"; let textStylingHeadingLevel = null; let textStylingCss = []; const textStylingProperties = Array.isArray(textAnnotationProperty.property) ? textAnnotationProperty.property : [textAnnotationProperty.property]; const textStylingVariantProperty = textStylingProperties.find((property) => property.label.uuid === TEXT_ANNOTATION_TEXT_STYLING_VARIANT_UUID); if (textStylingVariantProperty != null) { const textStylingPropertyVariant = parseFakeString(textStylingVariantProperty.value.content); const textStylingSizeProperty = (textStylingVariantProperty.property != null ? Array.isArray(textStylingVariantProperty.property) ? textStylingVariantProperty.property : [textStylingVariantProperty.property] : []).find((prop) => { return parseFakeString(prop.label.content) === "size"; }); if (textStylingSizeProperty != null) textStylingSize = parseFakeString(textStylingSizeProperty.value.content); textStylingVariant = textStylingPropertyVariant; } const textStylingHeadingLevelProperty = textStylingProperties.find((property) => property.label.uuid === TEXT_ANNOTATION_TEXT_STYLING_HEADING_LEVEL_UUID); if (textStylingHeadingLevelProperty != null) textStylingHeadingLevel = parseFakeString(textStylingHeadingLevelProperty.value.content); const textStylingCssProperties = textStylingProperties.filter((property) => property.label.uuid !== TEXT_ANNOTATION_TEXT_STYLING_VARIANT_UUID && property.label.uuid !== TEXT_ANNOTATION_TEXT_STYLING_HEADING_LEVEL_UUID); if (textStylingCssProperties.length > 0) textStylingCss = textStylingCssProperties.map((property) => ({ label: parseFakeString(property.label.content), value: parseFakeString(property.value.content) })); result.textStyling = { variant: textStylingVariant, size: textStylingSize, headingLevel: textStylingHeadingLevel, cssStyles: textStylingCss }; } } } return result; } /** * Wraps content with text styling annotation if provided * * @param content - The content to wrap * @param textStyling - Text styling metadata or null * @returns Content wrapped with Annotation element if styling exists, or original content * @internal */ function wrapWithTextStyling(content, textStyling) { if (textStyling == null) return content; return `<Annotation type="text-styling" variant="${textStyling.variant}" size="${textStyling.size}"${textStyling.headingLevel != null ? ` headingLevel="${textStyling.headingLevel}"` : ""}${textStyling.cssStyles.length > 0 ? ` cssStyles={{default: ${JSON.stringify(textStyling.cssStyles)}, tablet: [], mobile: []}}` : ""}>${content}</Annotation>`; } /** * Parses an OchreStringItem into a formatted string * * @param item - OchreStringItem to parse * @returns Formatted string with applied rendering and whitespace */ function parseStringItem(item) { let returnString = ""; switch (typeof item.string) { case "string": case "number": case "boolean": returnString = parseFakeString(item.string); break; case "object": { const stringItems = Array.isArray(item.string) ? item.string : [item.string]; for (const stringItem of stringItems) if (typeof stringItem === "string" || typeof stringItem === "number" || typeof stringItem === "boolean") returnString += parseFakeString(stringItem); else if ("string" in stringItem) returnString += parseStringDocumentItem(stringItem); else { const renderedText = stringItem.content == null ? "" : stringItem.rend != null ? parseRenderOptions(parseFakeString(stringItem.content), stringItem.rend) : parseFakeString(stringItem.content); const whitespacedText = stringItem.whitespace != null ? parseWhitespace(renderedText, stringItem.whitespace) : renderedText; returnString += whitespacedText; } break; } default: returnString = ""; break; } return returnString; } /** * Parses rich text content into a formatted string with links and annotations * * @param item - Rich text item to parse * @returns Formatted string with HTML/markdown elements */ function parseStringDocumentItem(item) { if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") return parseEmail(parseFakeString(item)); if ("whitespace" in item && !("content" in item) && !("string" in item)) if (item.whitespace === "newline") return " \n"; else return parseWhitespace("", item.whitespace); if ("links" in item) { const { content: itemString, whitespace: itemWhitespace } = parseRichTextItemString(item.string); const itemLinks = Array.isArray(item.links) ? item.links : [item.links]; for (const link of itemLinks) if ("resource" in link) { const linkResource = Array.isArray(link.resource) ? link.resource[0] : link.resource; let linkContent = null; if (linkResource.content != null) linkContent = parseFakeString(linkResource.content).replaceAll("<", String.raw`\<`).replaceAll("{", String.raw`\{`); switch (linkResource.type) { case "IIIF": case "image": if (linkResource.rend === "inline") return applyWhitespaceToResult(`<InlineImage uuid="${linkResource.uuid}" ${linkContent !== null ? `content="${linkContent}"` : ""} height={${linkResource.height?.toString() ?? "null"}} width={${linkResource.width?.toString() ?? "null"}} />`, itemWhitespace); else if (linkResource.publicationDateTime != null) { const annotationMetadata = extractAnnotationMetadata(item); const innerContent = wrapWithTextStyling(itemString, annotationMetadata.textStyling); let linkElement; switch (annotationMetadata.linkVariant) { case "hover-card": linkElement = `<Annotation type="hover-card" uuid="${linkResource.uuid}">${innerContent}</Annotation>`; break; case "item-page": linkElement = `<InternalLink type="item" uuid="${linkResource.uuid}">${innerContent}</InternalLink>`; break; case "entry-page": linkElement = `<InternalLink type="entry" uuid="${linkResource.uuid}">${innerContent}</InternalLink>`; break; default: linkElement = `<InternalLink uuid="${linkResource.uuid}">${innerContent}</InternalLink>`; } return applyWhitespaceToResult(linkElement, itemWhitespace); } else return applyWhitespaceToResult(`<TooltipSpan${linkContent !== null ? ` content="${linkContent}"` : ""}>${itemString}</TooltipSpan>`, itemWhitespace); case "internalDocument": { const annotationMetadata = extractAnnotationMetadata(item); const innerContent = wrapWithTextStyling(itemString, annotationMetadata.textStyling); let linkElement; switch (annotationMetadata.linkVariant) { case "hover-card": linkElement = `<Annotation type="hover-card" uuid="${linkResource.uuid}">${innerContent}</Annotation>`; break; case "item-page": linkElement = `<InternalLink type="item" uuid="${linkResource.uuid}">${innerContent}</InternalLink>`; break; case "entry-page": linkElement = `<InternalLink type="entry" uuid="${linkResource.uuid}">${innerContent}</InternalLink>`; break; default: if ("properties" in item && item.properties != null) { const itemProperty = Array.isArray(item.properties.property) ? item.properties.property[0] : item.properties.property; if (itemProperty != null) { const itemPropertyLabelUuid = itemProperty.label.uuid; const itemPropertyValueUuid = typeof itemProperty.value === "object" && "uuid" in itemProperty.value && itemProperty.value.uuid != null ? itemProperty.value.uuid : null; linkElement = `<InternalLink uuid="${linkResource.uuid}" properties="${itemPropertyLabelUuid}"${itemPropertyValueUuid !== null ? ` value="${itemPropertyValueUuid}"` : ""}>${innerContent}</InternalLink>`; } else linkElement = `<InternalLink uuid="${linkResource.uuid}">${innerContent}</InternalLink>`; } else linkElement = `<InternalLink uuid="${linkResource.uuid}">${innerContent}</InternalLink>`; } return applyWhitespaceToResult(linkElement, itemWhitespace); } case "externalDocument": if (linkResource.publicationDateTime != null) return applyWhitespaceToResult(String.raw`<ExternalLink href="https:\/\/ochre.lib.uchicago.edu/ochre?uuid=${linkResource.uuid}&load" ${linkContent !== null ? `content="${linkContent}"` : ""}>${itemString}</ExternalLink>`, itemWhitespace); else return applyWhitespaceToResult(`<TooltipSpan${linkContent !== null ? ` content="${linkContent}"` : ""}>${itemString}</TooltipSpan>`, itemWhitespace); case "webpage": return applyWhitespaceToResult(`<ExternalLink href="${linkResource.href}" ${linkContent !== null ? `content="${linkContent}"` : ""}>${itemString}</ExternalLink>`, itemWhitespace); default: return ""; } } else if ("spatialUnit" in link) { const linkSpatialUnit = Array.isArray(link.spatialUnit) ? link.spatialUnit[0] : link.spatialUnit; if (linkSpatialUnit.publicationDateTime != null) return applyWhitespaceToResult(`<InternalLink uuid="${linkSpatialUnit.uuid}">${itemString}</InternalLink>`, itemWhitespace); else return applyWhitespaceToResult(`<TooltipSpan>${itemString}</TooltipSpan>`, itemWhitespace); } else if ("concept" in link) { const linkConcept = Array.isArray(link.concept) ? link.concept[0] : link.concept; if (linkConcept.publicationDateTime != null) return applyWhitespaceToResult(`<InternalLink uuid="${linkConcept.uuid}">${itemString}</InternalLink>`, itemWhitespace); else return applyWhitespaceToResult(`<TooltipSpan>${itemString}</TooltipSpan>`, itemWhitespace); } else if ("set" in link) { const linkSet = Array.isArray(link.set) ? link.set[0] : link.set; if (linkSet.publicationDateTime != null) return applyWhitespaceToResult(`<InternalLink uuid="${linkSet.uuid}">${itemString}</InternalLink>`, itemWhitespace); else return applyWhitespaceToResult(`<TooltipSpan>${itemString}</TooltipSpan>`, itemWhitespace); } else if ("person" in link) { const linkPerson = Array.isArray(link.person) ? link.person[0] : link.person; const linkContent = linkPerson.identification ? [ "string", "number", "boolean" ].includes(typeof linkPerson.identification.label) ? parseFakeString(linkPerson.identification.label) : parseStringContent(linkPerson.identification.label) : null; if (linkPerson.publicationDateTime != null) return applyWhitespaceToResult(`<InternalLink uuid="${linkPerson.uuid}">${itemString}</InternalLink>`, itemWhitespace); else return applyWhitespaceToResult(`<TooltipSpan${linkContent !== null ? ` content="${linkContent}"` : ""}>${itemString}</TooltipSpan>`, itemWhitespace); } else if ("bibliography" in link) { const linkBibliography = Array.isArray(link.bibliography) ? link.bibliography[0] : link.bibliography; if (linkBibliography.publicationDateTime != null) return applyWhitespaceToResult(`<InternalLink uuid="${linkBibliography.uuid}">${itemString}</InternalLink>`, itemWhitespace); else return applyWhitespaceToResult(`<TooltipSpan>${itemString}</TooltipSpan>`, itemWhitespace); } } if ("properties" in item && item.properties != null) { const { content: itemString, whitespace: itemWhitespace } = parseRichTextItemString(item.string); const itemProperty = Array.isArray(item.properties.property) ? item.properties.property[0] : item.properties.property; if (itemProperty != null) { const itemPropertyLabelUuid = itemProperty.label.uuid; const itemPropertyValueUuid = typeof itemProperty.value === "object" && "uuid" in itemProperty.value && itemProperty.value.uuid != null ? itemProperty.value.uuid : null; if (itemPropertyLabelUuid === PRESENTATION_ITEM_UUID && itemPropertyValueUuid === TEXT_ANNOTATION_UUID) { const textAnnotationProperty = itemProperty.property != null ? Array.isArray(itemProperty.property) ? itemProperty.property[0] : itemProperty.property : null; if (textAnnotationProperty != null) { if ((typeof textAnnotationProperty.value === "object" && "uuid" in textAnnotationProperty.value && textAnnotationProperty.value.uuid != null ? textAnnotationProperty.value.uuid : null) === TEXT_ANNOTATION_TEXT_STYLING_UUID && textAnnotationProperty.property != null) { const textStylingType = "text-styling"; let textStylingVariant = "block"; let textStylingSize = "md"; let textStylingHeadingLevel = null; let textStylingCss = []; const textStylingProperties = Array.isArray(textAnnotationProperty.property) ? textAnnotationProperty.property : [textAnnotationProperty.property]; if (textStylingProperties.length > 0) { const textStylingVariantProperty = textStylingProperties.find((property) => property.label.uuid === TEXT_ANNOTATION_TEXT_STYLING_VARIANT_UUID); if (textStylingVariantProperty != null) { const textStylingPropertyVariant = parseFakeString(textStylingVariantProperty.value.content); const textStylingSizeProperty = textStylingVariantProperty.property != null ? Array.isArray(textStylingVariantProperty.property) ? textStylingVariantProperty.property[0] : textStylingVariantProperty.property : null; if (textStylingSizeProperty != null) textStylingSize = parseFakeString(textStylingSizeProperty.value.content); textStylingVariant = textStylingPropertyVariant; } const textStylingHeadingLevelProperty = textStylingProperties.find((property) => property.label.uuid === TEXT_ANNOTATION_TEXT_STYLING_HEADING_LEVEL_UUID); if (textStylingHeadingLevelProperty != null) textStylingHeadingLevel = parseFakeString(textStylingHeadingLevelProperty.value.content); const textStylingCssProperties = textStylingProperties.filter((property) => property.label.uuid !== TEXT_ANNOTATION_TEXT_STYLING_VARIANT_UUID && property.label.uuid !== TEXT_ANNOTATION_TEXT_STYLING_HEADING_LEVEL_UUID); if (textStylingCssProperties.length > 0) textStylingCss = textStylingCssProperties.map((property) => ({ label: parseFakeString(property.label.content), value: parseFakeString(property.value.content) })); } return applyWhitespaceToResult(`<Annotation type="${textStylingType}" variant="${textStylingVariant}" size="${textStylingSize}"${textStylingHeadingLevel != null ? ` headingLevel="${textStylingHeadingLevel}"` : ""}${textStylingCss.length > 0 ? ` cssStyles={{default: ${JSON.stringify(textStylingCss)}, tablet: [], mobile: []}}` : ""}>${itemString}</Annotation>`, itemWhitespace); } } } } } let returnString = ""; if ("string" in item) { const stringItems = Array.isArray(item.string) ? item.string : [item.string]; for (const stringItem of stringItems) returnString += parseStringDocumentItem(stringItem); if ("whitespace" in item && item.whitespace != null) returnString = parseWhitespace(parseEmail(returnString), item.whitespace); return returnString.replaceAll("&#39;", "'"); } else { returnString = parseFakeString(item.content); if (item.rend != null) returnString = parseRenderOptions(parseEmail(returnString), item.rend); if (item.whitespace != null) returnString = parseWhitespace(parseEmail(returnString), item.whitespace); } return returnString; } /** * Parses raw string content into a formatted string * * @param content - Raw string content to parse * @param language - Optional language code for content selection (defaults to "eng") * @returns Parsed and formatted string */ function parseStringContent(content, language = "eng") { switch (typeof content.content) { case "string": case "number": case "boolean": if (content.rend != null) return parseRenderOptions(parseFakeString(content.content), content.rend); return parseFakeString(content.content); case "object": if (Array.isArray(content.content)) { const stringItem = getStringItemByLanguage(content.content, language); if (stringItem) return parseStringItem(stringItem); else { const returnStringItem = content.content[0]; if (!returnStringItem) throw new Error(`No string item found for language “${language}” in the following content:\n${JSON.stringify(content.content)}.`); return parseStringItem(returnStringItem); } } else return parseStringItem(content.content); default: return String(content.content); } } //#endregion //#region src/utils/helpers.ts /** * The default API version to use * * @remarks * Version 1 of the OCHRE API is deprecated and will be removed in the future. * It points to the old Tamino server. * * Version 2 of the OCHRE API is the current version and is the default. * It points to the new MarkLogic server. */ const DEFAULT_API_VERSION = 2; /** * The default page size to use for fetching paginated items */ const DEFAULT_PAGE_SIZE = 48; /** * Flatten the properties of an item * @param item - The item whose properties to flatten * @returns The item with the properties flattened */ function flattenItemProperties(item) { const allProperties = []; if ("properties" in item) allProperties.push(...item.properties); if ("observations" in item) for (const observation of item.observations) allProperties.push(...observation.properties); if ("interpretations" in item) for (const interpretation of item.interpretations) allProperties.push(...interpretation.properties); if ("bibliographies" in item) for (const bibliography of item.bibliographies) allProperties.push(...bibliography.properties); return { ...item, properties: flattenProperties(allProperties) }; } /** * Get the leaf property values from an array of property values * @param propertyValues - The array of property values to get the leaf property values from * @returns The array of leaf property values */ function getLeafPropertyValues(propertyValues) { return propertyValues.filter((value) => value.hierarchy.isLeaf); } //#endregion //#region src/utils/parse.ts /** * Parses raw identification data into the standardized Identification type * * @param identification - Raw identification data from OCHRE format * @returns Parsed Identification object with label and abbreviation */ function parseIdentification(identification) { try { const returnIdentification = { label: [ "string", "number", "boolean" ].includes(typeof identification.label) ? parseFakeString(identification.label) : parseStringContent(identification.label), abbreviation: "", code: identification.code ?? null }; for (const key of Object.keys(identification).filter((key) => key !== "label" && key !== "code")) returnIdentification[key] = typeof identification[key] === "string" ? parseFakeString(identification[key]) : parseStringContent(identification[key]); return returnIdentification; } catch (error) { console.error(error); return { label: "", abbreviation: "", code: null }; } } /** * Parses raw language data into an array of language codes * * @param language - Raw language data, either single or array * @returns Array of language codes as strings */ function parseLanguages(language) { if (language == null) return ["eng"]; if (typeof language === "string") return [language]; if (Array.isArray(language)) return language.map((lang) => typeof lang === "object" ? parseStringContent(lang) : lang); else return [parseStringContent(language)]; } /** * Parses raw metadata into the standardized Metadata type * * @param metadata - Raw metadata from OCHRE format * @returns Parsed Metadata object */ function parseMetadata(metadata) { let identification = { label: "", abbreviation: "", code: null }; if (metadata.item) if (metadata.item.label || metadata.item.abbreviation) { let label = ""; let abbreviation = ""; let code = null; if (metadata.item.label) label = parseStringContent(metadata.item.label); if (metadata.item.abbreviation) abbreviation = parseStringContent(metadata.item.abbreviation); if (metadata.item.identification.code) code = metadata.item.identification.code; identification = { label, abbreviation, code }; } else identification = parseIdentification(metadata.item.identification); let projectIdentification = null; const baseProjectIdentification = metadata.project?.identification ? parseIdentification(metadata.project.identification) : null; if (baseProjectIdentification) projectIdentification = { ...baseProjectIdentification, website: metadata.project?.identification.website ?? null }; let collectionIdentification = null; if (metadata.collection) collectionIdentification = parseIdentification(metadata.collection.identification); let publicationIdentification = null; if (metadata.publication) publicationIdentification = parseIdentification(metadata.publication.identification); return { project: projectIdentification ? { identification: projectIdentification, dateFormat: metadata.project?.dateFormat ?? null, page: metadata.project?.page ?? null } : null, collection: metadata.collection != null && collectionIdentification ? { identification: collectionIdentification, page: metadata.collection.page } : null, publication: metadata.publication != null && publicationIdentification ? { identification: publicationIdentification, page: metadata.publication.page } : null, item: metadata.item ? { identification, category: metadata.item.category, type: metadata.item.type, maxLength: metadata.item.maxLength ?? null } : null, dataset: typeof metadata.dataset === "object" ? parseStringContent(metadata.dataset) : parseFakeString(metadata.dataset), publisher: typeof metadata.publisher === "object" ? parseStringContent(metadata.publisher) : parseFakeString(metadata.publisher), languages: parseLanguages(metadata.language), identifier: typeof metadata.identifier === "object" ? parseStringContent(metadata.identifier) : parseFakeString(metadata.identifier), description: typeof metadata.description === "object" ? parseStringContent(metadata.description) : parseFakeString(metadata.description) }; } /** * Parses raw context item data into the standardized ContextItem type * * @param contextItem - Raw context item data from OCHRE format * @returns Parsed ContextItem object */ function parseContextItem(contextItem) { return { uuid: contextItem.uuid, publicationDateTime: contextItem.publicationDateTime != null ? parseISO(contextItem.publicationDateTime) : null, number: contextItem.n, content: parseFakeString(contextItem.content) }; } /** * Parses raw context data into the standardized Context type * * @param context - Raw context data from OCHRE format * @returns Parsed Context object */ function parseContext(context) { return { nodes: (Array.isArray(context.context) ? context.context : [context.context]).map((context) => { const spatialUnit = []; if ("spatialUnit" in context && context.spatialUnit) { const contextsToParse = Array.isArray(context.spatialUnit) ? context.spatialUnit : [context.spatialUnit]; for (const contextItem of contextsToParse) spatialUnit.push(parseContextItem(contextItem)); } return { tree: parseContextItem(context.tree), project: parseContextItem(context.project), spatialUnit }; }), displayPath: context.displayPath }; } /** * Parses raw license data into the standardized License type * * @param license - Raw license data from OCHRE format * @returns Parsed License object or null if invalid */ function parseLicense(license) { if (typeof license.license === "string") return null; return { content: license.license.content, url: license.license.target }; } /** * Parses raw person data into the standardized Person type * * @param person - Raw person data from OCHRE format * @returns Parsed Person object */ function parsePerson(person, metadata, persistentUrl, belongsTo) { return { uuid: person.uuid, category: "person", belongsTo: belongsTo ?? null, metadata: metadata ?? null, publicationDateTime: person.publicationDateTime != null ? parseISO(person.publicationDateTime) : null, persistentUrl: persistentUrl ?? null, type: person.type ?? null, number: person.n ?? null, context: person.context ? parseContext(person.context) : null, date: person.date ?? null, identification: person.identification ? parseIdentification(person.identification) : null, availability: person.availability ? parseLicense(person.availability) : null, image: person.image ? parseImage(person.image) : null, address: person.address ? { country: person.address.country ?? null, city: person.address.city ?? null, state: person.address.state ?? null } : null, description: person.description ? typeof person.description === "string" || typeof person.description === "number" || typeof person.description === "boolean" ? parseFakeString(person.description) : parseStringContent(person.description) : null, coordinates: parseCoordinates(person.coordinates), content: person.content != null ? parseFakeString(person.content) : null, notes: person.notes ? parseNotes(Array.isArray(person.notes.note) ? person.notes.note : [person.notes.note]) : [], links: person.links ? parseLinks(Array.isArray(person.links) ? person.links : [person.links]) : [], events: person.events ? parseEvents(Array.isArray(person.events.event) ? person.events.event : [person.events.event]) : [], properties: person.properties ? parseProperties(Array.isArray(person.properties.property) ? person.properties.property : [person.properties.property]) : [], bibliographies: person.bibliographies ? parseBibliographies(Array.isArray(person.bibliographies.bibliography) ? person.bibliographies.bibliography : [person.bibliographies.bibliography]) : [] }; } /** * Parses raw person data into the standardized Person type * * @param persons - Array of raw person data from OCHRE format * @returns Array of parsed Person objects */ function parsePersons(persons) { const returnPersons = []; for (const person of persons) returnPersons.push(parsePerson(person)); return returnPersons; } /** * Parses an array of raw links into standardized Link objects * * @param linkRaw - Raw OCHRE link * @returns Parsed Link object */ function parseLink(linkRaw) { const links = "resource" in linkRaw ? linkRaw.resource : "spatialUnit" in linkRaw ? linkRaw.spatialUnit : "concept" in linkRaw ? linkRaw.concept : "set" in linkRaw ? linkRaw.set : "tree" in linkRaw ? linkRaw.tree : "person" in linkRaw ? linkRaw.person : "bibliography" in linkRaw ? linkRaw.bibliography : "propertyVariable" in linkRaw ? linkRaw.propertyVariable : "propertyValue" in linkRaw ? linkRaw.propertyValue : null; if (!links) throw new Error(`Invalid link provided: ${JSON.stringify(linkRaw, null, 2)}`); const linksToParse = Array.isArray(links) ? links : [links]; const returnLinks = []; for (const link of linksToParse) { const returnLink = { category: "resource" in linkRaw ? "resource" : "spatialUnit" in linkRaw ? "spatialUnit" : "concept" in linkRaw ? "concept" : "set" in linkRaw ? "set" : "person" in linkRaw ? "person" : "tree" in linkRaw ? "tree" : "bibliography" in linkRaw ? "bibliography" : "propertyVariable" in linkRaw ? "propertyVariable" : "propertyValue" in linkRaw ? "propertyValue" : null, content: "content" in link ? link.content != null ? parseFakeString(link.content) : null : null, href: "href" in link && link.href != null ? link.href : null, fileFormat: "fileFormat" in link