BookBrainz/bookbrainz-site

View on GitHub
src/server/helpers/revisions.ts

Summary

Maintainability
B
5 hrs
Test Coverage
/*
 * Copyright (C) 2020 Prabal Singh
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 */

import * as error from '../../common/helpers/error';
import {flatMap} from 'lodash';


function getRevisionModels(orm) {
    const {AuthorRevision, EditionRevision, EditionGroupRevision, PublisherRevision, SeriesRevision, WorkRevision} = orm;
    return [
        AuthorRevision,
        EditionGroupRevision,
        EditionRevision,
        PublisherRevision,
        SeriesRevision,
        WorkRevision
    ];
}

interface EntityProps {
    bbid: string;
    defaultAlias?: string;
    parentAlias?: string;
    type: any;
}

/* eslint-disable no-await-in-loop */
/**
 * Fetches the entities affected by a revision, their alias
 * or in case of deleted entities their last know alias.
 * It then attaches the necessary information to each revisions's entities array
 *
 * @param {array} revisions - the array of revisions
 * @param {object} orm - the BookBrainz ORM, initialized during app setup
 * @returns {array} - The modified revisions array
 */
export async function getAssociatedEntityRevisions(revisions, orm) {
    const revisionIDs = revisions.map(({revisionId}) => revisionId);
    const RevisionModels = getRevisionModels(orm);
    const {Entity} = orm;
    for (let i = 0; i < RevisionModels.length; i++) {
        const EntityRevision = RevisionModels[i];
        const entityRevisions = await new EntityRevision()
            .query((qb) => {
                qb.whereIn('id', revisionIDs);
            })
            .fetchAll({
                merge: false,
                remove: false,
                require: false,
                withRelated: [
                    'data.aliasSet.defaultAlias'
                ]
            })
            .catch(EntityRevision.NotFoundError, (err) => {
                // eslint-disable-next-line no-console
                console.log(err);
            });
        if (entityRevisions && entityRevisions.length) {
            const entityRevisionsJSON = entityRevisions.toJSON();
            for (let index = 0; index < entityRevisionsJSON.length; index++) {
                const entityRevision = entityRevisionsJSON[index];
                const entity = await new Entity({bbid: entityRevision.bbid}).fetch();
                const type = entity.get('type');
                const bbid = entity.get('bbid');
                const entityProps: EntityProps = {bbid, type};
                if (entityRevision.data) {
                    entityProps.defaultAlias = entityRevision.data.aliasSet.defaultAlias;
                }
                // Fetch the parent alias only if data property is nullish, i.e. it is deleted
                else {
                    entityProps.parentAlias = await orm.func.entity.getEntityParentAlias(orm, type, bbid);
                }
                // Find the revision by id and attach the current entity to it
                const revisionIndex = revisions.findIndex(rev => rev.revisionId === entityRevision.id);
                revisions[revisionIndex].entities.push(entityProps);
            }
        }
    }
    return revisions;
}

/**
 * Fetches revisions for Show All Revisions/Index Page
 * Fetches the last 'size' number of revisions with offset 'from'
 *
 * @param {number} from - the offset value
 * @param {number} size - no. of last revisions required
 * @param {object} orm - the BookBrainz ORM, initialized during app setup
 * @returns {array} - orderedRevisions
 */
export async function getOrderedRevisions(from, size, orm) {
    const {Revision} = orm;
    const revisions = await new Revision().orderBy('created_at', 'DESC')
        .fetchPage({
            limit: size,
            offset: from,
            withRelated: [
                'author'
            ]
        });
    const revisionsJSON = revisions.toJSON();

    /* Massage the revisions to match the expected format */
    const formattedRevisions = revisionsJSON.map(rev => {
        delete rev.authorId;
        const {author: editor, id: revisionId, ...otherProps} = rev;
        return {editor, entities: [], revisionId, ...otherProps};
    });

    /* Fetch associated ${entity}_revisions and last know alias for deleted entities */
    return getAssociatedEntityRevisions(formattedRevisions, orm);
}

