bookbrainz/bookbrainz-site

View on GitHub
src/server/routes/merge.ts

Summary

Maintainability
D
2 days
Test Coverage
/*
 * Copyright (C) 2019  Nicolas Pelletier
 *
 * 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 auth from '../helpers/auth';
import * as commonUtils from '../../common/helpers/utils';
import * as entityRoutes from './entity/entity';
import * as middleware from '../helpers/middleware';
import {BadRequestError, ConflictError, NotFoundError} from '../../common/helpers/error';
import {attachAttributes, getAdditionalRelations} from '../helpers/utils';
import {basicRelations,
    getEntityFetchPropertiesByType,
    getEntitySectionByType} from '../helpers/merge';
import {
    entityMergeMarkup,
    generateEntityMergeProps
} from '../helpers/entityRouteUtils';
import {PrivilegeType} from '../../common/helpers/privileges-utils';
import _ from 'lodash';
import {escapeProps} from '../helpers/props';
import express from 'express';
import renderRelationship from '../helpers/render';
import targetTemplate from '../templates/target';


const {ENTITY_EDITOR} = PrivilegeType;

const router = express.Router();

function entitiesToFormState(entities: any[]) {
    const [targetEntity, ...otherEntities] = entities;
    const aliases: any[] = entities.reduce((returnValue, entity) => {
        if (Array.isArray(_.get(entity, 'aliasSet.aliases'))) {
            return returnValue.concat(
                entity.aliasSet.aliases.map(({language, ...rest}) => ({
                    language: language.id,
                    ...rest
                }))
            );
        }
        return returnValue;
    }, []);
    let defaultAliasIndex;
    if (_.get(targetEntity, 'aliasSet.defaultAliasId')) {
        defaultAliasIndex = _.findIndex(aliases, alias => alias.id === targetEntity.aliasSet.defaultAliasId);
    }
    else {
        defaultAliasIndex = entityRoutes.getDefaultAliasIndex(aliases);
    }

    const aliasEditor = {};
    aliases.forEach((alias) => {
        aliasEditor[alias.id] = alias;
    });

    const nameSection = aliases[defaultAliasIndex] ||
    {
        disambiguation: null,
        language: null,
        name: '',
        sortName: ''
    };
    const hasDisambiguation = _.find(entities, 'disambiguation');
    nameSection.disambiguation = hasDisambiguation &&
        hasDisambiguation.disambiguation &&
        hasDisambiguation.disambiguation.comment;

    const identifiers: any[] = entities.reduce((returnValue, entity) => {
        if (entity.identifierSet) {
            const mappedIdentifiers = entity.identifierSet.identifiers.map(
                ({type, ...rest}) => ({
                    type: type.id,
                    ...rest
                })
            );
            return returnValue.concat(mappedIdentifiers);
        }
        return returnValue;
    }, []);

    const identifierEditor = {};
    const uniqueIdentifiers = _.uniqWith(identifiers, (identifierA, identifierB) =>
        identifierA.type === identifierB.type && identifierA.value === identifierB.value);
    uniqueIdentifiers.forEach((identifier) => {
        identifierEditor[identifier.id] = identifier;
    });
    const type = _.camelCase(targetEntity.type);

    const entityTypeSection = getEntitySectionByType(type, entities);

    const relationshipSection = {
        canEdit: false,
        lastRelationships: null,
        relationshipEditorProps: null,
        relationshipEditorVisible: false,
        relationships: {}
    };

    const relationships = entities.reduce((returnValue, entity) => {
        if (entity.relationships) {
            return returnValue.concat(entity.relationships);
        }
        return returnValue;
    }, []);
    const otherEntitiesBBIDs = otherEntities.map(entity => entity.bbid);
    relationships.forEach((relationship) => {
        const isAffectedByMerge = otherEntitiesBBIDs.includes(relationship.sourceBbid) || otherEntitiesBBIDs.includes(relationship.targetBbid);
        const formattedRelationship = {
            attributeSetId: relationship.attributeSetId,
            attributes: relationship.attributeSet ? relationship.attributeSet.relationshipAttributes : [],
            isAdded: isAffectedByMerge,
            relationshipType: relationship.type,
            rendered: relationship.rendered,
            rowID: `n${relationship.id}`,
            // Change the source and/or target BBIDs of the relationship accordingly
            // to the bbid of the entity we're merging into
            sourceEntity: otherEntitiesBBIDs.includes(relationship.sourceBbid) ? targetEntity : relationship.source,
            targetEntity: otherEntitiesBBIDs.includes(relationship.targetBbid) ? targetEntity : relationship.target
        };
        // separate series items from relationships
        if (type === 'series' && (relationship.typeId > 69 && relationship.typeId < 75)) {
            entityTypeSection.seriesItems[`n${relationship.id}`] = formattedRelationship;
        }
        else {
            relationshipSection.relationships[`n${relationship.id}`] = formattedRelationship;
        }
    });

    const annotations = entities.reduce((returnValue, entity) => {
        if (entity.annotation && entity.annotation.content) {
            return `${returnValue}${returnValue ? '\n——————\n' : ''}${entity.annotation.content}`;
        }
        return returnValue;
    }, '');
    const annotationSection = {content: annotations};


    const authorCredits = entities.reduce((returnValue, entity) => {
        if (entity.authorCredit) {
            return returnValue.concat(entity.authorCredit);
        }
        return returnValue;
    }, []);
    const authorCredit = authorCredits.length ? authorCredits[0] : null;

    const props = {
        aliasEditor,
        annotationSection,
        authorCredit,
        identifierEditor,
        nameSection,
        relationshipSection
    };
    props[`${type}Section`] = entityTypeSection;
    return props;
}

async function loadEntityRelationships(entity, orm, transacting): Promise<any> {
    async function getEntityWithAlias(relEntity) {
        const redirectBbid = await orm.func.entity.recursivelyGetRedirectBBID(orm, relEntity.bbid, null);
        const model = commonUtils.getEntityModelByType(orm, relEntity.type);
        return model.forge({bbid: redirectBbid})
          .fetch({require: true, withRelated: ['defaultAlias'].concat(getAdditionalRelations(relEntity.type))});
    }
    const {RelationshipSet} = orm;

    // Default to empty array, its presence is expected down the line
    entity.relationships = [];

    if (!entity.relationshipSetId) {
        return null;
    }
    try {
      const relationshipSet = await RelationshipSet.forge({id: entity.relationshipSetId})
            .fetch({
                transacting,
                withRelated: [
                    'relationships.source',
                    'relationships.target',
                    'relationships.type.attributeTypes',
                    'relationships.attributeSet.relationshipAttributes.value',
                    'relationships.attributeSet.relationshipAttributes.type'
                ]
            });
        if (relationshipSet) {
            entity.relationships = relationshipSet.related('relationships').toJSON();
        }

      attachAttributes(entity.relationships);

      /**
       * Source and target are generic Entity objects, so until we have
       * a good way of polymorphically fetching the right specific entity,
       * we need to fetch default alias in a somewhat sketchier way.
       */
      const relationships = await Promise.all(entity.relationships.map(async (relationship) => {
            const [relationshipSource, relationshipTarget] = await Promise.all([
                getEntityWithAlias(relationship.source),
                getEntityWithAlias(relationship.target)
            ]);

            relationship.source = relationshipSource.toJSON();
            relationship.target = relationshipTarget.toJSON();
            return relationship;
      }));

      // Set rendered relationships on relationship objects
        relationships.forEach((relationship) => {
            relationship.rendered = renderRelationship(relationship);
        });
      return entity;
    }
    catch (error) {
        /* eslint-disable no-console */
        console.error(error);
        /* eslint-enable no-console */
    }
    return null;
}
async function getEntityByBBID(orm, transacting, bbid) {
    const redirectBbid = await orm.func.entity.recursivelyGetRedirectBBID(orm, bbid, transacting);
    const entityHeader = await orm.Entity.forge({bbid: redirectBbid}).fetch({transacting});
    const entityType = entityHeader.get('type');
    const model = commonUtils.getEntityModelByType(orm, entityType);

    const entity = await model.forge({bbid: redirectBbid})
        .fetch({
            require: true,
            transacting,
            withRelated: basicRelations.concat(getEntityFetchPropertiesByType(entityType))
        });

    const entityJSON = entity.toJSON();
    await loadEntityRelationships(entityJSON, orm, transacting);

    // Return the loaded entity as JSON
    return entityJSON;
}


