ui/controllers/bagit_profile_controller.js

const $ = require('jquery');
const { Context } = require('../../core/context');
const { BagItProfile } = require('../../bagit/bagit_profile');
const { BagItProfileForm } = require('../forms/bagit_profile_form');
const { BagItUtil } = require('../../bagit/bagit_util');
const { BaseController } = require('./base_controller');
const { NewBagItProfileForm } = require('../forms/new_bagit_profile_form');
const request = require('request');
const { TagDefinition } = require('../../bagit/tag_definition');
const { TagDefinitionForm } = require('../forms/tag_definition_form');
const { TagFileForm } = require('../forms/tag_file_form');
const Templates = require('../common/templates');
const url = require('url');
const { Util } = require('../../core/util');

const typeMap = {
    allowFetchTxt: 'boolean',
    isBuiltIn: 'boolean',
    tarDirMustMatchName: 'boolean',
    userCanDelete: 'boolean'
}

/**
 * BagItProfileController provides methods to display and update
 * {@link BagItProfile} objects.
 *
 * @param {url.URLSearchParams} params - Query parameters. The most
 * important of these is "id", which specifies the id the BagItProfile
 * to load.
 */
class BagItProfileController extends BaseController {

    constructor(params) {
        super(params, 'Settings');
        /**
         * A {@link url.URLSearchParams} object containing parameters that
         * the controller will need to render the display. For forms, the
         * only required param is usually "id", which is the UUID of the
         * object to be edited. For lists, this typically includes
         * limit, offset, orderBy, and sortDirection.
         *
         * @type {url.URLSearchParams}
         */
        this.typeMap = typeMap;
        /**
         * The model which the controller represents. In this case, it's
         * the class {@link BagItProfile}.
         *
         * @type {PersistentObject|object}
         */
        this.model = BagItProfile;
        /**
         * This is the name of the form class that can render a form for
         * this controller's model. For this controller, it's
         * {@link BagItProfileForm}
         *
         * @type {Form}
         */
        this.formClass = BagItProfileForm;
        /**
         * This is the template that renders this controller's form.
         * Templates are properties of the {@link Template} object.
         *
         * @type {handlebars.Template}
         */
        this.formTemplate = Templates.bagItProfileForm;
        /**
         * This is the template that renders this controller's object list.
         * Templates are properties of the {@link Template} object.
         *
         * @type {handlebars.Template}
         */
        this.listTemplate = Templates.bagItProfileList;
        /**
         * The name property of this template's model. This is used when
         * ordering lists of objects by name. For the {@link BagItProfile}
         * object, it's would "name".
         *
         * @type {string}
         */
        this.nameProperty = 'name';
        /**
         * The property by which this controller sorts the list display.
         * Here, it's the "name" property of the BagItProfile object.
         *
         * @type {string}
         */
        this.defaultOrderBy = 'name';
        /**
         * The default order in which to sort the list of BagItProfiles.
         * Can be "asc" or "desc". This is set to "asc".
         *
         * @type {string}
         */
        this.defaultSortDirection = 'asc';
    }

    /**
     * This method presents a form that asks if the user would like
     * to create a new BagItProfile from scratch or based on an
     * existing BagItProfile. The user has to go through this form
     * before actually getting to the BagItProfile form.
     */
    new() {
        let form = new NewBagItProfileForm();
        let html = Templates.bagItProfileNew({ form: form });
        return this.containerContent(html);
    }

    /**
     * This method creates a new BagItProfile, either from scratch
     * or based on an existing profile that the user selected. It
     * then redirects to the edit() method, so the user can customize
     * the new profile.
     */
    create() {
        let newProfile = this.getNewProfileFromBase();
        newProfile.save();
        this.params.set('id', newProfile.id);
        return this.edit();
    }

    /**
     * This presents the tabbed BagItProfile edit form that enables
     * the user to customize a BagItProfile.
     */
    edit() {
        let profile = BagItProfile.find(this.params.get('id'));
        let opts = {
            alertMessage: this.alertMessage || this.params.get('alertMessage')
        };
        this.alertMessage = null;
        return this.containerContent(this._getPageHTML(profile, opts));
    }

