/**
* 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,
'$1'
);
// 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(`