router.get('/add/:bbid', auth.isAuthenticated, auth.isAuthorized(ENTITY_EDITOR),
    async (req, res, next) => {
        const {orm}: {orm?: any} = req.app.locals;
        let {mergeQueue} = req.session;
        if (_.isNil(req.params.bbid) ||
        !commonUtils.isValidBBID(req.params.bbid)) {
            return next(new BadRequestError(`Invalid bbid: ${req.params.bbid}`, req));
        }
        if (!mergeQueue) {
            mergeQueue = {
                entityType: '',
                mergingEntities: {},
                seriesEntityType: ''
            };
            req.session.mergeQueue = mergeQueue;
        }
        if (_.has(mergeQueue.mergingEntities, req.params.bbid)) {
            // Do nothing, redirect to referer.
            return res.redirect(req.headers.referer);
        }
        let fetchedEntity;
        try {
            await orm.bookshelf.transaction(async (transacting) => {
                fetchedEntity = await getEntityByBBID(orm, transacting, req.params.bbid);
            });
        }
        catch (error) {
            return next(new NotFoundError('Entity not found', req));
        }

        /* If there is no fetchedEntity or no dataId, the entity has been deleted */
        if (!_.get(fetchedEntity, 'dataId')) {
            const conflictError = new ConflictError('You cannot merge an entity that has been deleted');
            return next(conflictError);
        }
        const {bbid, type} = fetchedEntity;
        if (type !== mergeQueue.entityType) {
            mergeQueue.mergingEntities = {};
            // mergeQueue.entityType is the type of the entity
            mergeQueue.entityType = _.upperFirst(type);
            // fetchedEnitity.entityType is the series entity type
            mergeQueue.seriesEntityType = type === 'Series' ? fetchedEntity.entityType : null;
        }

        /* Disallow merging series entity of different types. */
        if (type === 'Series' && (mergeQueue.seriesEntityType !== fetchedEntity.entityType)) {
            mergeQueue.mergingEntities = {};
            mergeQueue.entityType = _.upperFirst(type);
            mergeQueue.seriesEntityType = fetchedEntity.entityType;
        }

        mergeQueue.mergingEntities[bbid] = fetchedEntity;

        return res.redirect(req.headers.referer);
    });