    /**
     * This is called when the user clicks the Save button on the
     * BagItProfile edit form. It saves the profile, if the profile
     * is valid, or re-displays the form with error message if the
     * profile is not valid.
     */
    update() {
        let profile = BagItProfile.find(this.params.get('id'));
        let form = new BagItProfileForm(profile);
        form.parseFromDOM();
        if (!form.obj.validate()) {
            let errors = this._getPageLevelErrors(form.obj);
            let opts = {
                errMessage: Context.y18n.__('Please correct the following errors.'),
                errors: errors
            }
            return this.containerContent(this._getPageHTML(form.obj, opts));
        }
        this.alertMessage = Context.y18n.__(
            "ObjectSaved_message",
            Util.camelToTitle(profile.constructor.name),
            profile.name);
        profile.save();
        return this.list();
    }

    /**
     * This fills in the page-level data structure and passes it to
     * the BagItProfileForm template. It returns the HTML rendered
     * by the template.
     *
     * @param {BagItProfile} profile - The BagItProfile whose properties
     * should be displayed in the form.
     *
     * @param {object} opts - Additional options to pass into the template
     * context. These may include errors, errMessage, and alertMessage.
     *
     * @returns {string} - The HTML to render.
     */
    _getPageHTML(profile, opts = {}) {
        let errors = this._getPageLevelErrors(profile);
        let tagsByFile = profile.tagsGroupedByFile();
        let tagFileNames = Object.keys(tagsByFile).sort();
        let data = {
            bagItProfileId: profile.id,
            form: new BagItProfileForm(profile),
            tagFileNames: tagFileNames,
            tagsByFile: tagsByFile
        }
        Object.assign(data, opts);
        return this.formTemplate(data, Templates.renderOptions);
    }

    /**
     * This sets page-level validation errors, including information about
     * which tab contains the the error.
     *
     * @param {BagItProfile} profile
     *
     * @returns {Array<string>} Array of error messages to be displayed at the
     * top of the page.
     *
     * @private
     */
    _getPageLevelErrors(profile) {
        let errors = [];
        if (!Util.isEmpty(profile.errors["name"])) {
            errors.push(Context.y18n.__("About Tab: %s", profile.errors["name"]));
        }
        if (!Util.isEmpty(profile.errors["acceptBagItVersion"])) {
            errors.push(Context.y18n.__("General Tab: %s", profile.errors["acceptBagItVersion"]));
        }
        if (!Util.isEmpty(profile.errors["manifestsAllowed"])) {
            errors.push(Context.y18n.__("Manifests Tab: %s", profile.errors["manifestsAllowed"]));
        }
        if (!Util.isEmpty(profile.errors["serialization"])) {
            errors.push(Context.y18n.__("Serialization Tab: %s", profile.errors["serialization"]));
        }
        if (!Util.isEmpty(profile.errors["acceptSerialization"])) {
            errors.push(Context.y18n.__("Serialization Tab: %s", profile.errors["acceptSerialization"]));
        }
        if (!Util.isEmpty(profile.errors["tags"])) {
            errors.push(Context.y18n.__("Tag Files Tab: %s", profile.errors["tags"]));
        }
        return errors;
    }

    /**
     * This returns an entirely new BagItProfile, or a new BagItProfile
     * that is a copy of a base profile.
     *
     * @returns {BagItProfile}
     */
    getNewProfileFromBase() {
        let newProfile = null;
        let form = new NewBagItProfileForm();
        form.parseFromDOM();
        if(form.obj.baseProfile) {
            let baseProfile = BagItProfile.find(form.obj.baseProfile);
            newProfile = new BagItProfile();
            Object.assign(newProfile, baseProfile);
            newProfile.id = Util.uuid4();
            newProfile.baseProfileId = baseProfile.id;
            newProfile.isBuiltIn = false;
            newProfile.userCanDelete = true;
            newProfile.name = `Copy of ${baseProfile.name}`;
            newProfile.description = `Customized version of ${baseProfile.name}`;
        } else {
            newProfile = new BagItProfile();
        }
        return newProfile;
    }

    /**
     * This displays a modal dialog asking the user to name a new
     * tag file to be added to this profile.
     */
    newTagFile() {
        let title = Context.y18n.__("New Tag File");
        let form = new TagFileForm('custom-tags.txt');
        let body = Templates.tagFileForm({
            form: form,
            bagItProfileId: this.params.get('id')
        });
        return this.modalContent(title, body);
    }

