Store and publish annotations on the web, as described in the Web Annotation Discovery proposal.

web-annotation-discovery-se.../routes/ ldp.ts
219 lines
5.8 KiB

  1. // Copyright (c) 2020 Jan Kaßel
  2. // Copyright (c) 2022 Gerben
  3. //
  4. // SPDX-License-Identifier: MIT
  5. import escapeString from 'escape-string-regexp';
  6. import etag from 'etag';
  7. import type { Request, Response } from 'express';
  8. import { CollectionInfo } from './handlers/collection.js';
  9. import { renderAnnotation } from './render/renderAnnotation.js';
  10. import { renderCollection } from './render/renderCollection.js';
  11. export function extractAnnotationIdFromUrl(
  12. fullAnnotationUrl: string,
  13. containerUrl: string,
  14. ) {
  15. const escapedUrl = escapeString(containerUrl);
  16. const pattern = new RegExp(`^${escapedUrl}\/([0-9a-z-]+)$`);
  17. const matches = fullAnnotationUrl.match(pattern);
  18. return !matches ? null : matches[1];
  19. }
  20. export function expandAnnotation(annotation, containerInfo: Container) {
  21. return {
  22. ...annotation,
  23. id: `${containerInfo.url}/${annotation.id}`,
  24. };
  25. }
  26. export function contractAnnotation(annotation, containerInfo: Container) {
  27. return {
  28. ...annotation,
  29. id: extractAnnotationIdFromUrl(annotation.id, containerInfo.url),
  30. };
  31. }
  32. export class Container {
  33. url: string;
  34. name: string;
  35. label: string;
  36. constructor(
  37. private req: Request,
  38. public containerPath: string,
  39. collectionInfo: CollectionInfo,
  40. ) {
  41. const host = this.req.headers['x-forwarded-host'] || this.req.headers.host;
  42. this.url = `${this.req.protocol}://${host}${this.req.baseUrl}/${this.containerPath}`;
  43. this.name = collectionInfo.name;
  44. this.label = collectionInfo.label;
  45. }
  46. }
  47. export class PagedContainer extends Container {
  48. public total: number;
  49. public pageSize: number;
  50. private _getPage: (pageNumber: number, pageSize: number) => any[];
  51. constructor(
  52. req: Request,
  53. containerPath: string,
  54. collectionInfo: CollectionInfo,
  55. getPage: any[],
  56. );
  57. constructor(
  58. req: Request,
  59. containerPath: string,
  60. collectionInfo: CollectionInfo,
  61. getPage: (pageNumber: number, pageSize: number) => any[],
  62. total: number,
  63. pageSize: number,
  64. );
  65. constructor(
  66. req: Request,
  67. containerPath: string,
  68. collectionInfo: CollectionInfo,
  69. getPage: ((pageNumber: number, pageSize: number) => any[]) | any[],
  70. total?: number,
  71. pageSize?: number,
  72. ) {
  73. super(req, containerPath, collectionInfo);
  74. if (Array.isArray(getPage)) {
  75. const items = getPage;
  76. this._getPage = () => items;
  77. this.total = items.length;
  78. this.pageSize = Infinity;
  79. } else {
  80. this._getPage = getPage;
  81. this.total = total as number;
  82. this.pageSize = pageSize as number;
  83. }
  84. }
  85. getPage(pageNumber: number) {
  86. return this._getPage(pageNumber, this.pageSize);
  87. }
  88. get lastPage(): number {
  89. return this.pageSize === Infinity
  90. ? 0
  91. : Math.floor(this.total / this.pageSize);
  92. }
  93. }
  94. export function sendPage(
  95. res: Response,
  96. container: PagedContainer,
  97. pageNumber: number,
  98. iris: boolean,
  99. ) {
  100. if (pageNumber > container.lastPage) {
  101. res.status(404).send('Not found');
  102. return;
  103. }
  104. const items = container.getPage(pageNumber);
  105. const page = {
  106. '@context': 'http://www.w3.org/ns/anno.jsonld',
  107. id: `${container.url}/?page=${pageNumber}&iris=${iris ? 1 : 0}`,
  108. type: 'AnnotationPage',
  109. partOf: {
  110. id: `${container.url}/?iris=${iris ? 1 : 0}`,
  111. total: container.total,
  112. },
  113. startIndex: pageNumber === 0 ? 0 : container.pageSize * pageNumber,
  114. items: iris ? items.map((item) => item.id) : items,
  115. };
  116. res.type('application/ld+json; profile="http://www.w3.org/ns/anno.jsonld"');
  117. res.header('Allow', 'HEAD, GET, OPTIONS');
  118. res.send(page);
  119. }
  120. export function sendContainer(
  121. req: Request,
  122. res: Response,
  123. container: PagedContainer,
  124. iris: boolean,
  125. embedFirstPage?: boolean,
  126. ) {
  127. let firstPage: any;
  128. if (embedFirstPage) {
  129. const items = container.getPage(0);
  130. firstPage = {
  131. id: `${container.url}/?page=0&iris=${iris ? 1 : 0}`,
  132. type: 'AnnotationPage',
  133. startIndex: 0,
  134. items: iris ? items.map((item) => item.id) : items,
  135. };
  136. } else {
  137. firstPage = `${container.url}/?iris=${iris ? 1 : 0}&page=0`;
  138. }
  139. const collectionJsonldObj = {
  140. '@context': [
  141. 'http://www.w3.org/ns/anno.jsonld',
  142. 'http://www.w3.org/ns/ldp.jsonld',
  143. ],
  144. id: `${container.url}/?iris=${iris ? 1 : 0}`,
  145. type: ['BasicContainer', 'AnnotationCollection'],
  146. total: container.total,
  147. label: container.label,
  148. first: firstPage,
  149. ...(container.lastPage > 0 && {
  150. last: `${container.url}/?iris=${iris ? 1 : 0}&page=${container.lastPage}`,
  151. }),
  152. };
  153. res.header('ETag', etag(JSON.stringify(collectionJsonldObj)));
  154. res.format({
  155. html: () =>
  156. renderCollection(req, res, {
  157. collection: collectionJsonldObj,
  158. container,
  159. user: req.params.user,
  160. }),
  161. default: () => {
  162. res.header('Link', [
  163. '<http://www.w3.org/ns/ldp#BasicContainer>; rel="type"',
  164. '<http://www.w3.org/TR/annotation-protocol/>; rel="http://www.w3.org/ns/ldp#constrainedBy"',
  165. ]);
  166. res.header(
  167. 'Accept-Post',
  168. 'application/ld+json; profile="http://www.w3.org/ns/anno.jsonld"',
  169. );
  170. res.type(
  171. 'application/ld+json; profile="http://www.w3.org/ns/anno.jsonld"',
  172. );
  173. res.header('Allow', 'HEAD, GET, POST, OPTIONS');
  174. res.send(collectionJsonldObj);
  175. },
  176. });
  177. }
  178. export function sendAnnotation(
  179. req: Request,
  180. res: Response,
  181. annotation,
  182. containerInfo: Container,
  183. ) {
  184. annotation = expandAnnotation(annotation, containerInfo);
  185. res.header('ETag', etag(JSON.stringify(annotation)));
  186. res.format({
  187. html: () => renderAnnotation(req, res, { annotation, containerInfo }),
  188. default: () => {
  189. res.header('Allow', 'OPTIONS,HEAD,GET,PUT,DELETE');
  190. res.header('Link', '<http://www.w3.org/ns/ldp#Resource>; rel="type"');
  191. res.type(
  192. 'application/ld+json; profile="http://www.w3.org/ns/anno.jsonld"',
  193. );
  194. res.send(annotation);
  195. },
  196. });
  197. }