import type { TextQuoteSelector } from '@apache-annotator/selector'; import type { BodyObject, WebAnnotation } from './WebAnnotation.js'; import { asArray, asSingleValue, OnlyOne } from './multiplicity-utils.js'; /** * Turn a partial annotation into a ‘well-formed’ WebAnnotation. * * It sets the following properties, if absent in the given stub: * - `@context` as required * - `type` as required, to `'Annotation'` * - `created` as recommended (to the current time) * - `target` to `'about:invalid'` * * @returns A shallow clone of the given annotation stub, with the missing * properties added. */ export function completeAnnotationStub(annotationStub: Partial) { const webAnnotation: WebAnnotation = { '@context': 'http://www.w3.org/ns/anno.jsonld', type: 'Annotation', created: new Date().toISOString(), id: '', target: 'about:invalid', ...annotationStub, }; return webAnnotation; } /** * Get the name of the creator. If there are multiple, returns the first. * Assumes the creator is a nested Agent object: if the creator a string * (presumably the URL of an Agent node), `undefined` is returned. */ export function getSingleCreatorName( annotationOrBody: WebAnnotation | BodyObject, ): string | undefined { const creator = asSingleValue(annotationOrBody.creator); if (typeof creator === 'string') return undefined; return asSingleValue(creator?.name ?? creator?.nickname); } /** * Check whether the annotation likely targets the given URL. * * The word “likely” is used because, in its comparison, this ignores the URL * scheme, fragment and query parameters. * * Note that, strictly speaking, a URL should be treated as an opaque string. * In practice, it may however be useful to consider URLs as ‘likely equivalent’ * in order to apply annotations targeting one URL to the document with the * very similar URL. Apply with caution: Especially a different query may, * depending on the website at hand, result in very different documents. */ export function targetsUrl(target: WebAnnotation['target'], url: string) { return getTargetUrls(target).some((targetUrl) => sameishUrl(targetUrl, url)); } // Compare URLs while ignoring the scheme, fragment identifier, query parameter and trailing slash. function sameishUrl(url1: string, url2: string) { return normaliseUrl(url1) === normaliseUrl(url2); } function normaliseUrl(url: string) { url = url .split('#')[0] .split('?')[0] .replace(/^[a-zA-Z0-9.+-]+:\/\//, ''); if (url.endsWith('/')) url = url.slice(0, -1); return url; } /** * Get the URLs of the resources that the annotation targets, for all its * targets. */ export function getTargetUrls(target: WebAnnotation['target']): string[] { return unique(asArray(target).map(getTargetUrl)); } /** * Get the URL of the resource that the annotation targets, for a single * target. */ export function getTargetUrl(target: OnlyOne): string { if (typeof target === 'string') { // This string *could* be referring to a non-nested SpecificResource that // then contains the actual target URL. But we are not able to fetch that // now, and simply assume the string refers to the target document. return target; } // Specific Resource if ('source' in target) return target.source; // External Resource return target.id; } /** * Get the exact quotes that the annotation targets using a TextQuoteSelector, * if any. */ export function getTargetQuotes(target: WebAnnotation['target']): string[] { const quotes = unique(asArray(target).map(getTargetQuote)).filter( (s) => s !== undefined, ) as string[]; return quotes; } /** * Get the exact quote that a single target of an annotation targets using a * TextQuoteSelector, if any. */ export function getTargetQuote(target: OnlyOne): string | undefined { if (typeof target === 'string') return undefined; if ('selector' in target) { // Find if target.selector is/has a TextQuoteSelector. const selectors = asArray(target.selector); const textQuoteSelector = selectors.find(selector => { if (typeof selector === 'string') { // The selector is not nested in the annotation. But we are not able to // fetch it now, and will thus have to ignore this selector. return false; } return selector.type === 'TextQuoteSelector'; }) as TextQuoteSelector | undefined; if (textQuoteSelector) return textQuoteSelector.exact; } } function unique(a: T[]): T[] { return [...new Set(a)]; }