/** pi-ntfy template rendering. */ const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g; export type EscapeMode = "none" | "json" | "url"; function escapeValue(value: string, mode: EscapeMode): string { if (mode === "json") { const encoded = JSON.stringify(value); return encoded.slice(1, -1); } if (mode === "url") return encodeURIComponent(value); return value; } /** Replace `{{name}}` in `template`. Unknown names collapse to "". */ export function render( template: string, vars: Record, mode: EscapeMode = "none", ): string { return template.replace(PLACEHOLDER, (_match, name: string) => escapeValue(vars[name] ?? "", mode), ); } export type RenderValue = | string | number | boolean | null | RenderValue[] | { [key: string]: RenderValue }; /** Recursively render every string inside an object/array template. */ export function renderDeep( value: RenderValue, vars: Record, ): RenderValue { if (typeof value === "string") return render(value, vars); if (Array.isArray(value)) return value.map((item) => renderDeep(item, vars)); if (value && typeof value === "object") { const out: { [key: string]: RenderValue } = {}; for (const [key, item] of Object.entries(value)) { out[render(key, vars)] = renderDeep(item, vars); } return out; } return value; } /** Format a millisecond duration as `1h2m`, `3m12s` or `12s`. */ export function formatDuration(ms: number): string { const totalSeconds = Math.max(0, Math.round(ms / 1000)); if (totalSeconds < 60) return `${totalSeconds}s`; const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes < 60) return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`; const hours = Math.floor(minutes / 60); const restMinutes = minutes % 60; return restMinutes === 0 ? `${hours}h` : `${hours}h${restMinutes}m`; } /** First non-empty line, trimmed and capped — used for error messages. */ export function firstLine(text: string | undefined, max: number): string { if (!text) return ""; const line = text .split("\n") .map((l) => l.trim()) .find((l) => l.length > 0) ?? ""; return line.length > max ? `${line.slice(0, max - 1)}…` : line; } function pickString(source: unknown, key: string): string { if (!source || typeof source !== "object") return ""; const value = (source as Record)[key]; return typeof value === "string" ? value : ""; } /** * Turn a provider error into something worth reading on a phone. * * pi surfaces raw upstream bodies, e.g. * `401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}` * which becomes `401 authentication_error: invalid x-api-key`. */ export function summarizeError(raw: string | undefined, max: number): string { if (!raw) return ""; const text = raw.trim(); const braceIndex = text.indexOf("{"); if (braceIndex >= 0) { try { const parsed = JSON.parse(text.slice(braceIndex)) as Record; const nested = (parsed.error ?? parsed) as Record; const message = pickString(nested, "message") || pickString(parsed, "message"); const type = pickString(nested, "type") || pickString(nested, "code"); const detail = [type, message].filter((part) => part.length > 0).join(": "); if (detail) { const prefix = text.slice(0, braceIndex).trim(); const combined = prefix ? `${prefix} ${detail}` : detail; return combined.length > max ? `${combined.slice(0, max - 1)}…` : combined; } } catch { // not JSON after all — fall through to the plain first line } } return firstLine(text, max); }