Files
windmill/frontend/src/lib/utils.ts
Guilhem eb5a8dab74 feat(frontend): inline edit summary & path from header (#7968)
* allow editing flow/script summary

* feat(frontend): wire up edit summary/path on flow detail page

- Fix on:click → onclick (Svelte 5) and add title on Save button
- Make can_write reactive ($state) so onEdit prop updates correctly
- Wire onEdit in flow detail page to call FlowService.updateFlow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(frontend): use Path component for path editing in detail page header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(frontend): extract SummaryPathDisplay component with edit popover

Consolidate the summary+path display and edit popover into a reusable
SummaryPathDisplay component, used in both the detail page header and
the flow editor toolbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(frontend): add size prop to Path/FolderPicker, compact popover

Add size prop ('sm' | 'md') to Path and FolderPicker components,
passed through to ToggleButton, TextInput, and Button children.
Use hideFullPath and size="sm" in the SummaryPathDisplay popover
for a compact inline path editor. Widen popover to 480px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix add folder in path editor

* fix(frontend): disable focus trap on edit popover for drawer access

Disable melt-ui's focus trap on the SummaryPathDisplay popover so
that inputs inside drawers (e.g. New Folder) can receive focus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* nit

* feat(frontend): auto-create folder and render drawer above popover

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(frontend): show placeholder and hover-reveal pencil in SummaryPathDisplay

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(frontend): click-to-edit SummaryPathDisplay with inline layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(frontend): move undo/redo and tutorials into dropdown submenu with notification dot

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(frontend): stack path above summary in SummaryPathDisplay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(frontend): bind summary/path directly in flow builder popover

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* nit

* chore: add PR screenshots (to be removed before merge)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove PR screenshots (moved to release assets)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:10:05 +00:00

2056 lines
53 KiB
TypeScript

// /* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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<string, string> = {}
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function addIfNotExists<T>(e: T, arr: Array<T> | undefined): Array<T> {
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[]>) | 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<MouseEvent>('click_outside', { detail: event }))
if (typeof options === 'object') options.onClickOutside?.(event)
}
}
if (opts?.customEventName) {
node.dispatchEvent(new CustomEvent<MouseEvent>(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<PointerEvent>(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<PointerEvent>('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<void>) | ((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<T>(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<T>(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<K, V>(
items: V[],
toGroup: (t: V) => K,
toSort: (t: V) => string,
dflts: K[] = []
): [K, V[]][] {
let r: Map<K, V[]> = 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 | undefined>): 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<boolean> {
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<string, any> {
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<T>(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<T>(o: T & object): T {
return Object.keys(o)
.sort()
.reduce((obj, key) => {
obj[key] = o[key]
return obj
}, {}) as T
}
export function sortArray<T>(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<T>(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<string, boolean>,
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<string, string> {
const result: Record<string, string> = {}
params.forEach((value, key) => {
result[key] = value
})
return result
}
export async function tryEvery({
tryCode,
timeoutCode,
interval,
timeout
}: {
tryCode: () => Promise<any>
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<object>()
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 `<a href="${token}" target="_blank" rel="noopener noreferrer">${token}</a>`
} 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<string, any> | 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<T> = {
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<T>(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): T {
return _clone(t)
}
export const editorPositionMap: Record<string, IPosition> = {}
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<T>(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<T>(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<Keys extends Record<string, any>, T, InitialKeys extends Keys = Keys>(
compute: (keys: Keys) => T,
params?: { maxSize?: number; initial?: InitialKeys; invalidateMs?: number }
): (keys: Keys) => T {
let cache = new Map<string, { value: T; timestamp: number }>()
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<T>(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<T extends object>(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<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
const result = {} as Pick<T, K>
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
}