// /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ // import { AppService, type Flow, FlowService, Script, ScriptService, type User } from '$lib/gen' // import { toast } from '@zerodevx/svelte-toast' // import type { Schema, SupportedLanguage } from './common' // import { hubScripts, type UserExt, workspaceStore } from './stores' // import { page } from '$app/stores' // import { get } from 'svelte/store' import { deepEqual } from 'fast-equals' import YAML from 'yaml' import { type UserExt } from './stores' import { sendUserToast } from './toast' import type { Job, RunnableKind, Script, ScriptLang, Retry } from './gen' import type { EnumType, SchemaProperty } from './common' import type { Schema } from './common' export { sendUserToast } import type { AnyMeltElement } from '@melt-ui/svelte' import type { TriggerKind } from './components/triggers' import { stateSnapshot } from './svelte5Utils.svelte' import { validate, dereference } from '@scalar/openapi-parser' export type RunsSelectionMode = 'cancel' | 're-run' export namespace OpenApi { export enum OpenApiVersion { V2, V3, V3_1 } export function isV2(doc: OpenAPI.Document): doc is OpenAPIV2.Document { return 'swagger' in doc && doc.swagger === '2.0' } export function isV3(doc: OpenAPI.Document): doc is OpenAPIV3.Document { return 'openapi' in doc && typeof doc.openapi === 'string' && doc.openapi.startsWith('3.0') } export function isV3_1(doc: OpenAPI.Document): doc is OpenAPIV3_1.Document { return 'openapi' in doc && typeof doc.openapi === 'string' && doc.openapi.startsWith('3.1') } export function getOpenApiVersion(version: string): OpenApiVersion { if (version.startsWith('2.0')) { return OpenApiVersion.V2 } else if (version.startsWith('3.0')) { return OpenApiVersion.V3 } else { return OpenApiVersion.V3_1 } } /** * Parses and validates an OpenAPI specification provided as a string in either JSON or YAML format. * * @param api - A string containing a valid OpenAPI specification in JSON or YAML format. * @returns A promise that resolves to a tuple: * - The first element is the validated OpenAPI document. * - The second element is the detected OpenAPI version (2, 3.0, or 3.1). * * @throws Will throw an error if the specification is invalid or cannot be parsed. */ export async function parse(api: string): Promise<[OpenAPI.Document, OpenApiVersion]> { const { valid, errors } = await validate(api) if (!valid) { const errorMessage = errors ? errors.map((error) => error.message).join('\n') : 'Invalid OpenAPI document' throw new Error(errorMessage) } const document = await dereference(api) const version = getOpenApiVersion(document.version!) return [document.schema, version] } } export function isJobCancelable(j: Job): boolean { return j.type === 'QueuedJob' && !j.schedule_path && !j.canceled } export function isJobReRunnable(j: Job): boolean { return (j.job_kind === 'script' || j.job_kind === 'flow') && j.parent_job === undefined } export const WORKER_NAME_PREFIX = 'wk' export function isJobSelectable(selectionType: RunsSelectionMode) { const f: (j: Job) => boolean = { cancel: isJobCancelable, 're-run': isJobReRunnable }[selectionType] return f } export function escapeHtml(unsafe: string) { return unsafe .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export function validateUsername(username: string): string { if (username != '' && !/^[a-zA-Z]\w+$/.test(username)) { return 'username can only contain letters and numbers and must start with a letter' } else { return '' } } export function parseQueryParams(url: string | undefined) { if (!url) return {} const index = url.indexOf('?') if (index == -1) return {} const paramArr = url?.slice(index + 1)?.split('&') const params: Record = {} paramArr?.map((param) => { const [key, val] = param.split('=') params[key] = decodeURIComponent(val) }) return params } export function displayDateOnly(dateString: string | Date | undefined): string { const date = new Date(dateString ?? '') if (date.toString() === 'Invalid Date') { return '' } else { return date.toLocaleDateString([], { year: 'numeric', month: '2-digit', day: '2-digit' }) } } export function retrieveCommonWorkerPrefix(workerName: string): string { const lastDashIndex = workerName.lastIndexOf('-') return workerName.substring(0, lastDashIndex) } export function subtractDaysFromDateString( dateString: string | null, days: number ): string | undefined { if (dateString == undefined) { return undefined } let date = new Date(dateString) date.setDate(date.getDate() - days) return date.toISOString() } export function displayDate( dateString: string | Date | undefined, displaySecond = false, displayDate = true ): string { const date = new Date(dateString ?? '') if (Number.isNaN(date.valueOf())) { return '' } else { const timeChoices: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', second: displaySecond ? '2-digit' : undefined } const dateChoices: Intl.DateTimeFormatOptions = displayDate ? { day: 'numeric', month: 'numeric' } : {} return date.toLocaleString(undefined, { ...timeChoices, ...dateChoices }) } } export function displayTime(dateString: string | Date | undefined): string { const date = new Date(dateString ?? '') if (date.toString() === 'Invalid Date') { return '' } else { return `${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}.${date.getMilliseconds()}` } } export function displaySize(sizeInBytes: number | undefined): string | undefined { if (sizeInBytes === undefined) { return undefined } const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] let size = sizeInBytes let unit_idx = 0 while (unit_idx < units.length - 1 && size > 1024) { size /= 1024 unit_idx += 1 } return `${size.toFixed(1)}${units[unit_idx]}` } export function msToSec(ms: number | undefined, maximumFractionDigits?: number): string { if (ms === undefined) return '?' return (ms / 1000).toLocaleString(undefined, { maximumFractionDigits: maximumFractionDigits ?? 3, minimumFractionDigits: maximumFractionDigits }) } export function removeTriggerKindIfUnused( length: number, triggerKind: TriggerKind, usedTriggerKinds: string[] ) { if (length === 0 && usedTriggerKinds.includes(triggerKind)) { return usedTriggerKinds.filter((kind) => kind != triggerKind) } return usedTriggerKinds } export function msToReadableTime(ms: number | undefined, maximumFractionDigits?: number): string { if (ms === undefined) return '?' const seconds = Math.floor(ms / 1000) const minutes = Math.floor(seconds / 60) const hours = Math.floor(minutes / 60) const days = Math.floor(hours / 24) if (days > 0) { return `${days}d ${hours % 24}h ${minutes % 60}m ${seconds % 60}s` } else if (hours > 0) { return `${hours}h ${minutes % 60}m ${seconds % 60}s` } else if (minutes > 0) { return `${minutes}m ${seconds % 60}s` } else { return `${msToSec(ms, maximumFractionDigits)}s` } } export function msToReadableTimeShort( ms: number | undefined, maximumFractionDigits?: number ): string { if (ms === undefined) return '?' const seconds = Math.floor(ms / 1000) const minutes = Math.floor(seconds / 60) const hours = Math.floor(minutes / 60) const days = Math.floor(hours / 24) if (days > 0) { return `${days}d` } else if (hours > 0) { return `${hours}h` } else if (minutes > 0) { return `${minutes}m` } else { return `${msToSec(ms, maximumFractionDigits)}s` } } export function getToday() { var today = new Date() return today } export function truncateHash(hash: string): string { if (hash.length >= 6) { return hash.substr(hash.length - 6) } else { return hash } } export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } export function addIfNotExists(e: T, arr: Array | undefined): Array { if (!arr) { return [e] } else if (arr.includes(e)) { return arr } else { return arr.concat([e]) } } export function validatePassword(password: string): boolean { const re = /^(?=.*[\d])(?=.*[!@#$%^&*])[\w!@#$%^&*]{8,30}$/ return re.test(password) } const portalDivs = ['#app-editor-select', '.select-dropdown-portal'] interface ClickOutsideOptions { capture?: boolean exclude?: (() => Promise) | HTMLElement[] | undefined stopPropagation?: boolean customEventName?: string eventToListenName?: 'click' | 'pointerdown' // on:click_outside cannot be used with svelte 5 onClickOutside?: (event: MouseEvent) => void } export function clickOutside( node: Node, options?: ClickOutsideOptions | boolean ): { destroy(): void; update(newOptions: ClickOutsideOptions | boolean): void } { const handleClick = async (event: MouseEvent) => { const target = event.target as HTMLElement const opts = typeof options === 'boolean' ? { capture: options } : options let excludedElements: HTMLElement[] = [] if (opts?.exclude) { if (Array.isArray(opts.exclude)) { excludedElements = opts.exclude } else { excludedElements = await opts.exclude() } } const isExcluded = excludedElements.some((excludedEl) => { const contains = excludedEl?.contains?.(target) const isTarget = target === excludedEl return contains || isTarget }) if (node && !node.contains(target) && !event.defaultPrevented && !isExcluded) { const portalDivsSelector = portalDivs.join(', ') const parent = target.closest(portalDivsSelector) if (!parent) { if (opts?.stopPropagation) { event.stopPropagation() } node.dispatchEvent(new CustomEvent('click_outside', { detail: event })) if (typeof options === 'object') options.onClickOutside?.(event) } } if (opts?.customEventName) { node.dispatchEvent(new CustomEvent(opts.customEventName, { detail: event })) } } const capture = typeof options === 'boolean' ? options : (options?.capture ?? true) const eventToListenName = (typeof options === 'object' && options.eventToListenName) || 'click' document.addEventListener(eventToListenName, handleClick, capture ?? true) return { update(newOptions: ClickOutsideOptions | boolean) { options = newOptions }, destroy() { document.removeEventListener(eventToListenName, handleClick, capture ?? true) } } } export function undefinedIfEmpty(obj: any): any { if (Object.keys(obj).length === 0) { return undefined } return obj } export function pointerDownOutside( node: Node, options?: ClickOutsideOptions ): { destroy(): void; update(newOptions: ClickOutsideOptions): void } { const handlePointerDown = async (event: PointerEvent) => { if (!event.isTrusted) return if (options?.customEventName) { node.dispatchEvent( new CustomEvent(options.customEventName, { detail: event, bubbles: true }) ) } const target = event.target as HTMLElement let excludedElements: HTMLElement[] = [] if (options?.exclude) { if (Array.isArray(options.exclude)) { excludedElements = options.exclude } else { excludedElements = await options.exclude() } } const isExcluded = excludedElements.some((excludedEl) => { const contains = excludedEl?.contains?.(target) const isTarget = target === excludedEl return contains || isTarget }) if (node && !node.contains(target) && !event.defaultPrevented && !isExcluded) { const portalDivsSelector = portalDivs.join(', ') const parent = target.closest(portalDivsSelector) if (!parent) { if (options?.stopPropagation) { event.stopPropagation() } node.dispatchEvent(new CustomEvent('pointerdown_outside', { detail: event })) if (typeof options === 'object') options.onClickOutside?.(event) return false } } } const capture = options?.capture ?? true document.addEventListener('pointerdown', handlePointerDown, capture ?? true) return { update(newOptions: ClickOutsideOptions) { options = newOptions }, destroy() { document.removeEventListener('pointerdown', handlePointerDown, capture ?? true) } } } export interface DropdownItem { // If a DropdownItem has an action, it will be declared as a button // If a DropdownItem has no action and an href, it will be declared as a link // If a DropdownItem has no action and no href, it will be created as a text line displayName: string eventName?: string //the event to send when clicking this item action?: ((event?: MouseEvent) => Promise) | ((event?: MouseEvent) => void) href?: string separatorTop?: boolean separatorBottom?: boolean type?: 'action' | 'delete' disabled?: boolean icon?: any | undefined } export const DELETE = 'delete' as 'delete' export function emptySchema(): Schema { return { $schema: 'https://json-schema.org/draft/2020-12/schema' as string | undefined, properties: {}, required: [], type: 'object' } } export function simpleSchema(): Schema { return { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', properties: { name: { description: 'The name to hello world to', type: 'string' } }, required: [] } } export function removeItemAll(arr: T[], value: T) { var i = 0 while (i < arr.length) { if (arr[i] === value) { arr.splice(i, 1) } else { ++i } } return arr } export function emptyString(str: string | undefined | null): boolean { return str === undefined || str === null || str === '' } export function emptyStringTrimmed(str: string | undefined | null): boolean { return str === undefined || str === null || str === '' || str.trim().length === 0 } export function defaultIfEmptyString(str: string | undefined, dflt: string): string { return emptyString(str) ? dflt : str! } export function removeKeysWithEmptyValues(obj: any): any { Object.keys(obj).forEach((key) => (obj[key] === undefined ? delete obj[key] : {})) } export function allTrue(dict: { [id: string]: boolean }): boolean { return Object.values(dict).every(Boolean) } export function elapsedSinceSecs(date: string): number { return Math.round((new Date().getTime() - new Date(date).getTime()) / 1000) } export function pathIsEmpty(path: string): boolean { return path == undefined || path.split('/')[2] == '' } export function encodeState(state: any): string { return btoa(encodeURIComponent(JSON.stringify(state))) } export function decodeState(query: string): any { try { return JSON.parse(decodeURIComponent(atob(query))) } catch (e) { sendUserToast('Impossible to parse state', true) return {} } } export function itemsExists(arr: T[] | undefined, item: T): boolean { if (!arr) { return false } for (const i of arr) { if (deepEqual(i, item)) { return true } } return false } export function groupBy( items: V[], toGroup: (t: V) => K, toSort: (t: V) => string, dflts: K[] = [] ): [K, V[]][] { let r: Map = new Map() for (const dflt of dflts) { r.set(dflt as K, []) } items.forEach((sc) => { let section = toGroup(sc) if (r.has(section)) { let arr = r.get(section)! arr.push(sc) arr.sort((a, b) => toSort(a).localeCompare(toSort(b))) } else { r.set(section, [sc]) } }) return [...r.entries()].sort((s1, s2) => { let n1 = s1[0] let n2 = s2[0] if (n1 > n2) { return 1 } else if (n1 < n2) { return -1 } else { return 0 } }) } export function removeMarkdown(text: string): string { return text.replace(/[[\*|\-|#\_]/g, '') } export function truncate(s: string, n: number, suffix: string = '...'): string { if (!s) { return '' } if (s.length <= n) { return s } else { return s.substring(0, n) + suffix } } export function truncateRev(s: string, n: number, prefix: string = '...'): string { if (!s) { return prefix } if (s.length <= n) { return s } else { return prefix + s.substring(s.length - n, s.length) } } export function isString(value: any) { return typeof value === 'string' || value instanceof String } export type InputCat = | 'string' | 'email' | 'number' | 'boolean' | 'list' | 'resource-object' | 'enum' | 'date' | 'base64' | 'resource-string' | 'object' | 'sql' | 'yaml' | 'currency' | 'oneOf' | 'dynamic' | 'json-schema' | 'ai-provider' export namespace DynamicInput { const DYN_FORMAT_PREFIX = ['dynmultiselect-', 'dynselect-'] export type HelperScript = | { source: 'deployed'; path: string; runnable_kind: RunnableKind } | { source: 'inline'; code: string; lang: ScriptLang } export const generatePythonFnTemplate = (functionName: string): string => { return ` def ${functionName}(): return [ { "label": "Foo", "value": "foo" }, { "label": "Bar", "value": "bar" } ] ` } export const generateJsFnTemplate = (functionName: string): string => { return ` // you can use filterText to filter the results from the backend // you can refer to other args directly as parameters (e.g. foobar: string) export function ${functionName}(filterText: string) { return [ { label: 'Foo', value: 'foo' }, { label: 'Bar', value: 'bar' } ]; } ` } export const generateDefaultTemplateFn = (functionName: string, lang: ScriptLang): string => { return lang === 'bun' ? generateJsFnTemplate(functionName) : generatePythonFnTemplate(functionName) } export const getGenerateTemplateFn = (lang: ScriptLang) => { return lang === 'bun' ? generateJsFnTemplate : generatePythonFnTemplate } export const isDynInputFormat = (format?: string) => { if (!format) return false return DYN_FORMAT_PREFIX.some((format_prefix) => format.startsWith(format_prefix)) } } export function setInputCat( type: string | undefined, format: string | undefined, itemsType: string | undefined, enum_: any, contentEncoding: string | undefined ): InputCat { if (type === 'number' || type === 'integer') { return 'number' } else if (type === 'boolean') { return 'boolean' } else if (type == 'array' && itemsType != undefined) { return 'list' } else if (type == 'object' && format?.startsWith('resource')) { return 'resource-object' } else if (type == 'object' && format == 'ai-provider') { return 'ai-provider' } else if (type == 'object' && DynamicInput.isDynInputFormat(format)) { return 'dynamic' } else if (!type || type == 'object' || type == 'array') { return 'object' } else if (type == 'string' && enum_) { return 'enum' } else if (type == 'string' && ['date-time', 'naive-date-time', 'date'].includes(format!)) { return 'date' } else if (type == 'string' && format == 'sql') { return 'sql' } else if (type == 'string' && format == 'yaml') { return 'yaml' } else if (type == 'string' && contentEncoding == 'base64') { return 'base64' } else if (type == 'string' && format == 'email') { return 'email' } else if (type == 'string' && format == 'currency') { return 'currency' } else if (type == 'oneOf') { return 'oneOf' } else { return 'string' } } export function formatCron(inp: string): string { // Allow for cron expressions inputted by the user to omit month and year let splitted = inp.split(' ') splitted = splitted.filter(String) //remove empty string elements if (6 - splitted.length > 0) { return splitted.concat(Array(6 - splitted.length).fill('*')).join(' ') } else { return inp } } export function scriptLangArrayToCommaList(languages: ScriptLang[]): string { return languages.join(',') } export function cronV1toV2(inp: string): string { let splitted = inp.split(' ') splitted = splitted.filter(String) // subtract 1 from day of week (last element of array if element is a number >= 1 ) let dowStr = splitted[splitted.length - 1] if (dowStr && !isNaN(parseInt(dowStr))) { let dow = parseInt(dowStr) if (dow > 0) { splitted[splitted.length - 1] = (dow - 1).toString() } } return splitted.concat(Array(6 - splitted.length).fill('*')).join(' ') } export function classNames(...classes: Array): string { return classes.filter(Boolean).join(' ') } export function download(filename: string, fileContent: string, mimeType?: string) { const blob = new Blob([fileContent], { type: mimeType }) const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename a.click() setTimeout(() => URL.revokeObjectURL(url), 100) } export async function copyToClipboard(value?: string, sendToast = true): Promise { if (!value) { return false } let success = false if (navigator?.clipboard && window.isSecureContext) { success = await navigator.clipboard .writeText(value) .then(() => true) .catch(() => false) } else { const textArea = document.createElement('textarea') textArea.value = value textArea.style.position = 'fixed' textArea.style.left = '-999999px' document.body.appendChild(textArea) textArea.select() try { document.execCommand('copy') success = true } catch (error) { // ignore (success = false) } finally { textArea.remove() } } sendToast && sendUserToast(success ? 'Copied to clipboard!' : "Couldn't copy to clipboard", !success) return success } export function pluralize(quantity: number, word: string, customPlural?: string) { if (quantity == 1) { return `${quantity} ${word}` } else if (customPlural) { return `${quantity} ${customPlural}` } else { return `${quantity} ${word}s` } } export function addDeterminant(word: string): string { return (/^[aeiou]/i.test(word) ? 'an ' : 'a ') + word } export { capitalize } from './sharedUtils' export function addWhitespaceBeforeCapitals(word?: string): string { if (!word) { return '' } return word.replace(/([A-Z])/g, ' $1').trim() } export function isObject(obj: any): obj is Record { return obj != null && typeof obj === 'object' && !Array.isArray(obj) } export function debounce(func: (...args: any[]) => any, wait: number) { let timeout: any return { debounced: function (...args: any[]) { // @ts-ignore const context = this clearTimeout(timeout) timeout = setTimeout(() => func.apply(context, args), wait) }, clearDebounce: () => clearTimeout(timeout) } } export function throttle(func: (...args: any[]) => T, wait: number) { let timeout: any return function (...args: any[]) { if (!timeout) { timeout = setTimeout(() => { timeout = null // @ts-ignore func.apply(this, args) }, wait) } } } export function isMac(): boolean { return navigator.userAgent.indexOf('Mac OS X') !== -1 } export function getModifierKey(): string { return isMac() ? '⌘' : 'Ctrl+' } export function isValidHexColor(color: string): boolean { return /^#(([A-F0-9]{2}){3,4}|[A-F0-9]{3})$/i.test(color) } /** * Calculates the relative luminance of a color according to WCAG 2.1 * @param r Red component (0-255) * @param g Green component (0-255) * @param b Blue component (0-255) * @returns Relative luminance value (0-1) */ function getRelativeLuminance(r: number, g: number, b: number): number { const [rs, gs, bs] = [r, g, b].map((val) => { val = val / 255 return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4) }) return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs } /** * Converts hex color to HSL * @param hex Hex color string (e.g., "#FF0000") * @returns Array of [hue (0-360), saturation (0-100), lightness (0-100)] */ function hexToHsl(hex: string): [number, number, number] { // Normalize hex color let normalizedHex = hex.replace('#', '') if (normalizedHex.length === 3) { normalizedHex = normalizedHex .split('') .map((char) => char + char) .join('') } const r = parseInt(normalizedHex.substring(0, 2), 16) / 255 const g = parseInt(normalizedHex.substring(2, 4), 16) / 255 const b = parseInt(normalizedHex.substring(4, 6), 16) / 255 const max = Math.max(r, g, b) const min = Math.min(r, g, b) let h = 0 let s = 0 const l = (max + min) / 2 if (max !== min) { const d = max - min s = l > 0.5 ? d / (2 - max - min) : d / (max + min) switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6 break case g: h = ((b - r) / d + 2) / 6 break case b: h = ((r - g) / d + 4) / 6 break } } return [h * 360, s * 100, l * 100] } /** * Converts HSL to hex color * @param h Hue (0-360) * @param s Saturation (0-100) * @param l Lightness (0-100) * @returns Hex color string */ function hslToHex(h: number, s: number, l: number): string { h = h / 360 s = s / 100 l = l / 100 let r: number, g: number, b: number if (s === 0) { r = g = b = l // Achromatic } else { const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) t += 1 if (t > 1) t -= 1 if (t < 1 / 6) return p + (q - p) * 6 * t if (t < 1 / 2) return q if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 return p } const q = l < 0.5 ? l * (1 + s) : l + s - l * s const p = 2 * l - q r = hue2rgb(p, q, h + 1 / 3) g = hue2rgb(p, q, h) b = hue2rgb(p, q, h - 1 / 3) } const toHex = (c: number) => { const hex = Math.round(c * 255).toString(16) return hex.length === 1 ? '0' + hex : hex } return `#${toHex(r)}${toHex(g)}${toHex(b)}` } /** * Generates a text color with the same hue as the background but adjusted lightness for good contrast * @param backgroundColor Hex color string (e.g., "#FF0000" or "#F00") * @returns Hex color string with same hue but good contrast, or undefined if invalid */ export function getContrastTextColor( backgroundColor: string | null | undefined ): string | undefined { if (!backgroundColor || !isValidHexColor(backgroundColor)) { return undefined } // Normalize hex color let hex = backgroundColor.replace('#', '') if (hex.length === 3) { hex = hex .split('') .map((char) => char + char) .join('') } // Parse RGB components and calculate background luminance const r = parseInt(hex.substring(0, 2), 16) const g = parseInt(hex.substring(2, 4), 16) const b = parseInt(hex.substring(4, 6), 16) const bgLuminance = getRelativeLuminance(r, g, b) // Convert to HSL to extract hue const [hue] = hexToHsl(backgroundColor) // Determine if background is light or dark const isLightBackground = bgLuminance > 0.5 // Use fixed saturation and lightness based on background lightness // For light backgrounds: use dark text (low lightness, high saturation) // For dark backgrounds: use light text (high lightness, high saturation) const saturation = 70 // Fixed saturation for good readability const lightness = isLightBackground ? 25 : 85 // Dark for light bg, light for dark bg // Generate the color with the same hue but adjusted saturation and lightness return hslToHex(hue, saturation, lightness) } export function sortObject(o: T & object): T { return Object.keys(o) .sort() .reduce((obj, key) => { obj[key] = o[key] return obj }, {}) as T } export function sortArray(array: T[], compareFn?: (a: T, b: T) => number): T[] { const arr = [...array] arr.sort(compareFn) return arr } export function generateRandomString(len: number = 24): string { let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' let result = '' for (let i = 0; i < len; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)) } return result } export function deepMergeWithPriority(target: T, source: T): T { if (typeof target !== 'object' || typeof source !== 'object') { return source } const merged = { ...target } for (const key in source) { if (source.hasOwnProperty(key) && merged?.hasOwnProperty(key)) { if (target?.hasOwnProperty(key)) { merged[key] = deepMergeWithPriority(target[key], source[key]) } else { merged[key] = source[key] } } else { if (merged) { merged[key] = source[key] } } } return merged } export function canWrite( path: string, extra_perms: Record, user?: UserExt ): boolean { if (user?.is_admin || user?.is_super_admin) { return true } let keys = Object.keys(extra_perms ?? {}) if (!user) { return false } if (isObviousOwner(path, user)) { return true } let userOwner = `u/${user.username}` if (keys.includes(userOwner) && extra_perms?.[userOwner]) { return true } if (user.pgroups.findIndex((x) => keys.includes(x) && extra_perms?.[x]) != -1) { return true } if (user.folders.findIndex((x) => path.startsWith('f/' + x + '/') && user.folders[x]) != -1) { return true } return false } export function isOwner( path: string, user: UserExt | undefined, workspace: string | undefined ): boolean { if (!user || !workspace) { return false } if (user.is_super_admin) { return true } if (workspace == 'admin') { return false } else if (user.is_admin) { return true } else if (path.startsWith('u/' + user.username + '/')) { return true } else if (path.startsWith('f/')) { return user.folders_owners.some((x) => path.startsWith('f/' + x + '/')) } else { return false } } export function isObviousOwner(path: string, user?: UserExt): boolean { if (!user) { return false } if (user.is_admin || user.is_super_admin) { return true } let userOwner = `u/${user.username}` if (path.startsWith(userOwner)) { return true } if (user.pgroups.findIndex((x) => path.startsWith(x)) != -1) { return true } if (user.folders.findIndex((x) => path.startsWith('f/' + x)) != -1) { return true } return false } export function extractCustomProperties(styleStr: string): string { let properties = styleStr.split(';') let customProperties = properties.filter((property) => property.trim().startsWith('--')) let customStyleStr = customProperties.join(';') return customStyleStr } export function computeSharableHash(args: any) { let nargs = {} for (let k in args) { let v = args[k] if (v !== undefined) { // if let size = roughSizeOfObject(v) > 1000000 if (size) { console.error(`Value at key ${k} too big (${size}) to be shared`) return '' } nargs[k] = JSON.stringify(v) } } try { let r = new URLSearchParams(nargs).toString() return r.length > 1000000 ? '' : r } catch (e) { console.error('Error computing sharable hash', e) return '' } } export function toCamel(s: string) { return s.replace(/([-_][a-z])/gi, ($1) => { return $1.toUpperCase().replace('-', '').replace('_', '') }) } export function cleanExpr(expr: string | undefined): string { if (!expr) { return '' } return expr .split('\n') .filter((x) => x != '' && !x.startsWith(`import `)) .join('\n') } const dynamicTemplateRegex = new RegExp(/\$\{(.*)\}/) export function isCodeInjection(expr: string | undefined): boolean { if (!expr) { return false } return dynamicTemplateRegex.test(expr) } export function urlParamsToObject(params: URLSearchParams): Record { const result: Record = {} params.forEach((value, key) => { result[key] = value }) return result } export async function tryEvery({ tryCode, timeoutCode, interval, timeout }: { tryCode: () => Promise timeoutCode: () => void interval: number timeout: number }) { const times = Math.floor(timeout / interval) let i = 0 while (i < times) { await sleep(interval) try { await tryCode() break } catch (err) {} i++ } if (i >= times) { timeoutCode() } } export function roughSizeOfObject(object: object | string | any) { if (typeof object === 'string') { return object.length * 2 } const visited = new Set() const stack = [object] let bytes = 0 while (stack.length) { const value = stack.pop() if (typeof value === 'boolean') { bytes += 4 } else if (typeof value === 'string') { bytes += value.length * 2 } else if (typeof value === 'number') { bytes += 8 } else if (typeof value === 'object' && value !== null && !visited.has(value)) { visited.add(value) for (const key in value) { bytes += 2 * key.length stack.push(value[key]) } } } return bytes } export type Value = { language?: Script['language'] content?: string path?: string draft_only?: boolean value?: any draft?: Value [key: string]: any } export function replaceFalseWithUndefined(obj: any) { return replaceFalseWithUndefinedRec(structuredClone(stateSnapshot(obj))) } function replaceFalseWithUndefinedRec(obj: any) { // Check if the input is an object and not null if (obj !== null && typeof obj === 'object') { for (const key in obj) { if (obj.hasOwnProperty(key)) { // If the value is false, replace it with undefined if (obj[key] === false) { // delete obj[key]; obj[key] = undefined } else { // If the value is an object, call the function recursively replaceFalseWithUndefinedRec(obj[key]) } } } } return obj } export function cleanValueProperties(obj: Value) { if (typeof obj !== 'object') { return obj } else { let newObj: any = {} for (const key of Object.keys(obj)) { if (key !== 'parent_hash' && key !== 'draft' && key !== 'draft_only') { newObj[key] = structuredClone(stateSnapshot(obj[key])) } } return newObj } } export function hasUnsavedChanges(saved: Value, modified: Value): boolean { const normalizedSaved = cleanValueProperties({ ...saved, path: undefined }) const normalizedModified = cleanValueProperties({ ...modified, path: undefined }) return ( orderedJsonStringify(replaceFalseWithUndefined(normalizedSaved)) !== orderedJsonStringify(replaceFalseWithUndefined(normalizedModified)) ) } export function orderedJsonStringify(obj: any, space?: string | number) { const allKeys = new Set() JSON.stringify( obj, (key, value) => (value != undefined && value != null && allKeys.add(key), value) ) return JSON.stringify(obj, (Array.from(allKeys) as string[]).sort(), space) } function sortObjectKeys(obj: any): any { if (obj && typeof obj === 'object' && !Array.isArray(obj)) { const sortedObj: any = {} Object.keys(obj) .sort() .forEach((key) => { sortedObj[key] = sortObjectKeys(obj[key]) }) return sortedObj } else if (Array.isArray(obj)) { return obj.map((item) => sortObjectKeys(item)) } else { return obj } } export function orderedYamlStringify(obj: any) { const sortedObj = sortObjectKeys(obj) return YAML.stringify(sortedObj) } function evalJs(expr: string) { let template = ` return function (fields) { "use strict"; return ${expr.startsWith('return ') ? expr.substring(7) : expr} } ` let functor = Function(template) return functor() } export function computeShow(argName: string, expr: string | undefined, args: any) { if (expr) { try { let r = evalJs(expr)(args ?? {}) return r } catch (e) { console.error(`Impossible to eval ${expr}:`, e) return true } } return true } function urlizeTokenInternal(token: string, formatter: 'html' | 'md'): string { if (token.startsWith('http://') || token.startsWith('https://')) { if (formatter == 'html') { return `${token}` } else { return `[${token}](${token})` } } else { return token } } export function urlize(input: string, formatter: 'html' | 'md'): string { if (!input) return '' return input .split('\n') .map((line) => { return line .split(' ') .map((word) => urlizeTokenInternal(word, formatter)) .join(' ') }) .join('\n') } export function storeLocalSetting(name: string, value: string | undefined) { if (value != undefined) { localStorage.setItem(name, value) } else { localStorage.removeItem(name) } } export function getLocalSetting(name: string) { try { return localStorage.getItem(name) } catch (e) { return undefined } } export function computeKind( enum_: EnumType, contentEncoding: 'base64' | 'binary' | undefined, pattern: string | undefined, format: string | undefined ): 'base64' | 'none' | 'pattern' | 'enum' | 'resource' | 'format' | 'date-time' { if (enum_ != undefined) { return 'enum' } if (contentEncoding == 'base64') { return 'base64' } if (pattern != undefined) { return 'pattern' } if (format == 'date-time') { return 'date-time' } if (format != undefined && format != '') { if (format?.startsWith('resource')) { return 'resource' } return 'format' } return 'none' } // Used to check whether a placeholder should be displayed in the input field, based on the schema export function shouldDisplayPlaceholder( type: string | undefined, format: string | undefined, enum_: EnumType, contentEncoding: 'base64' | 'binary' | undefined, pattern: string | undefined, extra: Record | undefined ): boolean { if (type === 'string') { const kind = computeKind(enum_, contentEncoding, pattern, format) if (kind === 'format' && format) { const whiteList = ['email', 'hostname', 'ipv4', 'uri', 'uuid'] return whiteList.includes(format) } return kind === 'none' || kind === 'pattern' } if (type === 'number' || type === 'integer') { return extra?.['min'] === undefined || extra?.['max'] === undefined } return type === undefined } export function getSchemaFromProperties(properties: { [name: string]: SchemaProperty }): Schema { return { properties: Object.fromEntries(Object.entries(properties).filter(([k, v]) => k !== 'label')), required: Object.keys(properties).filter((k) => properties[k].required), $schema: '', type: 'object', order: Object.keys(properties).filter((k) => k !== 'label') } } export function validateFileExtension(ext: string) { const validExtensionRegex = /^[a-zA-Z0-9]+([._][a-zA-Z0-9]+)*$/ return validExtensionRegex.test(ext) } export function isFlowPreview(job_kind: Job['job_kind'] | undefined) { return !!job_kind && (job_kind === 'flowpreview' || job_kind === 'flownode') } export function isNotFlow(job_kind: Job['job_kind'] | undefined) { return job_kind !== 'flow' && job_kind !== 'singlestepflow' && !isFlowPreview(job_kind) } export function isScriptPreview(job_kind: Job['job_kind'] | undefined) { return ( !!job_kind && (job_kind === 'preview' || job_kind === 'flowscript' || job_kind === 'appscript') ) } export function conditionalMelt(node: HTMLElement, meltItem: AnyMeltElement | undefined) { if (meltItem) { return meltItem(node) } return { destroy: () => {} } } export type Item = { displayName: string action?: (e: MouseEvent) => void icon?: any iconColor?: string href?: string hrefTarget?: '_blank' | '_self' | '_parent' | '_top' disabled?: boolean type?: 'action' | 'delete' hide?: boolean | undefined extra?: Snippet id?: string tooltip?: string separatorTop?: boolean submenuItems?: Item[] } export function isObjectTooBig(obj: any): boolean { const MAX_DEPTH = 10 const MAX_ITEMS = 50 function analyze(obj: any, currentDepth: number = 0): { totalItems: number; maxDepth: number } { if (currentDepth > MAX_DEPTH) { return { totalItems: 1, maxDepth: currentDepth } } if (typeof obj !== 'object' || obj === null) { return { totalItems: 1, maxDepth: currentDepth } } let totalItems = 1 let maxDepth = currentDepth for (const key in obj) { const result = analyze(obj[key], currentDepth + 1) if (result.maxDepth > MAX_DEPTH) { return result } totalItems += result.totalItems if (result.maxDepth > maxDepth) { maxDepth = result.maxDepth } } return { totalItems, maxDepth } } const { totalItems, maxDepth } = analyze(obj) return maxDepth > MAX_DEPTH || totalItems > MAX_ITEMS } export function localeConcatAnd(items: string[]) { if (!items.length) return '' if (items.length === 1) return items[0] return items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1] } export function formatDate(dateString: string | undefined): string { if (!dateString) return '' const date = new Date(dateString) return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(date) } export function formatDateShort(dateString: string | undefined): string { if (!dateString) return '' const date = new Date(dateString) const now = new Date() // If date is today, only show time if (date.toDateString() === now.toDateString()) { return new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit' }).format(date) } // If date is this year, show only month and day if (date.getFullYear() === now.getFullYear()) { return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date) } // If date is from another year, only show the date with year return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric' }).format(date) } export function toJsonStr(result: any) { try { // console.log(result) return JSON.stringify(result ?? null, null, 4) ?? 'null' } catch (e) { return 'error stringifying object: ' + e.toString() } } export function getOS() { const userAgent = window.navigator.userAgent const platform = window.navigator.platform const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'] const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE'] const iosPlatforms = ['iPhone', 'iPad', 'iPod'] if (macosPlatforms.includes(platform)) { return 'macOS' as const } else if (iosPlatforms.includes(platform)) { return 'iOS' as const } else if (windowsPlatforms.includes(platform)) { return 'Windows' as const } else if (/Android/.test(userAgent)) { return 'Android' as const } else if (/Linux/.test(platform)) { return 'Linux' as const } return 'Unknown OS' as const } import type { Component, Snippet } from 'svelte' import { OpenAPIV2, type OpenAPI, type OpenAPIV3, type OpenAPIV3_1 } from 'openapi-types' import type { IPosition } from 'monaco-editor' export type StateStore = { val: T } export type ReadFieldsRecursivelyOptions = { excludeField?: string[] } export function readFieldsRecursively(obj: any, options: ReadFieldsRecursivelyOptions = {}): void { if (Array.isArray(obj)) { // <= in case a new object is added. should read as undefined for (let i = 0; i <= obj.length; i++) { if (obj[i] && typeof obj[i] === 'object') { readFieldsRecursively(obj[i]) } } } else if (obj !== null && typeof obj === 'object') { Object.keys(obj).forEach((key) => { if (!options.excludeField?.includes(key)) readFieldsRecursively(obj[key], options) }) } } export function reorder(items: T[], oldIndex: number, newIndex: number): T[] { const updatedItems = [...items] const [removedItem] = updatedItems.splice(oldIndex, 1) updatedItems.splice(newIndex, 0, removedItem) return updatedItems } export function scroll_into_view_if_needed_polyfill(elem: Element, centerIfNeeded: boolean = true) { const observer = new IntersectionObserver( function ([entry]) { const ratio = entry.intersectionRatio if (ratio < 1) { const place = ratio <= 0 && centerIfNeeded ? `center` : `nearest` elem.scrollIntoView({ block: place, inline: place }) } observer.disconnect() }, { root: null, // or specify a scrolling parent if needed rootMargin: '0px 1000px', // Essentially making horizontal checks irrelevant threshold: 0.1 // Adjust threshold to control when observer should trigger } ) observer.observe(elem) return observer // return for testing } // Structured clone raises an error on $state values and some stuff like Window // $state.snapshot clones everything but prints warnings for some values (e.g. functions) import _clone from 'clone' export function clone(t: T): T { return _clone(t) } export const editorPositionMap: Record = {} export type S3Uri = `s3://${string}/${string}` export type S3Object = | S3Uri | { s3: string storage?: string } export function parseS3Object(s3Object: S3Object): { s3: string; storage?: string } { if (typeof s3Object === 'object') return s3Object const match = s3Object.match(/^s3:\/\/([^/]*)\/(.*)$/) return { storage: match?.[1] || undefined, s3: match?.[2] ?? '' } } export function formatS3Object(s3Object: S3Object): S3Uri { if (typeof s3Object === 'object') return `s3://${s3Object.storage ?? ''}/${s3Object.s3}` return s3Object } export function isS3Uri(uri: string): uri is S3Uri { const match = uri.match(/^s3:\/\/([^/]*)\/(.*)$/) return !!match && match.length === 3 } export function uniqueBy(array: T[], key: (t: T) => any): T[] { const seen = new Set() return array.filter((item) => { const value = key(item) if (seen.has(value)) { return false } else { seen.add(value) return true } }) } export function pruneNullishArray(array: (T | null | undefined)[]): T[] { return array.filter((item): item is T => item !== null && item !== undefined) } export function assert(msg: string, condition: boolean, value?: any) { if (!condition) { let m = 'Assertion failed: ' + msg if (value) m += '\nValue: ' + JSON.stringify(value, null, 2) m += '\nPlease alert the Windmill team about this' sendUserToast(m, true) console.error(m) } } export function createCache, T, InitialKeys extends Keys = Keys>( compute: (keys: Keys) => T, params?: { maxSize?: number; initial?: InitialKeys; invalidateMs?: number } ): (keys: Keys) => T { let cache = new Map() const maxSize = params?.maxSize ?? 15 if (params?.initial) { let key = JSON.stringify(params.initial, Object.keys(params.initial).sort()) let value = compute(params.initial) cache.set(key, { value, timestamp: Date.now() }) } return (keys: Keys) => { if (typeof params?.invalidateMs === 'number') { for (const [key, entry] of cache.entries()) { if (Date.now() - entry.timestamp > params.invalidateMs) { cache.delete(key) } } } let key = JSON.stringify(keys, Object.keys(keys).sort()) if (!cache.get(key)) { let value = compute(keys) cache.set(key, { value, timestamp: Date.now() }) if (cache.size > maxSize) { // remove the oldest entry (first inserted) const oldestKey = cache.keys().next().value! cache.delete(oldestKey) } } return cache.get(key)!.value } } export async function wait(ms: number) { return new Promise((resolve) => setTimeout(() => resolve(undefined), ms)) } export function validateRetryConfig(retry: Retry | undefined): string | null { if (retry?.exponential?.seconds !== undefined) { const seconds = retry.exponential.seconds if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds < 0) { return 'Exponential backoff base (seconds) must be an integer ≥ 0' } } return null } export type CssColor = keyof (typeof tokensFile)['tokens']['light'] import tokensFile from './assets/tokens/tokens.json' import { darkModeName, lightModeName } from './assets/tokens/colorTokensConfig' import BarsStaggered from './components/icons/BarsStaggered.svelte' import { GitIcon } from './components/icons' import { Bot, Code, Package } from 'lucide-svelte' import type { DbInput } from './components/dbTypes' export function getCssColor( color: CssColor, { alpha = 1, format = 'css-var' }: { alpha?: number format?: 'css-var' | 'hex-dark' | 'hex-light' } ): string { if (format === 'hex-light') { return tokensFile.tokens[lightModeName][color] } if (format === 'hex-dark') { return tokensFile.tokens[darkModeName][color] } return `rgb(var(--color-${color}) / ${alpha})` } export type IconType = Component<{ size?: number }> | typeof import('lucide-svelte').Dot export function getJobKindIcon(jobKind: Job['job_kind']) { if (jobKind === 'flow' || isFlowPreview(jobKind) || jobKind === 'unassigned_flow') { return BarsStaggered } else if (jobKind === 'deploymentcallback') { return GitIcon } else if ( jobKind === 'dependencies' || jobKind === 'appdependencies' || jobKind === 'flowdependencies' ) { return Package } else if ( jobKind === 'script' || isScriptPreview(jobKind) || jobKind === 'script_hub' || jobKind === 'singlestepflow' || jobKind === 'unassigned_script' || jobKind === 'unassigned_singlestepflow' ) { return Code } else if (jobKind === 'aiagent') { return Bot } else if (jobKind) return Code } export function chunkBy(array: T[], getKey: (key: T) => string): T[][] { const chunks: T[][] = [] for (const item of array) { const key = getKey(item) let lastChunk = chunks[chunks.length - 1] if (!lastChunk || getKey(lastChunk[0]) !== key) { lastChunk = [] chunks.push(lastChunk) } lastChunk.push(item) } return chunks } // AI generated export function getQueryStmtCountHeuristic(query: string): number { // Handle empty or whitespace-only strings if (query.trim() === '') { return 0 } let count = 0 let currState: 'normal' | 'single-quote' | 'double-quote' | 'line-comment' | 'block-comment' = 'normal' let hasContentAfterLastSemicolon = false for (let i = 0; i < query.length; i++) { const char = query[i] const nextChar = query[i + 1] switch (currState) { case 'normal': if (char === "'") { currState = 'single-quote' hasContentAfterLastSemicolon = true } else if (char === '"') { currState = 'double-quote' hasContentAfterLastSemicolon = true } else if (char === '-' && nextChar === '-') { currState = 'line-comment' i++ // skip next char } else if (char === '/' && nextChar === '*') { currState = 'block-comment' hasContentAfterLastSemicolon = true i++ // skip next char } else if (char === ';') { count++ hasContentAfterLastSemicolon = false } else if (char !== ' ' && char !== '\t' && char !== '\n' && char !== '\r') { // Non-whitespace character means we have content hasContentAfterLastSemicolon = true } break case 'single-quote': if (char === "'") { // In SQL, '' is an escaped single quote if (nextChar === "'") { i++ // skip the escaped quote } else { currState = 'normal' } } break case 'double-quote': if (char === '"') { // In SQL, "" is an escaped double quote if (nextChar === '"') { i++ // skip the escaped quote } else { currState = 'normal' } } break case 'line-comment': if (char === '\n') { currState = 'normal' } break case 'block-comment': if (char === '*' && nextChar === '/') { currState = 'normal' i++ // skip next char } break } } // Count implicit final statement if: // 1. We're in normal state and query doesn't end with semicolon, OR // 2. We're in a quote state (unclosed quote) - there's an implicit statement // 3. We're in a block comment state - there's an implicit statement before the unclosed comment // 4. We're in a line comment state and we had content after the last semicolon before entering the comment const trimmedQuery = query.trimEnd() if (currState === 'normal' && trimmedQuery !== '' && !trimmedQuery.endsWith(';')) { count++ } else if ( currState === 'single-quote' || currState === 'double-quote' || currState === 'block-comment' ) { // Unclosed quote or unclosed block comment means there's an implicit statement count++ } else if (currState === 'line-comment' && hasContentAfterLastSemicolon) { // Line comment with content before it means there's an implicit statement count++ } return count } export function countChars(str: string, char: string): number { let count = 0 for (let i = 0; i < str.length; i++) { if (str[i] === char) { count++ } } return count } export function onlyAlphaNumAndUnderscore(str: string): string { return str.replace(/[^a-zA-Z0-9_]/g, '') } export function buildReactiveObj(fields: { [name in keyof T]: [() => T[name], (v: T[name]) => void] }): T { const obj = {} as T for (const key in fields) { Object.defineProperty(obj, key, { get: fields[key][0], set: fields[key][1], enumerable: true, configurable: true }) } return obj } export function pick(obj: T, keys: readonly K[]): Pick { const result = {} as Pick for (const key of keys) { if (key in obj) { result[key] = obj[key] } } return result } export function parseDbInputFromAssetSyntax(path: string): DbInput | null { const [p1, _p2] = path.split('://') const [p2, _p3] = _p2.split('/') const [p3, p4] = _p3.split('.') return p1 === 'ducklake' ? { type: 'ducklake', ducklake: p2 || 'main', specificTable: p4 ?? p3 } : p1 === 'datatable' ? { type: 'database', resourcePath: `datatable://${p2 || 'main'}`, resourceType: 'postgresql', specificTable: p4 ?? p3, specificSchema: p4 ? p3 : undefined } : null } /** * Formats memory size in KB to human-readable format with appropriate units * @param sizeInKb - Memory size in kilobytes * @param includeTooltip - Whether to return tooltip data with precise values * @returns Formatted string with appropriate unit (KB, MB, GB) or object with display and tooltip */ export function formatMemory(sizeInKb: number): string export function formatMemory( sizeInKb: number, includeTooltip: true ): { display: string; tooltip: string } export function formatMemory( sizeInKb: number, includeTooltip = false ): string | { display: string; tooltip: string } { const precise = `${sizeInKb.toLocaleString()}KB` let display: string if (sizeInKb >= 1024 * 1024) { // Convert to GB for values >= 1GB display = `${(sizeInKb / (1024 * 1024)).toFixed(0)}GB` } else if (sizeInKb >= 1024) { // Convert to MB for values >= 1MB display = `${(sizeInKb / 1024).toFixed(0)}MB` } else { // Keep as KB for smaller values display = `${sizeInKb.toFixed(0)}KB` } if (includeTooltip) { return { display, tooltip: `${precise} (${(sizeInKb / 1024).toFixed(2)}MB)` } } return display }