@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
JavaScript
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("'", "'");
}
/**
* 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("'", "'");
}
/**
* 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("'", "'").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("'", "'");
} 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