Files
windmill/frontend/src/lib/utils.ts
T
hugocasaandClaude Opus 4.8 7a139ab23e feat(ai-chat): image attachments and agent raw-app screenshots (#10130)
* feat(ai-chat): add image attachments and agent raw-app screenshots

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

* docs(ai-chat): generalise take_screenshot fidelity caveat

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

* fix(ai-chat): keep compaction boundary on a displayed user message

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

* fix(raw-apps): count line boxes by vertical overlap, not rect count

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

* fix(ai-chat): enforce vision gating and bound image attachments

Refuse images on known text-only models instead of warning and sending them
anyway; cap input bytes before decode; keep clipboard text when it accompanies
a bitmap; don't queue a message whose images can't ride the plain-text queue.

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

* perf(ai-chat): trim take_screenshot schema and shrink the card's copy

Move the fidelity caveat from the tool def onto the tool result: the def is
re-sent every global iteration (~258 tok), while the caveat only matters once a
capture exists. Keep a downscaled copy in displayMessages when it is actually
smaller — those are never compacted and are re-cloned on every saveChat.

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

* fix(ai-chat): carry attached images through the message queue

Enter during a streaming turn queued the text and silently dropped the images,
so the auto-send was not the message the user submitted. The queue now holds
both, moved together via takeQueue/clearQueue/restoreQueue so none of the three
flush sites, the dequeue-to-composer path, or the two conversation-switch drops
can leak one without the other.

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

* fix(ai-chat): gate screenshots on vision, narrow when the tool fires

take_screenshot buffered an image unconditionally, so a text-only model got an
image_url and rejected the turn; the attach-time check never covered it, nor a
model switched after attaching. Gate before capture and again at send.

Only reach for the tool when the user raises how the app looks, rather than
after every UI edit.

A collapsed preview keeps the iframe mounted at zero width, passing the ready
checks and then failing inside the rasteriser as '[object Event]'. Name it.

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

* fix(ai-chat): hold sending while attachments decode

addImages read the free-slot count before its await and appended after it, so a
send during the ~50-800ms decode cleared images while the closure still wrote to
them, landing the picture on the following message; two drops also claimed the
same slots and could pass the cap. Reserve slots up front, block sending until
they resolve, and show a placeholder so the held send is explained.

Keep only a bounded copy in the transcript: displayMessages are never compacted
and are re-cloned on every save. Measured 6.1x smaller per attachment.

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

* fix(ai-chat): route screenshots to the visible tab, resend full-res on retry

Every mounted raw-app editor claimed the runtime's single screenshot slot, so
take_screenshot could capture a background tab's app; ownership now follows the
visible tab and only the owner releases it.

restartGeneration resent displayMessages' images, which became a 384px thumbnail
when the transcript copy was bounded — retries downgraded the model's own input.
Recover the sent parts from the API message instead.

Move modelSupportsVision to modelConfig: it was untestable behind lib.ts's monaco
import chain, and the denylist missed bundled text-only defaults (Groq/Together
Llama 3.3, Foundry Phi-4 and Mistral-Large). Llama 3.2 and Phi-4 split by variant,
so both are matched narrowly. Pinned against the shipped defaultModels.

Decode attachments one at a time and derive the preview from the bounded copy: a
12MP bitmap is ~48MB and the batch was held live at once, decoded twice each.

The attach tooltip claimed nothing is uploaded, which is untrue for images.

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

* fix(ai-chat): keep images out of text-only turns and bound the queue

The vision gate only dropped the current turn's images, so history's image parts
still went out after a switch to a text-only model and failed the request; strip
the outbound copy instead, leaving history intact for a switch back.

queueMessage had no cap, and each queued send clears the composer for another
eight, so repeated sends stacked an unbounded batch into one message.

Editing a message resent displayMessages' bounded copy, downgrading the model's
own input; retries recovered the full-size one but then re-persisted it at full
resolution. storedImages pairs the API message with its transcript entry so both
paths resend the original and re-persist the bounded copy.

Reserve image slots before awaiting text attachments: the gap left sending
enabled with an image pending, measured ~90ms for a 40-file drop, now ~8ms
regardless of batch size.

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

* fix(ai-chat): treat deepseek-v4 as text-only

deepseek-v4-pro ships as a bundled default and the gate let images through to
it, so an attachment would fail the turn. DeepSeek's vision line is deepseek-vl.

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

* fix(ai-chat): drop a rejected image instead of wedging the conversation

A provider that refuses an image leaves it in history, so every later turn
resends it and fails identically: the chat is stuck until the user edits the
message or starts over, and Retry re-sends the same image. The vision gate only
knows the models we ship, so this is the net for the rest. Strip the parts on an
image-related rejection and say so; unrelated failures keep the image.

Verified at the wire that no provider rejects a base64 data URL: anthropic
(source.base64), openai/gpt-4o (input_image), googleai and aws_bedrock/claude
(image_url passthrough) all 200 and read the image.

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

* fix(ai-chat): match text-only models exactly, from provider API docs

The gate guessed by substring over model names, which answers the wrong
question. What matters is whether a provider's API accepts image parts, not
whether the model can see: DeepSeek V4 ships vision in its chat product that its
API has no content type for, and o3-mini gained vision in ChatGPT the API never
exposed. Neither is inferable from a name.

Substrings also block working models. 'mistral-large' matches Mistral Large 3,
which takes images; 'phi-4' matches Phi-4-multimodal, which does too. A wrong
entry blocks with no override, while a missing one costs a turn and recovers via
the rejection path, so the list is now exact ids only, each backed by a provider
doc.

Verdicts verified against provider API docs rather than recall. Live-checked
where a doc was contradicted: Bedrock's compatibility matrix claims no Anthropic
model is served over chat completions, but it serves images fine.

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

* fix(ai-chat): stop retry resurrecting a rejected image

The rejection fallback strips the image from history but leaves the bubble's
thumbnail so the user can still see what they sent. storedImages fell back to
that thumbnail when the API message had no parts, so Retry re-attached the very
image the provider had just refused and failed identically — the conversation
stayed wedged through the one control offered to escape it.

Found by retrying in the UI; unit tests, wire tests and four review passes all
missed it, since it only exists between two separate fixes.

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

* fix(ai-chat): harden image rejection recovery and drop-path attachment

* fix(ai-chat): fix image drop race, mid-turn vision gate, retry aliasing

* fix(ai-chat): key vision denylist by provider, flatten alpha before jpeg

* feat(ai-chat): offer take_screenshot on chromium only, ask for one elsewhere

* feat(ai-chat): image-only sends and click-to-expand image previews

* fix(ai-chat): capture screenshots at 2x and expand tool images full-res

* feat(frontend): expandable image previews in composer and result views

* fix(ai-chat): image-only send edge cases from review round

* fix(ai-chat): keep image-only drafts on rollback, track failing model id

* fix(ai-chat): gate rejection recovery on the failing iteration's model

* refactor(ai-chat): record iteration model via onBeforeIteration, trim tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): restore composer draft when beforeSend preflight fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): bound cumulative outbound image bytes per request

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): make the image byte bound part-granular so over-cap turns keep a subset

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): evict newest-first within a message in the image byte bound

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): prune over-cap images from stored history, not just requests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): bound history at every save boundary, keep thumbnail pairing across eviction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): slot-align storedImages so the bubble expands the right image after eviction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): match rejection keywords as whole words so provisioning errors keep images

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): match input_image rejections, restore images refused by non-GLOBAL modes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): defer non-GLOBAL image refusal restore past the composer clear

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): persist full tool screenshots for post-reload expansion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ai-chat): persist chat images out-of-band via blob-store refs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): scope image blobs per chat and stop cap-eviction rotation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): keep blob-cap chronology across drop-oldest compaction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ai-chat): derive blob eviction from the saved record, not write times

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): serialize chat history DB writes per manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): pin queued history writes to the enqueue-time user database

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): delete stale image blobs only after the chat record commits

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): don't double-restore a queued image-only draft on vision refusal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): label image-only chats and evicted image-only bubbles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): keep the in-memory chat mirror hydrated for DB-less sessions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): converge the chat mirror to refs after a successful DB commit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): guard mirror convergence against rewinding newer saves

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): invalidate pending convergences on identity re-init, keep retry image names

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): bound the screenshot raster before rasterization

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ai-chat): drop the no-IndexedDB in-memory image fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 19:36:51 +02:00

2448 lines
67 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'
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
}
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 }
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
}
/**
* 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',
'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 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 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
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[]
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'
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('/')
// `_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
}