UNPKG

annotorious-tahqiq

Version:
392 lines (368 loc) 14.4 kB
import { TextGranularity, type Annotation, type SavedAnnotation } from "./types/Annotation"; import type { Source } from "./types/Source"; import type { Settings } from "./types/Settings"; // TODO: Add a typedef for the Annotorious client (anno) // eslint-disable-next-line @typescript-eslint/no-explicit-any /** * Annotorious plugin to use W3C protocol storage */ class AnnotationServerStorage { anno; annotationCount: number; settings: Settings; /** * Instantiate the storage plugin. * * @param {any} anno Instance of the Annotorious client. * @param {Settings} settings Settings object for the storage plugin. */ constructor( anno: any, // eslint-disable-line @typescript-eslint/no-explicit-any settings: Settings, ) { this.anno = anno; this.settings = settings; this.annotationCount = 0; // bind event handlers this.anno.on( "createAnnotation", this.handleCreateAnnotation.bind(this), ); this.anno.on( "updateAnnotation", this.handleUpdateAnnotation.bind(this), ); this.anno.on( "deleteAnnotation", this.handleDeleteAnnotation.bind(this), ); // load annotations from the server and signal for display this.loadAnnotations(); } /** * Helper function to load annotations asynchronously once the plugin * is initialized. */ async loadAnnotations() { try { const annotations: void | SavedAnnotation[] = await this.search( this.settings.target, ); if (this.settings.lineMode) { // in line-by-line editing mode, only render line-level annotations in annotorious await this.anno.setAnnotations( annotations?.filter((a) => a.textGranularity === TextGranularity.LINE), ); } else { // otherwise render block-level annotations await this.anno.setAnnotations(annotations); } if (annotations instanceof Array) { this.annotationCount = annotations.length; } document.dispatchEvent( new CustomEvent("annotations-loaded", { detail: { // include target with event to match canvases target: this.settings.target, // include annotations here (annotorious might be briefly out of sync) annotations, }, }), ); return annotations; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { this.alert(err.message, "error"); } } /** * Event handler for the createAnnotation event; adjusts the source if * needed, saves the annotation to the store and Annotorious, then returns * the stored annotation retrieved from storage in a Promise. * * @param {Annotation} annotation V3 (W3C) annotation */ async handleCreateAnnotation( annotation: Annotation, ): Promise<Annotation | void> { try { annotation.target.source = this.adjustTargetSource( annotation.target.source, ); // save source URI to dc:source attribute on annotation if (this.settings.sourceUri) { annotation["dc:source"] = this.settings.sourceUri; } // save primary and secondary (if applicable) motivation on annotation annotation.motivation = this.settings.secondaryMotivation ? ["sc:supplementing", this.settings.secondaryMotivation] : "sc:supplementing"; // increment annotation count and set position attribute this.setAnnotationCount(this.annotationCount + 1); if (!annotation["schema:position"]) { annotation["schema:position"] = this.annotationCount; } // wait for adapter to return saved annotation from storage const newAnnotation: Annotation = await this.create(annotation); // remove the annotation with the provisional ID from Annotorious display this.anno.removeAnnotation(annotation.id); // add the saved annotation returned by storage to Annotorious display this.anno.addAnnotation(newAnnotation); // reload annotations // TODO: Avoid extra network request here await this.loadAnnotations(); this.alert("Annotation created", "success"); return await Promise.resolve(newAnnotation); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { this.alert(err.message, "error"); } } /** * Event handler for the updateAnnotation event; adjusts the source if * needed, then updates the annotation in the store and in Annotorious, * * @param {SavedAnnotation} annotation Updated annotation. * @param {SavedAnnotation} previous Previously saved annotation. */ async handleUpdateAnnotation( annotation: SavedAnnotation, previous: SavedAnnotation, ): Promise<Annotation | void> { // The posted annotation should have an @id which exists in the store // we want to keep the same id, so we update the new annotation with // the previous id before saving. annotation.id = previous.id; // target needs to be updated if the image selection has changed annotation.target.source = this.adjustTargetSource( annotation.target.source, ); try { const updatedAnnotation: SavedAnnotation = await this.update( annotation, ); // redisplay the updated annotation in annotorious this.anno.addAnnotation(updatedAnnotation); // reload annotations from storage (for post-save effects e.g. html sanitization) await this.loadAnnotations(); this.alert("Annotation saved", "success"); return await Promise.resolve(updatedAnnotation); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { // in case of an error, ensure annotation gets re-selected while editor still open this.anno.selectAnnotation(annotation); this.alert(err.message, "error"); } } /** * Update the annotation in the store only (i.e. when image annotation editing is disabled). * * @param {SavedAnnotation} annotation Updated annotation. */ async handleUpdateAnnotationInStore( annotation: SavedAnnotation, ): Promise<Annotation | void> { try { const updatedAnnotation: SavedAnnotation = await this.update( annotation, ); // reload annotations from storage (for post-save effects e.g. html sanitization) await this.loadAnnotations(); this.alert("Annotation saved", "success"); return await Promise.resolve(updatedAnnotation); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { this.alert(err.message, "error"); } } /** * Event handler for the deleteAnnotation event; deletes the annotation * from the store. * * @param {SavedAnnotation} annotation Annotation to delete; must have an * id property that matches its id property in the store. */ async handleDeleteAnnotation(annotation: SavedAnnotation): Promise<void> { try { await this.delete(annotation); this.setAnnotationCount(this.annotationCount - 1); await this.loadAnnotations(); this.alert("Annotation deleted", "success"); return await Promise.resolve(); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { // in case of an error, ensure annotation gets re-selected while editor still open this.anno.selectAnnotation(annotation); this.alert(err.message, "error"); } } /** * Set the annotation count, for use in calculating position * * @param {number} count The new count */ setAnnotationCount(count: number) { this.annotationCount = count; } /** * Save a new annotation to storage. * * @param {Annotation} annotation V3 (W3C) annotation to save. */ async create(annotation: Annotation): Promise<SavedAnnotation> { const res = await fetch(`${this.settings.annotationEndpoint}`, { body: JSON.stringify(annotation), headers: { Accept: "application/json", "Content-Type": "application/json", "X-CSRFToken": this.settings.csrf_token, }, method: "POST", }); // fetch won't automatically throw error on bad HTTP code, so check for ok if (res.ok) { return res.json(); } else { throw new Error( `Error creating annotation: ${res.status} ${res.statusText}`, ); } } /** * Update an existing annotation in storage. * * @param {SavedAnnotation} annotation V3 (W3C) annotation to update. */ async update(annotation: SavedAnnotation): Promise<SavedAnnotation> { // post the revised annotation to its URI const res = await fetch(annotation.id, { body: JSON.stringify(annotation), headers: { Accept: "application/json", "Content-Type": "application/json", "X-CSRFToken": this.settings.csrf_token, // ensure match with the previously fetched ETag "If-Match": annotation.etag, }, method: "POST", }); if (res.ok) { return res.json(); } else if (res.status === 412) { throw new Error( `Error: Annotation was modified by another user while you were working. Refresh the page to get the latest version, then make your changes.`, ); } else { throw new Error( `Error updating annotation: ${res.status} ${res.statusText}`, ); } } /** * * Delete an existing annotation from storage. * * @param {SavedAnnotation} annotation to delete */ async delete(annotation: SavedAnnotation) { const res = await fetch(annotation.id, { headers: { Accept: "application/json", "Content-Type": "application/json", "X-CSRFToken": this.settings.csrf_token, // ensure match with the previously fetched ETag "If-Match": annotation.etag, }, method: "DELETE", }); if (res.ok) { return res; } else if (res.status === 412) { throw new Error( `Error: Annotation was modified by another user. Refresh the page to get the latest version, then delete it.`, ); } else { throw new Error( `Error deleting annotation: ${res.status} ${res.statusText}`, ); } } /** * * Search for annotations on the specified target, ordered by schema:position attribute. * * @param {string} targetUri URI of the target to search for */ async search(targetUri: string): Promise<void | SavedAnnotation[]> { const { annotationEndpoint, sourceUri, manifest, secondaryMotivation } = this.settings; const sourceQ = sourceUri ? `&source=${sourceUri}` : ""; const manifestQ = manifest ? `&manifest=${manifest}` : ""; const motivationQ = secondaryMotivation ? `&motivation=${secondaryMotivation}` : ""; const res = await fetch( `${annotationEndpoint}search/?uri=${targetUri}${sourceQ}${manifestQ}${motivationQ}`, { headers: { Accept: "application/json", "Content-Type": "application/json", "X-CSRFToken": this.settings.csrf_token, }, }, ); if (res.ok) { const data = await res.json(); return <SavedAnnotation[]>data.resources; } else { throw new Error( `Error retrieving annotations: ${res.status} ${res.statusText}`, ); } } /** * Utility function to change a source string (Annotorious output) into a * Source object, in order to to add canvas/manifest info. * * @param {Source|string} source Source to be adjusted * @returns {Source} Source object with set target and manifest */ adjustTargetSource(source: Source | string): Source { if (typeof source == "string") { // add manifest id to annotation source = { // use the configured target (should be canvas id) id: this.settings.target, // link to containing manifest partOf: { id: this.settings.manifest, type: "Manifest", }, type: "Canvas", }; } return source; } /** * Raises a custom event, tahqiq-alert, with passed message/status and the * target (i.e. canvas) with which this instance is associated. * * @param {string} message Message for the alert. * @param {string} status Optional alert status. */ alert(message: string, status?: string) { document.dispatchEvent( new CustomEvent("tahqiq-alert", { detail: { message, status: status || "info", target: this.settings.target, }, }), ); } } export default AnnotationServerStorage;