/**
 * Fetches revisions for an Editor
 * Fetches the last 'size' revisions with offset 'from'
 *
 * @param {number} from - the offset value
 * @param {number} size - no. of last revisions required
 * @param {object} req - req is an object containing information about the HTTP request
 * @returns {array} - orderedRevisions for particular Editor
 * @description
 * This checks whether Editor is valid or not.
 * If Editor is valid then this extracts the revisions done by that editor;
 * and then add associated entities in that array;
 */
export async function getOrderedRevisionForEditorPage(from, size, req) {
    const {Editor, Revision} = req.app.locals.orm;

    // If editor isn't present, throw an error
    await new Editor({id: req.params.id})
        .fetch()
        .catch(Editor.NotFoundError, () => {
            throw new error.NotFoundError('Editor not found', req);
        });
    const revisions = await new Revision()
        .query('where', 'author_id', '=', parseInt(req.params.id, 10))
        .orderBy('created_at', 'DESC')
        .fetchPage({
            limit: size,
            offset: from,
            withRelated: [
                {
                    'notes'(q) {
                        q.orderBy('note.posted_at');
                    }
                },
                'notes.author'
            ]
        });
    const revisionsJSON = revisions.toJSON();
    const formattedRevisions = revisionsJSON.map(rev => {
        delete rev.authorId;
        const {author: editor, id: revisionId, ...otherProps} = rev;
        return {editor, entities: [], revisionId, ...otherProps};
    });

    const orderedRevisions = await getAssociatedEntityRevisions(formattedRevisions, req.app.locals.orm);
    return orderedRevisions;
}


/**
 * @name recursivelyGetMergedEntitiesBBIDs
 * @async
 * @param {*} orm - The BookshelfJS ORM object
 * @param {array} bbids - Array of target BBIDs we want to get the source BBID of (if they exist)
 * @returns {Promise<Array<string>>} - Returns a promise resolving to an recursed array of BBIDs containing all descendants
 * @description This recursive function fetches all the merged BBIDs that are pointing to an array of BBIDs
 * An example; if entity A was merged into entity B, BBID A will point to BBID B.
 * Now if entity B is merged in entity C, BBID B will point to BBID C.
 * To allow for reverting merges cleanly, BBID A still points to B and not C (trust me on this).
 * In order to fetch the complete history tree containing all three entities, we need to recursively check
 * if a source_bbid appears as a target_bbid in other rows.
 */
async function recursivelyGetMergedEntitiesBBIDs(orm: any, bbids: string[]) {
    const returnValue: string[] = [];
    await Promise.all(bbids.map(async (bbid) => {
        let thisLevelBBIDs = await orm.bookshelf.knex
            .select('source_bbid')
            .from('bookbrainz.entity_redirect')
            .where('target_bbid', bbid);
            // Flatten the returned array of objects
        thisLevelBBIDs = flatMap(thisLevelBBIDs, 'source_bbid');
        const nextLevelBBIDs = await recursivelyGetMergedEntitiesBBIDs(orm, thisLevelBBIDs);
        returnValue.push(...thisLevelBBIDs, ...nextLevelBBIDs);
    }));
    return returnValue;
}

export async function getOrderedRevisionsForEntityPage(orm: any, from: number, size: number, RevisionModel, bbid: string) {
    const otherMergedBBIDs = await recursivelyGetMergedEntitiesBBIDs(orm, [bbid]);

    const revisions = await new RevisionModel()
        .query((qb) => {
            qb.distinct(`${RevisionModel.prototype.tableName}.id`, 'revision.created_at');
            qb.whereIn('bbid', [bbid, ...otherMergedBBIDs]);
            qb.join('bookbrainz.revision', `${RevisionModel.prototype.tableName}.id`, '=', 'bookbrainz.revision.id');
            qb.orderBy('revision.created_at', 'DESC');
        }).fetchPage({
            limit: size,
            offset: from,
            withRelated: [
                'revision.author',
                {
                    'revision.notes'(q) {
                        q.orderBy('note.posted_at');
                    }
                },
                'revision.notes.author'
            ]
        });

    const revisionsJSON = revisions ? revisions.toJSON() : [];
    return revisionsJSON.map(rev => {
        const {revision} = rev;
        const editor = revision.author;
        const revisionId = revision.id;
        delete revision.author;
        delete revision.authorId;
        delete revision.id;
        return {editor, revisionId, ...revision};
    });
}