max / goingson
9 files changed,
+64 insertions,
-44 deletions
| @@ -193,16 +193,19 @@ fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<Date | |||
| 193 | 193 | return None; | |
| 194 | 194 | } | |
| 195 | 195 | ||
| 196 | - | let (num_str, unit) = s.split_at(s.len() - 1); | |
| 196 | + | // Split off the last char (the unit) using char-boundary-safe indexing so a | |
| 197 | + | // trailing multibyte char (e.g. "1😀") returns None instead of panicking. | |
| 198 | + | let (unit_idx, unit) = s.char_indices().next_back()?; | |
| 199 | + | let num_str = &s[..unit_idx]; | |
| 197 | 200 | let num: i64 = num_str.parse().ok()?; | |
| 198 | 201 | if !(0..=MAX_RELATIVE_DATE_DAYS).contains(&num) { | |
| 199 | 202 | return None; | |
| 200 | 203 | } | |
| 201 | 204 | ||
| 202 | 205 | let target = match unit { | |
| 203 | - | "d" => from + Duration::days(num), | |
| 204 | - | "w" => from + Duration::weeks(num), | |
| 205 | - | "m" => { | |
| 206 | + | 'd' => from + Duration::days(num), | |
| 207 | + | 'w' => from + Duration::weeks(num), | |
| 208 | + | 'm' => { | |
| 206 | 209 | // Approximate month | |
| 207 | 210 | from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH) | |
| 208 | 211 | } | |
| @@ -456,4 +459,16 @@ mod tests { | |||
| 456 | 459 | assert_eq!(strip_prefix_ci("proj:test", "project:"), None); | |
| 457 | 460 | assert_eq!(strip_prefix_ci("pr", "project:"), None); | |
| 458 | 461 | } | |
| 462 | + | ||
| 463 | + | #[test] | |
| 464 | + | fn test_due_relative_multibyte_does_not_panic() { | |
| 465 | + | // Regression: a trailing multibyte char in a +Nunit relative date used to | |
| 466 | + | // panic in split_at(len-1). Relative dates are reached only via a leading '+'. | |
| 467 | + | for input in ["Task due:+1😀", "Task due:+3€", "Task due:+😀", "Task due:+1d"] { | |
| 468 | + | let _ = parse_quick_add(input); | |
| 469 | + | } | |
| 470 | + | // The multibyte unit is unrecognized → no due; "+1d" is valid → due set. | |
| 471 | + | assert!(parse_quick_add("Task due:+1😀").due.is_none()); | |
| 472 | + | assert!(parse_quick_add("Task due:+1d").due.is_some()); | |
| 473 | + | } | |
| 459 | 474 | } |
| @@ -321,7 +321,10 @@ fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<Date | |||
| 321 | 321 | return None; | |
| 322 | 322 | } | |
| 323 | 323 | ||
| 324 | - | let (num_str, unit) = rest.split_at(rest.len() - 1); | |
| 324 | + | // Split off the last char (the unit) using char-boundary-safe indexing so a | |
| 325 | + | // trailing multibyte char (e.g. "+1😀") returns None instead of panicking. | |
| 326 | + | let (unit_idx, unit) = rest.char_indices().next_back()?; | |
| 327 | + | let num_str = &rest[..unit_idx]; | |
| 325 | 328 | let raw: i64 = num_str.parse::<i64>().ok()?; | |
| 326 | 329 | if raw > MAX_RELATIVE_DATE_DAYS { | |
| 327 | 330 | return None; | |
| @@ -329,9 +332,9 @@ fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<Date | |||
| 329 | 332 | let num: i64 = raw * sign; | |
| 330 | 333 | ||
| 331 | 334 | let target = match unit { | |
| 332 | - | "d" => from + Duration::days(num), | |
| 333 | - | "w" => from + Duration::weeks(num), | |
| 334 | - | "m" => from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH), | |
| 335 | + | 'd' => from + Duration::days(num), | |
| 336 | + | 'w' => from + Duration::weeks(num), | |
| 337 | + | 'm' => from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH), | |
| 335 | 338 | _ => return None, | |
| 336 | 339 | }; | |
| 337 | 340 | ||
| @@ -430,4 +433,15 @@ mod tests { | |||
| 430 | 433 | let q = parse_search_query("is:pending is:pending"); | |
| 431 | 434 | assert_eq!(q.is_filters.len(), 1); | |
| 432 | 435 | } | |
| 436 | + | ||
| 437 | + | #[test] | |
| 438 | + | fn test_relative_date_multibyte_does_not_panic() { | |
| 439 | + | // Regression: a trailing multibyte char used to panic in split_at(len-1). | |
| 440 | + | for input in ["after:+1😀", "before:-3€", "after:+😀", "after:+1d"] { | |
| 441 | + | let _ = parse_search_query(input); | |
| 442 | + | } | |
| 443 | + | // "+1d" is the only valid relative date; multibyte input yields no filter. | |
| 444 | + | assert!(parse_search_query("after:+1😀").date_from.is_none()); | |
| 445 | + | assert!(parse_search_query("after:+1d").date_from.is_some()); | |
| 446 | + | } | |
| 433 | 447 | } |
| @@ -20,7 +20,6 @@ | |||
| 20 | 20 | ||
| 21 | 21 | // Session-scoped cache: email (lowercase) -> status string | |
| 22 | 22 | const statusCache = new Map(); | |
| 23 | - | let debounceTimer = null; | |
| 24 | 23 | ||
| 25 | 24 | /** | |
| 26 | 25 | * Attach address highlighting and ghost text to an email input element. | |
| @@ -32,6 +31,9 @@ | |||
| 32 | 31 | function attach(input, opts) { | |
| 33 | 32 | if (!input || input._addressHighlight) return; | |
| 34 | 33 | ||
| 34 | + | // Per-input debounce timer (each To/CC/BCC field validates independently). | |
| 35 | + | let debounceTimer = null; | |
| 36 | + | ||
| 35 | 37 | // Create mirror div | |
| 36 | 38 | const mirror = document.createElement('div'); | |
| 37 | 39 | mirror.className = 'address-highlight-mirror'; |
| @@ -503,8 +503,9 @@ | |||
| 503 | 503 | const result = await GoingsOn.api.emailAccounts.test(id); | |
| 504 | 504 | ||
| 505 | 505 | // Build detailed result modal | |
| 506 | - | const imapStatus = result.imapSuccess ? 'IMAP OK' : 'IMAP Failed: ' + result.imapMessage; | |
| 507 | - | const smtpStatus = result.smtpSuccess ? 'SMTP OK' : 'SMTP Failed: ' + result.smtpMessage; | |
| 506 | + | // imapMessage/smtpMessage are server-returned strings — escape before innerHTML. | |
| 507 | + | const imapStatus = result.imapSuccess ? 'IMAP OK' : 'IMAP Failed: ' + esc(result.imapMessage); | |
| 508 | + | const smtpStatus = result.smtpSuccess ? 'SMTP OK' : 'SMTP Failed: ' + esc(result.smtpMessage); | |
| 508 | 509 | ||
| 509 | 510 | const foldersHtml = result.availableFolders && result.availableFolders.length > 0 | |
| 510 | 511 | ? `<div class="test-conn-section"> |
| @@ -158,12 +158,12 @@ | |||
| 158 | 158 | const snippet = r.snippet ? `<span class="cp-snippet">${esc(r.snippet)}</span>` : ''; | |
| 159 | 159 | ||
| 160 | 160 | html += `<div class="cp-result${selected}" data-index="${i}" onmouseenter="GoingsOn.search._hover(${i})" onclick="GoingsOn.search._select(${i})"> | |
| 161 | - | <span class="cp-type-icon" title="${label}">${icon}</span> | |
| 161 | + | <span class="cp-type-icon" title="${escAttr(label)}">${icon}</span> | |
| 162 | 162 | <div class="cp-result-body"> | |
| 163 | 163 | <span class="cp-title">${esc(r.title)}</span> | |
| 164 | 164 | ${project}${snippet} | |
| 165 | 165 | </div> | |
| 166 | - | <span class="cp-type-badge">${label}</span> | |
| 166 | + | <span class="cp-type-badge">${esc(label)}</span> | |
| 167 | 167 | </div>`; | |
| 168 | 168 | } | |
| 169 | 169 |
| @@ -335,7 +335,7 @@ | |||
| 335 | 335 | refreshSyncSection(); | |
| 336 | 336 | } catch (err) { | |
| 337 | 337 | GoingsOn.ui.showToast('Sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', { | |
| 338 | - | action: { label: 'Retry', fn: syncNow }, | |
| 338 | + | action: { label: 'Retry', fn: doSyncNow }, | |
| 339 | 339 | duration: 8000, | |
| 340 | 340 | }); | |
| 341 | 341 | if (btn) { |
| @@ -90,7 +90,7 @@ | |||
| 90 | 90 | ||
| 91 | 91 | const highContrastGroup = highContrastThemes.length > 0 | |
| 92 | 92 | ? `<optgroup label="High Contrast"> | |
| 93 | - | ${highContrastThemes.map(t => `<option value="${t.id}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')} | |
| 93 | + | ${highContrastThemes.map(t => `<option value="${escAttr(t.id)}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')} | |
| 94 | 94 | </optgroup>` | |
| 95 | 95 | : ''; | |
| 96 | 96 | ||
| @@ -104,10 +104,10 @@ | |||
| 104 | 104 | <option value="system" ${savedTheme === 'system' ? 'selected' : ''}>Follow System</option> | |
| 105 | 105 | </optgroup> | |
| 106 | 106 | <optgroup label="Light Themes"> | |
| 107 | - | ${lightThemes.map(t => `<option value="${t.id}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')} | |
| 107 | + | ${lightThemes.map(t => `<option value="${escAttr(t.id)}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')} | |
| 108 | 108 | </optgroup> | |
| 109 | 109 | <optgroup label="Dark Themes"> | |
| 110 | - | ${darkThemes.map(t => `<option value="${t.id}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')} | |
| 110 | + | ${darkThemes.map(t => `<option value="${escAttr(t.id)}" ${savedTheme === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')} | |
| 111 | 111 | </optgroup> | |
| 112 | 112 | ${highContrastGroup} | |
| 113 | 113 | </select> |
| @@ -9,7 +9,6 @@ | |||
| 9 | 9 | use std::path::Path; | |
| 10 | 10 | use std::sync::Arc; | |
| 11 | 11 | ||
| 12 | - | use chrono::Utc; | |
| 13 | 12 | use serde::{Deserialize, Serialize}; | |
| 14 | 13 | use tauri::{Manager, State}; | |
| 15 | 14 | use tracing::instrument; | |
| @@ -245,32 +244,12 @@ pub async fn create_backup( | |||
| 245 | 244 | state: State<'_, Arc<AppState>>, | |
| 246 | 245 | app: tauri::AppHandle, | |
| 247 | 246 | ) -> Result<ExportResponse, ApiError> { | |
| 248 | - | // Get backup directory | |
| 249 | - | let backup_dir = app | |
| 250 | - | .path() | |
| 251 | - | .app_data_dir() | |
| 252 | - | .map_api_err("Failed to get app data dir", ApiError::internal)? | |
| 253 | - | .join("backups"); | |
| 254 | - | ||
| 255 | - | std::fs::create_dir_all(&backup_dir) | |
| 256 | - | .map_api_err("Failed to create backup directory", ApiError::internal)?; | |
| 257 | - | ||
| 258 | - | // Generate timestamped filename | |
| 259 | - | let timestamp = Utc::now().format("%Y%m%d-%H%M%S"); | |
| 260 | - | let filename = format!("goingson-backup-{}.json.gz", timestamp); | |
| 261 | - | let file_path = backup_dir.join(&filename); | |
| 262 | - | ||
| 263 | - | let export = crate::backup_scheduler::collect_full_export(&state, DESKTOP_USER_ID).await?; | |
| 264 | - | let item_count = export.total_count(); | |
| 265 | - | ||
| 266 | - | let size_bytes = backup::write_backup(&export, &file_path) | |
| 267 | - | .map_api_err("Failed to write backup", ApiError::internal)?; | |
| 268 | - | ||
| 269 | - | Ok(ExportResponse { | |
| 270 | - | file_path: file_path.to_string_lossy().into_owned(), | |
| 271 | - | item_count, | |
| 272 | - | size_bytes, | |
| 273 | - | }) | |
| 247 | + | // Delegate to the scheduler's on-demand path: it uses a collision-safe | |
| 248 | + | // filename (GO-11), offloads gzip to the blocking pool, and records | |
| 249 | + | // last_backup_at — none of which a hand-rolled body here would. | |
| 250 | + | crate::backup_scheduler::create_backup_now(&app, &state) | |
| 251 | + | .await | |
| 252 | + | .map_err(ApiError::internal) | |
| 274 | 253 | } | |
| 275 | 254 | ||
| 276 | 255 | /// Lists available backups in the backup directory. |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] | |
| 5 | 5 | ||
| 6 | 6 | use goingson_desktop::backup_scheduler; | |
| 7 | + | use goingson_desktop::blob_gc; | |
| 7 | 8 | use goingson_desktop::commands; | |
| 8 | 9 | use goingson_desktop::email_sync_scheduler; | |
| 9 | 10 | use goingson_desktop::sync_scheduler; | |
| @@ -305,6 +306,9 @@ fn main() { | |||
| 305 | 306 | tauri::async_runtime::block_on(async move { | |
| 306 | 307 | let state = AppState::new(&app_handle).await | |
| 307 | 308 | .expect("Failed to initialize app state"); | |
| 309 | + | // Reclaim orphaned attachment blobs before the schedulers spawn | |
| 310 | + | // and before the UI can create attachments — race-free here. | |
| 311 | + | blob_gc::reconcile(&state.pool, &state.data_dir).await; | |
| 308 | 312 | app_handle.manage(Arc::new(state)); | |
| 309 | 313 | }); | |
| 310 | 314 | ||
| @@ -352,6 +356,11 @@ fn main() { | |||
| 352 | 356 | sync_scheduler::start_sync_scheduler(cloud_sync_handle, cloud_cancel).await; | |
| 353 | 357 | }); | |
| 354 | 358 | ||
| 359 | + | // Clean up stale email preview temp files from previous sessions | |
| 360 | + | tauri::async_runtime::spawn(async { | |
| 361 | + | commands::cleanup_stale_temp_files().await; | |
| 362 | + | }); | |
| 363 | + | ||
| 355 | 364 | // Check for OTA updates after a short delay (desktop only). | |
| 356 | 365 | // Gated on the user's preference (default true). | |
| 357 | 366 | #[cfg(not(any(target_os = "ios", target_os = "android")))] |