UNPKG

@fontoxml/fontoxml-development-tools

Version:

Development tools for Fonto.

446 lines (395 loc) 12.6 kB
import { v4 as uuid } from 'uuid'; import Result, { STATUS_CREATED, STATUS_NOT_FOUND, STATUS_OK, STATUS_OUT_OF_SYNC, } from './Result.js'; /** @typedef {import('../../../src/getAppConfig.js').DevCmsConfig} DevCmsConfig */ const DATABASE_FILE_NAME = 'review-annotations-database.json'; export const STATUS_RESOLVED = 'resolved'; /** * Helper function to find a given annotation in a given database. It will look up the annotation * based in its ID. * * @param {Object} database The database in which to look for the annotation. * @param {string} annotationId The annotation id to look for. * @return {Annotation} The found annotation. */ function findAnnotationInDatabase(database, annotationId) { return database.annotations.find( (annotation) => annotation.id === annotationId, ); } /** * Helper function to determine if a given annotation is out of sync compared to a given database * annotation. * * @param {Annotation} databaseAnnotation The annotation in the database. * @param {Annotation|AnnotationIdentifier} clientAnnotationOrAnnotationIdentifier The annotation send by the client. * @return {boolean} True if the annotation send by the client if out of sync. */ function isAnnotationOutOfSync( databaseAnnotation, clientAnnotationOrAnnotationIdentifier, ) { return ( databaseAnnotation.revisionId !== clientAnnotationOrAnnotationIdentifier.revisionId ); } /** * Helper function to determine if the given annotation is private to an author other than the * given author. * * @param {Annotation} annotation The annotation to check. * @param {object} author The author to consider while checking the annotation. * @return {boolean} True if the annotation is private to another author. */ function isAnnotationPrivateToOtherAuthors(annotation, author) { return ( annotation.status === 'ANNOTATION_STATUS_PRIVATE' && annotation.author.id !== author.id ); } export default class AnnotationRepository { /** * @param {DevCmsConfig} config */ constructor(config) { /** @type {DevCmsConfig} */ this._config = config; /** @type {boolean} */ this._didWarnAboutFilterProperty = false; } async _getDatabase(cms, currentSession, fileLock) { const json = await cms.getFileWithoutHistory( DATABASE_FILE_NAME, currentSession.editSessionToken, fileLock, ); if (json === null) { return { annotations: [], }; } const reviewAnnotationsDatabase = JSON.parse(json); return reviewAnnotationsDatabase; } /** * Returns the annotations identified by the given annotation IDs from the database. * * @param {DevelopmentCms} cms * @param {object} currentSession * @param {string[]} annotationIds * @return {Promise<AnnotationResult[]>} */ async getAnnotations(cms, currentSession, annotationIds) { let fileLock; try { fileLock = await cms.acquireLock(DATABASE_FILE_NAME); const database = await this._getDatabase(cms, currentSession, fileLock); return annotationIds.map((annotationId) => { const annotationInDatabase = findAnnotationInDatabase( database, annotationId, ); if (!annotationInDatabase) { return new Result(STATUS_NOT_FOUND, annotationId, null); } if (annotationInDatabase.status === 'ANNOTATION_STATUS_ARCHIVED') { return new Result(STATUS_NOT_FOUND, annotationId, null); } annotationInDatabase.replies = annotationInDatabase.replies.filter( (reply) => reply.status !== 'REPLY_STATUS_ARCHIVED', ); return new Result( STATUS_OK, annotationInDatabase.id, annotationInDatabase, ); }); } finally { fileLock?.release(); } } /** * Adds new annotations to the database. * * @param {DevelopmentCms} cms * @param {object} currentSession * @param {Annotation[]} annotationsToAdd * @return {Promise<AnnotationResult[]>} */ async addAnnotations(cms, currentSession, annotationsToAdd) { let fileLock; try { fileLock = await cms.acquireLock(DATABASE_FILE_NAME); const database = await this._getDatabase(cms, currentSession, fileLock); const annotations = annotationsToAdd.map((annotationToAdd) => { const annotation = { ...annotationToAdd, author: { id: currentSession.user.id, displayName: currentSession.user.displayName, }, id: uuid(), replies: [], revisionId: uuid(), timestamp: new Date().toISOString(), }; if (annotation.status === 'ANNOTATION_STATUS_RESOLVED') { annotation.resolvedAuthor = { id: currentSession.user.id, displayName: currentSession.user.displayName, }; annotation.resolvedTimestamp = new Date().toISOString(); } return annotation; }); database.annotations = database.annotations.concat(annotations); await cms.updateOrCreateFileWithoutHistory( DATABASE_FILE_NAME, JSON.stringify(database, null, '\t'), currentSession.editSessionToken, fileLock, ); return annotations; } finally { fileLock?.release(); } } /** * Edits the given annotations. * * @param {DevelopmentCms} cms * @param {object} currentSession * @param {Annotation[]} annotationsToEdit * @return {Promise<AnnotationResult>} */ async editAnnotations(cms, currentSession, annotationsToEdit) { let fileLock; try { fileLock = await cms.acquireLock(DATABASE_FILE_NAME); const database = await this._getDatabase(cms, currentSession, fileLock); const results = annotationsToEdit.map((annotationToEdit) => { const annotationId = annotationToEdit.id; const annotationInDatabase = findAnnotationInDatabase( database, annotationId, ); if (!annotationInDatabase) { return new Result(STATUS_NOT_FOUND, annotationId, null); } if (annotationInDatabase.status === 'ANNOTATION_STATUS_ARCHIVED') { return new Result(STATUS_NOT_FOUND, annotationId, null); } if (isAnnotationOutOfSync(annotationInDatabase, annotationToEdit)) { return new Result(STATUS_OUT_OF_SYNC, annotationId, null); } // Generate a new revision ID for this annotation annotationToEdit.revisionId = uuid(); annotationToEdit.replies = annotationInDatabase.replies; const isResolving = annotationInDatabase.status !== 'ANNOTATION_STATUS_RESOLVED' && annotationToEdit.status === 'ANNOTATION_STATUS_RESOLVED'; if (isResolving) { annotationToEdit.resolvedAuthor = { id: currentSession.user.id, displayName: currentSession.user.displayName, }; annotationToEdit.resolvedTimestamp = new Date().toISOString(); } else { // Preserve any existing data in these fields annotationToEdit.resolvedAuthor = annotationInDatabase.resolvedAuthor; annotationToEdit.resolvedTimestamp = annotationInDatabase.resolvedTimestamp; } database.annotations[ database.annotations.indexOf(annotationInDatabase) ] = annotationToEdit; return new Result(STATUS_OK, annotationId, annotationToEdit); }); await cms.updateOrCreateFileWithoutHistory( DATABASE_FILE_NAME, JSON.stringify(database, null, '\t'), currentSession.editSessionToken, fileLock, ); return results; } finally { fileLock?.release(); } } /** * Returns the relevant identifiers for the annotations for the given document IDs. * * @param {DevelopmentCms} cms * @param {object} currentSession * @param {string[]} documentIds * @param {object} filterFormValueByName * @param {string} [navigatorId] * @return {Promise<AnnotationIdenfier[]>} */ async getAnnotationIdentifiers( cms, currentSession, documentIds, filterFormValueByName, navigatorId, ) { if ( !this._didWarnAboutFilterProperty && !this._config.reviewAnnotationFilter ) { console.warn( "\nMake sure to set a filter function in your application's configureDevCms.js using the reviewAnnotationFilter property.", ); this._didWarnAboutFilterProperty = true; } let fileLock; try { fileLock = await cms.acquireLock(DATABASE_FILE_NAME); const database = await this._getDatabase(cms, currentSession, fileLock); let matchAnnotationToCurrentFilter = this._config.reviewAnnotationFilter; if (!matchAnnotationToCurrentFilter) { matchAnnotationToCurrentFilter = () => true; } return database.annotations .filter((annotation) => { const matchesDocuments = documentIds.includes(annotation.documentId); if (!matchesDocuments) { return false; } if ( isAnnotationPrivateToOtherAuthors(annotation, currentSession.user) ) { return false; } if (annotation.status === 'ANNOTATION_STATUS_ARCHIVED') { return false; } const matchesFilter = matchAnnotationToCurrentFilter( filterFormValueByName, annotation, navigatorId, ); if (!matchesFilter) { return false; } return true; }) .map((annotation) => ({ id: annotation.id, documentId: annotation.documentId, revisionId: annotation.revisionId, })); } finally { fileLock?.release(); } } /** * Adds a reply to the database. * * @param {DevelopmentCms} cms * @param {object} currentSession * @param {Reply} replyToAdd * @param {AnnotationIdenfier} annotationIdentifier * @return {Promise<Result>} */ async addReply(cms, currentSession, replyToAdd, annotationIdentifier) { let fileLock; try { fileLock = await cms.acquireLock(DATABASE_FILE_NAME); const database = await this._getDatabase(cms, currentSession, fileLock); const annotation = findAnnotationInDatabase( database, annotationIdentifier.id, ); if (!annotation) { return new Result(STATUS_NOT_FOUND, replyToAdd.id, null); } if (annotation.status === 'ANNOTATION_STATUS_ARCHIVED') { return new Result(STATUS_NOT_FOUND, replyToAdd.id, null); } if (isAnnotationOutOfSync(annotation, annotationIdentifier)) { return new Result(STATUS_OUT_OF_SYNC, replyToAdd.id, null); } replyToAdd.author = { id: currentSession.user.id, displayName: currentSession.user.displayName, }; replyToAdd.id = uuid(); replyToAdd.timestamp = new Date().toISOString(); annotation.replies = annotation.replies.concat(replyToAdd); // Changes to the reply also mean "changes" to the annotation it belongs to annotation.revisionId = uuid(); await cms.updateOrCreateFileWithoutHistory( DATABASE_FILE_NAME, JSON.stringify(database, null, '\t'), currentSession.editSessionToken, fileLock, ); return new Result( STATUS_CREATED, replyToAdd.id, replyToAdd, annotation.revisionId, ); } finally { fileLock?.release(); } } /** * Edits a reply in the database. * * @param {DevelopmentCms} cms * @param {object} currentSession * @param {Reply} replyToEdit * @param {AnnotationIdenfier} annotationIdentifier * @return {Promise<Result>} */ async editReply(cms, currentSession, replyToEdit, annotationIdentifier) { let fileLock; try { fileLock = await cms.acquireLock(DATABASE_FILE_NAME); const database = await this._getDatabase(cms, currentSession, fileLock); const annotation = findAnnotationInDatabase( database, annotationIdentifier.id, ); if (!annotation) { return new Result(STATUS_NOT_FOUND, replyToEdit.id, null); } if (annotation.status === 'ANNOTATION_STATUS_ARCHIVED') { return new Result(STATUS_NOT_FOUND, replyToEdit.id, null); } if (isAnnotationOutOfSync(annotation, annotationIdentifier)) { return new Result(STATUS_OUT_OF_SYNC, replyToEdit.id, null); } const replyIndex = annotation.replies.findIndex( (reply) => reply.id === replyToEdit.id, ); if (replyIndex === -1) { return new Result(STATUS_NOT_FOUND, replyToEdit.id, null); } annotation.replies[replyIndex] = replyToEdit; // Changes to the reply also mean "changes" to the annotation it belongs to annotation.revisionId = uuid(); await cms.updateOrCreateFileWithoutHistory( DATABASE_FILE_NAME, JSON.stringify(database, null, '\t'), currentSession.editSessionToken, fileLock, ); return new Result( STATUS_OK, replyToEdit.id, replyToEdit, annotation.revisionId, ); } finally { fileLock?.release(); } } }