router.get('/remove/:bbid', auth.isAuthenticated, auth.isAuthorized(ENTITY_EDITOR),
    (req, res) => {
        const {mergeQueue} = req.session;
        if (!mergeQueue || _.isNil(req.params.bbid)) {
            res.redirect(req.headers.referer);
            return;
        }
        const {mergingEntities} = mergeQueue;

        delete mergingEntities[req.params.bbid];

        /* If there's only one item in the queue, delete the queue entirely */
        const mergingBBIDs = Object.keys(mergingEntities);
        if (mergingBBIDs.length === 0) {
            req.session.mergeQueue = null;
        }
        res.redirect(req.headers.referer);
    });

router.get('/cancel', auth.isAuthenticated, auth.isAuthorized(ENTITY_EDITOR),
    (req, res) => {
        req.session.mergeQueue = null;
        res.redirect(req.headers.referer);
    });

router.get('/submit/:targetBBID?', auth.isAuthenticated, auth.isAuthorized(ENTITY_EDITOR),
    middleware.loadIdentifierTypes, middleware.loadLanguages,
    middleware.loadRelationshipTypes,
    async (req, res, next) => {
        const {orm}: {orm?: any} = req.app.locals;
        const {bookshelf} = orm;
        const {mergeQueue} = req.session;
        if (!mergeQueue) {
            return next(new ConflictError('No entities selected for merge'));
        }
        const {mergingEntities, entityType} = mergeQueue;

        const bbids = Object.keys(mergingEntities);
        if (bbids.length < 2) {
            return next(new ConflictError('You must have at least 2 entities selected to merge'));
        }
        const invalidBBIDs = bbids.filter(bbid => !commonUtils.isValidBBID(bbid));
        if (invalidBBIDs.length) {
            return next(new ConflictError(`Invalid bbids: ${invalidBBIDs.join(', ')}`));
        }

        let mergingFetchedEntities = _.values(mergingEntities);
        let {targetBBID} = req.params;
        if (_.isNil(targetBBID)) {
            targetBBID = bbids[0];
        }

        if (_.uniqBy(mergingFetchedEntities, 'type').length !== 1) {
            const conflictError = new ConflictError('You can only merge entities of the same type');
            return next(conflictError);
        }

        try {
            await bookshelf.transaction(async (transacting) => {
                const refreshedMergingEntities = await Promise.all(bbids.map(
                    (bbid) =>
                        getEntityByBBID(orm, transacting, bbid)
                ));
                refreshedMergingEntities.forEach(entity => {
                    mergeQueue.mergingEntities[entity.bbid] = entity;
                });
                mergingFetchedEntities = refreshedMergingEntities;
            });
        }
        catch (error) {
            return next(error);
        }
        if (_.uniqBy(mergingFetchedEntities, 'bbid').length !== mergingFetchedEntities.length) {
            const conflictError = new ConflictError('You cannot merge an entity that has already been merged');
            return next(conflictError);
        }

        mergingFetchedEntities.sort((first, second) => {
            if (first.bbid === targetBBID) { return -1; }
            else if (second.bbid === targetBBID) { return 1; }
            return 0;
        });
        res.locals.entity = mergingFetchedEntities[0];

        const {markup, props} = entityMergeMarkup(generateEntityMergeProps(
            req, res, {
                entityType: _.camelCase(entityType),
                mergingEntities: mergingFetchedEntities,
                title: 'Merge Page'
            }
            , entitiesToFormState
        ));

        return res.send(targetTemplate({
            markup,
            props: escapeProps(props),
            script: '/js/entity-editor.js',
            title: `Merge ${mergingFetchedEntities.length} ${_.startCase(entityType)}s`
        }));
    });

export default router;