buttercup-pw/buttercup-core

View on GitHub
source/facades/vault.ts

Summary

Maintainability
D
1 day
Test Coverage
import { consumeEntryFacade, createEntryFacade } from "./entry.js";
import { idSignifiesNew } from "./tools.js";
import { FACADE_VERSION } from "./symbols.js";
import { Entry } from "../core/Entry.js";
import { Group } from "../core/Group.js";
import { Vault } from "../core/Vault.js";
import { generateUUID } from "../tools/uuid.js";
import { EntryFacade, GroupFacade, GroupID, VaultFacade } from "../types.js";

export interface ConsumeVaultFacadeOptions {
    mergeMode?: boolean;
}

export interface CreateVaultFacadeOptions {
    includeTrash?: boolean;
}

export interface GetGroupEntriesFacadesOptions {
    includeTrash?: boolean;
}

export interface GetGroupsFacadesOptions {
    includeTrash?: boolean;
}

const { FacadeType } = Entry.Attributes;

/**
 * Consume a group facade and apply the differences to a group instance
 * @param group The group instance to apply to
 * @param facade The facade to apply
 * @memberof module:Buttercup
 */
export function consumeGroupFacade(group: Group, facade: GroupFacade) {
    const { id, title, type, attributes } = facade;
    const existingAttributes = group.getAttribute();
    if (type !== "group") {
        throw new Error(`Failed consuming group facade: Invalid facade type: ${type}`);
    }
    if (id !== group.id) {
        throw new Error(
            `Failed consuming group facade: Provided facade ID (${id}) does not match target group's ID: ${group.id}`
        );
    }
    if (!title || title.trim().length <= 0) {
        throw new Error("Failed consuming group facade: Title must not be empty");
    }
    if (group.getTitle() !== title) {
        group.setTitle(title);
    }
    // Check attributes
    Object.keys(existingAttributes)
        .filter((attr) => !attributes.hasOwnProperty(attr))
        .forEach((attr) => {
            // Remove missing
            group.deleteAttribute(attr);
        });
    Object.keys(attributes).forEach((attr) => {
        if (!existingAttributes[attr] || existingAttributes[attr] !== attributes[attr]) {
            // Different value
            group.setAttribute(attr, attributes[attr]);
        }
    });
}

/**
 * Consume a vault facade and apply the differences to the vault
 * instance
 * @param vault The vault instance to apply to
 * @param facade The facade to apply
 * @param options Options for the consumption
 * @memberof module:Buttercup
 */
