%PDF- %PDF-
| Direktori : /proc/thread-self/root/backups/router/usr/local/opnsense/www/js/ |
| Current File : //proc/thread-self/root/backups/router/usr/local/opnsense/www/js/opnsense_ui.js |
/*
* Copyright (C) 2015-2024 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* User interface shared components, requires opnsense.js for supporting functions.
*/
/**
* format bytes
* @param bytes number of bytes to format
* @param decimals decimal places
* @return string
*/
function byteFormat(bytes, decimals)
{
if (decimals === undefined) {
decimals = 0;
}
const kb = 1024;
const ndx = bytes === 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(kb));
const fileSizeTypes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
return (bytes / Math.pow(kb, ndx)).toFixed(decimals) + ' ' + fileSizeTypes[ndx];
}
/**
* save form to server
* @param url endpoint url
* @param formid parent id to grep input data from
* @param callback_ok
* @param disable_dialog don't show input validation message box on failure
* @param callback_fail
*/
function saveFormToEndpoint(url, formid, callback_ok, disable_dialog, callback_fail)
{
disable_dialog = disable_dialog || false;
const data = getFormData(formid);
ajaxCall(url, data, function (data, status) {
if ( status === "success" ) {
// update field validation
handleFormValidation(formid, data['validations']);
// if there are validation issues, update our screen and show a dialog.
if (data['validations'] !== undefined) {
if (!disable_dialog) {
const detailsid = "errorfrm" + Math.floor((Math.random() * 10000000) + 1);
const errorMessage = $('<div></div>');
errorMessage.append('Please correct validation errors in form <br />');
errorMessage.append('<i class="fa fa-bug pull-right" aria-hidden="true" data-toggle="collapse" '+
'data-target="#'+detailsid+'" aria-expanded="false" aria-controls="'+detailsid+'"></i>');
errorMessage.append('<div class="collapse" id="'+detailsid+'"><hr/><pre></pre></div>');
// validation message box is optional, form is already updated using handleFormValidation
BootstrapDialog.show({
type:BootstrapDialog.TYPE_WARNING,
title: 'Input validation',
message: errorMessage,
buttons: [{
label: 'Dismiss',
action: function(dialogRef){
dialogRef.close();
}
}],
onshown: function(){
// set debug information
$("#"+detailsid + " > pre").html(JSON.stringify(data, null, 2));
}
});
}
if ( callback_fail !== undefined ) {
// execute callback function
callback_fail(data);
}
} else if ( callback_ok !== undefined ) {
// execute callback function
callback_ok(data);
}
} else if ( callback_fail !== undefined ) {
callback_fail(data);
}
});
}
/**
* standard data mapper to map json request data to forms on this page
* @param data_get_map named array containing form names and source url's to get data from {'frm_example':"/api/example/settings/get"};
* @param server_params parameters to send to server
* @return promise object, resolves when all are loaded
*/
function mapDataToFormUI(data_get_map, server_params) {
const dfObj = new $.Deferred();
// calculate number of items for deferred object to resolve
let data_map_seq = 1;
let data_map_count = 0;
$.each(data_get_map, function(){
data_map_count += 1;
});
if (server_params === undefined) {
server_params = {};
}
const collected_data = {};
$.each(data_get_map, function(data_index, data_url) {
ajaxGet(data_url,server_params , function(data, status) {
if (status === "success") {
$("form").each(function() {
if ( $(this).attr('id') && $(this).attr('id').split('-')[0] === data_index) {
// related form found, load data
setFormData($(this).attr('id'), data);
collected_data[$(this).attr('id')] = data;
}
});
}
if (data_map_seq === data_map_count) {
dfObj.resolve(collected_data);
}
data_map_seq += 1;
});
});
return dfObj;
}
/**
* update service status buttons in user interface
*/
function updateServiceStatusUI(status)
{
let status_html = '<span class="label label-opnsense label-opnsense-sm ';
if (status === "running") {
status_html += 'label-success';
} else if (status === "stopped") {
status_html += 'label-danger';
} else {
status_html += 'hidden';
}
status_html += '"><i class="fa fa-play fa-fw"></i></span>';
$('#service_status_container').html(status_html);
}
/**
* operate service status buttons in user interface
*/
function updateServiceControlUI(serviceName)
{
if (serviceName == '') {
return;
}
ajaxCall("/api/" + serviceName + "/service/status", {}, function(data) {
let status_html = '<span class="label label-opnsense label-opnsense-sm ';
let status_icon = '';
let buttons = '';
if (data['status'] === "running") {
status_html += 'label-success';
status_icon = 'play';
buttons += '<span id="restartService" class="btn btn-sm btn-default"><i class="fa fa-repeat fa-fw"></i></span>';
buttons += '<span id="stopService" class="btn btn-sm btn-default"><i class="fa fa-stop fa-fw"></span>';
} else if (data['status'] === "stopped") {
status_html += 'label-danger';
status_icon = 'stop';
buttons += '<span id="startService" class="btn btn-sm btn-default"><i class="fa fa-play fa-fw"></i></span>';
} else {
status_html += 'hidden';
}
status_html += '"><i class="fa fa-' + status_icon + ' fa-fw"></i></span>';
$('#service_status_container').html(status_html + " " + buttons);
if (data['widget'] !== undefined) {
// tooltip service action widgets
['stop', 'start', 'restart'].forEach(function(action){
let obj = $("#" + action + "Service");
if (obj.length > 0) {
obj.tooltip({
'placement': 'bottom',
'title': data['widget']['caption_' + action]
});
}
});
}
const commands = ["start", "restart", "stop"];
commands.forEach(function(command) {
$("#" + command + "Service").click(function(){
$('#OPNsenseStdWaitDialog').modal('show');
ajaxCall("/api/" + serviceName + "/service/" + command, {},function() {
$('#OPNsenseStdWaitDialog').modal('hide');
ajaxCall("/api/" + serviceName + "/service/status", {}, function() {
updateServiceControlUI(serviceName);
});
});
});
});
});
}
/**
* reformat all tokenizers on this document
*/
function formatTokenizersUI() {
$('select.tokenize').each(function () {
const sender = $(this);
if (!sender.hasClass('tokenize2_init_done')) {
// only init tokenize2 when not bound yet
const hint = $(this).data("hint");
const width = $(this).data("width");
let number_of_items = 10;
if (sender.data("size") !== undefined) {
number_of_items = $(this).data("size");
}
sender.tokenize2({
'tokensAllowCustom': $(this).data("allownew"),
'placeholder': hint,
'sortable': $(this).data('sortable') === true,
'dropdownMaxItems': number_of_items
});
sender.parent().find('ul.tokens-container').css("width", width);
// dropdown on focus (previous displayDropdownOnFocus)
sender.on('tokenize:select', function(){
$(this).tokenize2().trigger('tokenize:search', [$(this).tokenize2().input.val()]);
});
// bind add / remove events
sender.on('tokenize:tokens:add', function(){
sender.trigger("tokenize:tokens:change");
});
sender.on('tokenize:tokens:remove', function(){
sender.trigger("tokenize:tokens:change");
});
// hook keydown -> tab to blur event
sender.on('tokenize:deselect', function(){
const e = $.Event("keydown");
e.keyCode = 9;
sender.tokenize2().trigger('tokenize:keydown', [e]);
});
sender.addClass('tokenize2_init_done');
} else {
// unbind change event while loading initial content
sender.unbind('tokenize:tokens:change');
// selected items
const items = [];
sender.find('option:selected').each(function () {
items.push([$(this).val(), $(this).text()]);
});
// re-init tokenizer items
sender.tokenize2().trigger('tokenize:clear');
for (let i=0 ; i < items.length ; ++i) {
sender.tokenize2().trigger('tokenize:tokens:add', items[i]);
}
sender.tokenize2().trigger('tokenize:select');
sender.tokenize2().trigger('tokenize:dropdown:hide');
}
// propagate token changes to parent change()
sender.on('tokenize:tokens:change', function(){
sender.change();
});
});
}
/**
* clear multiselect boxes on click event, works on standard and tokenized versions
*/
function addMultiSelectClearUI() {
//enable Paste if supported
if ((typeof navigator.clipboard === 'object') && (typeof navigator.clipboard.readText === 'function')) {
$('.fa-paste').parent().show();
}
$('[id*="clear-options"]').each(function() {
$(this).click(function() {
const id = $(this).attr("id").replace(/_*clear-options_*/, '');
let element = $('select[id="' + id + '"]');
if (element.hasClass("tokenize")) {
// trigger close on all Tokens
element.tokenize2().trigger('tokenize:clear');
element.change();
} else {
// remove options from selection
element.find('option').prop('selected',false);
if (element.hasClass('selectpicker')) {
element.selectpicker('refresh');
}
}
});
});
$('[id*="select-options"]').each(function() {
const id = $(this).attr("id").replace(/_*select-options_*/, '');
$(this).click(function() {
let element = $('select[id="' + id + '"]');
element.find('option').prop('selected', true);
if (element.hasClass('selectpicker')) {
element.selectpicker('refresh');
}
});
});
$('[id*="copy-options"]').each(function() {
$(this).click(function(e) {
e.preventDefault();
var currentFocus = document.activeElement;
let src_id = $(this).attr("id").replace(/_*copy-options_*/, '');
let element = $('select[id="' + src_id + '"]');
let target = $("<textarea style='opacity:0;'/>").val(element.val().join('\n')) ;
element.after(target);
target.select().focus();
document.execCommand("copy");
target.remove();
if (currentFocus && typeof currentFocus.focus === "function") {
currentFocus.focus();
}
});
});
$('[id*="paste-options"]').each(function() {
$(this).click(function(e) {
e.preventDefault();
let id = $(this).attr("id").replace(/_*paste-options_*/, '');
let target = $('select[id="' + id + '"]');
var cpb = navigator.clipboard.readText();
$.when(cpb).then(function(cbtext) {
let values = $.trim(cbtext).replace(/\n|\r/g, ",").split(",");
$.each(values, function( index, value ) {
target.tokenize2().trigger('tokenize:tokens:add', [value, value, true]);
});
target.change(); // signal subscribers about changed data
});
});
});
/* Tokenizer <-> text for quick edits */
$('[id*="to-text"]').each(function() {
$(this).click(function(e) {
e.preventDefault();
let id = $(this).attr("id").replace(/_*to-text_*/, '');
let source = $('div[id="select_' + id + '"]').hide().find('select');
let destination = $('div[id="textarea_' + id + '"]').show().find('textarea');
if (!source.hasClass('text_area_hooked')) {
/* Switch to normal tokenizer view on change() */
source.addClass('text_area_hooked');
source.change(function(){
$('a[id="to-select_' + id + '"]').click();
});
}
destination.val(source.val().join('\n'));
destination.unbind('change').change(function(){
source.tokenize2().trigger('tokenize:clear');
$.each($(this).val().split("\n"), function( index, value ) {
source.tokenize2().trigger('tokenize:tokens:add', [value, value, true]);
});
});
});
});
$('[id*="to-select"]').each(function() {
$(this).click(function(e) {
e.preventDefault();
let id = $(this).attr("id").replace(/_*to-select_*/, '');
$('div[id="select_' + id + '"]').show();
$('div[id="textarea_' + id + '"]').hide();
});
});
}
/**
* setup form help buttons
*/
function initFormHelpUI() {
// handle help messages show/hide
$("a.showhelp").click(function (event) {
$("*[data-for='" + $(this).attr('id') + "']").toggleClass("hidden show");
event.preventDefault();
});
// handle all help messages show/hide
let elements = $('[id*="show_all_help"]');
elements.click(function(event) {
$(this).toggleClass("fa-toggle-on fa-toggle-off");
$(this).toggleClass("text-success text-danger");
if ($(this).hasClass("fa-toggle-on")) {
if (window.sessionStorage) {
sessionStorage.setItem('all_help_preset', 1);
}
$('[data-for*="help_for"]').addClass("show").removeClass("hidden");
} else {
$('[data-for*="help_for"]').addClass("hidden").removeClass("show");
if (window.sessionStorage) {
sessionStorage.setItem('all_help_preset', 0);
}
}
event.preventDefault();
});
if (window.sessionStorage && sessionStorage.getItem('all_help_preset') === "1") {
// show all help messages when preset was stored
elements.toggleClass("fa-toggle-on fa-toggle-off").toggleClass("text-success text-danger");
$('[data-for*="help_for"]').addClass("show").removeClass("hidden");
}
}
/**
* handle advanced show/hide
*/
function initFormAdvancedUI() {
// handle striped tables hidden rows
let targetNodes = $('table.table-striped tbody');
let config = { attributes: true, subtree: true, attributeFilter: ["style"] };
let callback = function(mutationsList, observer) {
mutationsList.forEach(function(mutation) {
if (mutation.target.tagName == "TR") {
let currentValue = mutation.target.style.display;
if (currentValue == "") {
// row is visible
$(mutation.target).next('.dummy_row').remove();
} else if (currentValue == "none" && !$(mutation.target).next().hasClass("dummy_row")) {
// row is hidden and no dummy rows after it. insert one to keep stripes order
$(mutation.target).after("<tr data-advanced='hidden_row' class='dummy_row'></tr>");
}
}
});
}
observer = new MutationObserver(callback);
// observe all striped tables on page for style changes
targetNodes.each(function(index, node) {
observer.observe(node, config);
});
// handle "advanced mode" toggle
let elements = $('[id*="show_advanced"]');
if (window.sessionStorage && sessionStorage.getItem('show_advanced_preset') === 1) {
// show advanced options when preset was stored
elements.toggleClass("fa-toggle-on fa-toggle-off");
elements.toggleClass("text-success text-danger");
} else {
$('[data-advanced*="true"]').hide();
}
elements.click(function() {
elements.toggleClass("fa-toggle-on fa-toggle-off");
elements.toggleClass("text-success text-danger");
if (elements.hasClass("fa-toggle-on")) {
$('[data-advanced*="true"]').show();
if (window.sessionStorage) {
sessionStorage.setItem('show_advanced_preset', 1);
}
} else {
$('[data-advanced*="true"]').hide()
if (window.sessionStorage) {
sessionStorage.setItem('show_advanced_preset', 0);
}
}
});
}
/**
* standard dialog when information is required, wrapper around BootstrapDialog
*/
function stdDialogInform(title, message, close, callback, type, cssClass) {
const types = {
"danger": BootstrapDialog.TYPE_DANGER,
"default": BootstrapDialog.TYPE_DEFAULT,
"info": BootstrapDialog.TYPE_INFO,
"primary": BootstrapDialog.TYPE_PRIMARY,
"success": BootstrapDialog.TYPE_SUCCESS,
"warning": BootstrapDialog.TYPE_WARNING
};
if (!(type in types)) {
type = 'info';
}
if (cssClass === undefined) {
cssClass = '';
}
BootstrapDialog.show({
title: title,
message: message,
cssClass: cssClass,
type: types[type],
buttons: [{
label: close,
action: function (dialogRef) {
if (typeof callback !== 'undefined') {
callback();
}
dialogRef.close();
}
}]
});
}
/**
* standard dialog when confirmation is required, wrapper around BootstrapDialog
*/
function stdDialogConfirm(title, message, accept, decline, callback, type) {
const types = {
"danger": BootstrapDialog.TYPE_DANGER,
"default": BootstrapDialog.TYPE_DEFAULT,
"info": BootstrapDialog.TYPE_INFO,
"primary": BootstrapDialog.TYPE_PRIMARY,
"success": BootstrapDialog.TYPE_SUCCESS,
"warning": BootstrapDialog.TYPE_WARNING
};
if (!(type in types)) {
type = 'warning';
}
BootstrapDialog.confirm({
title: title,
message: message,
type: types[type],
btnCancelLabel: decline,
btnOKLabel: accept,
btnOKClass: 'btn-' + type,
callback: function(result) {
if (result) {
callback();
}
}
});
}
/**
* wrapper for backwards compatibility (do not use)
*/
function stdDialogRemoveItem(message, callback) {
stdDialogConfirm(
stdDialogRemoveItem.defaults.title, message, stdDialogRemoveItem.defaults.accept,
stdDialogRemoveItem.defaults.decline, callback
);
}
stdDialogRemoveItem.defaults = {
'title': 'Remove',
'accept': 'Yes',
'decline': 'Cancel'
};
/**
* Action button, expects the following data attributes on the widget
* data-endpoint='/path/to/my/endpoint'
* data-label="Apply text"
* data-service-widget="service" (optional service widget to signal)
* data-error-title="My error message"
*/
$.fn.SimpleActionButton = function (params) {
let this_button = this;
this.construct = function () {
let label_content = '<b>' + this_button.data('label') + '</b> <i class="reload_progress">';
this_button.html(label_content);
this_button.on('click', function () {
this_button.find('.reload_progress').addClass("fa fa-spinner fa-pulse");
let pre_action = function () {
return (new $.Deferred()).resolve();
}
if (params && params.onPreAction) {
pre_action = params.onPreAction;
}
pre_action().done(function () {
ajaxCall(this_button.data('endpoint'), {}, function (data, status) {
let data_status = typeof data == 'object' && 'status' in data ? data['status'] : '';
if (params && params.onAction) {
params.onAction(data, status);
}
if ((status != "success" || (data_status.toLowerCase().trim() != 'ok')) && data_status !== '') {
BootstrapDialog.show({
type: BootstrapDialog.TYPE_WARNING,
title: this_button.data('error-title'),
message: data['status_msg'] ? data['status_msg'] : data['status'],
draggable: true
});
}
this_button.find('.reload_progress').removeClass("fa fa-spinner fa-pulse");
if (this_button.data('service-widget')) {
updateServiceControlUI(this_button.data('service-widget'));
}
if (this_button.data('grid-reload')) {
std_bootgrid_reload(this_button.data('grid-reload'));
}
});
}).fail(function () {
this_button.find('.reload_progress').removeClass("fa fa-spinner fa-pulse");
});
});
}
return this.each(function () {
const button = this_button.construct();
return button;
});
}
/**
* File upload dialog, constructs a modal, asks for a file to upload and sets {'payload': ..,, 'filename': ...}
* to specified endpoint. Endppoint response should contain validation errors including messages and row sequences,
* specified as :
* {
* validations: [
* {
* 'sequence': X << sequence number, first data record starts at 0
* 'message': '' << validation message
* }
* ]
* }
*
* data-endpoint='/path/to/my/endpoint'
* data-title="Apply text"
*/
$.fn.SimpleFileUploadDlg = function (params) {
let this_button = this;
this.construct = function () {
this_button.click(function(){
let content = $("<div/>");
let fileinp = $("<input type='file'/>");
let error_output = $("<textarea style='display:none; max-width:100%; height:200px;'/>");
let doinp = $('<button style="display:none" type="button" class="btn btn-xs"/>');
doinp.append($('<span class="fa fa-fw fa-check"></span>'));
content.append(
$("<table/>").append(
$("<tr/>").append(
$("<td style='width:200px;'/>").append(fileinp),
$("<td/>").append(doinp)
),
$("<tr/>").append($("<td colspan='2' style='height:10px;'/>"))
)
);
content.append(error_output);
fileinp.change(function(evt) {
if (evt.target.files[0]) {
var reader = new FileReader();
reader.onload = function(readerEvt) {
doinp.data('payload', readerEvt.target.result);
doinp.data('filename', fileinp.val().split('\\').pop());
doinp.show();
};
reader.readAsText(evt.target.files[0]);
}
});
let dialog = BootstrapDialog.show({
title: this_button.data('title'),
type: BootstrapDialog.TYPE_DEFAULT,
message: content
});
doinp.click(function(){
let eparams = {
'payload': $(this).data('payload'),
'filename': $(this).data('filename')
};
ajaxCall(this_button.data('endpoint'), eparams, function (data, status) {
if (params && params.onAction) {
params.onAction(data, status);
}
if (data.validations && data.validations.length > 0) {
// When validation errors are returned, write to textarea including original data lines.
let output = [];
let records = eparams.payload.split('\n');
records.shift();
for (r=0; r < records.length; ++r) {
let found = false;
for (i=0; i < data.validations.length ; ++i) {
if (r == data.validations[i].sequence) {
if (!found) {
output.push(records[data.validations[i].sequence]);
found = true;
}
output.push('!! ' + data.validations[i].message);
}
}
}
error_output.val(output.join('\n')).show();
} else {
dialog.close();
}
});
});
});
}
return this.each(function () {
const button = this_button.construct();
return button;
});
}
/**
* Changes an input to a selector with manual input option.
* Expects the following structure:
* {
* group_name: {
* label: 'this items label',
* items: { << omit to mark the manual (empty) input
* key: 'value',
* }
* }
* }
* @param {*} params data structure to use for the select picker
*/
$.fn.replaceInputWithSelector = function (data, multiple=false) {
let that = this;
this.new_item = function() {
let $div = $("<div/>");
let $table = $('<table style="max-width: 348px"/>');
let $select = $('<select data-live-search="true" data-size="5" data-width="348px"/>');
if (multiple) {
$select.attr('multiple', 'multiple');
}
$table.append(
$("<tr/>").append(
$("<td/>").append($select)
)
);
$table.append(
$("<tr/>").append(
$("<td/>").append($('<input style="display:none;" type="text"/>'))
)
);
$div.append($table);
return $div;
}
this.construct = function () {
let options = [];
Object.keys(data).forEach((key, idx) => {
if (data[key].items !== undefined) {
let optgrp = $("<optgroup/>").attr('label', data[key].label);
Object.keys(data[key].items).forEach((key2, idx2) => {
let this_item = data[key].items[key2];
optgrp.append($("<option/>").val(key2).text(this_item));
});
options.push(optgrp);
} else {
options.push($("<option/>").val('').text(data[key].label));
}
});
let $target = that.new_item();
$(this).replaceWith($target);
let $this_input = $target.find('input');
let $this_select = $target.find('select');
for (i=0; i < options.length; ++i) {
$this_select.append(options[i].clone());
}
$this_select.attr('for', $(this).attr('id')).selectpicker();
$this_select.change(function(){
let $value = $(this).val();
if (Array.isArray($value)) {
$value = $value.filter(value => value !== '').join(',');
}
if ($value !== '') {
$this_input.val($value);
$this_input.hide();
} else {
$this_input.show();
}
});
$this_input.attr('id', $(this).attr('id'));
$this_input.change(function(){
if (multiple) {
$this_select.val($(this).val().split(','));
} else {
$this_select.val($(this).val());
}
if ($(this).val() === '' || $this_select.val() === null || $this_select.val() == '') {
$this_select.val('');
$this_input.show();
} else {
$this_input.hide();
}
$this_select.selectpicker('refresh');
});
$this_input.show();
}
return this.each(function () {
return $.proxy(that.construct, $(this))();
});
}
/**
* Parse URL hash to activate a tab and/or fetch search or edit phrase for use in a grid.
* - Supports hashes with direct actions: "#edit=UUID" or "#search=UUID" without a tab.
* - If the hash includes a tab name, & must be used (e.g., "#peers&edit=UUID").
*/
function getUrlHash(key=null) {
const hash = window.location.hash.slice(1);
if (!hash) return;
const splitIndex = hash.indexOf('&');
const tabName = splitIndex !== -1 ? hash.substring(0, splitIndex) : null;
const action = splitIndex !== -1 ? hash.substring(splitIndex + 1) : hash;
if (tabName) {
const tabElement = $(`a[href="#${tabName}"]`);
if (tabElement.length) {
tabElement.tab('show');
}
}
if (action) {
const [prefix, rawPhrase] = action.includes('=') ? action.split('=') : [null, null];
const decodedPhrase = rawPhrase ? decodeURIComponent(rawPhrase.trim()) : '';
if ((prefix === key) || (key === null)) return decodedPhrase;
}
return '';
}