/** * GoingsOn - Utility Functions * Common utilities used across the application */ (function() { 'use strict'; // ============ HTML Escaping ============ // // The escaping primitives (the CHRONIC-XSS seal) live in js/escape.js as the // single source of truth, so the standalone compose window shares the exact same // implementation. utils.js re-exports them onto GoingsOn.utils; every existing // caller (GoingsOn.utils.escapeHtml / escapeAttrValue / escapeHandlerArg / // safeUrl) is unchanged. escape.js must load before utils.js. // // escapeHtml - text/innerHTML context (does NOT encode ") // escapeAttrValue - the ONLY escaper for a double-quoted attribute value // escapeHandlerArg - a value inside a quoted JS string within an inline handler // safeUrl - allow only http/https/mailto schemes in an href const { escapeHtml, escapeAttrValue, escapeHandlerArg, safeUrl } = GoingsOn.escape; /** * Human-friendly prefixes for machine-readable API error codes. * Backend sends structured ApiError { code, message, details }. */ const ERROR_CODE_LABELS = { NOT_FOUND: 'Not found', VALIDATION_ERROR: 'Invalid input', DATABASE_ERROR: 'Database error', BAD_REQUEST: 'Bad request', AUTH_ERROR: 'Authentication failed', PARSE_ERROR: 'Could not parse input', INTERNAL_ERROR: 'Something went wrong', CONFLICT: 'Conflict', EXTERNAL_SERVICE_ERROR: 'Service error', }; /** * Actionable hints per error code — helps users understand what to do next. */ const ERROR_CODE_HINTS = { VALIDATION_ERROR: 'Check that all required fields are filled in correctly.', AUTH_ERROR: 'Check your credentials or reconnect your account in Settings.', PARSE_ERROR: 'Try a simpler format — e.g. "tomorrow 3pm" or "2026-12-25".', EXTERNAL_SERVICE_ERROR: 'The remote service may be temporarily unavailable. Try again in a moment.', CONFLICT: 'This item was modified elsewhere. Reload and try again.', }; /** * Extract error message from various error types. * Handles: plain strings, Error objects, and structured ApiError objects * from the Tauri backend ({ code, message, details }). * @param {Error|string|object} err - Error object or string * @param {string} fallback - Fallback message if extraction fails * @returns {string} - Human-readable error message */ function getErrorMessage(err, fallback) { // Tauri returns errors as strings (legacy) or JSON strings (ApiError) if (typeof err === 'string') { // Try to parse as JSON ApiError try { const parsed = JSON.parse(err); if (parsed && parsed.code && parsed.message) { return humanizeApiError(parsed); } } catch (_) { /* not JSON, use as-is */ } return err; } // Structured ApiError object (code + message) if (err && err.code && err.message && typeof err.code === 'string') { return humanizeApiError(err); } // Standard Error object if (err && err.message) return err.message; return fallback || 'An error occurred'; } /** * Convert a structured ApiError into a user-friendly string. * Strips internal prefixes like "Failed to ..." and UUID resource IDs. * Appends actionable hints when available. * @param {{code: string, message: string, details?: object}} apiErr * @returns {string} */ function humanizeApiError(apiErr) { const label = ERROR_CODE_LABELS[apiErr.code]; const hint = ERROR_CODE_HINTS[apiErr.code]; let msg = apiErr.message; // Strip UUID suffixes from not-found messages (e.g. "task not found: 550e8400-...") if (apiErr.code === 'NOT_FOUND' && apiErr.details?.resource) { const resource = apiErr.details.resource; const capitalized = resource.charAt(0).toUpperCase() + resource.slice(1); return `${capitalized} not found`; } // For database/internal errors, hide the raw detail and show the friendly label if (apiErr.code === 'DATABASE_ERROR' || apiErr.code === 'INTERNAL_ERROR') { return label || msg; } let result = label ? `${label}: ${msg}` : msg; if (hint) result += ` ${hint}`; return result; } // ============ Input Validation ============ /** * Validation rules for form inputs */ const ValidationRules = { // Text input limits NAME_MAX: 100, DESCRIPTION_MAX: 500, TITLE_MAX: 200, EMAIL_SUBJECT_MAX: 200, SEARCH_MAX: 200, TAG_MAX: 50, LOCATION_MAX: 200, // Patterns EMAIL_PATTERN: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, TAG_PATTERN: /^[a-zA-Z0-9_-]+$/, }; /** * Add validation attributes to a dynamically created input * @param {string} type - Input type: 'name', 'description', 'title', 'email', 'tags', 'location' * @returns {string} - HTML attributes string */ function getValidationAttrs(type) { switch (type) { case 'name': return `maxlength="${ValidationRules.NAME_MAX}" required`; case 'description': return `maxlength="${ValidationRules.DESCRIPTION_MAX}"`; case 'title': return `maxlength="${ValidationRules.TITLE_MAX}" required`; case 'email': return `type="email" maxlength="${ValidationRules.EMAIL_SUBJECT_MAX}"`; case 'tags': return `maxlength="${ValidationRules.TAG_MAX * 10}" pattern="[a-zA-Z0-9_,\\s-]*" title="Tags should be comma-separated words"`; case 'location': return `maxlength="${ValidationRules.LOCATION_MAX}"`; default: return ''; } } /** * Validate a string against a maximum length * @param {string} value - Value to validate * @param {number} maxLength - Maximum allowed length * @returns {boolean} - True if valid */ function validateLength(value, maxLength) { return !value || value.length <= maxLength; } /** * Validate an email address * @param {string} email - Email to validate * @returns {boolean} - True if valid */ function validateEmail(email) { return !email || ValidationRules.EMAIL_PATTERN.test(email); } /** * Show validation error on a form field * @param {HTMLElement} input - The input element * @param {string} message - Error message to display */ function showFieldError(input, message) { input.setAttribute('aria-invalid', 'true'); // Find or create error element let errorEl = input.parentElement.querySelector('.form-error'); if (!errorEl) { errorEl = document.createElement('div'); errorEl.className = 'form-error'; errorEl.id = `${input.id || input.name}-error`; input.parentElement.appendChild(errorEl); } errorEl.textContent = message; errorEl.classList.add('visible'); input.setAttribute('aria-describedby', errorEl.id); } /** * Clear validation error on a form field * @param {HTMLElement} input - The input element */ function clearFieldError(input) { input.setAttribute('aria-invalid', 'false'); input.removeAttribute('aria-describedby'); const errorEl = input.parentElement.querySelector('.form-error'); if (errorEl) { errorEl.classList.remove('visible'); } } /** * Clear all validation errors in a form * @param {HTMLFormElement} form - The form element */ function clearAllFieldErrors(form) { form.querySelectorAll('[aria-invalid="true"]').forEach(input => { clearFieldError(input); }); } /** * Validate a form and show inline errors * @param {HTMLFormElement} form - The form to validate * @returns {boolean} - True if all fields are valid */ function validateForm(form) { let isValid = true; // Clear previous errors form.querySelectorAll('[aria-invalid]').forEach(input => { clearFieldError(input); }); let firstInvalidInput = null; // Validate required fields form.querySelectorAll('[required]').forEach(input => { if (!input.value.trim()) { showFieldError(input, 'This field is required'); isValid = false; if (!firstInvalidInput) firstInvalidInput = input; } }); // Validate email fields form.querySelectorAll('input[type="email"]').forEach(input => { if (input.value && !validateEmail(input.value)) { showFieldError(input, 'Please enter a valid email address (e.g. name@example.com)'); isValid = false; if (!firstInvalidInput) firstInvalidInput = input; } }); // Validate maxlength (for browsers that don't enforce it) form.querySelectorAll('[maxlength]').forEach(input => { const maxLength = parseInt(input.getAttribute('maxlength')); if (input.value.length > maxLength) { showFieldError(input, `Maximum ${maxLength} characters (currently ${input.value.length})`); isValid = false; if (!firstInvalidInput) firstInvalidInput = input; } }); // Scroll to and focus first invalid field if (firstInvalidInput) { firstInvalidInput.scrollIntoView({ behavior: 'smooth', block: 'center' }); firstInvalidInput.focus(); } return isValid; } // ============ Email Reader Mode ============ /** * Format email body for reader mode display. * - Strips HTML if present (for emails that weren't processed by backend) * - Escapes remaining HTML for XSS protection * - Converts extracted links in [url] format to clickable links * - Detects and styles quoted text (lines starting with >) * @param {string} body - Raw email body text (may contain HTML) * @returns {string} - HTML-safe formatted body */ function formatEmailBody(body) { if (!body) return ''; // Strip HTML for reader mode if the body contains any tag. (Detect any // `` rather than a hand-maintained denylist that missed e.g. /; // escapeHtml below is the actual XSS defense and runs unconditionally either // way, so this only affects readability — ultra-fuzz Run #28.) let text = body; if (/<[a-z][a-z0-9]*\b[^>]*>/i.test(body)) { text = stripHtmlForReaderMode(body); } // Escape any remaining HTML for XSS protection let escaped = escapeHtml(text); // Convert [url] patterns to clickable links, then bare URLs. Both routes // share one anchor builder so their char classes and escaping can't drift // apart again (the bracketed matcher previously used `[^\]]+`, which let `"` // and spaces through and broke out of the href attribute — a sibling of the // Run #28 bare-URL hardening that this consolidation closes for good). // `url` is already escapeHtml'd (safe as link text); the href is run through // safeUrl (scheme allow-list) then escapeAttrValue (encodes `"`/`&`). const linkAnchor = (_match, url) => `${url}`; // Pattern: text [https://...] or text [http://...] escaped = escaped.replace(/\[((https?:\/\/)[^\]\s"]+)\]/g, linkAnchor); // Also detect bare URLs that weren't wrapped (not already inside an href/anchor) escaped = escaped.replace(/(?)(https?:\/\/[^\s<>\[\]"]+)/g, linkAnchor); // Collapse quoted blocks (> lines and "On ... wrote:" attribution) const lines = escaped.split('\n'); const result = []; let i = 0; while (i < lines.length) { const trimmed = lines[i].trimStart(); const isQuote = trimmed.startsWith('>') || trimmed.startsWith('>'); // Check for "On ... wrote:" attribution line followed by quotes const isAttribution = /^On .+ wrote:$/.test(trimmed); if (isAttribution || isQuote) { // Collect the full quoted block const quoteLines = []; if (isAttribution) { quoteLines.push(lines[i]); i++; } while (i < lines.length) { const t = lines[i].trimStart(); if (t.startsWith('>') || t.startsWith('>') || t === '') { quoteLines.push(lines[i]); i++; // Allow blank lines within quotes but stop at two+ consecutive blanks if (t === '' && i < lines.length) { const next = lines[i].trimStart(); if (!next.startsWith('>') && !next.startsWith('>') && next !== '') { break; } } } else { break; } } // Trim trailing blank lines from the block while (quoteLines.length > 0 && quoteLines[quoteLines.length - 1].trim() === '') { quoteLines.pop(); } if (quoteLines.length > 0) { const id = 'quote-' + Math.random().toString(36).slice(2, 8); result.push(`
··· Show quoted text
`); result.push(``); } } else { result.push(lines[i]); i++; } } return result.join('\n'); } /** * Strip HTML tags and convert to readable plain text. * Similar to backend strip_html but for client-side fallback. * @param {string} html - HTML content * @returns {string} - Plain text */ function stripHtmlForReaderMode(html) { // Use DOMParser to safely parse HTML without executing event handlers // (unlike innerHTML, DOMParser does not fire onerror, onload, etc.) const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const temp = doc.body; // Remove script and style elements const scripts = temp.querySelectorAll('script, style, head'); scripts.forEach(el => el.remove()); // Convert links to "text [url]" format before extracting text const links = temp.querySelectorAll('a[href]'); links.forEach(link => { const href = link.getAttribute('href'); const text = link.textContent.trim(); if (href && !href.startsWith('#') && !href.startsWith('javascript:')) { // Only add URL if it's different from the text if (href !== text && !text.includes(href)) { link.textContent = `${text} [${href}]`; } } }); // Convert
and block elements to newlines temp.querySelectorAll('br').forEach(br => br.replaceWith('\n')); temp.querySelectorAll('p, div, tr, li, h1, h2, h3, h4, h5, h6').forEach(el => { el.prepend(document.createTextNode('\n')); el.append(document.createTextNode('\n')); }); // Convert list items to bullets temp.querySelectorAll('li').forEach(li => { li.prepend(document.createTextNode('• ')); }); // Get text content let text = temp.textContent || temp.innerText || ''; // Clean up whitespace text = text .replace(/\r\n/g, '\n') // Normalize line endings .replace(/\n{3,}/g, '\n\n') // Max 2 consecutive newlines .replace(/[ \t]+/g, ' ') // Collapse spaces .replace(/^ +| +$/gm, '') // Trim each line .trim(); return text; } // ============ Debounce ============ /** * Create a debounced version of a function that delays execution * until after the specified wait time has elapsed since the last call. * @param {Function} fn - Function to debounce * @param {number} wait - Milliseconds to wait (default: 500) * @returns {Function} - Debounced function */ function debounce(fn, wait = 500) { let timeoutId = null; return function(...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn.apply(this, args), wait); }; } // ============ Error Display ============ /** * Display an error message in a container element * @param {HTMLElement} container - DOM element to display error in * @param {Error|string|object} err - Error object or string * @param {string} fallback - Fallback message if extraction fails */ function showError(container, err, fallback) { const msg = getErrorMessage(err, fallback); container.innerHTML = `
${escapeHtml(msg)}
`; } // ============ Email Address Parsing ============ /** * Parse an email address from various formats: * - "Jane Smith " * - "" * - "jane@example.com" * @param {string} from - Raw email address string * @returns {{ name: string|null, email: string|null }} - Parsed name and email */ function parseEmailAddress(from) { if (!from) return { name: null, email: null }; // Match "Name " or "" const match = from.match(/^(?:"?([^"<]*?)"?\s*)?<([^>]+)>$/); if (match) { return { name: match[1]?.trim() || null, email: match[2]?.trim() || null, }; } // Plain email address const trimmed = from.trim(); if (trimmed.includes('@')) { return { name: null, email: trimmed }; } return { name: trimmed || null, email: null }; } // ============ Date Formatting ============ /** * Format a Date as YYYY-MM-DD for API calls. * @param {Date} date - Date to format * @returns {string} - Date string in YYYY-MM-DD format */ function formatDateForApi(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** * Convert a Date to a local ISO string (YYYY-MM-DDTHH:MM) for datetime-local inputs. * Accounts for timezone offset so the displayed time matches local time. * @param {Date} date - Date to convert * @returns {string} - Local ISO string (e.g., "2026-04-06T14:30") */ function toLocalISOString(date) { return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); } /** * Format a Date as a human-readable display string. * @param {Date} date - Date to format * @returns {string} - Localized date string (e.g., "Monday, April 15, 2026") */ function formatDateDisplay(date) { const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }; return date.toLocaleDateString(undefined, options); } // ============ Auto-Grow Textareas ============ /** * Make a textarea auto-grow to fit its content. * Sets height to scrollHeight on each input event. * Call once after the textarea is in the DOM. * @param {HTMLTextAreaElement} textarea - The textarea element */ function autoGrow(textarea) { if (!textarea) return; function resize() { textarea.style.height = 'auto'; textarea.style.height = textarea.scrollHeight + 'px'; } textarea.addEventListener('input', resize); // Initial size resize(); } // ============ Natural Date Parsing ============ /** * Parse natural language date expressions into YYYY-MM-DDTHH:MM format. * Accepts: "today", "tomorrow", "yesterday", "next monday", "friday", * "friday 3pm", "next week", "in 3 days", "dec 25", "2026-12-25", * "2026-12-25 3pm", ISO format. * @param {string} str - Natural date string * @returns {string|null} - ISO datetime string or null if unparseable */ async function parseNaturalDate(str) { if (!str || !str.trim()) return null; try { // The Rust `parse_natural_date` command is the single source of truth // for the grammar; returns "YYYY-MM-DDTHH:MM" or null. return await GoingsOn.api.app.parseNaturalDate(str); } catch (e) { return null; } } // ============ Tag Normalization ============ /** * Normalize a comma-separated tag string. * Splits on commas, trims whitespace, lowercases, filters empty, deduplicates. * @param {string} tagString - Raw tag string * @returns {string[]} - Array of clean tags */ function normalizeTags(tagString) { if (!tagString) return []; const seen = new Set(); return tagString.split(',') .map(t => t.trim().toLowerCase()) .filter(t => { if (!t || seen.has(t)) return false; seen.add(t); return true; }); } // ============ Date Parse Preview ============ /** * Live preview callback for natural language date fields. * Use as onInput in form field definitions. * @param {string} value - Current input value * @param {HTMLElement} previewEl - Element to show parsed result */ async function dateParsePreview(value, previewEl) { if (!value || !value.trim()) { previewEl.textContent = ''; return; } const parsed = await parseNaturalDate(value); if (parsed) { const d = new Date(parsed); const display = d.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', }); previewEl.textContent = display; previewEl.style.color = 'var(--action)'; } else { // Only show "not recognized" if it doesn't look like an ISO date being typed if (value.trim().length > 2) { previewEl.textContent = 'Date not recognized'; previewEl.style.color = 'var(--content-secondary)'; } else { previewEl.textContent = ''; } } } // ============ Populate GoingsOn.utils Namespace ============ /** Toggle a quoted-email block's visibility and swap the trigger's label. * Target block id is on `data-target`; replaces the old inline handler. */ function toggleQuoted(el) { const block = document.getElementById(el.dataset.target); if (block) block.classList.toggle('hidden'); el.textContent = el.textContent === '··· Show quoted text' ? '··· Hide quoted text' : '··· Show quoted text'; } GoingsOn.utils = { toggleQuoted, // HTML escaping. escapeJsString is intentionally NOT exported: the only // sound attribute escapers are escapeAttrValue (plain attrs) and // escapeHandlerArg (a value inside a quoted JS string in an inline handler). escapeHtml, escapeAttrValue, escapeHandlerArg, safeUrl, getErrorMessage, showError, // Email formatting formatEmailBody, // Date formatting formatDateForApi, formatDateDisplay, toLocalISOString, // Natural date parsing parseNaturalDate, dateParsePreview, // Tag normalization normalizeTags, // Debounce debounce, // Email address parsing parseEmailAddress, // Auto-grow autoGrow, // Validation ValidationRules, getValidationAttrs, validateLength, validateEmail, showFieldError, clearFieldError, clearAllFieldErrors, validateForm, }; })();