jsoneditor
Version:
A web-based tool to view, edit, format, and validate JSON
1,282 lines (1,130 loc) • 35.5 kB
JavaScript
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)
}