@fontoxml/fontoxml-development-tools
Version:
Development tools for Fonto.
493 lines (439 loc) • 13.1 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
);
}
class MutexLock {
constructor() {
this._counter = 0;
this._resolve = null;
this.promise = new Promise((resolve) => (this._resolve = resolve));
}
lock() {
this._counter++;
}
unlock() {
this._counter--;
if (this._counter === 0) {
this._resolve();
this.promise = new Promise((resolve) => (this._resolve = resolve));
}
}
waitForUnlock() {
if (this._counter === 0) {
return Promise.resolve();
}
return this.promise;
}
}
export default class AnnotationDatabase {
/**
* @param {DevCmsConfig} config
*/
constructor(config) {
/** @type {DevCmsConfig} */
this._config = config;
/** @type {boolean} */
this._didWarnAboutFilterProperty = false;
if (this._config.saveMode === 'session') {
this._databaseByEditSessionToken =
this._sessionDatabaseBySessionToken = {};
this._databaseCleanupTimeoutByEditSessionToken =
this._databaseCleanupTimeoutByEditSessionToken = {};
}
this.readLock = new MutexLock();
this.writeLock = new MutexLock();
}
_getDatabase(cms, currentSession) {
if (
!cms.existsSync(DATABASE_FILE_NAME, currentSession.editSessionToken)
) {
return Promise.resolve({
annotations: [],
});
}
return this.writeLock.waitForUnlock().then(() => {
return new Promise((resolve, reject) => {
this.readLock.lock();
cms.load(
DATABASE_FILE_NAME,
currentSession.editSessionToken,
(error, json) => {
this.readLock.unlock();
if (error) {
reject(error);
return;
}
try {
const reviewAnnotationsDatabase = JSON.parse(json);
resolve(reviewAnnotationsDatabase);
} catch (error) {
reject(error);
}
},
);
});
});
}
_updateDatabase(cms, currentSession, updatedDatabase) {
return this.writeLock.waitForUnlock().then(() => {
return new Promise((resolve, reject) => {
this.writeLock.lock();
return this.readLock.waitForUnlock().then(() => {
cms.saveToStore(
DATABASE_FILE_NAME,
JSON.stringify(updatedDatabase, null, '\t'),
currentSession.editSessionToken,
(error, _documentId) => {
this.writeLock.unlock();
if (error) {
reject(error);
return;
}
resolve();
},
);
});
});
});
}
/**
* Returns the annotations identified by the given annotation IDs from the database.
*
* @param {DevelopmentCms} cms
* @param {object} currentSession
* @param {string[]} annotationIds
* @return {Promise<AnnotationResult[]>}
*/
getAnnotations(cms, currentSession, annotationIds) {
const databasePromise = this._getDatabase(cms, currentSession);
return databasePromise.then((database) =>
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,
);
}),
);
}
/**
* Adds new annotations to the database.
*
* @param {DevelopmentCms} cms
* @param {object} currentSession
* @param {Annotation[]} annotationsToAdd
* @return {Promise<AnnotationResult[]>}
*/
addAnnotations(cms, currentSession, annotationsToAdd) {
return this._getDatabase(cms, currentSession).then((database) => {
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);
return this._updateDatabase(cms, currentSession, database).then(
() => {
return annotations;
},
);
});
}
/**
* Edits the given annotations.
*
* @param {DevelopmentCms} cms
* @param {object} currentSession
* @param {Annotation[]} annotationsToEdit
* @return {Promise<AnnotationResult>}
*/
editAnnotations(cms, currentSession, annotationsToEdit) {
return this._getDatabase(cms, currentSession).then((database) => {
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);
});
return this._updateDatabase(cms, currentSession, database).then(
() => results,
);
});
}
/**
* Returns the relevant identifiers for the annotations for the given document IDs.
*
* @param {DevelopmentCms} cms
* @param {object} currentSession
* @param {string[]} documentIds
* @param {object} filterFormValueByName
* @return {Promise<AnnotationIdenfier[]>}
*/
getAnnotationIdentifiers(
cms,
currentSession,
documentIds,
filterFormValueByName,
) {
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;
}
return this._getDatabase(cms, currentSession).then((database) => {
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,
);
if (!matchesFilter) {
return false;
}
return true;
})
.map((annotation) => ({
id: annotation.id,
documentId: annotation.documentId,
revisionId: annotation.revisionId,
}));
});
}
/**
* Adds a reply to the database.
*
* @param {DevelopmentCms} cms
* @param {object} currentSession
* @param {Reply} replyToAdd
* @param {AnnotationIdenfier} annotationIdentifier
* @return {Promise<Result>}
*/
addReply(cms, currentSession, replyToAdd, annotationIdentifier) {
return this._getDatabase(cms, currentSession).then((database) => {
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();
return this._updateDatabase(cms, currentSession, database).then(
() =>
new Result(
STATUS_CREATED,
replyToAdd.id,
replyToAdd,
annotation.revisionId,
),
);
});
}
/**
* Edits a reply in the database.
*
* @param {DevelopmentCms} cms
* @param {object} currentSession
* @param {Reply} replyToEdit
* @param {AnnotationIdenfier} annotationIdentifier
* @return {Promise<Result>}
*/
editReply(cms, currentSession, replyToEdit, annotationIdentifier) {
return this._getDatabase(cms, currentSession).then((database) => {
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);
}
annotation.replies = annotation.replies.map((reply) => {
if (reply.id !== replyToEdit.id) {
return reply;
}
return replyToEdit;
});
// Changes to the reply also mean "changes" to the annotation it belongs to
annotation.revisionId = uuid();
return this._updateDatabase(cms, currentSession, database).then(
() =>
new Result(
STATUS_OK,
replyToEdit.id,
replyToEdit,
annotation.revisionId,
),
);
});
}
}