    /**
     * This creates a new tag file in the {@link BagItProfile} by adding
     * a single new {@link TagDefinition} whose tagFile attribute
     * matches the tag file name the user typed in.
     */
    newTagFileCreate() {
        let title = Context.y18n.__("New Tag File");
        let form = new TagFileForm();
        form.parseFromDOM();
        if (Util.isEmpty(form.obj.tagFileName)) {
            $('#tagFileForm_tagFileNameError').text(Context.y18n.__('Tag file name is required'));
            return this.noContent();
        }
        let profile = BagItProfile.find(this.params.get('id'));
        if (profile.hasTagFile(form.obj.tagFileName)) {
            $('#tagFileForm_tagFileNameError').text(Context.y18n.__('This profile already has a tag file called %s', form.obj.tagFileName));
            return this.noContent();
        }
        profile.tags.push(new TagDefinition({
            tagName: Context.y18n.__('New-Tag'),
            tagFile: form.obj.tagFileName
        }));
        profile.save();
        this.alertMessage = Context.y18n.__("New tag file %s is available from the Tag Files menu below.", form.obj.tagFileName);
        return this.edit();
    }

    /**
     * This deletes a TagDefinition from this BagItProfile, if the user
     * confirms the deletion.
     */
    deleteTagDef() {
        let profile = BagItProfile.find(this.params.get('id'));
        let tagDef = profile.firstMatchingTag('id', this.params.get('tagDefId'));
        let isLastTagInFile = false;
        let tagsGroupedByFile = profile.tagsGroupedByFile();
        let message = Context.y18n.__("Delete tag %s from this profile?", tagDef.tagName);
        if (tagsGroupedByFile[tagDef.tagFile].length < 2) {
            message += ' ' + Context.y18n.__(
                "Deleting the last tag in the file will delete the tag file as well.")
            isLastTagInFile = true;
        }
        if (confirm(message)) {
            profile.tags = profile.tags.filter(t => t.id !== tagDef.id);
            profile.save();
            $(`tr[data-tag-id="${tagDef.id}"]`).remove();
            if (isLastTagInFile) {
                this.alertMessage = Context.y18n.__(
                    "Deleted tag %s and tag file %s",
                    tagDef.tagName, tagDef.tagFile);
                return this.edit(profile);
            }
        }
        return this.noContent();
    }

    /**
     * This shows a screen where user can choose to import a profile from
     * a URL or a textarea.
     *
     */
    importStart() {
        let title = Context.y18n.__("Import BagIt Profile");
        let body = Templates.bagItProfileImport();
        return this.modalContent(title, body);
    }

    /**
     * Exports a DART BagIt Profile to the format used by
     * https://github.com/bagit-profiles/bagit-profiles/
     *
     * The exported profile appears in a modal window.
     *
     */
    exportProfile() {
        let profile = BagItProfile.find(this.params.get('id'));
        let json = BagItUtil.profileToStandardJson(profile);
        let title = Context.y18n.__(profile.name);
        let body = Templates.bagItProfileExport({
            profile: profile,
            json: json
        });
        return this.modalContent(title, body);
    }

    /**
     * Attaches event handlers to elements after they are rendered
     * on the page.
     *
     * @private
     */
    postRenderCallback(fnName) {
        let controller = this;
        if (fnName == 'newTagFile' || fnName == 'newTagFileCreate') {
            $('#tagFileForm_tagFileName').keydown(this._enterKeyHandler);
        } else if (fnName == 'importStart') {
            $('#importSourceUrl').click(this._importSourceUrlClick);
            $('#importSourceTextArea').click(this._importSourceTextAreaClick);
            $('#btnImport').click(function() { controller._importProfile() });
        }
    }

    /**
     * Handler for clicks on the radio button where user specifies
     * that they want to import a BagIt profile from a URL.
     *
     * This shows the URL field and hides the textarea.
     *
     * @private
     */
    _importSourceUrlClick(e) {
        $('#txtJsonContainer').hide();
        $('#txtUrlContainer').show();
    }


    /**
     * Handler for clicks on the radio button where user specifies
     * that they want to import a BagIt profile from cut-and-paste JSON.
     *
     * This shows the textarea and hides the URL field.
     *
     * @private
     */
    _importSourceTextAreaClick(e) {
        $('#txtUrlContainer').hide();
        $('#txtJsonContainer').show();
    }