export function consumeVaultFacade(
    vault: Vault,
    facade: VaultFacade,
    options: ConsumeVaultFacadeOptions = {}
) {
    if (facade._ver !== FACADE_VERSION) {
        throw new Error("Invalid vault facade version");
    }
    if (!facade || (facade && facade.type !== "vault")) {
        throw new Error(
            `Failed consuming vault facade: Second parameter expected to be a vault facade, got: ${facade.type}`
        );
    }
    const { mergeMode = false } = options;
    const { id, type, attributes, groups, entries } = facade;
    if (type !== "vault") {
        throw new Error(`Failed consuming vault facade: Invalid facade type: ${type}`);
    }
    if (!mergeMode && id !== vault.id) {
        throw new Error(
            `Failed consuming vault facade: Provided facade ID (${id}) does not match target vault ID: ${vault.id}`
        );
    }
    const newIDLookup = {};
    // Create comparison facade
    let {
        groups: currentGroups,
        entries: currentEntries,
        attributes: currentAttributes
    } = createVaultFacade(vault);
    // Handle group removal
    if (!mergeMode) {
        currentGroups.forEach((currentGroupFacade) => {
            const existing = groups.find((group) => group.id === currentGroupFacade.id);
            if (!existing) {
                // Removed, so delete
                const targetItem = vault.findGroupByID(currentGroupFacade.id);
                if (targetItem) {
                    // Only attempt deleting if it comes back as a result. It's possible
                    // that if a parent was deleted, the children were also removed and
                    // this call to `findGroupByID` might return nothing..
                    targetItem.delete();
                }
            }
        });
    }
    // Update facade properties after groups deletion
    currentGroups = getGroupsFacades(vault);
    // Manage other group operations
    let groupsLeft = [...groups];
    while (groupsLeft.length > 0) {
        let originalLength = groupsLeft.length;
        groupsLeft = groupsLeft.filter((groupRaw) => {
            const groupFacade = Object.assign({}, groupRaw);
            const groupIDTargetedNew = idSignifiesNew(groupFacade.id, mergeMode);
            if (!groupFacade.id || groupIDTargetedNew) {
                let targetParentID = groupFacade.parentID;
                if (idSignifiesNew(targetParentID, mergeMode)) {
                    if (newIDLookup[targetParentID]) {
                        targetParentID = newIDLookup[targetParentID];
                    } else {
                        // No parent created yet, stalled
                        return true; // keep in loop - not ready
                    }
                }
                // Handle group addition
                const targetParent =
                    targetParentID === "0" ? vault : vault.findGroupByID(targetParentID);
                const newGroupInst = targetParent.createGroup(groupFacade.title);
                if (groupIDTargetedNew) {
                    newIDLookup[`${groupFacade.id}`] = newGroupInst.id;
                }
                groupFacade.id = newGroupInst.id;
            } else {
                if (!currentGroups.find((group) => group.id === groupFacade.id)) {
                    // Group had an ID which is now gone, so it was removed
                    return;
                }
                // Handle group move
                const { id: groupID, parentID: groupParentID } = groupFacade;
                const ref = vault.findGroupByID(groupID);
                const refGroup = ref.getParentGroup();
                if (
                    (refGroup === null && groupParentID !== "0") ||
                    (refGroup !== null && refGroup.id !== groupParentID)
                ) {
                    // Group has different parent, so move
                    ref.moveTo(groupParentID === "0" ? vault : vault.findGroupByID(groupParentID));
                }
            }
            consumeGroupFacade(vault.findGroupByID(groupFacade.id), groupFacade);
            return false; // remove from loop - done
        });
        if (originalLength === groupsLeft.length) {
            const ids = groupsLeft.map((group) => group.id);
            throw new Error(`Processing facade stalled: groups not resolveable: ${ids.join(", ")}`);
        }
    }
    // Handle entry removal
    if (!mergeMode) {
        currentEntries.forEach((currentEntryFacade) => {
            const existing = entries.find((entry) => entry.id === currentEntryFacade.id);
            if (!existing) {
                // Removed, so delete
                const entry = vault.findEntryByID(currentEntryFacade.id);
                if (entry) {
                    entry.delete();
                }
            }
        });
    }
    // Update facade properties after entries deletion
    currentEntries = getEntriesFacades(vault);
    // Manage other entry operations
    let entriesLeft = [...entries];
    entriesLeft = entriesLeft.filter((entryRaw) => {
        const entryFacade = Object.assign({}, entryRaw);
        const entryIDTargetedNew = idSignifiesNew(entryFacade.id, mergeMode);
        if (!entryFacade.id || entryIDTargetedNew) {
            let targetGroupID = entryFacade.parentID;
            if (idSignifiesNew(targetGroupID, mergeMode)) {
                if (newIDLookup[targetGroupID]) {
                    targetGroupID = newIDLookup[targetGroupID];
                } else {
                    // No parent created yet, stalled
                    return true; // keep in loop - not ready
                }
            }
            // Handle entry addition
            const targetGroup = vault.findGroupByID(targetGroupID);
            const newEntry = targetGroup.createEntry();
            if (entryIDTargetedNew) {
                // Not used at the moment, but if references towards
                // entries are needed later, this provides the lookup
                newIDLookup[`${entryFacade.id}`] = newEntry.id;
            }
            entryFacade.id = newEntry.id;
            if (entryFacade.type) {
                newEntry.setAttribute(FacadeType, entryFacade.type);
            }
        } else {
            if (!currentEntries.find((entry) => entry.id === entryRaw.id)) {
                // Entry had an ID which is now gone, so it was removed
                return;
            }
            // Handle entry move
            const ref = vault.findEntryByID(entryFacade.id);
            const refGroup = ref.getGroup();
            if (refGroup.id !== entryFacade.parentID) {
                // Entry has different group, so move
                ref.moveToGroup(vault.findGroupByID(entryFacade.parentID));
            }
        }
        const entryToUpdate = vault.findEntryByID(entryFacade.id);
        consumeEntryFacade(entryToUpdate, entryFacade);
        return false; // done - remove
    });
    // Check attributes
    Object.keys(currentAttributes)
        .filter((attr) => !attributes.hasOwnProperty(attr))
        .forEach((attr) => {
            // Remove missing
            vault.deleteAttribute(attr);
        });
    Object.keys(attributes).forEach((attr) => {
        // Skip this attribute if it's the attachments key
        if (attr === Vault.Attribute.AttachmentsKey && mergeMode) return;
        if (!currentAttributes[attr] || currentAttributes[attr] !== attributes[attr]) {
            // Different value
            vault.setAttribute(attr, attributes[attr]);
        }
    });
}

