diff --git a/package-lock.json b/package-lock.json index 5a9b5d8c..9928e2f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "plugins/importer-postman", "plugins/importer-yaak", "plugins/template-function-cookie", + "plugins/template-function-datetime", "plugins/template-function-encode", "plugins/template-function-fs", "plugins/template-function-hash", @@ -4142,6 +4143,10 @@ "resolved": "plugins/template-function-cookie", "link": true }, + "node_modules/@yaak/template-function-datetime": { + "resolved": "plugins/template-function-datetime", + "link": true + }, "node_modules/@yaak/template-function-encode": { "resolved": "plugins/template-function-encode", "link": true @@ -18585,6 +18590,12 @@ "name": "@yaak/template-function-cookie", "version": "0.1.0" }, + "plugins/template-function-datetime": { + "version": "0.1.0", + "dependencies": { + "date-fns": "^4.1.0" + } + }, "plugins/template-function-encode": { "name": "@yaak/template-function-encode", "version": "0.1.0" diff --git a/package.json b/package.json index 898fcfa8..5b24aeb5 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "plugins/importer-postman", "plugins/importer-yaak", "plugins/template-function-cookie", + "plugins/template-function-datetime", "plugins/template-function-encode", "plugins/template-function-fs", "plugins/template-function-hash", diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index 31f17224..e48203b4 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -36,7 +36,7 @@ export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, }; -export type CallTemplateFunctionResponse = { value: string | null, }; +export type CallTemplateFunctionResponse = { value: string | null, error?: string, }; export type CloseWindowRequest = { label: string, }; diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index 7bebe633..7116878d 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -308,15 +308,27 @@ export class PluginInstance { const fn = this.#mod.templateFunctions.find((a) => a.name === payload.name); if (typeof fn?.onRender === 'function') { applyFormInputDefaults(fn.args, payload.args.values); - const result = await fn.onRender(ctx, payload.args); - this.#sendPayload( - windowContext, - { - type: 'call_template_function_response', - value: result ?? null, - }, - replyId, - ); + try { + const result = await fn.onRender(ctx, payload.args); + this.#sendPayload( + windowContext, + { + type: 'call_template_function_response', + value: result ?? null, + }, + replyId, + ); + } catch (err) { + this.#sendPayload( + windowContext, + { + type: 'call_template_function_response', + value: null, + error: `${err}`.replace(/^Error:\s*/g, ''), + }, + replyId, + ); + } return; } } diff --git a/plugins/template-function-datetime/package.json b/plugins/template-function-datetime/package.json new file mode 100755 index 00000000..f05e250a --- /dev/null +++ b/plugins/template-function-datetime/package.json @@ -0,0 +1,13 @@ +{ + "name": "@yaak/template-function-datetime", + "private": true, + "version": "0.1.0", + "scripts": { + "build": "yaakcli build", + "dev": "yaakcli dev", + "lint": "eslint . --ext .ts,.tsx" + }, + "dependencies": { + "date-fns": "^4.1.0" + } +} diff --git a/plugins/template-function-datetime/src/index.ts b/plugins/template-function-datetime/src/index.ts new file mode 100755 index 00000000..718c0236 --- /dev/null +++ b/plugins/template-function-datetime/src/index.ts @@ -0,0 +1,156 @@ +import type { TemplateFunctionArg } from '@yaakapp-internal/plugins'; +import type { PluginDefinition } from '@yaakapp/api'; + +import { + addDays, + addHours, + addMinutes, + addMonths, + addSeconds, + addYears, + format as formatDate, + isValid, + parseISO, + subDays, + subHours, + subMinutes, + subMonths, + subSeconds, + subYears, +} from 'date-fns'; + +const dateArg: TemplateFunctionArg = { + type: 'text', + name: 'date', + label: 'Timestamp', + optional: true, + defaultValue: '${[ timestamp.iso8601() ]}', + description: 'Can be a timestamp in milliseconds, ISO string, or anything parseable by JS `new Date()`', + placeholder: new Date().toISOString(), +}; + +const expressionArg: TemplateFunctionArg = { + type: 'text', + name: 'expression', + label: 'Expression', + description: "Modification expression (eg. '-5d +2h 3m'). Available units: y, M, d, h, m, s", + optional: true, + placeholder: '-5d +2h 3m', +}; + +const formatArg: TemplateFunctionArg = { + name: 'format', + label: 'Format String', + description: "Format string to describe the output (eg. 'yyyy-MM-dd at HH:mm:ss')", + optional: true, + placeholder: 'yyyy-MM-dd HH:mm:ss', + type: 'text', +}; + +export const plugin: PluginDefinition = { + templateFunctions: [ + { + name: 'timestamp.unix', + description: 'Get the current timestamp in seconds', + args: [], + onRender: async () => String(Math.floor(Date.now() / 1000)), + }, + { + name: 'timestamp.unixMillis', + description: 'Get the current timestamp in milliseconds', + args: [], + onRender: async () => String(Date.now()), + }, + { + name: 'timestamp.iso8601', + description: 'Get the current date in ISO8601 format', + args: [], + onRender: async () => new Date().toISOString(), + }, + { + name: 'timestamp.format', + description: 'Format a date using a dayjs-compatible format string', + args: [dateArg, formatArg], + onRender: async (_ctx, args) => formatDatetime(args.values), + }, + { + name: 'timestamp.offset', + description: 'Get the offset of a date based on an expression', + args: [dateArg, expressionArg], + onRender: async (_ctx, args) => calculateDatetime(args.values), + }, + ], +}; + +function applyDateOp(d: Date, sign: string, amount: number, unit: string): Date { + switch (unit) { + case 'y': + return sign === '-' ? subYears(d, amount) : addYears(d, amount); + case 'M': + return sign === '-' ? subMonths(d, amount) : addMonths(d, amount); + case 'd': + return sign === '-' ? subDays(d, amount) : addDays(d, amount); + case 'h': + return sign === '-' ? subHours(d, amount) : addHours(d, amount); + case 'm': + return sign === '-' ? subMinutes(d, amount) : addMinutes(d, amount); + case 's': + return sign === '-' ? subSeconds(d, amount) : addSeconds(d, amount); + default: + throw new Error(`Invalid data calculation unit: ${unit}`); + } +} + +function parseOp(op: string): { sign: string; amount: number; unit: string } | null { + const match = op.match(/^([+-]?)(\d+)([yMdhms])$/); + if (!match) { + throw new Error(`Invalid date expression: ${op}`); + } + const [, sign, amount, unit] = match; + if (!unit) return null; + return { sign: sign ?? '+', amount: Number(amount ?? 0), unit }; +} + +function parseDateString(date: string): Date { + if (!date.trim()) { + return new Date(); + } + + const isoDate = parseISO(date); + if (isValid(isoDate)) { + return isoDate; + } + + const jsDate = /^\d+(\.\d+)?$/.test(date) ? new Date(Number(date)) : new Date(date); + if (isValid(jsDate)) { + return jsDate; + } + + throw new Error(`Invalid date: ${date}`); +} + +export function calculateDatetime(args: { date?: string; expression?: string }): string { + const { date, expression } = args; + let jsDate = parseDateString(date ?? ''); + + if (expression) { + const ops = String(expression) + .split(' ') + .map((s) => s.trim()) + .filter(Boolean); + for (const op of ops) { + const parsed = parseOp(op); + if (parsed) { + jsDate = applyDateOp(jsDate, parsed.sign, parsed.amount, parsed.unit); + } + } + } + + return jsDate.toISOString(); +} + +export function formatDatetime(args: { date?: string; format?: string }): string { + const { date, format = 'yyyy-MM-dd HH:mm:ss' } = args; + const d = parseDateString(date ?? ''); + return formatDate(d, String(format)); +} diff --git a/plugins/template-function-datetime/tests/formatDatetime.test.ts b/plugins/template-function-datetime/tests/formatDatetime.test.ts new file mode 100644 index 00000000..549b574a --- /dev/null +++ b/plugins/template-function-datetime/tests/formatDatetime.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { calculateDatetime, formatDatetime } from '../src'; + +describe('formatDatetime', () => { + it('returns formatted current date', () => { + const result = formatDatetime({}); + expect(result).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); + }); + + it('returns formatted specific date', () => { + const result = formatDatetime({ date: '2025-07-13T12:34:56' }); + expect(result).toBe('2025-07-13 12:34:56'); + }); + + it('returns formatted specific timestamp', () => { + const result = formatDatetime({ date: '1752435296000' }); + expect(result).toBe('2025-07-13 12:34:56'); + }); + + it('returns formatted specific timestamp with decimals', () => { + const result = formatDatetime({ date: '1752435296000.19' }); + expect(result).toBe('2025-07-13 12:34:56'); + }); + + it('returns formatted date with custom output', () => { + const result = formatDatetime({ date: '2025-07-13T12:34:56', format: 'dd/MM/yyyy' }); + expect(result).toBe('13/07/2025'); + }); + + it('handles invalid date gracefully', () => { + expect(() => formatDatetime({ date: 'invalid-date' })).toThrow('Invalid date: invalid-date'); + }); +}); + +describe('calculateDatetime', () => { + it('returns ISO string for current date', () => { + const result = calculateDatetime({}); + expect(result).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it('returns ISO string for specific date', () => { + const result = calculateDatetime({ date: '2025-07-13T12:34:56Z' }); + expect(result).toBe('2025-07-13T12:34:56.000Z'); + }); + + it('applies calc operations', () => { + const result = calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '+1d 2h' }); + expect(result).toBe('2025-07-14T14:00:00.000Z'); + }); + + it('applies negative calc operations', () => { + const result = calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '-1d -2h 1m' }); + expect(result).toBe('2025-07-12T10:01:00.000Z'); + }); + + it('throws error for invalid unit', () => { + expect(() => calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '+1x' })).toThrow( + 'Invalid date expression: +1x', + ); + }); + it('throws error for invalid unit weird', () => { + expect(() => calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '+1&#^%' })).toThrow( + 'Invalid date expression: +1&#^%', + ); + }); + it('throws error for bad expression', () => { + expect(() => + calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: 'bad expr' }), + ).toThrow('Invalid date expression: bad'); + }); +}); diff --git a/plugins/template-function-datetime/tsconfig.json b/plugins/template-function-datetime/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/plugins/template-function-datetime/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/src-tauri/src/uri_scheme.rs b/src-tauri/src/uri_scheme.rs index fd4daaea..f9ee6c41 100644 --- a/src-tauri/src/uri_scheme.rs +++ b/src-tauri/src/uri_scheme.rs @@ -53,6 +53,7 @@ pub(crate) async fn handle_deep_link( "import-data" => { let mut file_path = query_map.get("path").map(|s| s.to_owned()); let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string()); + _ = window.set_focus(); if let Some(file_url) = query_map.get("url") { let confirmed_import = app_handle @@ -96,7 +97,6 @@ pub(crate) async fn handle_deep_link( }; let results = import_data(window, &file_path).await?; - _ = window.set_focus(); window.emit( "show_toast", ShowToastRequest { diff --git a/src-tauri/yaak-plugins/bindings/gen_events.ts b/src-tauri/yaak-plugins/bindings/gen_events.ts index 31f17224..e48203b4 100644 --- a/src-tauri/yaak-plugins/bindings/gen_events.ts +++ b/src-tauri/yaak-plugins/bindings/gen_events.ts @@ -36,7 +36,7 @@ export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, }; -export type CallTemplateFunctionResponse = { value: string | null, }; +export type CallTemplateFunctionResponse = { value: string | null, error?: string, }; export type CloseWindowRequest = { label: string, }; diff --git a/src-tauri/yaak-plugins/src/events.rs b/src-tauri/yaak-plugins/src/events.rs index 30fd1152..e9b5136d 100644 --- a/src-tauri/yaak-plugins/src/events.rs +++ b/src-tauri/yaak-plugins/src/events.rs @@ -974,6 +974,8 @@ pub struct CallTemplateFunctionRequest { #[ts(export, export_to = "gen_events.ts")] pub struct CallTemplateFunctionResponse { pub value: Option, + #[ts(optional)] + pub error: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] diff --git a/src-tauri/yaak-plugins/src/manager.rs b/src-tauri/yaak-plugins/src/manager.rs index e8231c90..44293af4 100644 --- a/src-tauri/yaak-plugins/src/manager.rs +++ b/src-tauri/yaak-plugins/src/manager.rs @@ -6,12 +6,13 @@ use crate::events::{ BootRequest, CallGrpcRequestActionRequest, CallHttpAuthenticationActionArgs, CallHttpAuthenticationActionRequest, CallHttpAuthenticationRequest, CallHttpAuthenticationResponse, CallHttpRequestActionRequest, CallTemplateFunctionArgs, - CallTemplateFunctionRequest, CallTemplateFunctionResponse, EmptyPayload, FilterRequest, - FilterResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigRequest, - GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, - GetHttpRequestActionsResponse, GetTemplateFunctionsResponse, GetThemesRequest, - GetThemesResponse, ImportRequest, ImportResponse, InternalEvent, InternalEventPayload, - JsonPrimitive, PluginWindowContext, RenderPurpose, + CallTemplateFunctionRequest, CallTemplateFunctionResponse, EmptyPayload, ErrorResponse, + FilterRequest, FilterResponse, GetGrpcRequestActionsResponse, + GetHttpAuthenticationConfigRequest, GetHttpAuthenticationConfigResponse, + GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, + GetTemplateFunctionsResponse, GetThemesRequest, GetThemesResponse, ImportRequest, + ImportResponse, InternalEvent, InternalEventPayload, JsonPrimitive, PluginWindowContext, + RenderPurpose, }; use crate::native_template_functions::template_function_secure; use crate::nodejs::start_nodejs_plugin_runtime; @@ -644,7 +645,7 @@ impl PluginManager { info!("Not applying disabled auth {:?}", auth_name); return Ok(CallHttpAuthenticationResponse { set_headers: None, - set_query_parameters: None + set_query_parameters: None, }); } @@ -689,16 +690,25 @@ impl PluginManager { .map_err(|e| RenderError(format!("Failed to call template function {e:}")))?; let value = events.into_iter().find_map(|e| match e.payload { + // Error returned + InternalEventPayload::CallTemplateFunctionResponse(CallTemplateFunctionResponse { + error: Some(error), + .. + }) => Some(Err(error)), + // Value or null returned InternalEventPayload::CallTemplateFunctionResponse(CallTemplateFunctionResponse { value, - }) => Some(value), + .. + }) => Some(Ok(value.unwrap_or_default())), + // Generic error returned + InternalEventPayload::ErrorResponse(ErrorResponse { error }) => Some(Err(error)), _ => None, }); match value { None => Err(RenderError(format!("Template function {fn_name}(…) not found "))), - Some(Some(v)) => Ok(v), // Plugin returned string - Some(None) => Ok("".to_string()), // Plugin returned null + Some(Ok(v)) => Ok(v), + Some(Err(e)) => Err(RenderError(e)), } } diff --git a/src-web/components/TemplateFunctionDialog.tsx b/src-web/components/TemplateFunctionDialog.tsx index 61408af9..1e388dc6 100644 --- a/src-web/components/TemplateFunctionDialog.tsx +++ b/src-web/components/TemplateFunctionDialog.tsx @@ -11,10 +11,10 @@ import { import { useToggle } from '../hooks/useToggle'; import { convertTemplateToInsecure } from '../lib/encryption'; import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption'; -import { Banner } from './core/Banner'; import { Button } from './core/Button'; import { IconButton } from './core/IconButton'; import { InlineCode } from './core/InlineCode'; +import { LoadingIcon } from './core/LoadingIcon'; import { PlainInput } from './core/PlainInput'; import { HStack, VStack } from './core/Stacks'; import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from './DynamicForm'; @@ -178,7 +178,10 @@ function InitializedTemplateFunctionDialog({ {enablePreview && ( -
Rendered Preview
+ + Rendered Preview + {rendered.isPending && } +
- {rendered.error || tagText.error ? ( - {`${rendered.error || tagText.error}`} - ) : ( - - {dataContainsSecrets && !showSecretsInPreview ? ( - - ------ sensitive values hidden ------ - - ) : tooLarge ? ( - 'too large to preview' - ) : ( - rendered.data || <>  - )} - - )} + + {rendered.error || tagText.error ? ( + + {`${rendered.error || tagText.error}`.replace(/^Render Error: /, '')} + + ) : dataContainsSecrets && !showSecretsInPreview ? ( + ------ sensitive values hidden ------ + ) : tooLarge ? ( + 'too large to preview' + ) : ( + rendered.data || <>  + )} +
)}
diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 989d6dac..7429a76e 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -279,7 +279,7 @@ export const Editor = forwardRef(function E const show = () => showDialog({ id: 'template-function-' + Math.random(), // Allow multiple at once - size: 'sm', + size: 'md', title: {fn.name}(…), description: fn.description, render: ({ hide }) => ( diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index 4e0b1b28..9320388c 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -142,7 +142,7 @@ export const Icon = memo(function Icon({ title={title} className={classNames( className, - !spin && 'transform-cpu', + !spin && 'transform-gpu', spin && 'animate-spin', 'flex-shrink-0', size === 'xl' && 'h-6 w-6', diff --git a/src-web/components/core/IconTooltip.tsx b/src-web/components/core/IconTooltip.tsx index c6072ec1..af921f52 100644 --- a/src-web/components/core/IconTooltip.tsx +++ b/src-web/components/core/IconTooltip.tsx @@ -9,6 +9,7 @@ type Props = Omit & { iconSize?: IconProps['size']; iconColor?: IconProps['color']; className?: string; + tabIndex?: number; }; export function IconTooltip({ diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx index 741b63ad..ec875cbf 100644 --- a/src-web/components/core/Input.tsx +++ b/src-web/components/core/Input.tsx @@ -519,7 +519,7 @@ function EncryptionInput({ color="danger" size={props.size} className="text-sm" - rightSlot={} + rightSlot={} onClick={() => { setupOrConfigureEncryption(); }} diff --git a/src-web/components/core/Label.tsx b/src-web/components/core/Label.tsx index 148a8fa6..13c2e90b 100644 --- a/src-web/components/core/Label.tsx +++ b/src-web/components/core/Label.tsx @@ -39,7 +39,7 @@ export function Label({ ({tag}) ))} - {help && } + {help && } ); } diff --git a/src-web/hooks/useRenderTemplate.ts b/src-web/hooks/useRenderTemplate.ts index e89bda0c..f6dec142 100644 --- a/src-web/hooks/useRenderTemplate.ts +++ b/src-web/hooks/useRenderTemplate.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useAtomValue } from 'jotai'; +import { minPromiseMillis } from '../lib/minPromiseMillis'; import { invokeCmd } from '../lib/tauri'; import { useActiveEnvironment } from './useActiveEnvironment'; import { activeWorkspaceIdAtom } from './useActiveWorkspace'; @@ -8,10 +9,9 @@ export function useRenderTemplate(template: string) { const workspaceId = useAtomValue(activeWorkspaceIdAtom) ?? 'n/a'; const environmentId = useActiveEnvironment()?.id ?? null; return useQuery({ - placeholderData: (prev) => prev, // Keep previous data on refetch refetchOnWindowFocus: false, queryKey: ['render_template', template, workspaceId, environmentId], - queryFn: () => renderTemplate({ template, workspaceId, environmentId }), + queryFn: () => minPromiseMillis(renderTemplate({ template, workspaceId, environmentId }), 200), }); } diff --git a/src-web/hooks/useTemplateTokensToString.ts b/src-web/hooks/useTemplateTokensToString.ts index 7d228fe1..d2e6f75d 100644 --- a/src-web/hooks/useTemplateTokensToString.ts +++ b/src-web/hooks/useTemplateTokensToString.ts @@ -4,7 +4,6 @@ import { invokeCmd } from '../lib/tauri'; export function useTemplateTokensToString(tokens: Tokens) { return useQuery({ - placeholderData: (prev) => prev, // Keep previous data on refetch refetchOnWindowFocus: false, queryKey: ['template_tokens_to_string', tokens], queryFn: () => templateTokensToString(tokens),