    /**
     * This calls the correct function to import a BagIt Profile, based
     * on the input source (URL or text area).
     *
     * @private
     */
    _importProfile() {
        var importSource = $("input[name='importSource']:checked").val();
        if (importSource == 'URL') {
            this._importProfileFromUrl();
        } else if (importSource == 'TextArea') {
            this._importProfileFromTextArea();
        }
    }

    /**
     * Imports a bagit-profile of Library of Congress style BagIt Profile
     * from the URL the user specified.
     *
     * @private
     */
    _importProfileFromUrl() {
        let controller = this;
        let profileUrl = $("#txtUrl").val();
        try {
            new url.URL(profileUrl);
        } catch (ex) {
            alert(Context.y18n.__("Please enter a valid URL."));
        }
        request(profileUrl, function (error, response, body) {
            if (error) {
                let msg = Context.y18n.__("Error retrieving profile from %s: %s", profileUrl, error);
                Context.logger.error(msg);
                alert(msg);
            } else if (response && response.statusCode == 200) {
                // TODO: Make sure response is JSON, not HTML.
                controller._importWithErrHandling(body, profileUrl);
            } else {
                let statusCode = (response && response.statusCode) || Context.y18n.__('Unknown');
                let msg = Context.y18n.__("Got response %s from %s", statusCode, profileUrl);
                Context.logger.error(msg);
                alert(msg);
            }
        });
    }

    /**
     * Imports a bagit-profile of Library of Congress style BagIt Profile
     * from the JSON in the textarea.
     *
     * @private
     */
    _importProfileFromTextArea() {
        let profileJson = $("#txtJson").val();
        this._importWithErrHandling(profileJson);
    }


    /**
     * This wraps the import process in a general error handler.
     *
     * @private
     */
    _importWithErrHandling(json, profileUrl) {
        try {
            this._importProfileObject(json, profileUrl);
            return true;
        } catch (ex) {
            let msg = Context.y18n.__("Error importing profile: %s", ex);
            Context.logger.error(msg);
            Context.logger.error(ex);
            alert(msg);
            return false;
        }
    }


    /**
     * This performs the actual import of the BagIt profile. It may throw
     * any number of errors, which must be handled by the caller.
     *
     * @private
     */
    _importProfileObject(json, profileUrl) {
        let obj;
        try {
            obj = JSON.parse(json);
        } catch (ex) {
            let msg = Context.y18n.__("Error parsing JSON: %s. ", ex.message || ex);
            if (profileUrl) {
                msg += Context.y18n.__("Be sure the URL returned JSON, not HTML.");
            }
            throw msg;
        }
        let convertedProfile;
        let profileType = BagItUtil.guessProfileType(obj);
        switch (profileType) {
        case 'dart':
            convertedProfile = obj;
            break;
        case 'loc_ordered':
            convertedProfile = BagItUtil.profileFromLOCOrdered(obj, profileUrl);
            break;
        case 'loc_unordered':
            convertedProfile = BagItUtil.profileFromLOC(obj, profileUrl);
            break;
        case 'bagit_profiles':
            convertedProfile = BagItUtil.profileFromStandardObject(obj);
            break;
        default:
            alert(Context.y18n.__("DART does not recognize this BagIt Profile structure."));
        }
        if (convertedProfile) {
            convertedProfile.save();
            let params = new URLSearchParams({
                id: convertedProfile.id,
                alertMessage: Context.y18n.__("Imported BagIt profile. Please review the profile to ensure it is accurate.")
            });
            return this.redirect('BagItProfile', 'edit', params);
        } else {
            let msg = Context.y18n.__("Failed to import profile");
            Context.logger.error(msg);
            alert(msg);
        }
    }

    /**
     * Handle the enter key press in the New Tag File modal. If we don't
     * handle this, the Electron browser window takes the default action
     * of submitting the single-element form, resulting in a blank window.
     *
     * This handler does what the user expects, which is the same as clicking
     * on the save button.
     *
     *
     */
    _enterKeyHandler(e) {
        if (e.keyCode == 13) {
            e.stopPropagation();
            e.preventDefault();
            if (e.type == 'keydown') {
                // trigger('click') doesn't do it...
                location.href = $('#newTagFileSave').attr('href');
            }
        }
    }
}

module.exports.BagItProfileController = BagItProfileController;
module.exports.BagItProfileControllerTypeMap = typeMap;