ui/controllers/settings_controller.js
const $ = require('jquery');
const { AppSetting } = require('../../core/app_setting');
const { BagItProfile } = require('../../bagit/bagit_profile');
const { BaseController } = require('./base_controller');
const { Constants } = require('../../core/constants');
const { Context } = require('../../core/context');
const Dart = require('../../core');
const { ExportQuestion } = require('../../core/export_question');
const { ExportSettings } = require('../../core/export_settings');
const { RemoteRepository } = require('../../core/remote_repository');
const request = require('request');
const { SettingsExportForm } = require('../forms/settings_export_form');
const { SettingsQuestionsForm } = require('../forms/settings_questions_form');
const { SettingsResponseForm } = require('../forms/settings_response_form');
const { StorageService } = require('../../core/storage_service');
const Templates = require('../common/templates');
const url = require('url');
// This controller got a little out of hand. Sorry.
// This should probably be split into two controllers,
// one for export and one for import.
// This var is used to persist the imported settings object
// between requests. We can theoretically pass the object in the URL
// params, but the JSON can be 10-50 kb, which makes for a long
// query string.
var importedSettings = null;
/**
* SettingsController imports JSON settings from a URL or from
* cut-and-pasted text. The settings JSON should be in the format below.
* Note that each array is optional.
*
* @example
* {
* appSettings: [ ... array of AppSetting objects ... ],
* bagItProfiles: [ ... array of BagItProfile objects ... ],
* remoteRepositories: [ ... array of RemoteRepository objects ... ],
* storageServices: [ ... array of StorageService objects ... ],
* }
*
* @see {@link AppSetting}
* @see {@link BagItProfile}
* @see {@link RemoteRepository}
* @see {@link StorageService}
*
*/
class SettingsController extends BaseController {
constructor(params) {
super(params, 'Settings');
this.questionsForm = null;
this.importSucceeded = false;
}
/**
* Shows the page where a user can import settings from text or a URL.
*
*/
import() {
let html = Templates.settingsImport;
return this.containerContent(html);
}
/**
* Shows the page where a user can choose which settings to export.
*
*/
export() {
let settings = ExportSettings.find(Constants.EMPTY_UUID) || new ExportSettings();
let form = new SettingsExportForm(settings);
let data = { form: form };
let html = Templates.settingsExport(data);
return this.containerContent(html);
}
/**
* Resets the settings export form by erasing all export settings and
* questions.
*
*/
reset() {
new ExportSettings().save();
return this.redirect('Settings', 'export', this.params);
}
/**
* Saves export settings, then redirects to the export settings page.
*
*/
saveAndGoToExport() {
let settings = ExportSettings.find(Constants.EMPTY_UUID) || new ExportSettings();
this.questionsForm = new SettingsQuestionsForm(settings);
this.questionsForm.parseQuestionsForExport();
this.questionsForm.obj.save();
return this.redirect("Settings", "export", this.params);
}
/**
* Saves export settings, then redirects to the questions page.
*
*/
saveAndGoToQuestions() {
let settings = ExportSettings.find(Constants.EMPTY_UUID) || new ExportSettings();
let itemsForm = new SettingsExportForm(settings);
itemsForm.parseItemsForExport();
if (!itemsForm.obj.anythingSelected()) {
alert(Context.y18n.__("You must select at least one item to export before you can add questions."));
return this.noContent();
}
itemsForm.obj.save();
return this.redirect("Settings", "showQuestionsForm", this.params);
}
/**
* Shows the form where a user can define setup questions.
*
*/
showQuestionsForm() {
let settings = ExportSettings.find(Constants.EMPTY_UUID);
this.questionsForm = new SettingsQuestionsForm(settings);
let html = Templates.settingsQuestions({
questions: this.questionsForm.getQuestionsAsArray()
});
return this.containerContent(html);
}
/**
* Shows the exported settings in JSON format in a modal dialog.
* The user can copy the JSON to the system clipboard from here.
*
*/
showExportJson() {
let settings = ExportSettings.find(Constants.EMPTY_UUID) || new ExportSettings();
let form = null;
let fromPage = this.params.get("fromPage")
if (fromPage == "export") {
form = new SettingsExportForm(settings);
form.parseItemsForExport();
} else { // fromPage == "questions"
form = new SettingsQuestionsForm(settings);
form.parseQuestionsForExport();
}
form.obj.save();
let title = Context.y18n.__("Exported Settings");
let body = Templates.settingsExportResult({
json: form.obj.toJson()
});
return this.modalContent(title, body);
}
/**
* Show the result of a successful import. This displays a list of
* imported settings and questions, if there are any.
*
*/
showImportResult() {
let form = null;
if (importedSettings.questions && importedSettings.questions.length > 0) {
form = new SettingsResponseForm(importedSettings);
form.preloadValues();
}
let html = Templates.importResult({
settings: importedSettings,
form: form,
});
return this.containerContent(html);
}
/**
* 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 attaches event handlers after the page loads.
*
*/
postRenderCallback(fnName) {
let controller = this;
if (fnName == 'import') {
this._attachImportHandlers();
} else if (fnName == 'export') {
this._attachExportHandlers();
} else if (fnName == 'showExportJson') {
$('#btnCopyToClipboard').click(function() {
controller._copyToClipboard()
});
} else if (fnName == 'showQuestionsForm') {
for (let i = 0; i < this.questionsForm.rowCount; i++) {
controller._attachQuestionCallbacks(i);
}
$('#btnAdd').click(() => { controller._addQuestion() });
$('button[data-action-type=delete-question]').click((e) => {
let questionNumber = parseInt($(e.currentTarget).attr('data-question-number'), 10);
controller._deleteQuestion(questionNumber);
});
} else if (fnName == 'showImportResult') {
$('#btnSubmit').click(() => {
controller._processResponses(controller);
});
}
}
/**
* This attaches event handlers to the import page.
*
* @private
*/
_attachImportHandlers() {
let controller = this;
$('#importSourceUrl').click(() => {
controller._clearMessage();
controller._importSourceUrlClick();
});
$('#importSourceTextArea').click(() => {
controller._clearMessage();
controller._importSourceTextAreaClick();
});
$('#btnImport').click(function() {
controller._clearMessage();
controller._importSettings();
});
}
/**
* This attaches event handlers to the export page.
*
* @private
*/
_attachExportHandlers() {
$(`input[name="addQuestions"]`).click(() => {
if ($(`input[name="addQuestions"]`).is(':checked')) {
$("#btnNext").text(Context.y18n.__("Add Questions"));
$("#btnNext").attr("href", "#Settings/saveAndGoToQuestions");
} else {
$("#btnNext").text(Context.y18n.__("Export"));
$("#btnNext").attr("href", "#Settings/showExportJson");
}
})
$('#btnReset').click(() => {
if(confirm(Context.y18n.__("Do you want to clear this form and remove questions related to these settings?"))){
location.href = '#Settings/reset';
}
})
}
/**
* Adds a new, blank question to the export questions form.
*
* @private
*/
_addQuestion() {
this.questionsForm.parseQuestionsForExport();
this.questionsForm.obj.questions.push(new ExportQuestion());
this.questionsForm.obj.save();
return this.redirect("Settings", "showQuestionsForm", this.params);
}
/**
* Adds a new, blank question to the export questions form.
*
* @private
*/
_deleteQuestion(questionNumber) {
this.questionsForm.parseQuestionsForExport();
this.questionsForm.obj.questions.splice(questionNumber, 1);
this.questionsForm.obj.save();
return this.redirect("Settings", "showQuestionsForm", this.params);
}
/**
* Attaches callbacks after the export questions form is rendered.
*
* @private
*/
_attachQuestionCallbacks(rowNumber) {
let controller = this;
// When selected object type changes, update the object names list.
$(`select[data-control-type=object-type][data-row-number=${rowNumber}]`).change(function() {
let namesList = controller.questionsForm.getNamesList(rowNumber);
$(`#objId_${rowNumber}`).empty();
$(`#objId_${rowNumber}`).append(new Option());
for (let opt of namesList) {
$(`#objId_${rowNumber}`).append(new Option(opt.name, opt.id));
}
});
// When selected object name changes, updated the fields list.
$(`select[data-control-type=object-name][data-row-number=${rowNumber}]`).change(function() {
let fieldsList = controller.questionsForm.getFieldsList(rowNumber);
$(`#field_${rowNumber}`).empty();
$(`#field_${rowNumber}`).append(new Option());
for (let opt of fieldsList) {
$(`#field_${rowNumber}`).append(new Option(opt.name, opt.id));
}
});
}
/**
* This processes the user's responses to import questions.
*
*/
_processResponses(controller) {
let form = new SettingsResponseForm(importedSettings);
let responses = form.getResponses();
let hasEmptyAnswers = false;
let errors = '';
let qNumber = 1;
for (let [id, userResponse] of Object.entries(responses)) {
if (userResponse == '') {
hasEmptyAnswers = true;
}
try {
let q = importedSettings.questions.find(q => q.id == id);
let question = new ExportQuestion(q);
question.copyResponseToObject(userResponse);
} catch (ex) {
Context.logger.error(ex);
errors += Context.y18n.__("DART could not save the answer to question %s %s", qNumber.toString(), "\n");
}
qNumber++;
}
if (errors.length) {
alert(errors);
} else {
let params = new url.URLSearchParams({
alertMessage: Context.y18n.__("DART imported the settings.")
});
controller.redirect('Dashboard', 'show', params);
}
}
/**
* This calls the correct function to import DART settings based
* on the input source (URL or text area).
*
* @private
*/
async _importSettings() {
let controller = this;
var importSource = $("input[name='importSource']:checked").val();
if (importSource == 'URL') {
await controller._importSettingsFromUrl($("#txtUrl").val());
} else if (importSource == 'TextArea') {
controller._importWithErrHandling($("#txtJson").val(), null);
}
if (controller.importSucceeded) {
return controller.redirect('Settings', 'showImportResult', this.params);
}
}
/**
* Imports settings from the URL the user specified.
*
* @private
*/
_importSettingsFromUrl(settingsUrl) {
let controller = this;
return new Promise((resolve, reject) => {
try {
new url.URL(settingsUrl);
} catch (ex) {
controller._showError(Context.y18n.__("Please enter a valid URL."));
reject();
}
request(settingsUrl, function (error, response, body) {
if (error) {
let msg = Context.y18n.__("Error retrieving profile from %s: %s", settingsUrl, error);
Context.logger.error(msg);
controller._showError(msg);
} else if (response && response.statusCode == 200) {
// TODO: Make sure response is JSON, not HTML.
resolve(controller._importWithErrHandling(body, settingsUrl));
} else {
let statusCode = (response && response.statusCode) || Context.y18n.__('Unknown');
let msg = Context.y18n.__("Got response %s from %s", statusCode, settingsUrl);
Context.logger.error(msg);
controller._showError(msg);
}
reject()
});
});
}
/**
* This wraps the import process in a general error handler.
*
* @private
*/
_importWithErrHandling(json, settingsUrl) {
this.importSucceeded = false;
try {
this._importSettingsJson(json, settingsUrl);
this._showSuccess(Context.y18n.__("DART successfully imported the settings."));
this.importSucceeded = true;
return true;
} catch (ex) {
let msg = Context.y18n.__("Error importing settings: %s", ex);
Context.logger.error(msg);
Context.logger.error(ex);
this._showError(msg);
return false;
}
}
/**
* This performs the actual import of the settings. It may throw
* any number of errors, which must be handled by the caller.
*
* @private
*/
_importSettingsJson(json, settingsUrl) {
importedSettings = null;
let obj;
try {
obj = JSON.parse(json);
} catch (ex) {
let msg = Context.y18n.__("Error parsing JSON: %s. ", ex.message || ex);
if (settingsUrl) {
msg += Context.y18n.__("Be sure the URL returned JSON, not HTML.");
}
throw msg;
}
this._importSettingsList(obj.appSettings, 'App Setting');
this._importSettingsList(obj.bagItProfiles, 'BagIt Profile');
this._importSettingsList(obj.remoteRepositories, 'Remote Repository');
this._importSettingsList(obj.storageServices, 'Storage Service');
importedSettings = obj;
}
/**
* Imports a list of settings from the parsed JSON object.
*
* @private
*/
_importSettingsList(list, objType) {
if (!Array.isArray(list)) {
return;
}
for (let obj of list) {
let fullObj = this._inflateObject(obj, objType);
if (fullObj.validate()) {
fullObj.save()
} else {
let errs = Object.entries(fullObj.errors).map((k,v) => `${k}: ${v}`)
alert(Context.y18n.__(
"Error importing %s '%s': %s",
objType, obj.name, errs.join("\n\n")));
}
}
}
/**
* Converts a vanilla object to a typed object.
*
* @param {Object} obj - An untyped JavaScript object (usually parsed
* from JSON).
*
* @param {string} objType - The type to which to convert the object.
*
* @private
*/
_inflateObject(obj, objType) {
let fullObj = null;
switch (objType) {
case 'App Setting':
fullObj = AppSetting.inflateFrom(obj);
break;
case 'BagIt Profile':
fullObj = BagItProfile.inflateFrom(obj);
break;
case 'Remote Repository':
fullObj = RemoteRepository.inflateFrom(obj);
break;
case 'Storage Service':
fullObj = StorageService.inflateFrom(obj);
break;
break;
default:
throw `Unknown setting type: ${objType}`
}
return fullObj;
}
/**
* Copies exported settings (JSON) to the system clipboard.
*
* @private
*/
_copyToClipboard() {
var copyText = document.querySelector("#txtJson");
copyText.select();
document.execCommand("copy");
$("#copied").show();
$("#copied").fadeOut({duration: 1800});
}
/**
* Displays a success message.
*
* @private
*/
_showSuccess(message) {
$('#result').hide();
$('#result').removeClass('text-danger');
$('#result').addClass('text-success');
$('#result').text(message);
$('#result').show();
}
/**
* Displays an error message
*
* @private
*/
_showError(message) {
$('#result').hide();
$('#result').addClass('text-danger');
$('#result').removeClass('text-success');
$('#result').text(message);
$('#result').show();
}
/**
* Clears any success/error message from the display.
*
* @private
*/
_clearMessage() {
$('#result').hide();
$('#result').text('');
}
}
module.exports.SettingsController = SettingsController;