UNPKG

jsoneditor

Version:

A web-based tool to view, edit, format, and validate JSON

1,282 lines (1,130 loc) 35.5 kB
'use strict' import './polyfills' import naturalSort from 'javascript-natural-sort' import { jsonrepair } from 'jsonrepair' import jsonlint from './assets/jsonlint/jsonlint' import jsonMap from 'json-source-map' import { translate } from './i18n' const MAX_ITEMS_FIELDS_COLLECTION = 10000 const YEAR_2000 = 946684800000 /** * Parse JSON using the parser built-in in the browser. * On exception, the jsonString is validated and a detailed error is thrown. * @param {String} jsonString * @return {JSON} json */ export function parse (jsonString) { try { return JSON.parse(jsonString) } catch (err) { // try to throw a more detailed error message using validate validate(jsonString) // rethrow the original error throw err } } /** * Try to fix the JSON string. If not successful, return the original string * @param {string} jsonString */ export function tryJsonRepair (jsonString) { try { return jsonrepair(jsonString) } catch (err) { // repair was not successful, return original text return jsonString } } /** * Escape unicode characters. * For example input '\u2661' (length 1) will output '\\u2661' (length 5). * @param {string} text * @return {string} */ export function escapeUnicodeChars ( // see https://www.wikiwand.com/en/UTF-16 text ) { return ( // note: we leave surrogate pairs as two individual chars, // as JSON doesn't interpret them as a single unicode char. text.replace( /[\u007F-\uFFFF]/g, c => '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4) ) ) } /** * Validate a string containing a JSON object * This method uses JSONLint to validate the String. If JSONLint is not * available, the built-in JSON parser of the browser is used. * @param {String} jsonString String with an (invalid) JSON object * @throws Error */ export function validate (jsonString) { if (typeof (jsonlint) !== 'undefined') { jsonlint.parse(jsonString) } else { JSON.parse(jsonString) } } /** * Extend object a with the properties of object b * @param {Object} a * @param {Object} b * @return {Object} a */ export function extend (a, b) { for (const prop in b) { if (hasOwnProperty(b, prop)) { a[prop] = b[prop] } } return a } /** * Remove all properties from object a * @param {Object} a * @return {Object} a */ export function clear (a) { for (const prop in a) { if (hasOwnProperty(a, prop)) { delete a[prop] } } return a } /** * Get the type of an object * @param {*} object * @return {String} type */ export function getType (object) { if (object === null) { return 'null' } if (object === undefined) { return 'undefined' } if ((object instanceof Number) || (typeof object === 'number')) { return 'number' } if ((object instanceof String) || (typeof object === 'string')) { return 'string' } if ((object instanceof Boolean) || (typeof object === 'boolean')) { return 'boolean' } if (object instanceof RegExp) { return 'regexp' } if (isArray(object)) { return 'array' } return 'object' } /** * Test whether a text contains a url (matches when a string starts * with 'http://*' or 'https://*' and has no whitespace characters) * @param {String} text */ const isUrlRegex = /^https?:\/\/\S+$/ export function isUrl (text) { return (typeof text === 'string' || text instanceof String) && isUrlRegex.test(text) } /** * Tes whether given object is an Array * @param {*} obj * @returns {boolean} returns true when obj is an array */ export function isArray (obj) { return Object.prototype.toString.call(obj) === '[object Array]' } /** * Gets a DOM element's Window. This is normally just the global `window` * variable, but if we opened a child window, it may be different. * @param {HTMLElement} element * @return {Window} */ export function getWindow (element) { return element.ownerDocument.defaultView } /** * Retrieve the absolute left value of a DOM element * @param {Element} elem A dom element, for example a div * @return {Number} left The absolute left position of this element * in the browser page. */ export function getAbsoluteLeft (elem) { const rect = elem.getBoundingClientRect() return rect.left + window.pageXOffset || document.scrollLeft || 0 } /** * Retrieve the absolute top value of a DOM element * @param {Element} elem A dom element, for example a div * @return {Number} top The absolute top position of this element * in the browser page. */ export function getAbsoluteTop (elem) { const rect = elem.getBoundingClientRect() return rect.top + window.pageYOffset || document.scrollTop || 0 } /** * add a className to the given elements style * @param {Element} elem * @param {String} className */ export function addClassName (elem, className) { const classes = elem.className.split(' ') if (classes.indexOf(className) === -1) { classes.push(className) // add the class to the array elem.className = classes.join(' ') } } /** * remove all classes from the given elements style * @param {Element} elem */ export function removeAllClassNames (elem) { elem.className = '' } /** * add a className to the given elements style * @param {Element} elem * @param {String} className */ export function removeClassName (elem, className) { const classes = elem.className.split(' ') const index = classes.indexOf(className) if (index !== -1) { classes.splice(index, 1) // remove the class from the array elem.className = classes.join(' ') } } /** * Strip the formatting from the contents of a div * the formatting from the div itself is not stripped, only from its childs. * @param {Element} divElement */ export function stripFormatting (divElement) { const childs = divElement.childNodes for (let i = 0, iMax = childs.length; i < iMax; i++) { const child = childs[i] // remove the style if (child.style) { // TODO: test if child.attributes does contain style child.removeAttribute('style') } // remove all attributes const attributes = child.attributes if (attributes) { for (let j = attributes.length - 1; j >= 0; j--) { const attribute = attributes[j] if (attribute.specified === true) { child.removeAttribute(attribute.name) } } } // recursively strip childs stripFormatting(child) } } /** * Set focus to the end of an editable div * code from Nico Burns * http://stackoverflow.com/users/140293/nico-burns * http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity * @param {Element} contentEditableElement A content editable div */ export function setEndOfContentEditable (contentEditableElement) { let range, selection if (document.createRange) { range = document.createRange()// Create a range (a range is a like the selection but invisible) range.selectNodeContents(contentEditableElement)// Select the entire contents of the element with the range range.collapse(false)// collapse the range to the end point. false means collapse to end rather than the start selection = window.getSelection()// get the selection object (allows you to change selection) selection.removeAllRanges()// remove any selections already made selection.addRange(range)// make the range you have just created the visible selection } } /** * Select all text of a content editable div. * http://stackoverflow.com/a/3806004/1262753 * @param {Element} contentEditableElement A content editable div */ export function selectContentEditable (contentEditableElement) { if (!contentEditableElement || contentEditableElement.nodeName !== 'DIV') { return } let sel, range if (window.getSelection && document.createRange) { range = document.createRange() range.selectNodeContents(contentEditableElement) sel = window.getSelection() sel.removeAllRanges() sel.addRange(range) } } /** * Get text selection * http://stackoverflow.com/questions/4687808/contenteditable-selected-text-save-and-restore * @return {Range | TextRange | null} range */ export function getSelection () { if (window.getSelection) { const sel = window.getSelection() if (sel.getRangeAt && sel.rangeCount) { return sel.getRangeAt(0) } } return null } /** * Set text selection * http://stackoverflow.com/questions/4687808/contenteditable-selected-text-save-and-restore * @param {Range | TextRange | null} range */ export function setSelection (range) { if (range) { if (window.getSelection) { const sel = window.getSelection() sel.removeAllRanges() sel.addRange(range) } } } /** * Get selected text range * @return {Object} params object containing parameters: * {Number} startOffset * {Number} endOffset * {Element} container HTML element holding the * selected text element * Returns null if no text selection is found */ export function getSelectionOffset () { const range = getSelection() if (range && 'startOffset' in range && 'endOffset' in range && range.startContainer && (range.startContainer === range.endContainer)) { return { startOffset: range.startOffset, endOffset: range.endOffset, container: range.startContainer.parentNode } } return null } /** * Set selected text range in given element * @param {Object} params An object containing: * {Element} container * {Number} startOffset * {Number} endOffset */ export function setSelectionOffset (params) { if (document.createRange && window.getSelection) { const selection = window.getSelection() if (selection) { const range = document.createRange() if (!params.container.firstChild) { params.container.appendChild(document.createTextNode('')) } // TODO: do not suppose that the first child of the container is a textnode, // but recursively find the textnodes range.setStart(params.container.firstChild, params.startOffset) range.setEnd(params.container.firstChild, params.endOffset) setSelection(range) } } } /** * Get the inner text of an HTML element (for example a div element) * @param {Element} element * @param {Object} [buffer] * @return {String} innerText */ export function getInnerText (element, buffer) { const first = (buffer === undefined) if (first) { buffer = { _text: '', flush: function () { const text = this._text this._text = '' return text }, set: function (text) { this._text = text } } } // text node if (element.nodeValue) { // remove return characters and the whitespaces surrounding those return characters const trimmedValue = removeReturnsAndSurroundingWhitespace(element.nodeValue) if (trimmedValue !== '') { return buffer.flush() + trimmedValue } else { // ignore empty text return '' } } // divs or other HTML elements if (element.hasChildNodes()) { const childNodes = element.childNodes let innerText = '' for (let i = 0, iMax = childNodes.length; i < iMax; i++) { const child = childNodes[i] if (child.nodeName === 'DIV' || child.nodeName === 'P') { const prevChild = childNodes[i - 1] const prevName = prevChild ? prevChild.nodeName : undefined if (prevName && prevName !== 'DIV' && prevName !== 'P' && prevName !== 'BR') { if (innerText !== '') { innerText += '\n' } buffer.flush() } innerText += getInnerText(child, buffer) buffer.set('\n') } else if (child.nodeName === 'BR') { innerText += buffer.flush() buffer.set('\n') } else { innerText += getInnerText(child, buffer) } } return innerText } // br or unknown return '' } // regular expression matching one or multiple return characters with all their // enclosing white spaces export function removeReturnsAndSurroundingWhitespace (text) { return text.replace(/(\b|^)\s*(\b|$)/g, (match) => { return /\n/.exec(match) ? '' : match }) } /** * Test whether an element has the provided parent node somewhere up the node tree. * @param {Element} elem * @param {Element} parent * @return {boolean} */ export function hasParentNode (elem, parent) { let e = elem ? elem.parentNode : undefined while (e) { if (e === parent) { return true } e = e.parentNode } return false } /** * Returns the version of Internet Explorer or a -1 * (indicating the use of another browser). * Source: http://msdn.microsoft.com/en-us/library/ms537509(v=vs.85).aspx * @return {Number} Internet Explorer version, or -1 in case of an other browser */ export function getInternetExplorerVersion () { if (_ieVersion === -1) { let rv = -1 // Return value assumes failure. if (typeof navigator !== 'undefined' && navigator.appName === 'Microsoft Internet Explorer') { const ua = navigator.userAgent const re = /MSIE ([0-9]+[.0-9]+)/ if (re.exec(ua) != null) { rv = parseFloat(RegExp.$1) } } _ieVersion = rv } return _ieVersion } /** * cached internet explorer version * @type {Number} * @private */ let _ieVersion = -1 /** * Test whether the current browser is Firefox * @returns {boolean} isFirefox */ export function isFirefox () { return (typeof navigator !== 'undefined' && navigator.userAgent.indexOf('Firefox') !== -1) } /** * Add an event listener. Works for all browsers * @param {Element} element An html element * @param {string} action The action, for example "click", * without the prefix "on" * @param {function} listener The callback function to be executed * @param {boolean} [useCapture] false by default * @return {function} the created event listener */ export function addEventListener (element, action, listener, useCapture) { if (element.addEventListener) { if (useCapture === undefined) { useCapture = false } if (action === 'mousewheel' && isFirefox()) { action = 'DOMMouseScroll' // For Firefox } element.addEventListener(action, listener, useCapture) return listener } else if (element.attachEvent) { // Old IE browsers const f = () => listener.call(element, window.event) element.attachEvent('on' + action, f) return f } } /** * Remove an event listener from an element * @param {Element} element An html dom element * @param {string} action The name of the event, for example "mousedown" * @param {function} listener The listener function * @param {boolean} [useCapture] false by default */ export function removeEventListener (element, action, listener, useCapture) { if (element.removeEventListener) { if (useCapture === undefined) { useCapture = false } if (action === 'mousewheel' && isFirefox()) { action = 'DOMMouseScroll' // For Firefox } element.removeEventListener(action, listener, useCapture) } else if (element.detachEvent) { // Old IE browsers element.detachEvent('on' + action, listener) } } /** * Test if an element is a child of a parent element. * @param {Element} elem * @param {Element} parent * @return {boolean} returns true if elem is a child of the parent */ export function isChildOf (elem, parent) { let e = elem.parentNode while (e) { if (e === parent) { return true } e = e.parentNode } return false } /** * Parse a JSON path like '.items[3].name' into an array * @param {string} jsonPath * @return {Array} */ export function parsePath (jsonPath) { const path = [] let i = 0 function parseProperty () { let prop = '' while (jsonPath[i] !== undefined && /[\w$]/.test(jsonPath[i])) { prop += jsonPath[i] i++ } if (prop === '') { throw new Error('Invalid JSON path: property name expected at index ' + i) } return prop } function parseIndex (end) { let name = '' while (jsonPath[i] !== undefined && jsonPath[i] !== end) { name += jsonPath[i] i++ } if (jsonPath[i] !== end) { throw new Error('Invalid JSON path: unexpected end, character ' + end + ' expected') } return name } while (jsonPath[i] !== undefined) { if (jsonPath[i] === '.') { i++ path.push(parseProperty()) } else if (jsonPath[i] === '[') { i++ if (jsonPath[i] === '\'' || jsonPath[i] === '"') { const end = jsonPath[i] i++ path.push(parseIndex(end)) if (jsonPath[i] !== end) { throw new Error('Invalid JSON path: closing quote \' expected at index ' + i) } i++ } else { let index = parseIndex(']').trim() if (index.length === 0) { throw new Error('Invalid JSON path: array value expected at index ' + i) } // Coerce numeric indices to numbers, but ignore star index = index === '*' ? index : JSON.parse(index) path.push(index) } if (jsonPath[i] !== ']') { throw new Error('Invalid JSON path: closing bracket ] expected at index ' + i) } i++ } else { throw new Error('Invalid JSON path: unexpected character "' + jsonPath[i] + '" at index ' + i) } } return path } /** * Stringify an array with a path in a JSON path like '.items[3].name' * @param {Array.<string | number>} path * @returns {string} */ export function stringifyPath (path) { return path .map(p => { if (typeof p === 'number') { return ('[' + p + ']') } else if (typeof p === 'string' && p.match(/^[A-Za-z0-9_$]+$/)) { return '.' + p } else { return '["' + p + '"]' } }) .join('') } /** * Improve the error message of a JSON schema error * @param {Object} error * @return {Object} The error */ export function improveSchemaError (error) { if (error.keyword === 'enum' && Array.isArray(error.schema)) { let enums = error.schema if (enums) { enums = enums.map(value => JSON.stringify(value)) if (enums.length > 5) { const more = ['(' + (enums.length - 5) + ' more...)'] enums = enums.slice(0, 5) enums.push(more) } error.message = 'should be equal to one of: ' + enums.join(', ') } } if (error.keyword === 'additionalProperties') { error.message = 'should NOT have additional property: ' + error.params.additionalProperty } return error } /** * Test whether something is a Promise * @param {*} object * @returns {boolean} Returns true when object is a promise, false otherwise */ export function isPromise (object) { return object && typeof object.then === 'function' && typeof object.catch === 'function' } /** * Test whether a custom validation error has the correct structure * @param {*} validationError The error to be checked. * @returns {boolean} Returns true if the structure is ok, false otherwise */ export function isValidValidationError (validationError) { return typeof validationError === 'object' && Array.isArray(validationError.path) && typeof validationError.message === 'string' } /** * Test whether the child rect fits completely inside the parent rect. * @param {ClientRect} parent * @param {ClientRect} child * @param {number} margin */ export function insideRect (parent, child, margin) { const _margin = margin !== undefined ? margin : 0 return child.left - _margin >= parent.left && child.right + _margin <= parent.right && child.top - _margin >= parent.top && child.bottom + _margin <= parent.bottom } /** * Returns a function, that, as long as it continues to be invoked, will not * be triggered. The function will be called after it stops being called for * N milliseconds. * * Source: https://davidwalsh.name/javascript-debounce-function * * @param {function} func * @param {number} wait Number in milliseconds * @param {boolean} [immediate=false] If `immediate` is passed, trigger the * function on the leading edge, instead * of the trailing. * @return {function} Return the debounced function */ export function debounce (func, wait, immediate) { let timeout return function () { const context = this; const args = arguments const later = () => { timeout = null if (!immediate) func.apply(context, args) } const callNow = immediate && !timeout clearTimeout(timeout) timeout = setTimeout(later, wait) if (callNow) func.apply(context, args) } } /** * Determines the difference between two texts. * Can only detect one removed or inserted block of characters. * @param {string} oldText * @param {string} newText * @return {{start: number, end: number}} Returns the start and end * of the changed part in newText. */ export function textDiff (oldText, newText) { const len = newText.length let start = 0 let oldEnd = oldText.length let newEnd = newText.length while (newText.charAt(start) === oldText.charAt(start) && start < len) { start++ } while (newText.charAt(newEnd - 1) === oldText.charAt(oldEnd - 1) && newEnd > start && oldEnd > 0) { newEnd-- oldEnd-- } return { start, end: newEnd } } /** * Return an object with the selection range or cursor position (if both have the same value) * Support also old browsers (IE8-) * Source: http://ourcodeworld.com/articles/read/282/how-to-get-the-current-cursor-position-and-selection-within-a-text-input-or-textarea-in-javascript * @param {DOMElement} el A dom element of a textarea or input text. * @return {Object} reference Object with 2 properties (start and end) with the identifier of the location of the cursor and selected text. **/ export function getInputSelection (el) { let startIndex = 0; let endIndex = 0; let normalizedValue; let range; let textInputRange; let len; let endRange if (typeof el.selectionStart === 'number' && typeof el.selectionEnd === 'number') { startIndex = el.selectionStart endIndex = el.selectionEnd } else { range = document.selection.createRange() if (range && range.parentElement() === el) { len = el.value.length normalizedValue = el.value.replace(/\r\n/g, '\n') // Create a working TextRange that lives only in the input textInputRange = el.createTextRange() textInputRange.moveToBookmark(range.getBookmark()) // Check if the startIndex and endIndex of the selection are at the very end // of the input, since moveStart/moveEnd doesn't return what we want // in those cases endRange = el.createTextRange() endRange.collapse(false) if (textInputRange.compareEndPoints('StartToEnd', endRange) > -1) { startIndex = endIndex = len } else { startIndex = -textInputRange.moveStart('character', -len) startIndex += normalizedValue.slice(0, startIndex).split('\n').length - 1 if (textInputRange.compareEndPoints('EndToEnd', endRange) > -1) { endIndex = len } else { endIndex = -textInputRange.moveEnd('character', -len) endIndex += normalizedValue.slice(0, endIndex).split('\n').length - 1 } } } } return { startIndex, endIndex, start: _positionForIndex(startIndex), end: _positionForIndex(endIndex) } /** * Returns textarea row and column position for certain index * @param {Number} index text index * @returns {{row: Number, column: Number}} */ function _positionForIndex (index) { const textTillIndex = el.value.substring(0, index) const row = (textTillIndex.match(/\n/g) || []).length + 1 const col = textTillIndex.length - textTillIndex.lastIndexOf('\n') return { row, column: col } } } /** * Returns the index for certain position in text element * @param {DOMElement} el A dom element of a textarea or input text. * @param {Number} row row value, > 0, if exceeds rows number - last row will be returned * @param {Number} column column value, > 0, if exceeds column length - end of column will be returned * @returns {Number} index of position in text, -1 if not found */ export function getIndexForPosition (el, row, column) { const text = el.value || '' if (row > 0 && column > 0) { const rows = text.split('\n', row) row = Math.min(rows.length, row) column = Math.min(rows[row - 1].length, column - 1) const columnCount = (row === 1 ? column : column + 1) // count new line on multiple rows return rows.slice(0, row - 1).join('\n').length + columnCount } return -1 } /** * Returns location of json paths in certain json string * @param {String} text json string * @param {Array<String>} paths array of json paths * @returns {Array<{path: String, line: Number, row: Number}>} */ export function getPositionForPath (text, paths) { const result = [] let jsmap if (!paths || !paths.length) { return result } try { jsmap = jsonMap.parse(text) } catch (err) { return result } paths.forEach(path => { const pathArr = parsePath(path) const pointerName = compileJSONPointer(pathArr) const pointer = jsmap.pointers[pointerName] if (pointer) { result.push({ path, line: pointer.key ? pointer.key.line : (pointer.value ? pointer.value.line : 0), column: pointer.key ? pointer.key.column : (pointer.value ? pointer.value.column : 0) }) } }) return result } /** * Compile a JSON Pointer * WARNING: this is an incomplete implementation * @param {Array.<string | number>} path * @return {string} */ export function compileJSONPointer (path) { return path .map(p => ('/' + String(p) .replace(/~/g, '~0') .replace(/\//g, '~1') )) .join('') } /** * Get the applied color given a color name or code * Source: https://stackoverflow.com/questions/6386090/validating-css-color-names/33184805 * @param {string} color * @returns {string | null} returns the color if the input is a valid * color, and returns null otherwise. Example output: * 'rgba(255,0,0,0.7)' or 'rgb(255,0,0)' */ export function getColorCSS (color) { const ele = document.createElement('div') ele.style.color = color return ele.style.color.split(/\s+/).join('').toLowerCase() || null } /** * Test if a string contains a valid color name or code. * @param {string} color * @returns {boolean} returns true if a valid color, false otherwise */ export function isValidColor (color) { return !!getColorCSS(color) } /** * Make a tooltip for a field based on the field's schema. * @param {object} schema JSON schema * @param {string} [locale] Locale code (for example, zh-CN) * @returns {string} Field tooltip, may be empty string if all relevant schema properties are missing */ export function makeFieldTooltip (schema, locale) { if (!schema) { return '' } let tooltip = '' if (schema.title) { tooltip += schema.title } if (schema.description) { if (tooltip.length > 0) { tooltip += '\n' } tooltip += schema.description } if (schema.default) { if (tooltip.length > 0) { tooltip += '\n\n' } tooltip += translate('default', undefined, locale) + '\n' tooltip += JSON.stringify(schema.default, null, 2) } if (Array.isArray(schema.examples) && schema.examples.length > 0) { if (tooltip.length > 0) { tooltip += '\n\n' } tooltip += translate('examples', undefined, locale) + '\n' schema.examples.forEach((example, index) => { tooltip += JSON.stringify(example, null, 2) if (index !== schema.examples.length - 1) { tooltip += '\n' } }) } return tooltip } /** * Get a nested property from an object. * Returns undefined when the property does not exist. * @param {Object} object * @param {string[]} path * @return {*} */ export function get (object, path) { let value = object for (let i = 0; i < path.length && value !== undefined && value !== null; i++) { value = value[path[i]] } return value } /** * Find a unique name. Suffix the name with ' (copy)', '(copy 2)', etc * until a unique name is found * @param {string} name * @param {Array} existingPropNames Array with existing prop names */ export function findUniqueName (name, existingPropNames) { if (existingPropNames.indexOf(name) === -1) { return name } const strippedName = name.replace(/ \(copy( \d+)?\)$/, '') let validName = strippedName let i = 1 while (existingPropNames.indexOf(validName) !== -1) { const copy = 'copy' + (i > 1 ? (' ' + i) : '') validName = strippedName + ' (' + copy + ')' i++ } return validName } /** * Get the child paths of an array * @param {JSON} json * @param {boolean} [includeObjects=false] If true, object and array paths are returned as well * @return {string[]} */ export function getChildPaths (json, includeObjects) { const pathsMap = {} function getObjectChildPaths (json, pathsMap, rootPath, includeObjects) { const isValue = !Array.isArray(json) && !isObject(json) if (isValue || includeObjects) { pathsMap[rootPath || ''] = true } if (isObject(json)) { Object.keys(json).forEach(field => { getObjectChildPaths(json[field], pathsMap, rootPath + '.' + field, includeObjects) }) } } if (Array.isArray(json)) { const max = Math.min(json.length, MAX_ITEMS_FIELDS_COLLECTION) for (let i = 0; i < max; i++) { const item = json[i] getObjectChildPaths(item, pathsMap, '', includeObjects) } } else { pathsMap[''] = true } return Object.keys(pathsMap).sort() } /** * Sort object keys using natural sort * @param {Array} array * @param {String} [path] JSON pointer * @param {'asc' | 'desc'} [direction] */ export function sort (array, path, direction) { const parsedPath = path && path !== '.' ? parsePath(path) : [] const sign = direction === 'desc' ? -1 : 1 const sortedArray = array.slice() sortedArray.sort((a, b) => { const aValue = get(a, parsedPath) const bValue = get(b, parsedPath) return sign * (aValue > bValue ? 1 : aValue < bValue ? -1 : 0) }) return sortedArray } /** * Sort object keys using natural sort * @param {Object} object * @param {'asc' | 'desc'} [direction] */ export function sortObjectKeys (object, direction) { const sign = (direction === 'desc') ? -1 : 1 const sortedFields = Object.keys(object).sort((a, b) => sign * naturalSort(a, b)) const sortedObject = {} sortedFields.forEach(field => { sortedObject[field] = object[field] }) return sortedObject } /** * Cast contents of a string to the correct type. * This can be a string, a number, a boolean, etc * @param {String} str * @return {*} castedStr * @private */ export function parseString (str) { if (str === '') { return '' } const lower = str.toLowerCase() if (lower === 'null') { return null } if (lower === 'true') { return true } if (lower === 'false') { return false } const containsLeadingZero = /^0\d+$/ const startsWithZeroPrefix = /^0[xbo]/i // hex, binary, octal numbers if (containsLeadingZero.test(str) || startsWithZeroPrefix.test(str)) { // treat '001', '0x1A', '0b1101', and '0o3700' as a string return str } const num = Number(str) // will nicely fail with '123ab' const numFloat = parseFloat(str) // will nicely fail with ' ' const isFiniteNumber = !isNaN(num) && !isNaN(numFloat) && isFinite(num) const isInSafeRange = num <= Number.MAX_SAFE_INTEGER && num >= Number.MIN_SAFE_INTEGER const isInteger = /^\d+$/.test(str) if (isFiniteNumber && (isInSafeRange || !isInteger)) { return num } return str } /** * Test whether some field contains a timestamp in milliseconds after the year 2000. * @param {string} field * @param {number} value * @return {boolean} */ export function isTimestamp (field, value) { return typeof value === 'number' && value > YEAR_2000 && isFinite(value) && Math.floor(value) === value && !isNaN(new Date(value).valueOf()) } /** * Return a human readable document size * For example formatSize(7570718) outputs '7.6 MB' * @param {number} size * @return {string} Returns a human readable size */ export function formatSize (size) { if (size < 900) { return size.toFixed() + ' B' } const KB = size / 1000 if (KB < 900) { return KB.toFixed(1) + ' KB' } const MB = KB / 1000 if (MB < 900) { return MB.toFixed(1) + ' MB' } const GB = MB / 1000 if (GB < 900) { return GB.toFixed(1) + ' GB' } const TB = GB / 1000 return TB.toFixed(1) + ' TB' } /** * Limit text to a maximum number of characters * @param {string} text * @param {number} maxCharacterCount * @return {string} Returns the limited text, * ending with '...' if the max was exceeded */ export function limitCharacters (text, maxCharacterCount) { if (text.length <= maxCharacterCount) { return text } return text.slice(0, maxCharacterCount) + '...' } /** * Test whether a value is an Object * @param {*} value * @return {boolean} */ export function isObject (value) { return typeof value === 'object' && value !== null && !Array.isArray(value) } /** * Helper function to test whether an array contains an item * @param {Array} array * @param {*} item * @return {boolean} Returns true if `item` is in `array`, returns false otherwise. */ export function contains (array, item) { return array.indexOf(item) !== -1 } /** * Checks if validation has changed from the previous execution * @param {Array} currErr current validation errors * @param {Array} prevErr previous validation errors */ export function isValidationErrorChanged (currErr, prevErr) { if (!currErr && !prevErr) { return false } if (!Array.isArray(currErr) || !Array.isArray(prevErr) || prevErr.length !== currErr.length) { return true } for (let i = 0; i < currErr.length; i++) { const currItem = currErr[i] const prevItem = prevErr[i] if ( currItem.type !== prevItem.type || JSON.stringify(currItem.error) !== JSON.stringify(prevItem.error) ) { return true } } return false } /** * Uniquely merge array of elements * @param {Array<string|number>} inputArray1 * @param {Array<string|number>} inputArray2 * @returns {Array<string|number>} an array with unique merged elements */ export function uniqueMergeArrays (inputArray1, inputArray2) { const arr1 = inputArray1?.length ? inputArray1 : [] const arr2 = inputArray2?.length ? inputArray2 : [] return [...new Set(arr1.concat(arr2))] } export function asyncExec (callback) { setTimeout(callback) } function hasOwnProperty (object, key) { return Object.prototype.hasOwnProperty.call(object, key) }