const DEFAULT_RANDOM_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; const REMEMBER_PATH_KEY = "pb_redirect"; const navigationStore = store({ hash: window.location.hash, }); window.addEventListener("hashchange", () => { navigationStore.hash = window.location.hash; }); // https://prismjs.com/extending.html // https://prismjs.com/tokens Prism.languages.pbrule = { "string": Prism.languages.js.string, "number": Prism.languages.js.number, "function": Prism.languages.js.function, "boolean": /\b(?:true|false)\b/i, "constant": /\b(?:null)\b/i, "comment": { pattern: /\/\/.*/, greedy: true, }, "italic": /_via_|\:\w+/, "keyword": /&&|\|\||\??(?:!~|!=|>=|<=|=|~|>|>|<)(?=[@\w\s]|$)/, }; const utils = { /** * Checks whether value is plain object. * * @param {Mixed} value * @return {boolean} */ isObject(value) { return value !== null && typeof value === "object" && value.constructor === Object; }, /** * Checks whether a value is empty. The following values are considered as empty: * - null * - undefined * - empty string * - empty array * - empty object * * @param {Mixed} value * @return {boolean} */ isEmpty(value) { return ( value == null || value === "" || (Array.isArray(value) && value.length === 0) || (typeof value === "object" && app.utils.isEmptyObject(value)) ); }, /** * Checks if an object doesn't have any properties. * * @param {object obj * @return {boolean} */ isEmptyObject(obj) { for (let i in obj) { return false; } return true; }, /** * Normalizes and returns arr as a new array instance. * * @param {Array} arr * @param {boolean} [allowEmpty] * @return {Array} */ toArray(arr, allowEmpty = false) { if (Array.isArray(arr)) { return arr.slice(); } return (allowEmpty || !utils.isEmpty(arr)) && typeof arr !== "undefined" ? [arr] : []; }, /** * Removes single element from array by loosely comparying values. * * @param {Array} arr * @param {Mixed} value */ removeByValue(arr, value) { if (!Array.isArray(arr)) { console.warn("[removeByValue] not an array:", arr); return; } for (let i = arr.length - 1; i >= 0; i--) { if (arr[i] == value) { arr.splice(i, 1); break; } } }, /** * Removes single element from an objects array by matching a property value. * * @param {Array} objectsArr * @param {string} key * @param {Mixed} value */ removeByKey(objectsArr, key, value) { if (!Array.isArray(objectsArr)) { console.warn("[removeByKey] not an array:", objectsArr); return; } for (let i in objectsArr) { if (objectsArr[i][key] == value) { objectsArr.splice(i, 1); break; } } }, /** * Adds `value` in `arr` only if it's not added already. * * @param {Array} arr * @param {Mixed} value */ pushUnique(arr, value) { if (!Array.isArray(arr)) { console.warn("[pushUnique] not an array:", arr); return; } if (!arr.includes(value)) { arr.push(value); } }, /** * Merges all `valuesArr` items that don't exist in `targetArr`. * * @param {Array} targetArr * @param {Array} valuesArr */ mergeUnique(targetArr, valuesArr) { for (let v of valuesArr) { app.utils.pushUnique(targetArr, v); } return targetArr; }, /** * Adds or replace an object array element by comparing its key value. * * @param {Array} objectsArr * @param {Object} item * @param {string} [key] */ pushOrReplaceObject(objectsArr, item, key = "id") { for (let i = objectsArr.length - 1; i >= 0; i--) { if (objectsArr[i][key] == item[key]) { objectsArr[i] = item; return; } } objectsArr.push(item); }, /** * Filters and returns a new objects array with duplicated elements removed. * * @param {Array} objectsArr * @param {string} key * @return {Array} */ filterDuplicatesByKey(objectsArr, key = "id") { objectsArr = Array.isArray(objectsArr) ? objectsArr : []; const uniqueMap = {}; for (const item of objectsArr) { uniqueMap[item[key]] = item; } return Object.values(uniqueMap); }, /** * Filters and returns a new object with removed redacted props. * * @param {Object} obj * @param {string} [mask] Default to "******" * @return {Object} */ filterRedactedProps(obj, mask = "******") { const result = JSON.parse(JSON.stringify(obj || {})); for (let prop in result) { if (typeof result[prop] === "object" && result[prop] !== null) { result[prop] = utils.filterRedactedProps(result[prop], mask); } else if (result[prop] === mask) { delete result[prop]; } } return result; }, /** * Safely access nested object/array key with dot-notation. * * @example * ```javascript * let myObj = {a: {b: {c: 3}}} * this.getByPath(myObj, "a.b.c"); // returns 3 * this.getByPath(myObj, "a.b.c.d"); // returns null * this.getByPath(myObj, "a.b.c.d", -1); // returns -1 * ``` * * @param {Object|Array} data * @param {string} path * @param {Mixed} [defaultVal] * @param {string} [delimiter] * @return {Mixed} */ getByPath(data, path, defaultVal = null, delimiter = ".") { let result = data || {}; let parts = (path || "").split(delimiter); for (const part of parts) { if ((!utils.isObject(result) && !Array.isArray(result)) || typeof result[part] === "undefined") { return defaultVal; } result = result[part]; } return result; }, /** * Sets a new value to an object (or array) by its key path. * * @example * ```javascript * this.setByPath({}, "a.b.c", 1); // results in {a: b: {c: 1}} * this.setByPath({a: {b: {c: 3}}}, "a.b", 4); // results in {a: {b: 4}} * ``` * * @param {Array|Object} data * @param {string} path * @param {string} delimiter */ setByPath(data, path, newValue, delimiter = ".") { if (data === null || typeof data !== "object") { console.warn("setByPath: data not an object or array."); return; } let result = data; let parts = path.split(delimiter); let lastPart = parts.pop(); for (const part of parts) { if ( (!utils.isObject(result) && !Array.isArray(result)) || (!utils.isObject(result[part]) && !Array.isArray(result[part])) ) { result[part] = {}; } result = result[part]; } result[lastPart] = newValue; }, /** * Recursively delete element from an object (or array) by its key path. * Empty array or object elements from the parents chain will be also removed. * * @example * ```javascript * this.deleteByPath({a: {b: {c: 3}}}, "a.b.c"); // results in {} * this.deleteByPath({a: {b: {c: 3, d: 4}}}, "a.b.c"); // results in {a: {b: {d: 4}}} * ``` * * @param {Array|Object} data * @param {string} path * @param {string} delimiter */ deleteByPath(data, path, delimiter = ".") { let result = data || {}; let parts = (path || "").split(delimiter); let lastPart = parts.pop(); for (const part of parts) { if ( (!utils.isObject(result) && !Array.isArray(result)) || (!utils.isObject(result[part]) && !Array.isArray(result[part])) ) { result[part] = {}; } result = result[part]; } if (Array.isArray(result)) { result.splice(lastPart, 1); } else if (utils.isObject(result)) { delete result[lastPart]; } // cleanup the parents chain if ( parts.length > 0 && ((Array.isArray(result) && !result.length) || (utils.isObject(result) && !Object.keys(result).length)) && ((Array.isArray(data) && data.length > 0) || (utils.isObject(data) && Object.keys(data).length > 0)) ) { utils.deleteByPath(data, parts.join(delimiter), delimiter); } }, /** * Returns a new object with the zero value of the existing defined props. * * @param {Object} obj * @param {Array} [preservedProps] * @return {Object} */ emptyClone(obj, preservedProps = []) { const clone = JSON.parse(JSON.stringify(obj)); for (let prop in clone) { if (preservedProps.includes(prop)) { continue; } if (typeof clone[prop] == "string") { clone[prop] = ""; } else if (typeof clone[prop] == "number") { clone[prop] = 0; } else if (Array.isArray(clone[prop])) { clone[prop] = []; } else if (app.utils.isObject(clone[prop])) { clone[prop] = {}; } } return clone; }, /** * Generates pseudo-random string (suitable for ids and keys). * * @param {number} [length] The string of the resulting random string (default 8) * @return {string} */ randomString(length = 8, alphabet = DEFAULT_RANDOM_ALPHABET) { let result = ""; for (let i = 0; i < length; i++) { result += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); } return result; }, /** * Attempts to generates cryptographically random secret when `crypto` * is supported, otherwise fallback to `app.utils.randomString`. * * @param {number} [length] The string of the resulting random string (default 30) * @return {string} */ randomSecret(length = 30) { if (typeof crypto === "undefined") { return app.utils.randomString(length); } const arr = new Uint8Array(length); crypto.getRandomValues(arr); const alphabet = "-_0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; // 64 to devide "cleanly" 256 let result = ""; for (let i = 0; i < length; i++) { result += alphabet.charAt(arr[i] % alphabet.length); } return result; }, /** * Converts and normalizes string into a sentence. * * @param {string} str * @param {boolean} [stopCheck] * @return {string} */ sentenize(str, stopCheck = true) { if (typeof str !== "string") { return ""; } str = str.trim().split("_").join(" "); if (str === "") { return str; } str = str[0].toUpperCase() + str.substring(1); if (stopCheck) { let lastChar = str[str.length - 1]; if (lastChar !== "." && lastChar !== "?" && lastChar !== "!") { str += "."; } } return str; }, /** * Trims the matching quotes from the provided value. * * The value will be returned unchanged if `val` is not * wrapped with quotes or it is not string. * * @param {Mixed} val * @return {Mixed} */ trimQuotedValue(val) { if ( typeof val == "string" && (val[0] == `"` || val[0] == `'` || val[0] == "`") && val[0] == val[val.length - 1] ) { return val.slice(1, -1); } return val; }, /** * Normalizes and converts the provided string to a slug. * * @param {string} str * @param {string} [delimiter] * @param {Array} [preserved] List of special characters to keep unmodified. * @return {string} */ slugify(str, delimiter = "_", preserved = []) { if (str === "") { return ""; } // special characters const specialCharsMap = { a: /а|à|á|å|â/gi, b: /б/gi, c: /ц|ç/gi, d: /д/gi, e: /е|è|é|ê|ẽ|ë/gi, f: /ф/gi, g: /г/gi, h: /х/gi, i: /й|и|ì|í|î/gi, j: /ж/gi, k: /к/gi, l: /л/gi, m: /м/gi, n: /н|ñ/gi, o: /о|ò|ó|ô|ø/gi, p: /п/gi, q: /я/gi, r: /р/gi, s: /с/gi, t: /т/gi, u: /ю|ù|ú|ů|û/gi, v: /в/gi, w: /в/gi, x: /ь/gi, y: /ъ/gi, z: /з/gi, ae: /ä|æ/gi, oe: /ö/gi, ue: /ü/gi, Ae: /Ä/gi, Ue: /Ü/gi, Oe: /Ö/gi, ss: /ß/gi, and: /&/gi, }; // replace special characters for (let k in specialCharsMap) { str = str.replace(specialCharsMap[k], k); } return str .replace(new RegExp("[" + preserved.join("") + "]", "g"), " ") // replace preserved characters with spaces .replace(/[^\w\ ]/gi, "") // replaces all non-alphanumeric with empty string .replace(/\s+/g, delimiter); // collapse whitespaces and replace with `delimiter` }, /** * Encodes the HTML entities of the specified string. * * @param {string} str * @return {string} */ encodeEntities(str) { if (!str) { return ""; } return str .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("\"", """) .replaceAll("'", "'"); }, /** * Returns the plain text version (aka. strip tags) of the provided string. * * NB! HTML entities are preserved. If you want to remove them call * `app.utils.encodeEntities(result)` on the plainText result. * * @param {string} str * @return {string} */ plainText(str) { if (!str) { return ""; } const doc = new DOMParser().parseFromString(str, "text/html"); return (doc.body.textContent || "").trim(); }, /** * Truncates the provided text to the specified max characters length. * * @param {string} str * @param {Number} [length] * @param {boolean} [dots] * @return {string} */ truncate(str, length = 150, dots = true) { str = "" + str; if (str.length <= length) { return str; } str = str.slice(0, length); if (dots) { while (str.endsWith(".")) { str = str.slice(0, -1); } str += "..."; } return str; }, /** * Returns a new object/array copy with truncated large text fields. * * @param {Object|Array} objOrArr * @param {Number} [length] * @param {boolean} [dots] * @return {Object|Array} */ truncateObject(objOrArr, length = 150, dots = true) { const truncated = Array.isArray(objOrArr) ? [] : {}; for (let key in objOrArr) { let value = objOrArr[key]; if (typeof value === "string") { value = app.utils.truncate(value, length, dots); } else if (Array.isArray(value)) { value = app.utils.truncateObject(value, length, dots); } else if (app.utils.isObject(value)) { value = app.utils.truncateObject(value, length, dots); } truncated[key] = value; } return truncated; }, /** * Stringifies the provided value or fallback to missingValue in case it is empty. * * @param {Mixed} val * @param {string} [missingValue] * @param {string} [truncateLength] * @return {string} */ stringifyValue(val, missingValue = "N/A", truncateLength = 150) { if (utils.isEmpty(val)) { return missingValue; } if (typeof val == "number") { return "" + val; } if (typeof val == "boolean") { return val ? "True" : "False"; } if (typeof val == "string") { val = val.indexOf("<") >= 0 ? utils.plainText(val) : val; return utils.truncate(val, truncateLength) || missingValue; } // plain array if (Array.isArray(val) && typeof val[0] != "object") { return utils.truncate(val.join(","), truncateLength); } // json if (typeof val == "object") { try { return utils.truncate(JSON.stringify(val), truncateLength) || missingValue; } catch (_) { return missingValue; } } // return as it is return val; }, /** * Splits `str` and returns its non empty parts as an array. * * @param {string} str * @param {string} [separator] * @return {Array} */ splitNonEmpty(str, separator = ",") { const items = (str || "").split(separator); const result = []; for (let item of items) { item = item.trim(); if (!utils.isEmpty(item)) { result.push(item); } } return result; }, /** * Returns a concatenated `items` string of only the none empty values. * * @param {Array} items * @param {string} [separator] * @return {Array} */ joinNonEmpty(items, separator = ", ") { items = items || []; const result = []; for (let item of items) { item = typeof item === "string" ? item.trim() : item; if (!utils.isEmpty(item)) { result.push("" + item); } } return result.join(separator); }, /** * Returns a human readable file size string from size in bytes. * * @param {Number} size s * @return {string} */ formattedFileSize(size) { const i = size ? Math.floor(Math.log(size) / Math.log(1024)) : 0; return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "KB", "MB", "GB", "TB"][i]; }, /** * Returns a RFC3339 datetime formatted string (YYYY-MM-DD HH:mm:ss.nnnZ) * from the specified `datetime-local` input value (e.g. YYYY-MM-DDTHH:mm:ss). * * @param {string|number|Date} strOrDate * @return {string} */ toRFC3339Datetime(strOrDate) { if (!strOrDate) { return ""; } let date; if (strOrDate instanceof Date) { date = strOrDate; } else if (typeof strOrDate == "string") { date = new Date(strOrDate.replace(" ", "T")); } else { date = new Date(strOrDate); } return date.toISOString().replace("T", " "); }, toLocalDatetime(strOrDate) { if (!strOrDate) { return ""; } let date; if (strOrDate instanceof Date) { date = strOrDate; } else if (typeof strOrDate == "string") { date = new Date(strOrDate.replace(" ", "T")); } else { date = new Date(strOrDate); } const year = date.getFullYear(); if (isNaN(year)) { return ""; // invalid date } const month = (date.getMonth() + 1).toString().padStart(2, "0"); const day = date.getDate().toString().padStart(2, "0"); const hours = date.getHours().toString().padStart(2, "0"); const minutes = date.getMinutes().toString().padStart(2, "0"); const seconds = date.getSeconds().toString().padStart(2, "0"); const milliseconds = date.getMilliseconds().toString().padStart(3, "0"); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`; }, /** * Returns `datetime-local` input value string (YYYY-MM-DDTHH:mm:ss) * from the specified date (e.g. YYYY-MM-DD HH:mm:ss.nnnZ). * * @param {string|number|Date} strOrDate * @return {string} */ toDatetimeLocalInputValue(strOrDate) { if (!strOrDate) { return ""; } let date; if (strOrDate instanceof Date) { date = strOrDate; } else if (typeof strOrDate == "string") { date = new Date(strOrDate.replaceAll(" ", "T")); } else { date = new Date(strOrDate); } const year = date.getFullYear(); if (isNaN(year)) { return ""; // invalid date } const month = (date.getMonth() + 1).toString().padStart(2, "0"); const day = date.getDate().toString().padStart(2, "0"); const hours = date.getHours().toString().padStart(2, "0"); const minutes = date.getMinutes().toString().padStart(2, "0"); const seconds = date.getSeconds().toString().padStart(2, "0"); return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; }, /** * Copies the string representation of `val` to the user clipboard. * * @param {Mixed} val * @return {Promise} */ async copyToClipboard(val) { // normalizes val if (val === null || typeof val == "undefined") { val = ""; } else if (val instanceof Date) { val = val.toISOString(); } else if (typeof val == "object") { val = JSON.stringify(val); } else { val = "" + val; } if (!val.length || !window.navigator?.clipboard) { return; } return window.navigator.clipboard.writeText(val).catch((err) => { console.warn("Failed to copy.", err); }); }, /** * Forces the browser to start downloading the specified url. * * @param {string} url The url of the file to download. * @param {string} name The result file name. */ download(url, name) { let tempLink = document.createElement("a"); tempLink.setAttribute("href", url); tempLink.setAttribute("download", name); tempLink.setAttribute("target", "_blank"); tempLink.setAttribute("rel", "noopener noreferrer"); tempLink.click(); tempLink = null; }, /** * Downloads a json file created from the provide object. * * @param {mixed} obj The JS object to download. * @param {string} name The result file name. */ downloadJSON(obj, name) { name = name.endsWith(".json") ? name : name + ".json"; const blob = new Blob([JSON.stringify(obj, null, 2)], { type: "application/json", }); const url = window.URL.createObjectURL(blob); utils.download(url, name); }, /** * Returns a normalized API URL address that is used in the API example docs. * * @return {string} */ getApiExampleURL() { let url; // use the JS SDK url if it is already an absolute url if ( app.pb.baseURL.startsWith("http://") || app.pb.baseURL.startsWith("https://") ) { url = app.pb.baseURL; } else { url = window.location.href; // check for the ui path in case the app is under a subpath const start = url.indexOf("/_/"); if (start >= 0) { url = url.substring(0, start); } else { url = window.location.origin; } } // for broader compatibility replace localhost with 127.0.0.1 // (see https://github.com/pocketbase/js-sdk/issues/21) return url.replace("//localhost", "//127.0.0.1"); }, /** * @todo consider making part of Shablon or at least suggest as a snippet? * * Returns if the specified href address match the current hash route path. * * @param {string} href * @param {boolean} [subPathPattern] Whether to use exact or sub path matching pattern (default to true). * @param {string} [customHash] Hash string to parse (if not set, fallbacks to `navigationStore.hash`). * @return {boolean} */ isActivePath(href, subPathPattern = true, customHash = "") { customHash = customHash || navigationStore.hash; let pattern; if (subPathPattern) { pattern = new RegExp("^" + RegExp.escape(href) + "\\/?.*$"); } else { pattern = new RegExp("^" + RegExp.escape(href) + "\\/?(?:\\?.+)?$"); } return pattern.test(customHash); }, /** * @todo move to Shablon? * @todo consider making the value arrays for consistency with the router * * Extracts the hash query parameters from the current url and * returns them as plain object. * * @param {string} [customHash] Hash string to parse (if not set, fallbacks to `window.location.hash`). * @return {Object} */ getHashQueryParams(customHash = "") { customHash = customHash || navigationStore.hash; let query = ""; const queryStart = customHash.indexOf("?"); if (queryStart > -1) { query = window.location.hash.substring(queryStart + 1); } return Object.fromEntries(new URLSearchParams(query)); }, /** * @todo move to Shablon? * * Replaces the current hash query parameters with the provided `params`. * * Empty `params` values are removed from the final qyery string. * * @param {Object} params The new parameters to replace. * @param {null|boolean} [updateHistory] Specifies what to do with window.history: * - true: push a new history state * - false: does nothing to window.history * - null/undefined: replaces the current history state (default) * @return {string} A new absolute url with the replaced hash query params. */ replaceHashQueryParams(params, updateHistory = null) { params = params || {}; let query = ""; let hash = window.location.hash; const queryStart = hash.indexOf("?"); if (queryStart > -1) { query = hash.substring(queryStart + 1); hash = hash.substring(0, queryStart); } const parsed = new URLSearchParams(query); for (const key in params) { const val = params[key]; if (utils.isEmpty(val)) { parsed.delete(key); } else { parsed.set(key, val); } } query = parsed.toString(); if (query != "") { hash += "?" + query; } // replace the hash/fragment part with the updated one const original = window.location.href; let base = original; const hashIndex = base.indexOf("#"); if (hashIndex > -1) { base = base.substring(0, hashIndex); } const newHref = base + hash; if (updateHistory === false) { // no-op... } else if (updateHistory === true) { window.history.pushState(null, "", newHref); } else { window.history.replaceState(null, "", newHref); } return newHref; }, /** * Locally stores the current path for later redirect. */ rememberPath() { window.localStorage.setItem(REMEMBER_PATH_KEY, window.location.hash); }, /** * Redirect to a remembered local path. * * @param {string} [fallback] Fallback path if there is nothing stored. */ toRememberedPath(fallback = "#/collections") { let path = window.localStorage.getItem(REMEMBER_PATH_KEY); if (path) { window.localStorage.removeItem(REMEMBER_PATH_KEY); } window.location.hash = path || fallback; }, /** * Returns and deserializes a localStorage stored JSON value. * * @param {string} key * @param {Mixed} [defaultVal] * @return {Mixed} The deserialized found value (or `defaultVal` if missing). */ getLocalHistory(key, defaultVal = null) { try { const raw = window.localStorage.getItem(key); if (raw) { return JSON.parse(raw) || defaultVal; } } catch (err) { console.log("failed to load local history:", key, err); } return defaultVal; }, /** * Serializes and saves in localStorage the provided data. * * If `data` is "empty" value the localStorage entry will be removed. * If data is string it is saved as it is, otherwise with `JSON.stringify()`. * * @param {string} key * @param {Mixed} data */ saveLocalHistory(key, data) { try { if (app.utils.isEmpty(data)) { window.localStorage.removeItem(key); } else if (typeof data == "string") { window.localStorage.setItem(key, data); } else { window.localStorage.setItem(key, JSON.stringify(data)); } } catch (err) { console.log("failed to save local history:", key, err); } }, /** * Creates a thumbnail from `File` with the specified `width` and `height` params. * Returns a `Promise` with the generated base64 url. * * @param {File} file * @param {Number} [width] * @param {Number} [height] * @return {Promise} */ generateThumb(file, width = 100, height = 100) { return new Promise((resolve) => { let reader = new FileReader(); reader.onload = function(e) { let img = new Image(); img.onload = function() { let canvas = document.createElement("canvas"); let ctx = canvas.getContext("2d"); let imgWidth = img.width; let imgHeight = img.height; canvas.width = width; canvas.height = height; ctx.drawImage( img, imgWidth > imgHeight ? (imgWidth - imgHeight) / 2 : 0, 0, // top aligned imgWidth > imgHeight ? imgHeight : imgWidth, imgWidth > imgHeight ? imgHeight : imgWidth, 0, 0, width, height, ); return resolve(canvas.toDataURL(file.type)); }; img.src = e.target.result; }; reader.readAsDataURL(file); }); }, /** * Normalizes the search filter by converting a simple search term into * a wildcard filter expression using the provided fallback search fields. * * If searchTerm is already an expression it is returned without changes. * * @param {string} searchTerm * @param {Array} fallbackFields * @return {string} */ normalizeSearchFilter(searchTerm, fallbackFields = []) { searchTerm = (searchTerm || "").trim(); if (!searchTerm || !fallbackFields.length) { return searchTerm; } const opChars = ["=", "!=", "~", "!~", ">", ">=", "<", "<="]; // loosely check if it is already a filter expression for (const op of opChars) { if (searchTerm.includes(op)) { return searchTerm; } } searchTerm = isNaN(searchTerm) && searchTerm != "true" && searchTerm != "false" ? `"${searchTerm.replace(/^[\"\'\`]|[\"\'\`]$/gm, "")}"` : searchTerm; return fallbackFields.map((f) => `${f}~${searchTerm}`).join("||"); }, logLevels: { [-4]: { label: "DEBUG", class: "", }, 0: { label: "INFO", class: "success", }, 4: { label: "WARN", class: "warning", }, 8: { label: "ERROR", class: "danger", }, }, logDataFormatters: { execTime: function(log) { if (typeof log?.data?.execTime == "undefined") { return "N/A"; } return log.data.execTime + "ms"; }, }, /** * @todo consider defining as helper in shablon? * * Extends a reactive baseStore with the provided attrs by applying * similar rules as the attrs for DOM elements. * * Returns an array with the resulting watchers that you can use to unsubscribe * once you are done with the store. * * @param {Proxy} baseStore * @param {Object} attrs * @param {Array} [exclude] * @return {Array} */ extendStore(baseStore, attrs = {}, ...exclude) { const watchers = []; for (let key in attrs) { let val = attrs[key]; if ( typeof baseStore.__raw?.[key] == "function" || typeof val != "function" || (key.length > 2 && key.startsWith("on")) // @todo consider using exclude to skip prop loading? || exclude.includes(key) ) { baseStore[key] = val; } else { watchers.push( watch(val, (result) => { baseStore[key] = result; }), ); } } return watchers; }, /** * Converts a CSS time string into a millisecond number. * Returns 0 if empty or invalid. * * @param {string} cssTimeStr * @return {number} */ cssTimeToMs(cssTimeStr) { if (!cssTimeStr) { return 0; } cssTimeStr = cssTimeStr.toLowerCase(); if (cssTimeStr.endsWith("ms")) { return Number(cssTimeStr.substring(0, cssTimeStr.length - 2)); } if (cssTimeStr.endsWith("s")) { return Number(cssTimeStr.substring(0, cssTimeStr.length - 1)); } return Number(cssTimeStr) || 0; }, /** * Very rudimentary check to evaluate if the provided HEX color is "dark", * aka. whether it is suitable as background for a white text. * * @see https://en.wikipedia.org/wiki/YIQ * @see https://24ways.org/2010/calculating-color-contrast/ * * @param {string} hexcolor The HEX color in its 6 chars expanded format (with or without the "#" prefix). * @return {boolean} */ isDarkEnoughForWhiteText(hexcolor) { hexcolor = hexcolor?.startsWith("#") ? hexcolor.substring(1) : hexcolor; if (hexcolor?.length != 6) { return false; } const r = parseInt(hexcolor.substring(0, 2), 16); const g = parseInt(hexcolor.substring(2, 4), 16); const b = parseInt(hexcolor.substring(4, 6), 16); const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; return yiq < 128; }, // --------------------------------------------------------------- imageExtensions: [".jpg", ".jpeg", ".png", ".svg", ".gif", ".jfif", ".webp", ".avif"], videoExtensions: [".mp4", ".avi", ".mov", ".3gp", ".wmv"], audioExtensions: [".aa", ".aac", ".m4v", ".mp3", ".ogg", ".oga", ".mogg", ".amr"], documentExtensions: [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odp", ".odt", ".ods", ".txt"], /** * Loosely check if a file has image extension. * * @param {string} filename * @return {boolean} */ hasImageExtension(filename) { filename = (filename || "").toLowerCase(); return !!app.utils.imageExtensions.find((ext) => filename.endsWith(ext)); }, /** * Loosely check if a file has video extension. * * @param {string} filename * @return {boolean} */ hasVideoExtension(filename) { filename = (filename || "").toLowerCase(); return !!app.utils.videoExtensions.find((ext) => filename.endsWith(ext)); }, /** * Loosely check if a file has audio extension. * * @param {string} filename * @return {boolean} */ hasAudioExtension(filename) { filename = (filename || "").toLowerCase(); return !!app.utils.audioExtensions.find((ext) => filename.endsWith(ext)); }, /** * Loosely check if a file has document extension. * * @param {string} filename * @return {boolean} */ hasDocumentExtension(filename) { filename = (filename || "").toLowerCase(); return !!app.utils.documentExtensions.find((ext) => filename.endsWith(ext)); }, /** * Returns the file type based on its filename. * * @param {string} filename * @return {string} */ getFileType(filename) { if (app.utils.hasImageExtension(filename)) return "image"; if (app.utils.hasVideoExtension(filename)) return "video"; if (app.utils.hasAudioExtension(filename)) return "audio"; if (app.utils.hasDocumentExtension(filename)) return "document"; return "file"; }, fileTypeIcons: { image: "ri-image-line", video: "ri-movie-line", audio: "ri-music-2-line", document: "ri-file-line", file: "ri-file-line", }, // --------------------------------------------------------------- fallbackFieldIcon: "ri-puzzle-line", fallbackCollectionIcon: "ri-puzzle-line", fallbackProviderIcon: "ri-puzzle-line", fallbackPresentableProps: [ "title", "name", "slug", "email", "username", "nickname", "displayName", "label", "subject", "topic", "message", "heading", "headline", "header", "caption", "key", "identifier", "id", ], /** * Returns a shallow copy of the specified collections ordered by their name. * * @param {Array} collections * @return {Array} */ sortedCollections(collections = []) { let underscoreA, underscoreB; function sortNames(a, b) { // order system collections last underscoreA = a.name.startsWith("_"); underscoreB = b.name.startsWith("_"); if (underscoreA && !underscoreB) { return 1; } if (!underscoreA && underscoreB) { return -1; } if (a.name > b.name) { return 1; } if (a.name < b.name) { return -1; } return 0; } return collections.slice().sort(sortNames); }, /** * Groups and sorts collections array by type (auth, base, view) and name. * * @param {Array} collections * @return {Array} */ sortedCollectionsByType(collections = []) { const auth = []; const base = []; const view = []; for (const collection of collections) { if (collection.type === "auth") { auth.push(collection); } else if (collection.type === "base") { base.push(collection); } else { view.push(collection); } } return [].concat( app.utils.sortedCollections(auth), app.utils.sortedCollections(base), app.utils.sortedCollections(view), ); }, /** * Checks if the provided 2 collections has any change (ignoring root fields order). * * @param {Object} oldCollection * @param {Object} newCollection * @param {boolean} [withDeleteMissing] Skip missing fields from the newCollection. * @return {boolean} */ hasCollectionChanges(oldCollection, newCollection, withDeleteMissing = false) { oldCollection = oldCollection || {}; newCollection = newCollection || {}; if (oldCollection.id != newCollection.id) { return true; } for (let prop in oldCollection) { if (prop !== "fields" && JSON.stringify(oldCollection[prop]) !== JSON.stringify(newCollection[prop])) { return true; } } const oldFields = Array.isArray(oldCollection.fields) ? oldCollection.fields : []; const newFields = Array.isArray(newCollection.fields) ? newCollection.fields : []; const removedFields = oldFields.filter((oldField) => { return oldField?.id && !newFields.find((f) => f.id == oldField.id); }); const addedFields = newFields.filter((newField) => { return newField?.id && !oldFields.find((f) => f.id == newField.id); }); const changedFields = newFields.filter((newField) => { const oldField = app.utils.isObject(newField) && oldFields.find((f) => f.id == newField.id); if (!oldField) { return false; } for (let prop in oldField) { if (JSON.stringify(newField[prop]) != JSON.stringify(oldField[prop])) { return true; } } return false; }); return !!(addedFields.length || changedFields.length || (withDeleteMissing && removedFields.length)); }, /** * Rudimentary SELECT query columns extractor. * Returns an array with the identifier aliases * (expressions wrapped in parenthesis are skipped). * * @param {string} selectQuery * @return {Array} */ extractColumnsFromQuery(selectQuery) { const groupReplacement = "__PBGROUP__"; selectQuery = (selectQuery || "") // replace parenthesis/group expessions .replace(/\([\s\S]+?\)/gm, groupReplacement) // replace multi-whitespace characters with single space .replace(/[\t\r\n]|(?:\s\s)+/g, " "); const match = selectQuery.match(/select\s+([\s\S]+)\s+from/); const expressions = match?.[1]?.split(",") || []; const result = []; for (let expr of expressions) { const column = expr.trim().split(" ").pop(); // get only the alias if (column != "" && column != groupReplacement) { result.push(column.replace(/[\'\"\`\[\]\s]/g, "")); } } return result; }, /** * Returns an array with all public collection identifiers (collection fields + type specific fields). * * @param {Object} collection The collection to extract identifiers from. * @param {string} [prefix] Optional prefix for each found identified. * @return {Array} */ getAllCollectionIdentifiers(collection, prefix = "") { if (!collection) { return []; } let result = [prefix + "id"]; const isAuth = collection.type == "auth"; const isView = collection.type === "view"; if (isView) { for (let col of app.utils.extractColumnsFromQuery(collection.viewQuery)) { app.utils.pushUnique(result, prefix + col); } } const fields = collection.fields || []; for (const field of fields) { if (field.type == "password" || (isAuth && field.name == "tokenKey")) { continue; } if (app.fieldTypes[field.type]?.identifierExtractor) { const vals = app.utils.toArray(app.fieldTypes[field.type]?.identifierExtractor(field, prefix)); for (let val of vals) { app.utils.pushUnique(result, val); } } else { app.utils.pushUnique(result, prefix + field.name); } } return result; }, /** * Returns a plain record object populated with dummy data (used usually in the API previews). * * @param {Object} collection * @param {Boolean} [forSubmit] * @return {Object} */ getDummyFieldsData(collection, forSubmit = false) { const fields = collection?.fields || []; const result = {}; for (const field of fields) { if (field.hidden) { continue; } if (app.fieldTypes[field.type]?.dummyData) { const val = app.fieldTypes[field.type].dummyData(field, forSubmit); if (typeof val !== "undefined") { result[field.name] = val; } } else { result[field.name] = "[[DATA]]"; } } return result; }, // SQL indexes // --------------------------------------------------------------- /** * Parses the specified SQL index and returns an object with its components. * * For example: * * ```js * parseIndex("CREATE UNIQUE INDEX IF NOT EXISTS schemaname.idxname on tablename (col1, col2) where expr") * // output: * { * "unique": true, * "optional": true, * "schemaName": "schemaname" * "indexName": "idxname" * "tableName": "tablename" * "columns": [{name: "col1", "collate": "", "sort": ""}, {name: "col1", "collate": "", "sort": ""}] * "where": "expr" * } * ``` * * @param {string} idx * @return {Object} */ parseIndex(idx) { const result = { unique: false, optional: false, schemaName: "", indexName: "", tableName: "", columns: [], where: "", }; const indexRegex = /create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*)\)(?:\s*where\s+([\s\S]*))?/gim; const matches = indexRegex.exec((idx || "").trim()); if (matches?.length != 7) { return result; } const sqlQuoteRegex = /^[\"\'\`\[\{}]|[\"\'\`\]\}]$/gm; // unique result.unique = matches[1]?.trim().toLowerCase() === "unique"; // optional result.optional = !app.utils.isEmpty(matches[2]?.trim()); // schemaName and indexName const namePair = (matches[3] || "").split("."); if (namePair.length == 2) { result.schemaName = namePair[0].replace(sqlQuoteRegex, ""); result.indexName = namePair[1].replace(sqlQuoteRegex, ""); } else { result.schemaName = ""; result.indexName = namePair[0].replace(sqlQuoteRegex, ""); } // tableName result.tableName = (matches[4] || "").replace(sqlQuoteRegex, ""); // columns const rawColumns = (matches[5] || "") .replace(/,(?=[^\(]*\))/gim, "{PB_TEMP}") // temporary replace comma within expressions for easier splitting .split(","); // split columns for (let col of rawColumns) { col = col.trim().replaceAll("{PB_TEMP}", ","); // revert temp replacement const colRegex = /^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?$/gim; const colMatches = colRegex.exec(col); if (colMatches?.length != 4) { continue; } const colOrExpr = colMatches[1]?.trim()?.replace(sqlQuoteRegex, ""); if (!colOrExpr) { continue; } result.columns.push({ name: colOrExpr, collate: colMatches[2] || "", sort: colMatches[3]?.toUpperCase() || "", }); } // WHERE expression result.where = matches[6] || ""; return result; }, /** * Builds an index expression from parsed index parts (see parseIndex()). * * @param {Array} indexParts * @return {string} */ buildIndex(indexParts) { let result = "CREATE "; if (indexParts.unique) { result += "UNIQUE "; } result += "INDEX "; if (indexParts.optional) { result += "IF NOT EXISTS "; } if (indexParts.schemaName) { result += `\`${indexParts.schemaName}\`.`; } result += `\`${indexParts.indexName || "idx_" + app.utils.randomString(10)}\` `; result += `ON \`${indexParts.tableName}\` (`; const nonEmptyCols = indexParts.columns.filter((col) => !!col?.name); if (nonEmptyCols.length > 1) { result += "\n "; } result += nonEmptyCols .map((col) => { let item = ""; if (col.name.includes("(") || col.name.includes(" ")) { // most likely an expression item += col.name; } else { // regular identifier item += "`" + col.name + "`"; } if (col.collate) { item += " COLLATE " + col.collate; } if (col.sort) { item += " " + col.sort.toUpperCase(); } return item; }) .join(",\n "); if (nonEmptyCols.length > 1) { result += "\n"; } result += `)`; if (indexParts.where) { result += ` WHERE ${indexParts.where}`; } return result; }, /** * Parses and merges the current index with the specified `newFields`. * * The `newFields` argument could be: * - plain object with the same props as `parseIndex` * - function that accepted the current parsed index and returns a new object with the fields to overwrite * * @param {string} idx * @param {Object|Function} newFields * @return {string} */ replaceIndexFields(idx, newFields) { let parsed = app.utils.parseIndex(idx); if (typeof newFields == "function") { Object.assign(parsed, newFields(parsed) || {}); } else { Object.assign(parsed, newFields || {}); } return app.utils.buildIndex(parsed); }, /** * Replaces an idx column name with a new one (if exists). * * @param {string} idx * @param {string} oldColumn * @param {string} newColumn * @return {string} */ replaceIndexColumn(idx, oldColumn, newColumn) { if (oldColumn === newColumn) { return idx; // no change } const parsed = app.utils.parseIndex(idx); let hasChange = false; for (let col of parsed.columns) { if (col.name === oldColumn) { col.name = newColumn; hasChange = true; } } return hasChange ? app.utils.buildIndex(parsed) : idx; }, }; window.app = window.app || {}; window.app.utils = utils;