/**
 * Create a vault facade from an Vault instance
 * @param vault A vault instance
 * @returns A vault facade
 * @memberof module:Buttercup
 */
export function createVaultFacade(
    vault: Vault,
    options: CreateVaultFacadeOptions = {}
): VaultFacade {
    const { includeTrash = true } = options;
    return {
        _tag: generateUUID(),
        _ver: FACADE_VERSION,
        type: "vault",
        id: vault.id,
        attributes: vault.getAttribute() as { [key: string]: string },
        groups: getGroupsFacades(vault, { includeTrash }),
        entries: getEntriesFacades(vault, { includeTrash })
    };
}

/**
 * Create a group facade from a Group instance
 * @param group The group instance
 * @param parentID The parent ID of the group
 * @memberof module:Buttercup
 */
export function createGroupFacade(group: Group, parentID: GroupID = "0"): GroupFacade {
    return {
        type: "group",
        id: group ? group.id : null,
        title: group ? group.getTitle() : "",
        attributes: group ? (group.getAttribute() as { [key: string]: string }) : {},
        parentID
    };
}

/**
 * Get all entry facades for a vault
 * @param vault A vault instance
 * @param options Options for getting entry facades
 * @returns An array of entry facades
 */
function getEntriesFacades(vault: Vault, options: GetGroupEntriesFacadesOptions = {}) {
    return vault
        .getGroups()
        .reduce((output, group) => [...output, ...getGroupEntriesFacades(group, options)], []);
}

/**
 * Convert a group of entries into an array of facades
 * @param entryCollection A group instance
 * @param options Options for getting entry facades
 * @returns An array of entry facades
 */
function getGroupEntriesFacades(
    entryCollection: Group,
    options: GetGroupEntriesFacadesOptions = {}
): Array<EntryFacade> {
    const { includeTrash = true } = options;
    const facades = entryCollection.getEntries().reduce((facades, entry) => {
        if (includeTrash === false && entry.isInTrash()) {
            return facades;
        }
        return [...facades, Object.assign({}, createEntryFacade(entry))];
    }, []);
    entryCollection.getGroups().forEach((group) => {
        facades.push(...getGroupEntriesFacades(group, options));
    });
    return facades;
}

/**
 * Convert an array of groups into an array of facades
 * @param vault The vault instance
 * @param options Options for getting group facades
 * @returns An array of group facades
 */
function getGroupsFacades(vault: Vault, options: GetGroupsFacadesOptions = {}): Array<GroupFacade> {
    const { includeTrash = true } = options;
    return vault._groups.reduce((output, group) => {
        if (includeTrash === false && (group.isTrash() || group.isInTrash())) {
            return output;
        }
        return [
            ...output,
            createGroupFacade(group, group.vault.format.getItemParentID(group._source))
        ];
    }, []);
}