/** * GoingsOn - Utility Functions * Common utilities used across the application */ (function() { 'use strict'; // ============ HTML Escaping ============ /** * Escape HTML special characters to prevent XSS * @param {string} text - Text to escape * @returns {string} - Escaped text safe for innerHTML */ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; } /** * Escape string for use in JavaScript attribute values (onclick, etc.) * Prevents XSS when IDs or other values are used in inline handlers * @param {string} str - String to escape * @returns {string} - Escaped string safe for JS attribute values */ function escapeAttr(str) { if (str == null) return ''; return String(str) .replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(/"/g, '\\"') .replace(/\n/g, '\\n') .replace(/\r/g, '\\r'); } /** * 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 ''; // Check if body contains HTML that needs stripping let text = body; if (body.includes('') || body.includes('$1' ); // Also detect bare URLs that weren't wrapped // Match URLs that aren't already inside an href or anchor tag escaped = escaped.replace( /(?)(https?:\/\/[^\s<>\[\]"]+)/g, '' ); // 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(``); 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 */ function parseNaturalDate(str) { if (!str) return null; const input = str.trim().toLowerCase(); if (!input) return null; // Already ISO format (YYYY-MM-DDTHH:MM or YYYY-MM-DD HH:MM) if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(str.trim())) { const d = new Date(str.trim()); if (!isNaN(d)) return toLocalISOString(d); } // YYYY-MM-DD with optional time const dateMatch = input.match(/^(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/); if (dateMatch) { const d = new Date(dateMatch[1] + 'T12:00:00'); if (!isNaN(d)) { if (dateMatch[2]) applyTime(d, dateMatch[2], dateMatch[3], dateMatch[4]); else d.setHours(9, 0, 0, 0); return toLocalISOString(d); } } const now = new Date(); // Relative: today, tomorrow, yesterday if (input === 'today') { now.setHours(9, 0, 0, 0); return toLocalISOString(now); } if (input === 'tomorrow') { now.setDate(now.getDate() + 1); now.setHours(9, 0, 0, 0); return toLocalISOString(now); } if (input === 'yesterday') { now.setDate(now.getDate() - 1); now.setHours(9, 0, 0, 0); return toLocalISOString(now); } // "next week" = next Monday 9am if (input === 'next week') { const daysUntilMon = (8 - now.getDay()) % 7 || 7; now.setDate(now.getDate() + daysUntilMon); now.setHours(9, 0, 0, 0); return toLocalISOString(now); } // "in N days" const inDaysMatch = input.match(/^in\s+(\d+)\s+days?$/); if (inDaysMatch) { now.setDate(now.getDate() + parseInt(inDaysMatch[1])); now.setHours(9, 0, 0, 0); return toLocalISOString(now); } // Weekday names: "friday", "next friday", "friday 3pm" const days = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday']; const dayMatch = input.match(/^(?:next\s+)?(sunday|monday|tuesday|wednesday|thursday|friday|saturday)(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/); if (dayMatch) { const targetDay = days.indexOf(dayMatch[1]); const isNext = input.startsWith('next'); let diff = targetDay - now.getDay(); if (diff <= 0 || isNext) diff += 7; if (isNext && diff <= 7) diff += 7; now.setDate(now.getDate() + diff); if (dayMatch[2]) applyTime(now, dayMatch[2], dayMatch[3], dayMatch[4]); else now.setHours(9, 0, 0, 0); return toLocalISOString(now); } // Month + day: "dec 25", "december 25", "jan 5 3pm" const months = ['january','february','march','april','may','june','july','august','september','october','november','december']; const monthAbbrs = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']; const monthMatch = input.match(/^([a-z]+)\s+(\d{1,2})(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/); if (monthMatch) { let monthIdx = monthAbbrs.indexOf(monthMatch[1]); if (monthIdx === -1) monthIdx = months.indexOf(monthMatch[1]); if (monthIdx !== -1) { const d = new Date(now.getFullYear(), monthIdx, parseInt(monthMatch[2])); if (d < now) d.setFullYear(d.getFullYear() + 1); if (monthMatch[3]) applyTime(d, monthMatch[3], monthMatch[4], monthMatch[5]); else d.setHours(9, 0, 0, 0); return toLocalISOString(d); } } return null; } function applyTime(date, hours, minutes, ampm) { let h = parseInt(hours); const m = minutes ? parseInt(minutes) : 0; if (ampm === 'pm' && h < 12) h += 12; if (ampm === 'am' && h === 12) h = 0; date.setHours(h, m, 0, 0); } // ============ 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 */ function dateParsePreview(value, previewEl) { if (!value || !value.trim()) { previewEl.textContent = ''; return; } const parsed = 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(--accent-primary)'; } 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(--text-secondary)'; } else { previewEl.textContent = ''; } } } // ============ Populate GoingsOn.utils Namespace ============ GoingsOn.utils = { // HTML escaping escapeHtml, escapeAttr, 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, }; })();