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

web-annotation-discovery-se.../routes/ ldp.ts
220 lines
5.9 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. const protocol = this.req.headers['x-forwarded-proto'] || this.req.protocol;
  43. this.url = `${protocol}://${host}${this.req.baseUrl}/${this.containerPath}`;
  44. this.name = collectionInfo.name;
  45. this.label = collectionInfo.label;
  46. }
  47. }
  48. export class PagedContainer extends Container {
  49. public total: number;
  50. public pageSize: number;
  51. private _getPage: (pageNumber: number, pageSize: number) => any[];
  52. constructor(
  53. req: Request,
  54. containerPath: string,
  55. collectionInfo: CollectionInfo,
  56. getPage: any[],
  57. );
  58. constructor(
  59. req: Request,
  60. containerPath: string,
  61. collectionInfo: CollectionInfo,
  62. getPage: (pageNumber: number, pageSize: number) => any[],
  63. total: number,
  64. pageSize: number,
  65. );
  66. constructor(
  67. req: Request,
  68. containerPath: string,
  69. collectionInfo: CollectionInfo,
  70. getPage: ((pageNumber: number, pageSize: number) => any[]) | any[],
  71. total?: number,
  72. pageSize?: number,
  73. ) {
  74. super(req, containerPath, collectionInfo);
  75. if (Array.isArray(getPage)) {
  76. const items = getPage;
  77. this._getPage = () => items;
  78. this.total = items.length;
  79. this.pageSize = Infinity;
  80. } else {
  81. this._getPage = getPage;
  82. this.total = total as number;
  83. this.pageSize = pageSize as number;
  84. }
  85. }
  86. getPage(pageNumber: number) {
  87. return this._getPage(pageNumber, this.pageSize);
  88. }
  89. get lastPage(): number {
  90. return this.pageSize === Infinity
  91. ? 0
  92. : Math.floor(this.total / this.pageSize);
  93. }
  94. }
  95. export function sendPage(
  96. res: Response,
  97. container: PagedContainer,
  98. pageNumber: number,
  99. iris: boolean,
  100. ) {
  101. if (pageNumber > container.lastPage) {
  102. res.status(404).send('Not found');
  103. return;
  104. }
  105. const items = container.getPage(pageNumber);
  106. const page = {
  107. '@context': 'http://www.w3.org/ns/anno.jsonld',
  108. id: `${container.url}/?page=${pageNumber}&iris=${iris ? 1 : 0}`,
  109. type: 'AnnotationPage',
  110. partOf: {
  111. id: `${container.url}/?iris=${iris ? 1 : 0}`,
  112. total: container.total,
  113. },
  114. startIndex: pageNumber === 0 ? 0 : container.pageSize * pageNumber,
  115. items: iris ? items.map((item) => item.id) : items,
  116. };
  117. res.type('application/ld+json; profile="http://www.w3.org/ns/anno.jsonld"');
  118. res.header('Allow', 'HEAD, GET, OPTIONS');
  119. res.send(page);
  120. }
  121. export function sendContainer(
  122. req: Request,
  123. res: Response,
  124. container: PagedContainer,
  125. iris: boolean,
  126. embedFirstPage?: boolean,
  127. ) {
  128. let firstPage: any;
  129. if (embedFirstPage) {
  130. const items = container.getPage(0);
  131. firstPage = {
  132. id: `${container.url}/?page=0&iris=${iris ? 1 : 0}`,
  133. type: 'AnnotationPage',
  134. startIndex: 0,
  135. items: iris ? items.map((item) => item.id) : items,
  136. };
  137. } else {
  138. firstPage = `${container.url}/?iris=${iris ? 1 : 0}&page=0`;
  139. }
  140. const collectionJsonldObj = {
  141. '@context': [
  142. 'http://www.w3.org/ns/anno.jsonld',
  143. 'http://www.w3.org/ns/ldp.jsonld',
  144. ],
  145. id: `${container.url}/?iris=${iris ? 1 : 0}`,
  146. type: ['BasicContainer', 'AnnotationCollection'],
  147. total: container.total,
  148. label: container.label,
  149. first: firstPage,
  150. ...(container.lastPage > 0 && {
  151. last: `${container.url}/?iris=${iris ? 1 : 0}&page=${container.lastPage}`,
  152. }),
  153. };
  154. res.header('ETag', etag(JSON.stringify(collectionJsonldObj)));
  155. res.format({
  156. html: () =>
  157. renderCollection(req, res, {
  158. collection: collectionJsonldObj,
  159. container,
  160. user: req.params.user,
  161. }),
  162. default: () => {
  163. res.header('Link', [
  164. '<http://www.w3.org/ns/ldp#BasicContainer>; rel="type"',
  165. '<http://www.w3.org/TR/annotation-protocol/>; rel="http://www.w3.org/ns/ldp#constrainedBy"',
  166. ]);
  167. res.header(
  168. 'Accept-Post',
  169. 'application/ld+json; profile="http://www.w3.org/ns/anno.jsonld"',
  170. );
  171. res.type(
  172. 'application/ld+json; profile="http://www.w3.org/ns/anno.jsonld"',
  173. );
  174. res.header('Allow', 'HEAD, GET, POST, OPTIONS');
  175. res.send(collectionJsonldObj);
  176. },
  177. });
  178. }
  179. export function sendAnnotation(
  180. req: Request,
  181. res: Response,
  182. annotation,
  183. containerInfo: Container,
  184. ) {
  185. annotation = expandAnnotation(annotation, containerInfo);
  186. res.header('ETag', etag(JSON.stringify(annotation)));
  187. res.format({
  188. html: () => renderAnnotation(req, res, { annotation, containerInfo }),
  189. default: () => {
  190. res.header('Allow', 'OPTIONS,HEAD,GET,PUT,DELETE');
  191. res.header('Link', '<http://www.w3.org/ns/ldp#Resource>; rel="type"');
  192. res.type(
  193. 'application/ld+json; profile="http://www.w3.org/ns/anno.jsonld"',
  194. );
  195. res.send(annotation);
  196. },
  197. });
  198. }