Skip to main content

max / goingson

22.3 KB · 674 lines History Blame Raw
1 /**
2 * GoingsOn - Utility Functions
3 * Common utilities used across the application
4 */
5
6 (function() {
7 'use strict';
8
9 // ============ HTML Escaping ============
10 //
11 // The escaping primitives (the CHRONIC-XSS seal) live in js/escape.js as the
12 // single source of truth, so the standalone compose window shares the exact same
13 // implementation. utils.js re-exports them onto GoingsOn.utils; every existing
14 // caller (GoingsOn.utils.escapeHtml / escapeAttrValue / escapeHandlerArg /
15 // safeUrl) is unchanged. escape.js must load before utils.js.
16 //
17 // escapeHtml - text/innerHTML context (does NOT encode ")
18 // escapeAttrValue - the ONLY escaper for a double-quoted attribute value
19 // escapeHandlerArg - a value inside a quoted JS string within an inline handler
20 // safeUrl - allow only http/https/mailto schemes in an href
21 const { escapeHtml, escapeAttrValue, escapeHandlerArg, safeUrl } = GoingsOn.escape;
22
23 /**
24 * Human-friendly prefixes for machine-readable API error codes.
25 * Backend sends structured ApiError { code, message, details }.
26 */
27 const ERROR_CODE_LABELS = {
28 NOT_FOUND: 'Not found',
29 VALIDATION_ERROR: 'Invalid input',
30 DATABASE_ERROR: 'Database error',
31 BAD_REQUEST: 'Bad request',
32 AUTH_ERROR: 'Authentication failed',
33 PARSE_ERROR: 'Could not parse input',
34 INTERNAL_ERROR: 'Something went wrong',
35 CONFLICT: 'Conflict',
36 EXTERNAL_SERVICE_ERROR: 'Service error',
37 };
38
39 /**
40 * Actionable hints per error code — helps users understand what to do next.
41 */
42 const ERROR_CODE_HINTS = {
43 VALIDATION_ERROR: 'Check that all required fields are filled in correctly.',
44 AUTH_ERROR: 'Check your credentials or reconnect your account in Settings.',
45 PARSE_ERROR: 'Try a simpler format — e.g. "tomorrow 3pm" or "2026-12-25".',
46 EXTERNAL_SERVICE_ERROR: 'The remote service may be temporarily unavailable. Try again in a moment.',
47 CONFLICT: 'This item was modified elsewhere. Reload and try again.',
48 };
49
50 /**
51 * Extract error message from various error types.
52 * Handles: plain strings, Error objects, and structured ApiError objects
53 * from the Tauri backend ({ code, message, details }).
54 * @param {Error|string|object} err - Error object or string
55 * @param {string} fallback - Fallback message if extraction fails
56 * @returns {string} - Human-readable error message
57 */
58 function getErrorMessage(err, fallback) {
59 // Tauri returns errors as strings (legacy) or JSON strings (ApiError)
60 if (typeof err === 'string') {
61 // Try to parse as JSON ApiError
62 try {
63 const parsed = JSON.parse(err);
64 if (parsed && parsed.code && parsed.message) {
65 return humanizeApiError(parsed);
66 }
67 } catch (_) { /* not JSON, use as-is */ }
68 return err;
69 }
70
71 // Structured ApiError object (code + message)
72 if (err && err.code && err.message && typeof err.code === 'string') {
73 return humanizeApiError(err);
74 }
75
76 // Standard Error object
77 if (err && err.message) return err.message;
78
79 return fallback || 'An error occurred';
80 }
81
82 /**
83 * Convert a structured ApiError into a user-friendly string.
84 * Strips internal prefixes like "Failed to ..." and UUID resource IDs.
85 * Appends actionable hints when available.
86 * @param {{code: string, message: string, details?: object}} apiErr
87 * @returns {string}
88 */
89 function humanizeApiError(apiErr) {
90 const label = ERROR_CODE_LABELS[apiErr.code];
91 const hint = ERROR_CODE_HINTS[apiErr.code];
92 let msg = apiErr.message;
93
94 // Strip UUID suffixes from not-found messages (e.g. "task not found: 550e8400-...")
95 if (apiErr.code === 'NOT_FOUND' && apiErr.details?.resource) {
96 const resource = apiErr.details.resource;
97 const capitalized = resource.charAt(0).toUpperCase() + resource.slice(1);
98 return `${capitalized} not found`;
99 }
100
101 // For database/internal errors, hide the raw detail and show the friendly label
102 if (apiErr.code === 'DATABASE_ERROR' || apiErr.code === 'INTERNAL_ERROR') {
103 return label || msg;
104 }
105
106 let result = label ? `${label}: ${msg}` : msg;
107 if (hint) result += ` ${hint}`;
108 return result;
109 }
110
111 // ============ Input Validation ============
112
113 /**
114 * Validation rules for form inputs
115 */
116 const ValidationRules = {
117 // Text input limits
118 NAME_MAX: 100,
119 DESCRIPTION_MAX: 500,
120 TITLE_MAX: 200,
121 EMAIL_SUBJECT_MAX: 200,
122 SEARCH_MAX: 200,
123 TAG_MAX: 50,
124 LOCATION_MAX: 200,
125
126 // Patterns
127 EMAIL_PATTERN: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
128 TAG_PATTERN: /^[a-zA-Z0-9_-]+$/,
129 };
130
131 /**
132 * Add validation attributes to a dynamically created input
133 * @param {string} type - Input type: 'name', 'description', 'title', 'email', 'tags', 'location'
134 * @returns {string} - HTML attributes string
135 */
136 function getValidationAttrs(type) {
137 switch (type) {
138 case 'name':
139 return `maxlength="${ValidationRules.NAME_MAX}" required`;
140 case 'description':
141 return `maxlength="${ValidationRules.DESCRIPTION_MAX}"`;
142 case 'title':
143 return `maxlength="${ValidationRules.TITLE_MAX}" required`;
144 case 'email':
145 return `type="email" maxlength="${ValidationRules.EMAIL_SUBJECT_MAX}"`;
146 case 'tags':
147 return `maxlength="${ValidationRules.TAG_MAX * 10}" pattern="[a-zA-Z0-9_,\\s-]*" title="Tags should be comma-separated words"`;
148 case 'location':
149 return `maxlength="${ValidationRules.LOCATION_MAX}"`;
150 default:
151 return '';
152 }
153 }
154
155 /**
156 * Validate a string against a maximum length
157 * @param {string} value - Value to validate
158 * @param {number} maxLength - Maximum allowed length
159 * @returns {boolean} - True if valid
160 */
161 function validateLength(value, maxLength) {
162 return !value || value.length <= maxLength;
163 }
164
165 /**
166 * Validate an email address
167 * @param {string} email - Email to validate
168 * @returns {boolean} - True if valid
169 */
170 function validateEmail(email) {
171 return !email || ValidationRules.EMAIL_PATTERN.test(email);
172 }
173
174 /**
175 * Show validation error on a form field
176 * @param {HTMLElement} input - The input element
177 * @param {string} message - Error message to display
178 */
179 function showFieldError(input, message) {
180 input.setAttribute('aria-invalid', 'true');
181
182 // Find or create error element
183 let errorEl = input.parentElement.querySelector('.form-error');
184 if (!errorEl) {
185 errorEl = document.createElement('div');
186 errorEl.className = 'form-error';
187 errorEl.id = `${input.id || input.name}-error`;
188 input.parentElement.appendChild(errorEl);
189 }
190
191 errorEl.textContent = message;
192 errorEl.classList.add('visible');
193 input.setAttribute('aria-describedby', errorEl.id);
194 }
195
196 /**
197 * Clear validation error on a form field
198 * @param {HTMLElement} input - The input element
199 */
200 function clearFieldError(input) {
201 input.setAttribute('aria-invalid', 'false');
202 input.removeAttribute('aria-describedby');
203
204 const errorEl = input.parentElement.querySelector('.form-error');
205 if (errorEl) {
206 errorEl.classList.remove('visible');
207 }
208 }
209
210 /**
211 * Clear all validation errors in a form
212 * @param {HTMLFormElement} form - The form element
213 */
214 function clearAllFieldErrors(form) {
215 form.querySelectorAll('[aria-invalid="true"]').forEach(input => {
216 clearFieldError(input);
217 });
218 }
219
220 /**
221 * Validate a form and show inline errors
222 * @param {HTMLFormElement} form - The form to validate
223 * @returns {boolean} - True if all fields are valid
224 */
225 function validateForm(form) {
226 let isValid = true;
227
228 // Clear previous errors
229 form.querySelectorAll('[aria-invalid]').forEach(input => {
230 clearFieldError(input);
231 });
232
233 let firstInvalidInput = null;
234
235 // Validate required fields
236 form.querySelectorAll('[required]').forEach(input => {
237 if (!input.value.trim()) {
238 showFieldError(input, 'This field is required');
239 isValid = false;
240 if (!firstInvalidInput) firstInvalidInput = input;
241 }
242 });
243
244 // Validate email fields
245 form.querySelectorAll('input[type="email"]').forEach(input => {
246 if (input.value && !validateEmail(input.value)) {
247 showFieldError(input, 'Please enter a valid email address (e.g. name@example.com)');
248 isValid = false;
249 if (!firstInvalidInput) firstInvalidInput = input;
250 }
251 });
252
253 // Validate maxlength (for browsers that don't enforce it)
254 form.querySelectorAll('[maxlength]').forEach(input => {
255 const maxLength = parseInt(input.getAttribute('maxlength'));
256 if (input.value.length > maxLength) {
257 showFieldError(input, `Maximum ${maxLength} characters (currently ${input.value.length})`);
258 isValid = false;
259 if (!firstInvalidInput) firstInvalidInput = input;
260 }
261 });
262
263 // Scroll to and focus first invalid field
264 if (firstInvalidInput) {
265 firstInvalidInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
266 firstInvalidInput.focus();
267 }
268
269 return isValid;
270 }
271
272 // ============ Email Reader Mode ============
273
274 /**
275 * Format email body for reader mode display.
276 * - Strips HTML if present (for emails that weren't processed by backend)
277 * - Escapes remaining HTML for XSS protection
278 * - Converts extracted links in [url] format to clickable links
279 * - Detects and styles quoted text (lines starting with >)
280 * @param {string} body - Raw email body text (may contain HTML)
281 * @returns {string} - HTML-safe formatted body
282 */
283 function formatEmailBody(body) {
284 if (!body) return '';
285
286 // Strip HTML for reader mode if the body contains any tag. (Detect any
287 // `<tag …>` rather than a hand-maintained denylist that missed e.g. <img>/<a>;
288 // escapeHtml below is the actual XSS defense and runs unconditionally either
289 // way, so this only affects readability — ultra-fuzz Run #28.)
290 let text = body;
291 if (/<[a-z][a-z0-9]*\b[^>]*>/i.test(body)) {
292 text = stripHtmlForReaderMode(body);
293 }
294
295 // Escape any remaining HTML for XSS protection
296 let escaped = escapeHtml(text);
297
298 // Convert [url] patterns to clickable links, then bare URLs. Both routes
299 // share one anchor builder so their char classes and escaping can't drift
300 // apart again (the bracketed matcher previously used `[^\]]+`, which let `"`
301 // and spaces through and broke out of the href attribute — a sibling of the
302 // Run #28 bare-URL hardening that this consolidation closes for good).
303 // `url` is already escapeHtml'd (safe as link text); the href is run through
304 // safeUrl (scheme allow-list) then escapeAttrValue (encodes `"`/`&`).
305 const linkAnchor = (_match, url) =>
306 `<a href="${escapeAttrValue(safeUrl(url))}" class="email-link" target="_blank" rel="noopener noreferrer">${url}</a>`;
307
308 // Pattern: text [https://...] or text [http://...]
309 escaped = escaped.replace(/\[((https?:\/\/)[^\]\s"]+)\]/g, linkAnchor);
310
311 // Also detect bare URLs that weren't wrapped (not already inside an href/anchor)
312 escaped = escaped.replace(/(?<!href="|">)(https?:\/\/[^\s<>\[\]"]+)/g, linkAnchor);
313
314 // Collapse quoted blocks (> lines and "On ... wrote:" attribution)
315 const lines = escaped.split('\n');
316 const result = [];
317 let i = 0;
318
319 while (i < lines.length) {
320 const trimmed = lines[i].trimStart();
321 const isQuote = trimmed.startsWith('&gt;') || trimmed.startsWith('>');
322
323 // Check for "On ... wrote:" attribution line followed by quotes
324 const isAttribution = /^On .+ wrote:$/.test(trimmed);
325
326 if (isAttribution || isQuote) {
327 // Collect the full quoted block
328 const quoteLines = [];
329 if (isAttribution) {
330 quoteLines.push(lines[i]);
331 i++;
332 }
333 while (i < lines.length) {
334 const t = lines[i].trimStart();
335 if (t.startsWith('&gt;') || t.startsWith('>') || t === '') {
336 quoteLines.push(lines[i]);
337 i++;
338 // Allow blank lines within quotes but stop at two+ consecutive blanks
339 if (t === '' && i < lines.length) {
340 const next = lines[i].trimStart();
341 if (!next.startsWith('&gt;') && !next.startsWith('>') && next !== '') {
342 break;
343 }
344 }
345 } else {
346 break;
347 }
348 }
349 // Trim trailing blank lines from the block
350 while (quoteLines.length > 0 && quoteLines[quoteLines.length - 1].trim() === '') {
351 quoteLines.pop();
352 }
353 if (quoteLines.length > 0) {
354 const id = 'quote-' + Math.random().toString(36).slice(2, 8);
355 result.push(`<div class="email-quote-toggle" data-act="utils.toggleQuoted" data-a1="@el" data-target="${id}">··· Show quoted text</div>`);
356 result.push(`<div id="${id}" class="email-quote-block hidden">${quoteLines.join('\n')}</div>`);
357 }
358 } else {
359 result.push(lines[i]);
360 i++;
361 }
362 }
363
364 return result.join('\n');
365 }
366
367 /**
368 * Strip HTML tags and convert to readable plain text.
369 * Similar to backend strip_html but for client-side fallback.
370 * @param {string} html - HTML content
371 * @returns {string} - Plain text
372 */
373 function stripHtmlForReaderMode(html) {
374 // Use DOMParser to safely parse HTML without executing event handlers
375 // (unlike innerHTML, DOMParser does not fire onerror, onload, etc.)
376 const parser = new DOMParser();
377 const doc = parser.parseFromString(html, 'text/html');
378 const temp = doc.body;
379
380 // Remove script and style elements
381 const scripts = temp.querySelectorAll('script, style, head');
382 scripts.forEach(el => el.remove());
383
384 // Convert links to "text [url]" format before extracting text
385 const links = temp.querySelectorAll('a[href]');
386 links.forEach(link => {
387 const href = link.getAttribute('href');
388 const text = link.textContent.trim();
389 if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
390 // Only add URL if it's different from the text
391 if (href !== text && !text.includes(href)) {
392 link.textContent = `${text} [${href}]`;
393 }
394 }
395 });
396
397 // Convert <br> and block elements to newlines
398 temp.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
399 temp.querySelectorAll('p, div, tr, li, h1, h2, h3, h4, h5, h6').forEach(el => {
400 el.prepend(document.createTextNode('\n'));
401 el.append(document.createTextNode('\n'));
402 });
403
404 // Convert list items to bullets
405 temp.querySelectorAll('li').forEach(li => {
406 li.prepend(document.createTextNode(''));
407 });
408
409 // Get text content
410 let text = temp.textContent || temp.innerText || '';
411
412 // Clean up whitespace
413 text = text
414 .replace(/\r\n/g, '\n') // Normalize line endings
415 .replace(/\n{3,}/g, '\n\n') // Max 2 consecutive newlines
416 .replace(/[ \t]+/g, ' ') // Collapse spaces
417 .replace(/^ +| +$/gm, '') // Trim each line
418 .trim();
419
420 return text;
421 }
422
423 // ============ Debounce ============
424
425 /**
426 * Create a debounced version of a function that delays execution
427 * until after the specified wait time has elapsed since the last call.
428 * @param {Function} fn - Function to debounce
429 * @param {number} wait - Milliseconds to wait (default: 500)
430 * @returns {Function} - Debounced function
431 */
432 function debounce(fn, wait = 500) {
433 let timeoutId = null;
434 return function(...args) {
435 clearTimeout(timeoutId);
436 timeoutId = setTimeout(() => fn.apply(this, args), wait);
437 };
438 }
439
440 // ============ Error Display ============
441
442 /**
443 * Display an error message in a container element
444 * @param {HTMLElement} container - DOM element to display error in
445 * @param {Error|string|object} err - Error object or string
446 * @param {string} fallback - Fallback message if extraction fails
447 */
448 function showError(container, err, fallback) {
449 const msg = getErrorMessage(err, fallback);
450 container.innerHTML = `<div class="error-state">${escapeHtml(msg)}</div>`;
451 }
452
453 // ============ Email Address Parsing ============
454
455 /**
456 * Parse an email address from various formats:
457 * - "Jane Smith <jane@example.com>"
458 * - "<jane@example.com>"
459 * - "jane@example.com"
460 * @param {string} from - Raw email address string
461 * @returns {{ name: string|null, email: string|null }} - Parsed name and email
462 */
463 function parseEmailAddress(from) {
464 if (!from) return { name: null, email: null };
465
466 // Match "Name <email>" or "<email>"
467 const match = from.match(/^(?:"?([^"<]*?)"?\s*)?<([^>]+)>$/);
468 if (match) {
469 return {
470 name: match[1]?.trim() || null,
471 email: match[2]?.trim() || null,
472 };
473 }
474
475 // Plain email address
476 const trimmed = from.trim();
477 if (trimmed.includes('@')) {
478 return { name: null, email: trimmed };
479 }
480
481 return { name: trimmed || null, email: null };
482 }
483
484 // ============ Date Formatting ============
485
486 /**
487 * Format a Date as YYYY-MM-DD for API calls.
488 * @param {Date} date - Date to format
489 * @returns {string} - Date string in YYYY-MM-DD format
490 */
491 function formatDateForApi(date) {
492 const year = date.getFullYear();
493 const month = String(date.getMonth() + 1).padStart(2, '0');
494 const day = String(date.getDate()).padStart(2, '0');
495 return `${year}-${month}-${day}`;
496 }
497
498 /**
499 * Convert a Date to a local ISO string (YYYY-MM-DDTHH:MM) for datetime-local inputs.
500 * Accounts for timezone offset so the displayed time matches local time.
501 * @param {Date} date - Date to convert
502 * @returns {string} - Local ISO string (e.g., "2026-04-06T14:30")
503 */
504 function toLocalISOString(date) {
505 return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
506 }
507
508 /**
509 * Format a Date as a human-readable display string.
510 * @param {Date} date - Date to format
511 * @returns {string} - Localized date string (e.g., "Monday, April 15, 2026")
512 */
513 function formatDateDisplay(date) {
514 const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
515 return date.toLocaleDateString(undefined, options);
516 }
517
518 // ============ Auto-Grow Textareas ============
519
520 /**
521 * Make a textarea auto-grow to fit its content.
522 * Sets height to scrollHeight on each input event.
523 * Call once after the textarea is in the DOM.
524 * @param {HTMLTextAreaElement} textarea - The textarea element
525 */
526 function autoGrow(textarea) {
527 if (!textarea) return;
528
529 function resize() {
530 textarea.style.height = 'auto';
531 textarea.style.height = textarea.scrollHeight + 'px';
532 }
533
534 textarea.addEventListener('input', resize);
535 // Initial size
536 resize();
537 }
538
539 // ============ Natural Date Parsing ============
540
541 /**
542 * Parse natural language date expressions into YYYY-MM-DDTHH:MM format.
543 * Accepts: "today", "tomorrow", "yesterday", "next monday", "friday",
544 * "friday 3pm", "next week", "in 3 days", "dec 25", "2026-12-25",
545 * "2026-12-25 3pm", ISO format.
546 * @param {string} str - Natural date string
547 * @returns {string|null} - ISO datetime string or null if unparseable
548 */
549 async function parseNaturalDate(str) {
550 if (!str || !str.trim()) return null;
551 try {
552 // The Rust `parse_natural_date` command is the single source of truth
553 // for the grammar; returns "YYYY-MM-DDTHH:MM" or null.
554 return await GoingsOn.api.app.parseNaturalDate(str);
555 } catch (e) {
556 return null;
557 }
558 }
559
560 // ============ Tag Normalization ============
561
562 /**
563 * Normalize a comma-separated tag string.
564 * Splits on commas, trims whitespace, lowercases, filters empty, deduplicates.
565 * @param {string} tagString - Raw tag string
566 * @returns {string[]} - Array of clean tags
567 */
568 function normalizeTags(tagString) {
569 if (!tagString) return [];
570 const seen = new Set();
571 return tagString.split(',')
572 .map(t => t.trim().toLowerCase())
573 .filter(t => {
574 if (!t || seen.has(t)) return false;
575 seen.add(t);
576 return true;
577 });
578 }
579
580 // ============ Date Parse Preview ============
581
582 /**
583 * Live preview callback for natural language date fields.
584 * Use as onInput in form field definitions.
585 * @param {string} value - Current input value
586 * @param {HTMLElement} previewEl - Element to show parsed result
587 */
588 async function dateParsePreview(value, previewEl) {
589 if (!value || !value.trim()) {
590 previewEl.textContent = '';
591 return;
592 }
593 const parsed = await parseNaturalDate(value);
594 if (parsed) {
595 const d = new Date(parsed);
596 const display = d.toLocaleDateString(undefined, {
597 weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
598 hour: 'numeric', minute: '2-digit',
599 });
600 previewEl.textContent = display;
601 previewEl.style.color = 'var(--action)';
602 } else {
603 // Only show "not recognized" if it doesn't look like an ISO date being typed
604 if (value.trim().length > 2) {
605 previewEl.textContent = 'Date not recognized';
606 previewEl.style.color = 'var(--content-secondary)';
607 } else {
608 previewEl.textContent = '';
609 }
610 }
611 }
612
613 // ============ Populate GoingsOn.utils Namespace ============
614
615 /** Toggle a quoted-email block's visibility and swap the trigger's label.
616 * Target block id is on `data-target`; replaces the old inline handler. */
617 function toggleQuoted(el) {
618 const block = document.getElementById(el.dataset.target);
619 if (block) block.classList.toggle('hidden');
620 el.textContent = el.textContent === '··· Show quoted text'
621 ? '··· Hide quoted text'
622 : '··· Show quoted text';
623 }
624
625 GoingsOn.utils = {
626 toggleQuoted,
627 // HTML escaping. escapeJsString is intentionally NOT exported: the only
628 // sound attribute escapers are escapeAttrValue (plain attrs) and
629 // escapeHandlerArg (a value inside a quoted JS string in an inline handler).
630 escapeHtml,
631 escapeAttrValue,
632 escapeHandlerArg,
633 safeUrl,
634 getErrorMessage,
635 showError,
636
637 // Email formatting
638 formatEmailBody,
639
640 // Date formatting
641 formatDateForApi,
642 formatDateDisplay,
643 toLocalISOString,
644
645 // Natural date parsing
646 parseNaturalDate,
647 dateParsePreview,
648
649 // Tag normalization
650 normalizeTags,
651
652 // Debounce
653 debounce,
654
655 // Email address parsing
656 parseEmailAddress,
657
658 // Auto-grow
659 autoGrow,
660
661 // Validation
662 ValidationRules,
663 getValidationAttrs,
664 validateLength,
665 validateEmail,
666 showFieldError,
667 clearFieldError,
668 clearAllFieldErrors,
669 validateForm,
670 };
671
672 })();
673
674