@fontoxml/fontoxml-development-tools
Version:
Development tools for Fonto.
446 lines (395 loc) • 12.6 kB
JavaScript
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();
}
}
}