TypeScript types and utility functions for handling Web Annotations.

web-annotation-utils/src/ wa-attribute-utils.ts
134 lines
4.5 KiB

  1. import type { TextQuoteSelector } from '@apache-annotator/selector';
  2. import type { BodyObject, WebAnnotation } from './WebAnnotation.js';
  3. import { asArray, asSingleValue, OnlyOne } from './multiplicity-utils.js';
  4. /**
  5. * Turn a partial annotation into a ‘well-formed’ WebAnnotation.
  6. *
  7. * It sets the following properties, if absent in the given stub:
  8. * - `@context` as required
  9. * - `type` as required, to `'Annotation'`
  10. * - `created` as recommended (to the current time)
  11. * - `target` to `'about:invalid'`
  12. *
  13. * @returns A shallow clone of the given annotation stub, with the missing
  14. * properties added.
  15. */
  16. export function completeAnnotationStub(annotationStub: Partial<WebAnnotation>) {
  17. const webAnnotation: WebAnnotation = {
  18. '@context': 'http://www.w3.org/ns/anno.jsonld',
  19. type: 'Annotation',
  20. created: new Date().toISOString(),
  21. id: '',
  22. target: 'about:invalid',
  23. ...annotationStub,
  24. };
  25. return webAnnotation;
  26. }
  27. /**
  28. * Get the name of the creator. If there are multiple, returns the first.
  29. * Assumes the creator is a nested Agent object: if the creator a string
  30. * (presumably the URL of an Agent node), `undefined` is returned.
  31. */
  32. export function getSingleCreatorName(
  33. annotationOrBody: WebAnnotation | BodyObject,
  34. ): string | undefined {
  35. const creator = asSingleValue(annotationOrBody.creator);
  36. if (typeof creator === 'string') return undefined;
  37. return asSingleValue(creator?.name ?? creator?.nickname);
  38. }
  39. /**
  40. * Check whether the annotation likely targets the given URL.
  41. *
  42. * The word “likely” is used because, in its comparison, this ignores the URL
  43. * scheme, fragment and query parameters.
  44. *
  45. * Note that, strictly speaking, a URL should be treated as an opaque string.
  46. * In practice, it may however be useful to consider URLs as ‘likely equivalent’
  47. * in order to apply annotations targeting one URL to the document with the
  48. * very similar URL. Apply with caution: Especially a different query may,
  49. * depending on the website at hand, result in very different documents.
  50. */
  51. export function targetsUrl(target: WebAnnotation['target'], url: string) {
  52. return getTargetUrls(target).some((targetUrl) => sameishUrl(targetUrl, url));
  53. }
  54. // Compare URLs while ignoring the scheme, fragment identifier, query parameter and trailing slash.
  55. function sameishUrl(url1: string, url2: string) {
  56. return normaliseUrl(url1) === normaliseUrl(url2);
  57. }
  58. function normaliseUrl(url: string) {
  59. url = url
  60. .split('#')[0]
  61. .split('?')[0]
  62. .replace(/^[a-zA-Z0-9.+-]+:\/\//, '');
  63. if (url.endsWith('/')) url = url.slice(0, -1);
  64. return url;
  65. }
  66. /**
  67. * Get the URLs of the resources that the annotation targets, for all its
  68. * targets.
  69. */
  70. export function getTargetUrls(target: WebAnnotation['target']): string[] {
  71. return unique(asArray(target).map(getTargetUrl));
  72. }
  73. /**
  74. * Get the URL of the resource that the annotation targets, for a single
  75. * target.
  76. */
  77. export function getTargetUrl(target: OnlyOne<WebAnnotation['target']>): string {
  78. if (typeof target === 'string') {
  79. // This string *could* be referring to a non-nested SpecificResource that
  80. // then contains the actual target URL. But we are not able to fetch that
  81. // now, and simply assume the string refers to the target document.
  82. return target;
  83. }
  84. // Specific Resource
  85. if ('source' in target) return target.source;
  86. // External Resource
  87. return target.id;
  88. }
  89. /**
  90. * Get the exact quotes that the annotation targets using a TextQuoteSelector,
  91. * if any.
  92. */
  93. export function getTargetQuotes(target: WebAnnotation['target']): string[] {
  94. const quotes = unique(asArray(target).map(getTargetQuote)).filter(
  95. (s) => s !== undefined,
  96. ) as string[];
  97. return quotes;
  98. }
  99. /**
  100. * Get the exact quote that a single target of an annotation targets using a
  101. * TextQuoteSelector, if any.
  102. */
  103. export function getTargetQuote(target: OnlyOne<WebAnnotation['target']>): string | undefined {
  104. if (typeof target === 'string') return undefined;
  105. if ('selector' in target) {
  106. // Find if target.selector is/has a TextQuoteSelector.
  107. const selectors = asArray(target.selector);
  108. const textQuoteSelector = selectors.find(selector => {
  109. if (typeof selector === 'string') {
  110. // The selector is not nested in the annotation. But we are not able to
  111. // fetch it now, and will thus have to ignore this selector.
  112. return false;
  113. }
  114. return selector.type === 'TextQuoteSelector';
  115. }) as TextQuoteSelector | undefined;
  116. if (textQuoteSelector)
  117. return textQuoteSelector.exact;
  118. }
  119. }
  120. function unique<T>(a: T[]): T[] {
  121. return [...new Set(a)];
  122. }