Files
windmill/frontend/src/lib/utils.ts
T
ca44043e12 feat: let operators compose flows when the workspace grants the right (#11228)
* feat: let a workspace withdraw operator schedule and trigger writes

Operators can create, edit and delete schedules and triggers today through the
API, CLI and MCP, while the operator_settings flags beside them only hide those
pages. An admin who wants operators to see what is scheduled without letting
them change it cannot express that. Add manage_schedules and manage_triggers as
enforced settings, gated at the schedule handlers and at the generic TriggerCrud
routes so every trigger kind is covered by one check.

They name capabilities operators already hold, so they are granted unless
withdrawn, and absence has to mean "never configured" rather than a value. The
read coalesces to true; the update endpoint merges into the stored jsonb with
the two fields as Option<bool>, so an omitted key keeps what is stored.
operator_settings is git-synced as a whole object, so a settings file written
before these keys existed reaches the endpoint on every pull, and a serde or SQL
default of either polarity would turn that pull into a silent withdrawal or
restoration.

The rights are read through a per-process cache, so withdrawing one publishes a
notify_event that drops the entry on every replica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4

* feat: let operators compose flows when the workspace grants the right

Adds operator_settings.builder_flows: a workspace setting that lets every
operator compose flows out of runnables that already exist. It does not make
them authors. The boundary the operator role draws is authoring code and running
arbitrary code, and this does not move it: check_flow_is_composition_only walks
the value and refuses anything carrying code, including the shapes an obvious
walk misses (code hoisted into a flow_node, an AI agent step's tools, and a
linked ai_agent resource whose tool list is resolved at run time).

What the walk cannot settle it returns for the caller to authorize under RLS:
the worker tags the steps pin, every runnable they reference, and the (path,
hash) of every version-pinned step. Composing a path is enough to run it and to
run it as whoever it runs as, since the worker resolves a step's path with the
root DB handle and adopts that runnable's on_behalf_of. A pinned hash needs its
own check because dispatch ignores the path beside it.

The gate runs on every write and on both request-supplied-value paths, flow
preview and flow dependencies, or either becomes the way to run what the write
path refuses.

Operators of a builder workspace consume a full author seat; the EE companion
carries the counting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4

* feat: enforce operator write rights on the router and in the UI

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: close the capture gap and gate the trigger editors' write actions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate acl writes and the native trigger drawer behind manage rights

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse operator writes with 403 and gate sharing at the drawer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: resolve identity in the operator write gate only for writes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate the suspended-jobs actions and stop the route check refusing reads

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: explain the empty-state create button when operator writes are withdrawn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: audit operator settings changes and fold path writes into native rows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: open locked editors read-only and group the operator settings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: skip email and azure lookups on editor open while triggers are locked

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: state each operator-rights rationale once in comments

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: address CI review findings on operator write rights

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep capture move gated and skip it in the builders while locked

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: refuse builder-rights violations with 403 so operators stay logged in

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: trim duplication in the operator builder gates

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: hide build app from builder operators on the flow page

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: point at the companion EE PR merged with EE main

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep a builder's drafts list loading past drafts they cannot write

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: hide saved agents from builder operators in the step picker

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: note the inlined seat rule and drop orphaned sqlx entries

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: stop a builder's step test from logging them out

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: refuse a builder's dependency job on a path it cannot write

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep builders from adding dynamic dropdown code to a flow

A flow's dropdown code runs as whoever loads its form, so a builder may keep or drop the code stored on the flow it updates, never add or change it. The builder's editor hides the dropdown types and code, and previews options through the deployed flow; the inline dropdown refusal is a 403 so it no longer logs operators out. Also trims rationale comments repeated across sites.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: show why saving operator settings failed

The seat-cap refusal on granting builder rights explains what to do; the toast now carries the server's message instead of a generic failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: check builder flow drafts like deploys and treat dropdown code as code

A developer who loads a builder's flow draft in the editor runs its dynamic dropdown code as themselves, so a builder's draft now passes the same checks as a deploy. Dropdown code is refused like step code rather than kept or dropped, which also removes the exact-match comparison that refused builders over whitespace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: bill a builder workspace's operators as developers on cloud

The cloud seat count behind the Premium page, the sidebar usage and the fork cap still weighed every operator at half a seat, while the builder right makes them authors. The out-of-repo invoicing job must follow the same rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: check a builder's flow draft as it will be stored

Draft storage strips NUL escapes after the builder check, so a key ending in one (value\u0000, x-windmill-dyn-select-code\u0000) passed the check as an unknown field and was stored under its plain name. The check now reads the sanitized text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: list a builder's flow drafts and hide hub imports from builders

A builder's undeployed flows now appear in the home list, the flow list and the folder counts. Hub project imports and templates bring scripts and apps along, so builders are no longer offered them. The docs record builders' JavaScript expressions as an accepted risk.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the stored builder right when a settings payload omits it

A git-synced settings file written before the key existed withdrew the right on every push. builder_flows now follows the manage_* rights: an omitted key leaves the stored value, and the CLI does not count it as a difference.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: word builder refusals by what the flow contains, test the tag refusal

A builder refused on a developer's flow never changed its code, so the refusals now describe the flow ("has inline code, so only a developer can edit this flow") rather than an authoring attempt. The grant confirmation uses the neutral dialog: granting changes billing but destroys nothing. The integration test pins the refusal of a worker tag the workspace cannot use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: read builder rights from the operating workspace, gate the flow page's audit logs entry

Builder rights now come from useOperatorBuilderFlows(), next to the schedule and trigger locks, so an editor embedded for another workspace answers about that workspace; the legacy AI chat, one instance for the whole app, reads the navigation workspace. The flow page's Audit logs entry follows the operator audit_logs setting now that builders open that menu. Operator settings reset every value on load, null settings included, so nothing carries over from the previous workspace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: pick a dynamic dropdown's code source by the operating user's role

The flow input editor, the flow test panel and the flow chat send the dropdown request to the operating workspace, so they now also choose inline versus deployed code by the role held there, through useOperatingUser(), instead of the navigation workspace's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 40ac1c5f8cbce3843b582d9b392d3f3cc7eca3e6

This commit updates the EE repository reference after PR #815 was merged in windmill-ee-private.

Previous ee-repo-ref: 31c9e66884b8ca805b20bbfad41fc428fbedbc0e

New ee-repo-ref: 40ac1c5f8cbce3843b582d9b392d3f3cc7eca3e6

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-30 18:09:14 +02:00

2482 lines
69 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 { type UserExt } from './stores'
import { sendUserToast } from './toast'
import type { CompletedJob, 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'
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 { validate, dereference } = await import('@scalar/openapi-parser')
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
}
// Only a top-level failure can carry a resolution: cancellations and skipped steps are not
// failures, and a flow step resolved on its own would render orange inside a flow whose status
// is still red. The resolve endpoint enforces the same three conditions.
// Runs obscured from another workspace stand in with `id: '-'`; they look like failures but
// address nothing, and one reaching the endpoint fails the whole batch on UUID parsing.
export function isJobResolvable(j: Job): j is CompletedJob {
return j.type === 'CompletedJob' && !j.success && !j.canceled && !j.is_flow_step && j.id !== '-'
}
// Mirror the caps enforced by /jobs/completed/resolve, so the UI can chunk and validate
// before the API rejects a whole action.
export const MAX_RESOLUTION_BATCH = 1000
export const MAX_RESOLUTION_NOTE_LEN = 2000
export const WORKER_NAME_PREFIX = 'wk'
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', '.dropdown-portal', '[data-context-menu]']
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 }
/** A flow's dropdown options for its editor. An operator may not run request-supplied code, and
* a builder cannot change the stored code, so an operator reads it from the deployed flow. */
export function flowHelperScript(
code: string | undefined,
lang: ScriptLang | undefined,
deployedPath: string | undefined,
operator: boolean | undefined
): HelperScript | undefined {
if (!code || !lang) return undefined
return operator && deployedPath
? { source: 'deployed', path: deployedPath, runnable_kind: 'flow' }
: { source: 'inline', code, lang }
}
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
}
// Not from './sharedUtils': that module drags the app policy code into every page.
export { capitalize } from './utils/capitalize'
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
}
/**
* True on Chromium-based browsers (Chrome, Edge, Brave, Opera, ...). Gates
* capabilities that are only faithful on Blink, e.g. DOM screenshot capture.
* userAgentData is itself Chromium-only; the UA fallback covers Chromium
* versions predating it ("Chrome/" never appears in Gecko or WebKit UAs).
*/
export function isChromiumBrowser(): boolean {
if (typeof navigator === 'undefined') return false
const brands = (navigator as any).userAgentData?.brands
if (Array.isArray(brands)) {
return brands.some((entry) => typeof entry?.brand === 'string' && /chromium/i.test(entry.brand))
}
return /chrome\//i.test(navigator.userAgent)
}
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
}
/** `u/<sanitized-username>/` — the default scope prefix for items owned by the
* current user. The username is normalized so emails and special characters
* don't leak into paths. */
export function userPathPrefix(username: string | undefined): string {
const u = username?.includes('@')
? username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')
: (username ?? '')
return `u/${u}/`
}
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
}
// Value prefix marking the reserved `__tag` key as a carried tag: no JSON-encoded
// arg value can start with `t:` (JSON strings start with `"`, numbers with a digit
// or `-`, etc.), so it cannot be confused with a genuine arg named `__tag`
const SHARABLE_HASH_TAG_PREFIX = 't:'
// `tag` is carried as the reserved key `__tag` with a SHARABLE_HASH_TAG_PREFIX value;
// entry pairs allow a duplicate `__tag` key so an arg with that name can coexist with
// the carried tag (they are told apart by the value prefix)
export function computeSharableHash(args: any, tag?: string) {
let entries: [string, string][] = []
if (tag) {
entries.push(['__tag', SHARABLE_HASH_TAG_PREFIX + tag])
}
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 ''
}
entries.push([k, JSON.stringify(v)])
}
}
try {
let r = new URLSearchParams(entries).toString()
return r.length > 1000000 ? '' : r
} catch (e) {
console.error('Error computing sharable hash', e)
return ''
}
}
// `$args[...]` tags are resolved by the backend at push time from the run's args; a
// job's stored tag is the resolved value, so re-running with it would pin a value
// that no longer matches edited args. `$workspace` is also interpolated but resolves
// identically on a re-run (same workspace), so it does not make a tag dynamic here.
export function isDynamicTag(tag: string | undefined): boolean {
return !!tag && tag.includes('$args[')
}
// Must mirror the backend's RE_ARG_TAG (windmill-queue/src/jobs.rs) so frontend
// interpolation resolves exactly the same placeholders as push time.
const RE_ARG_TAG = /\$args\[((?:\w+\.)*\w+)\]/
// True when the tag contains placeholders that the backend resolves at push time.
// A carried tag override that is itself a template re-resolves on every run, so
// it never pins a stale resolved value.
export function isTagTemplate(tag: string | undefined): boolean {
return !!tag && (tag.includes('$workspace') || RE_ARG_TAG.test(tag))
}
// Frontend mirror of the backend's `interpolate_args` (windmill-queue/src/jobs.rs):
// `$workspace` first, then each `$args[name]` from the run's args — simple names use
// the arg's JSON text with surrounding quotes trimmed, dotted names traverse nested
// objects, anything missing resolves to the empty string.
export function interpolateTag(
template: string,
workspaceId: string,
args: Record<string, any> | undefined
): string {
const workspaced = template.replaceAll('$workspace', workspaceId)
return workspaced.replace(new RegExp(RE_ARG_TAG.source, 'g'), (_, name: string) => {
const parts = name.split('.')
let value: any = args?.[parts[0]]
for (const part of parts.slice(1)) {
value = value != null && typeof value === 'object' ? value[part] : undefined
}
if (value === undefined) {
return ''
}
return (JSON.stringify(value) ?? '').replace(/^"+|"+$/g, '')
})
}
// Maps a job's stored (resolved) tag back to the raw custom-tag entry it can only
// have come from: an exact entry, or a templated entry that resolves to it with the
// job's workspace and args. Custom tags are the only tags a user can pick as an
// override and the only ones non-superadmins may pass explicitly, so a tag that maps
// to no entry is backend-derived and must be re-derived on re-run, not carried.
export function findMatchingCustomTag(
resolvedTag: string,
customTags: string[],
workspaceId: string,
args: Record<string, any> | undefined
): string | undefined {
if (customTags.includes(resolvedTag)) {
return resolvedTag
}
return customTags.find(
(t) => isTagTemplate(t) && interpolateTag(t, workspaceId, args) === resolvedTag
)
}
// Counterpart of computeSharableHash's `tag`: extracts and removes the carried tag.
// Only SHARABLE_HASH_TAG_PREFIX-prefixed `__tag` values are carried tags; any other
// `__tag` value is a genuine arg with that name and is left in `params` for arg parsing.
export function extractTagFromSharableHash(params: URLSearchParams): string | undefined {
const values = params.getAll('__tag')
const carried = values.find((v) => v.startsWith(SHARABLE_HASH_TAG_PREFIX))
if (carried == undefined) {
return undefined
}
params.delete('__tag')
for (const v of values) {
if (!v.startsWith(SHARABLE_HASH_TAG_PREFIX)) {
params.append('__tag', v)
}
}
return carried.slice(SHARABLE_HASH_TAG_PREFIX.length)
}
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)
}
// Query params Windmill consumes internally and that should not be exposed to
// app logic via the `query` context. Only params we actually own are listed
// here — the `wm_` prefix is a naming convention, not a reserved namespace, so
// we don't strip it wholesale (that would break apps reading their own `wm_*`
// params). `wm_coep` is a transport flag for cross-origin isolation headers;
// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params
// (see PublicAppFrame).
export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin'])
export function urlParamsToObject(
params: URLSearchParams,
opts?: { stripReserved?: boolean }
): Record<string, string> {
const result: Record<string, string> = {}
params.forEach((value, key) => {
if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) {
return
}
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
}
// Keys that are server-managed bookkeeping, never user-editable, and therefore
// must not surface in any value diff or unsaved-change comparison. `getScriptByPath`
// (and the flow/app equivalents) return the full DB row, so the editing object
// carries these while the deployed side is fetched trimmed — leaving them in would
// render as spurious metadata diff.
//
// `hash` is the deployed version's identity, `assets` is re-derived from the
// script content by the editor, and `inherited_labels` is computed at read time
// from the parent folder — none is editable content, so all three are noise in a
// fork/workspace or version diff.
//
// `lock` and `extra_perms` are deliberately NOT in this set: both are legitimate,
// user-meaningful fields in some diff contexts (lockfile changes in version-to-version
// diffs, folder sharing-permission changes in workspace/fork diffs). The script-editor
// noise they would otherwise cause is stripped at the source instead (the deployed side
// in `ScriptBuilder.syncWithDeployed`, the current side in `ScriptBuilder.openDiffDrawer`).
const CLEANED_VALUE_KEYS = new Set([
'parent_hash',
'hash',
'assets',
'inherited_labels',
'draft',
'draft_only',
'draft_saved_at',
'draft_created_at',
'draft_base',
'is_draft',
'other_drafts_users',
'created_at',
'created_by',
'workspace_id',
'parent_hashes',
'lock_error_logs'
])
export function cleanValueProperties(obj: Value) {
if (typeof obj !== 'object') {
return obj
} else {
let newObj: any = {}
for (const key of Object.keys(obj)) {
if (!CLEANED_VALUE_KEYS.has(key)) {
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 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 isHubFlowPath(scriptPath: string | undefined | null) {
return !!scriptPath && scriptPath.startsWith('hub/flows/')
}
export function getHubFlowIdFromPath(scriptPath: string | undefined | null) {
if (!scriptPath || !isHubFlowPath(scriptPath)) {
return undefined
}
const hubFlowPath = scriptPath.substring('hub/flows/'.length)
const [idPart] = hubFlowPath.split('/')
const id = Number(idPart)
return Number.isInteger(id) && id > 0 ? id : undefined
}
export function getJobKindDisplayLabel(
jobKind: Job['job_kind'] | undefined,
scriptPath: string | undefined | null
) {
if (jobKind === 'script_hub') {
return 'Script from hub'
}
if (isFlowPreview(jobKind) && isHubFlowPath(scriptPath)) {
return 'Flow from hub'
}
return jobKind ?? ''
}
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
/** Second line under the label, for an action whose name alone does not say what it does. */
description?: string
action?: (e: MouseEvent) => void
icon?: any
iconColor?: string
/** Extra props for `icon`, for an icon that does not take lucide's `size`. */
iconProps?: Record<string, any>
customSubmenu?: import('svelte').Component<any>
customSubmenuProps?: Record<string, unknown>
href?: string
hrefTarget?: '_blank' | '_self' | '_parent' | '_top'
disabled?: boolean
type?: 'action' | 'delete'
hide?: boolean | undefined
extra?: Snippet
id?: string
tooltip?: string
separatorTop?: boolean
/** Renders an on/off switch at the end of the row, so `icon` keeps the leading
* slot. Presentational: the row's own click is what flips it, so `action` must
* apply the change. */
toggle?: boolean
submenuItems?: Item[]
shortcut?: string
// Renders a trailing check on the right of the label to mark the
// currently-selected item (for dropdowns used as a single-choice picker).
selected?: boolean
}
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 formatDateRange(
start: string | Date | undefined,
end: string | Date | undefined
): string {
if (typeof start === 'string') start = new Date(start)
if (typeof end === 'string') end = new Date(end)
if (start && end) {
const differentDays =
start.getFullYear() !== end.getFullYear() ||
start.getMonth() !== end.getMonth() ||
start.getDate() !== end.getDate()
const differentYears = start.getFullYear() !== end.getFullYear()
if (differentDays || differentYears) {
// Clone to avoid mutating originals
start = new Date(start)
end = new Date(end)
// Zero out time for display
start.setHours(0, 0, 0, 0)
end.setHours(0, 0, 0, 0)
}
if (differentYears) {
// Zero out month and day for display
start.setMonth(0, 1)
end.setMonth(0, 1)
}
return `${formatDatePretty(start)} to ${formatDatePretty(end)}`
}
if (!end && start) return `After ${formatDatePretty(start)}`
if (!start && end) return `Before ${formatDatePretty(end)}`
return 'No input'
}
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'
// Hand-authored variant kept out of the Figma-generated tokens.json.
import githubDarkTokens from './assets/tokens/githubDark.json'
import { darkModeName, lightModeName } from './assets/tokens/colorTokensConfig'
import BarsStaggered from './components/icons/BarsStaggered.svelte'
import GitIcon from './components/icons/GitIcon.svelte'
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' | 'hex-github-dark'
}
): string {
if (format === 'hex-light') {
return tokensFile.tokens[lightModeName][color]
}
if (format === 'hex-dark') {
return tokensFile.tokens[darkModeName][color]
}
if (format === 'hex-github-dark') {
return githubDarkTokens[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('/')
// `_p3` is undefined for a catalog-only path (e.g. `ducklake://main`, no
// table segment) — guard the split so the helper returns a table-less input
// instead of throwing.
const [p3, p4] = (_p3 ?? '').split('.')
const specificTable = p4 || p3 || undefined
const specificSchema = p4 ? p3 : undefined
return p1 === 'ducklake'
? {
type: 'ducklake',
ducklake: p2 || 'main',
specificTable,
specificSchema
}
: p1 === 'datatable'
? {
type: 'database',
resourcePath: `datatable://${p2 || 'main'}`,
resourceType: 'postgresql',
specificTable,
specificSchema
}
: 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
}
export function assignObjInPlace(
target: Record<string, any>,
source: Record<string, any>,
options: { onDelete?: 'Delete' | 'SetNull' } = { onDelete: 'Delete' }
) {
for (const key in target) {
if (!(key in source)) {
if (options?.onDelete === 'Delete') delete target[key]
else if (options?.onDelete === 'SetNull') target[key] = null
}
}
for (const key in source) target[key] = source[key]
}
export function isUSLocale(): boolean {
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale
return locale.startsWith('en-US')
} catch {
return false
}
}
export function formatDatePretty(date: Date): string {
if (!date || isNaN(date.getTime())) return ''
const now = new Date()
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const hours = date.getHours()
const minutes = date.getMinutes()
const isCurrentYear = year === now.getFullYear()
const isToday = isCurrentYear && month === now.getMonth() + 1 && day === now.getDate()
const isOnlyYear = month === 1 && day === 1 && hours === 0 && minutes === 0
const hasTime = hours !== 0 || minutes !== 0
// If only year is defined (rest is 01/01 00:00)
if (isOnlyYear) {
return String(year)
}
// Format month/day depending on locale: MM/DD for US, DD/MM otherwise
const mm = String(month).padStart(2, '0')
const dd = String(day).padStart(2, '0')
const monthDay = isUSLocale() ? `${mm}/${dd}` : `${dd}/${mm}`
// Format time if present (12-hour format with AM/PM)
let timeStr = ''
if (hasTime) {
const isPM = hours >= 12
const displayHours = hours % 12 || 12
const displayMinutes = String(minutes).padStart(2, '0')
timeStr = ` ${displayHours}:${displayMinutes} ${isPM ? 'PM' : 'AM'}`
}
// If today and same year, only show time (if present)
if (isToday) {
return timeStr ? timeStr.trim() : monthDay
}
// If same year, show month/day and time (if present)
if (isCurrentYear) {
return `${monthDay}${timeStr}`
}
// Otherwise, show full date with year and time (if present)
return `${monthDay}/${year}${timeStr}`
}
export function parsePrettyDate(text: string): Date | null {
if (!text) return null
const now = new Date()
const currentYear = now.getFullYear()
// Try parsing as year-only (e.g., "2025")
if (/^\d{4}$/.test(text)) {
const year = parseInt(text)
return new Date(year, 0, 1, 0, 0, 0)
}
// Try parsing time-only (e.g., "11:02 AM") - assumes today
const timeOnlyMatch = text.match(/^(\d{1,2}):(\d{2})\s+(AM|PM)$/i)
if (timeOnlyMatch) {
const [, hourStr, minuteStr, meridiem] = timeOnlyMatch
let hours = parseInt(hourStr)
const minutes = parseInt(minuteStr)
if (meridiem.toUpperCase() === 'PM' && hours !== 12) hours += 12
if (meridiem.toUpperCase() === 'AM' && hours === 12) hours = 0
return new Date(currentYear, now.getMonth(), now.getDate(), hours, minutes, 0)
}
const usLocale = isUSLocale()
// Parse a "first/second" pair as month/day (US) or day/month (non-US)
function parseFirstSecond(firstStr: string, secondStr: string): { month: number; day: number } {
const first = parseInt(firstStr)
const second = parseInt(secondStr)
return usLocale ? { month: first - 1, day: second } : { month: second - 1, day: first }
}
// Try parsing NN/NN (e.g., "01/04") - assumes current year, no time
// US: MM/DD, non-US: DD/MM
const monthDayMatch = text.match(/^(\d{2})\/(\d{2})$/)
if (monthDayMatch) {
const { month, day } = parseFirstSecond(monthDayMatch[1], monthDayMatch[2])
return new Date(currentYear, month, day, 0, 0, 0)
}
// Try parsing NN/NN TIME (e.g., "01/04 11:02 AM") - assumes current year with time
// US: MM/DD TIME, non-US: DD/MM TIME
const monthDayTimeMatch = text.match(/^(\d{2})\/(\d{2})\s+(\d{1,2}):(\d{2})\s+(AM|PM)$/i)
if (monthDayTimeMatch) {
const { month, day } = parseFirstSecond(monthDayTimeMatch[1], monthDayTimeMatch[2])
let hours = parseInt(monthDayTimeMatch[3])
const minutes = parseInt(monthDayTimeMatch[4])
const meridiem = monthDayTimeMatch[5]
if (meridiem.toUpperCase() === 'PM' && hours !== 12) hours += 12
if (meridiem.toUpperCase() === 'AM' && hours === 12) hours = 0
return new Date(currentYear, month, day, hours, minutes, 0)
}
// Try parsing NN/NN/YYYY (e.g., "01/04/2025") - no time
// US: MM/DD/YYYY, non-US: DD/MM/YYYY
const fullDateMatch = text.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
if (fullDateMatch) {
const { month, day } = parseFirstSecond(fullDateMatch[1], fullDateMatch[2])
const year = parseInt(fullDateMatch[3])
return new Date(year, month, day, 0, 0, 0)
}
// Try parsing NN/NN/YYYY TIME (e.g., "01/04/2025 3:00 PM")
// US: MM/DD/YYYY TIME, non-US: DD/MM/YYYY TIME
const fullDateTimeMatch = text.match(/^(\d{2})\/(\d{2})\/(\d{4})\s+(\d{1,2}):(\d{2})\s+(AM|PM)$/i)
if (fullDateTimeMatch) {
const { month, day } = parseFirstSecond(fullDateTimeMatch[1], fullDateTimeMatch[2])
const year = parseInt(fullDateTimeMatch[3])
let hours = parseInt(fullDateTimeMatch[4])
const minutes = parseInt(fullDateTimeMatch[5])
const meridiem = fullDateTimeMatch[6]
if (meridiem.toUpperCase() === 'PM' && hours !== 12) hours += 12
if (meridiem.toUpperCase() === 'AM' && hours === 12) hours = 0
return new Date(year, month, day, hours, minutes, 0)
}
// Fallback to standard Date parsing (e.g., ISO strings)
const date = new Date(text)
return isNaN(date.getTime()) ? null : date
}
// Surface the backend's explanation: API errors carry the real message in
// `.body` (plain text for Windmill 4xx), while `.message` is only the generic
// status text ("Bad Request") the generated client fills in per status code.
export function apiErrorMessage(e: any): string {
const body = e?.body
if (typeof body === 'string' && body.trim() !== '') return body
if (body && typeof body === 'object')
return body.error?.message ?? body.message ?? JSON.stringify(body)
return e?.message ?? String(e)
}