mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
* 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>
2448 lines
67 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
export function validateUsername(username: string): string {
|
|
if (username != '' && !/^[a-zA-Z]\w+$/.test(username)) {
|
|
return 'username can only contain letters and numbers and must start with a letter'
|
|
} else {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export function parseQueryParams(url: string | undefined) {
|
|
if (!url) return {}
|
|
const index = url.indexOf('?')
|
|
if (index == -1) return {}
|
|
const paramArr = url?.slice(index + 1)?.split('&')
|
|
const params: Record<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
|
|
}
|