max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+1242 insertions,
-69 deletions
| @@ -27,6 +27,20 @@ | |||
| 27 | 27 | /// A setting of 0 still means "keep everything" and is exempt. | |
| 28 | 28 | const MIN_BACKUPS_TO_KEEP: usize = 3; | |
| 29 | 29 | ||
| 30 | + | /// Where backups are kept. | |
| 31 | + | /// | |
| 32 | + | /// Derived from [`AppState::data_dir`](crate::state::AppState::data_dir), which | |
| 33 | + | /// is the app data directory resolved once at startup. It was | |
| 34 | + | /// `app.path().app_data_dir().join("backups")` at four sites until 2026-08-16 — | |
| 35 | + | /// here, the on-demand path, `list_backups` and `delete_backup` — so four | |
| 36 | + | /// callers reached past the state for a path the state already held, and | |
| 37 | + | /// nothing that is not a Tauri command could ask where a backup lives at all. | |
| 38 | + | /// The described Import & Export screen is what could not: its handlers see | |
| 39 | + | /// `AppState` and nothing else. | |
| 40 | + | pub(crate) fn backup_dir(state: &AppState) -> PathBuf { | |
| 41 | + | state.data_dir.join("backups") | |
| 42 | + | } | |
| 43 | + | ||
| 30 | 44 | /// Build a unique backup filename. The second-granular timestamp keeps files | |
| 31 | 45 | /// human-sortable; the short random suffix prevents a manual backup and the | |
| 32 | 46 | /// scheduler firing in the same second from colliding and silently overwriting | |
| @@ -230,14 +244,14 @@ | |||
| 230 | 244 | }; | |
| 231 | 245 | ||
| 232 | 246 | // Check if backup is needed and perform it | |
| 233 | - | if let Err(e) = check_and_backup(&app, &state).await { | |
| 247 | + | if let Err(e) = check_and_backup(&state).await { | |
| 234 | 248 | error!(error = %e, "Error in backup scheduler"); | |
| 235 | 249 | } | |
| 236 | 250 | } | |
| 237 | 251 | } | |
| 238 | 252 | ||
| 239 | 253 | /// Checks if a backup is needed based on settings and performs it if necessary. | |
| 240 | - | async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Result<(), String> { | |
| 254 | + | async fn check_and_backup(state: &Arc<AppState>) -> Result<(), String> { | |
| 241 | 255 | // Get backup settings (create defaults if not set) | |
| 242 | 256 | let settings = match state.backup_settings.get(DESKTOP_USER_ID) { | |
| 243 | 257 | Ok(Some(s)) => s, | |
| @@ -283,11 +297,7 @@ | |||
| 283 | 297 | info!("Starting automated backup"); | |
| 284 | 298 | ||
| 285 | 299 | // Perform the backup | |
| 286 | - | let backup_dir = app | |
| 287 | - | .path() | |
| 288 | - | .app_data_dir() | |
| 289 | - | .map_err(|e| format!("Failed to get app data dir: {e}"))? | |
| 290 | - | .join("backups"); | |
| 300 | + | let backup_dir = backup_dir(state); | |
| 291 | 301 | ||
| 292 | 302 | let filename = backup_filename(now); | |
| 293 | 303 | let file_path = backup_dir.join(&filename); | |
| @@ -361,16 +371,11 @@ | |||
| 361 | 371 | ||
| 362 | 372 | /// Performs an immediate backup (for manual trigger or on-demand). | |
| 363 | 373 | pub async fn create_backup_now( | |
| 364 | - | app: &tauri::AppHandle, | |
| 365 | 374 | state: &Arc<AppState>, | |
| 366 | 375 | ) -> Result<crate::commands::ExportResponse, String> { | |
| 367 | 376 | let now = Utc::now(); | |
| 368 | 377 | ||
| 369 | - | let backup_dir = app | |
| 370 | - | .path() | |
| 371 | - | .app_data_dir() | |
| 372 | - | .map_err(|e| format!("Failed to get app data dir: {e}"))? | |
| 373 | - | .join("backups"); | |
| 378 | + | let backup_dir = backup_dir(state); | |
| 374 | 379 | ||
| 375 | 380 | let filename = backup_filename(now); | |
| 376 | 381 | let file_path = backup_dir.join(&filename); |
| @@ -10,7 +10,7 @@ | |||
| 10 | 10 | use std::sync::Arc; | |
| 11 | 11 | ||
| 12 | 12 | use serde::{Deserialize, Serialize}; | |
| 13 | - | use tauri::{Manager, State}; | |
| 13 | + | use tauri::State; | |
| 14 | 14 | use tracing::instrument; | |
| 15 | 15 | ||
| 16 | 16 | use goingson_core::ProjectId; | |
| @@ -234,14 +234,11 @@ | |||
| 234 | 234 | /// This format is optimized for storage and can be restored later. | |
| 235 | 235 | #[tauri::command] | |
| 236 | 236 | #[instrument(skip_all)] | |
| 237 | - | pub async fn create_backup( | |
| 238 | - | state: State<'_, Arc<AppState>>, | |
| 239 | - | app: tauri::AppHandle, | |
| 240 | - | ) -> Result<ExportResponse, ApiError> { | |
| 237 | + | pub async fn create_backup(state: State<'_, Arc<AppState>>) -> Result<ExportResponse, ApiError> { | |
| 241 | 238 | // Delegate to the scheduler's on-demand path: it uses a collision-safe | |
| 242 | 239 | // filename (GO-11), offloads gzip to the blocking pool, and records | |
| 243 | 240 | // last_backup_at, none of which a hand-rolled body here would. | |
| 244 | - | crate::backup_scheduler::create_backup_now(&app, &state) | |
| 241 | + | crate::backup_scheduler::create_backup_now(&state) | |
| 245 | 242 | .await | |
| 246 | 243 | .map_err(ApiError::internal) | |
| 247 | 244 | } | |
| @@ -249,12 +246,20 @@ | |||
| 249 | 246 | /// Lists available backups in the backup directory. | |
| 250 | 247 | #[tauri::command] | |
| 251 | 248 | #[instrument(skip_all)] | |
| 252 | - | pub async fn list_backups(app: tauri::AppHandle) -> Result<Vec<BackupInfoResponse>, ApiError> { | |
| 253 | - | let backup_dir = app | |
| 254 | - | .path() | |
| 255 | - | .app_data_dir() | |
| 256 | - | .map_api_err("Failed to get app data dir", ApiError::internal)? | |
| 257 | - | .join("backups"); | |
| 249 | + | pub async fn list_backups( | |
| 250 | + | state: State<'_, Arc<AppState>>, | |
| 251 | + | ) -> Result<Vec<BackupInfoResponse>, ApiError> { | |
| 252 | + | list_backups_in(&state) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | /// The backups on disk, newest first. | |
| 256 | + | /// | |
| 257 | + | /// The body of [`list_backups`], lifted out of the command 2026-08-16 so the | |
| 258 | + | /// described Import & Export screen reads the same list rather than a second | |
| 259 | + | /// copy of the directory walk. Sync for the reason | |
| 260 | + | /// [`attach_path`](super::attachment::attach_path) is. | |
| 261 | + | pub(crate) fn list_backups_in(state: &AppState) -> Result<Vec<BackupInfoResponse>, ApiError> { | |
| 262 | + | let backup_dir = crate::backup_scheduler::backup_dir(state); | |
| 258 | 263 | ||
| 259 | 264 | if !backup_dir.exists() { | |
| 260 | 265 | return Ok(vec![]); | |
| @@ -327,10 +332,23 @@ | |||
| 327 | 332 | file_path: String, | |
| 328 | 333 | options: RestoreOptions, | |
| 329 | 334 | ) -> Result<RestoreResponse, ApiError> { | |
| 330 | - | validate_export_path(&file_path)?; | |
| 335 | + | restore_backup_from(&state, &file_path, &options) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | /// Read a backup and merge it back in. | |
| 339 | + | /// | |
| 340 | + | /// The body of [`restore_backup`], lifted out of the command 2026-08-16 with | |
| 341 | + | /// [`list_backups_in`] and for the same reason: the described Import & Export | |
| 342 | + | /// screen restores through this rather than through a second copy of it. | |
| 343 | + | pub(crate) fn restore_backup_from( | |
| 344 | + | state: &AppState, | |
| 345 | + | file_path: &str, | |
| 346 | + | options: &RestoreOptions, | |
| 347 | + | ) -> Result<RestoreResponse, ApiError> { | |
| 348 | + | validate_export_path(file_path)?; | |
| 331 | 349 | ||
| 332 | 350 | // Read and decompress backup | |
| 333 | - | let export = backup::read_backup(&file_path) | |
| 351 | + | let export = backup::read_backup(file_path) | |
| 334 | 352 | .map_api_err("Failed to read backup", ApiError::bad_request)?; | |
| 335 | 353 | ||
| 336 | 354 | if options.replace_all { | |
| @@ -379,19 +397,27 @@ | |||
| 379 | 397 | /// Deletes a backup file. | |
| 380 | 398 | #[tauri::command] | |
| 381 | 399 | #[instrument(skip_all)] | |
| 382 | - | pub async fn delete_backup(app: tauri::AppHandle, file_path: String) -> Result<bool, ApiError> { | |
| 383 | - | let path = Path::new(&file_path); | |
| 400 | + | pub async fn delete_backup( | |
| 401 | + | state: State<'_, Arc<AppState>>, | |
| 402 | + | file_path: String, | |
| 403 | + | ) -> Result<bool, ApiError> { | |
| 404 | + | delete_backup_at(&state, &file_path) | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | /// Delete one backup file, refusing anything outside the backup directory. | |
| 408 | + | /// | |
| 409 | + | /// The body of [`delete_backup`], lifted with [`list_backups_in`] and for the | |
| 410 | + | /// same reason. Both containment checks stay here rather than at the callers: | |
| 411 | + | /// the described screen addresses a backup by name and the command by path, and | |
| 412 | + | /// a check that lived at the call site would be two of them. | |
| 413 | + | pub(crate) fn delete_backup_at(state: &AppState, file_path: &str) -> Result<bool, ApiError> { | |
| 414 | + | let path = Path::new(file_path); | |
| 384 | 415 | ||
| 385 | 416 | if !path.exists() { | |
| 386 | 417 | return Ok(false); | |
| 387 | 418 | } | |
| 388 | 419 | ||
| 389 | - | // Build the canonical backup directory from the app data dir | |
| 390 | - | let backup_dir = app | |
| 391 | - | .path() | |
| 392 | - | .app_data_dir() | |
| 393 | - | .map_api_err("Failed to get app data dir", ApiError::internal)? | |
| 394 | - | .join("backups"); | |
| 420 | + | let backup_dir = crate::backup_scheduler::backup_dir(state); | |
| 395 | 421 | ||
| 396 | 422 | let canonical_backup_dir = std::fs::canonicalize(&backup_dir) | |
| 397 | 423 | .map_api_err("Failed to resolve backup directory", ApiError::internal)?; | |
| @@ -481,6 +507,18 @@ | |||
| 481 | 507 | pub async fn save_backup_settings( | |
| 482 | 508 | state: State<'_, Arc<AppState>>, | |
| 483 | 509 | input: BackupSettingsInput, | |
| 510 | + | ) -> Result<BackupSettingsResponse, ApiError> { | |
| 511 | + | save_backup_settings_for(&state, &input) | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | /// Write the automatic-backup settings. | |
| 515 | + | /// | |
| 516 | + | /// The body of [`save_backup_settings`], lifted with [`restore_backup_from`]. | |
| 517 | + | /// The clamping stays here rather than at either caller, so the floor holds | |
| 518 | + | /// whichever screen asked. | |
| 519 | + | pub(crate) fn save_backup_settings_for( | |
| 520 | + | state: &AppState, | |
| 521 | + | input: &BackupSettingsInput, | |
| 484 | 522 | ) -> Result<BackupSettingsResponse, ApiError> { | |
| 485 | 523 | let settings = goingson_core::NewBackupSettings { | |
| 486 | 524 | auto_backup_enabled: input.auto_backup_enabled, |
| @@ -48,12 +48,26 @@ | |||
| 48 | 48 | pub selected_indices: Vec<usize>, | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | /// Parse a CSV/TSV file into typed items, creating nothing. | |
| 52 | + | /// | |
| 53 | + | /// The body of [`preview_import`], lifted out of the command 2026-08-16 so the | |
| 54 | + | /// described Import & Export screen can call the same code rather than a second | |
| 55 | + | /// copy of it. Sync for the reason [`attach_path`](super::attachment::attach_path) | |
| 56 | + | /// is: a `quasi_router` handler is a plain `fn(&S, Request)` with nothing async | |
| 57 | + | /// about it. | |
| 58 | + | pub(crate) fn preview_csv_at( | |
| 59 | + | file_path: &str, | |
| 60 | + | options: &ImportOptions, | |
| 61 | + | ) -> Result<ImportParseResult, ApiError> { | |
| 62 | + | let content = read_import_file(file_path)?; | |
| 63 | + | parse_csv_import(&content, options) | |
| 64 | + | } | |
| 65 | + | ||
| 51 | 66 | /// Previews a CSV/TSV import by parsing the file without creating entities. | |
| 52 | 67 | #[tauri::command] | |
| 53 | 68 | #[instrument(skip_all)] | |
| 54 | 69 | pub async fn preview_import(input: PreviewImportInput) -> Result<ImportParseResult, ApiError> { | |
| 55 | - | let content = read_import_file(&input.file_path)?; | |
| 56 | - | parse_csv_import(&content, &input.options) | |
| 70 | + | preview_csv_at(&input.file_path, &input.options) | |
| 57 | 71 | } | |
| 58 | 72 | ||
| 59 | 73 | /// Executes a CSV/TSV import, creating entities in the database. | |
| @@ -63,8 +77,29 @@ | |||
| 63 | 77 | state: State<'_, Arc<AppState>>, | |
| 64 | 78 | input: ExecuteImportInput, | |
| 65 | 79 | ) -> Result<ImportExecuteResult, ApiError> { | |
| 66 | - | let content = read_import_file(&input.file_path)?; | |
| 67 | - | let parsed = parse_csv_import(&content, &input.options)?; | |
| 80 | + | execute_csv_at( | |
| 81 | + | &state, | |
| 82 | + | &input.file_path, | |
| 83 | + | &input.options, | |
| 84 | + | &input.selected_indices, | |
| 85 | + | ) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Parse a CSV/TSV file and create what it holds. | |
| 89 | + | /// | |
| 90 | + | /// The body of [`execute_import`], lifted out with [`preview_csv_at`] and for | |
| 91 | + | /// the same reason. The file is parsed again rather than carried over from the | |
| 92 | + | /// preview: the preview is a dry run over a path, and re-reading is what makes | |
| 93 | + | /// the write answer for the file as it is now rather than as it was when it was | |
| 94 | + | /// last looked at. | |
| 95 | + | pub(crate) fn execute_csv_at( | |
| 96 | + | state: &AppState, | |
| 97 | + | file_path: &str, | |
| 98 | + | options: &ImportOptions, | |
| 99 | + | selected_indices: &[usize], | |
| 100 | + | ) -> Result<ImportExecuteResult, ApiError> { | |
| 101 | + | let content = read_import_file(file_path)?; | |
| 102 | + | let parsed = parse_csv_import(&content, options)?; | |
| 68 | 103 | ||
| 69 | 104 | let projects = state | |
| 70 | 105 | .projects | |
| @@ -75,22 +110,22 @@ | |||
| 75 | 110 | .map(|p| (p.id.to_string(), p.name.clone())) | |
| 76 | 111 | .collect(); | |
| 77 | 112 | ||
| 78 | - | let items: Vec<&ImportItem> = if input.selected_indices.is_empty() { | |
| 113 | + | let items: Vec<&ImportItem> = if selected_indices.is_empty() { | |
| 79 | 114 | parsed.items.iter().collect() | |
| 80 | 115 | } else { | |
| 81 | 116 | parsed | |
| 82 | 117 | .items | |
| 83 | 118 | .iter() | |
| 84 | 119 | .enumerate() | |
| 85 | - | .filter(|(idx, _)| input.selected_indices.contains(idx)) | |
| 120 | + | .filter(|(idx, _)| selected_indices.contains(idx)) | |
| 86 | 121 | .map(|(_, item)| item) | |
| 87 | 122 | .collect() | |
| 88 | 123 | }; | |
| 89 | 124 | ||
| 90 | 125 | match parsed.entity_type { | |
| 91 | - | ImportEntityType::Task => import_tasks(&state, &items, &project_list), | |
| 92 | - | ImportEntityType::Project => import_projects(&state, &items), | |
| 93 | - | ImportEntityType::Event => import_events(&state, &items, &project_list), | |
| 126 | + | ImportEntityType::Task => import_tasks(state, &items, &project_list), | |
| 127 | + | ImportEntityType::Project => import_projects(state, &items), | |
| 128 | + | ImportEntityType::Event => import_events(state, &items, &project_list), | |
| 94 | 129 | } | |
| 95 | 130 | } | |
| 96 | 131 |
| @@ -103,7 +103,21 @@ | |||
| 103 | 103 | state: State<'_, Arc<AppState>>, | |
| 104 | 104 | file_path: String, | |
| 105 | 105 | ) -> Result<Vec<VCardPreview>, ApiError> { | |
| 106 | - | let content = read_import_file(&file_path)?; | |
| 106 | + | preview_vcf_at(&state, &file_path) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// Parse a vCard file and say what importing it would do. | |
| 110 | + | /// | |
| 111 | + | /// The body of [`preview_vcf`], lifted out of the command 2026-08-16 so the | |
| 112 | + | /// described Import & Export screen can call the same code rather than a second | |
| 113 | + | /// copy of it. Sync for the reason | |
| 114 | + | /// [`attach_path`](super::attachment::attach_path) is: a `quasi_router` handler | |
| 115 | + | /// is a plain `fn(&S, Request)` with nothing async about it. | |
| 116 | + | pub(crate) fn preview_vcf_at( | |
| 117 | + | state: &AppState, | |
| 118 | + | file_path: &str, | |
| 119 | + | ) -> Result<Vec<VCardPreview>, ApiError> { | |
| 120 | + | let content = read_import_file(file_path)?; | |
| 107 | 121 | ||
| 108 | 122 | let cards = vcard::parse_vcf(&content) | |
| 109 | 123 | .map_err(|e| ApiError::internal(format!("Failed to parse vCard: {e}")))?; | |
| @@ -115,7 +129,7 @@ | |||
| 115 | 129 | email_count: card.emails.len(), | |
| 116 | 130 | phone_count: card.phones.len(), | |
| 117 | 131 | company: card.company.clone(), | |
| 118 | - | duplicate_of: find_duplicate(&state, &card).map(|existing| existing.display_name), | |
| 132 | + | duplicate_of: find_duplicate(state, &card).map(|existing| existing.display_name), | |
| 119 | 133 | }); | |
| 120 | 134 | } | |
| 121 | 135 | Ok(previews) | |
| @@ -126,10 +140,7 @@ | |||
| 126 | 140 | /// Email is the only key that survives a round trip through someone else's | |
| 127 | 141 | /// address book; display names collide and vCard UIDs are regenerated by most | |
| 128 | 142 | /// exporters. A card with no email is therefore never a duplicate. | |
| 129 | - | fn find_duplicate( | |
| 130 | - | state: &Arc<AppState>, | |
| 131 | - | card: &vcard::ParsedVCard, | |
| 132 | - | ) -> Option<goingson_core::Contact> { | |
| 143 | + | fn find_duplicate(state: &AppState, card: &vcard::ParsedVCard) -> Option<goingson_core::Contact> { | |
| 133 | 144 | for email in &card.emails { | |
| 134 | 145 | if let Ok(Some(existing)) = state | |
| 135 | 146 | .contacts | |
| @@ -145,7 +156,16 @@ | |||
| 145 | 156 | #[tauri::command] | |
| 146 | 157 | #[instrument(skip_all)] | |
| 147 | 158 | pub async fn preview_ics(file_path: String) -> Result<Vec<IcsPreview>, ApiError> { | |
| 148 | - | let content = read_import_file(&file_path)?; | |
| 159 | + | preview_ics_at(&file_path) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// Parse an iCalendar file and say what importing it would do. | |
| 163 | + | /// | |
| 164 | + | /// [`preview_vcf_at`]'s sibling, lifted for the same reason. It takes no state | |
| 165 | + | /// because an event preview has nothing to look up: ICS dedup is by UID at | |
| 166 | + | /// import time, so there is no equivalent of the vCard duplicate check here. | |
| 167 | + | pub(crate) fn preview_ics_at(file_path: &str) -> Result<Vec<IcsPreview>, ApiError> { | |
| 168 | + | let content = read_import_file(file_path)?; | |
| 149 | 169 | ||
| 150 | 170 | let events = ical::parse_ics(&content) | |
| 151 | 171 | .map_err(|e| ApiError::internal(format!("Failed to parse ICS: {e}")))?; | |
| @@ -177,8 +197,21 @@ | |||
| 177 | 197 | file_path: String, | |
| 178 | 198 | duplicate_strategy: Option<DuplicateStrategy>, | |
| 179 | 199 | ) -> Result<ImportResult, ApiError> { | |
| 180 | - | let content = read_import_file(&file_path)?; | |
| 181 | - | let strategy = duplicate_strategy.unwrap_or_default(); | |
| 200 | + | import_vcf_at(&state, &file_path, duplicate_strategy.unwrap_or_default()) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /// Import a vCard file, one strategy for every card that already exists. | |
| 204 | + | /// | |
| 205 | + | /// The body of [`import_vcf`], lifted with [`preview_vcf_at`] and for the same | |
| 206 | + | /// reason. The strategy is required here rather than optional: a default chosen | |
| 207 | + | /// twice is two answers to what a duplicate means, so the command supplies its | |
| 208 | + | /// own and this takes what it is given. | |
| 209 | + | pub(crate) fn import_vcf_at( | |
| 210 | + | state: &AppState, | |
| 211 | + | file_path: &str, | |
| 212 | + | strategy: DuplicateStrategy, | |
| 213 | + | ) -> Result<ImportResult, ApiError> { | |
| 214 | + | let content = read_import_file(file_path)?; | |
| 182 | 215 | ||
| 183 | 216 | let cards = vcard::parse_vcf(&content) | |
| 184 | 217 | .map_err(|e| ApiError::internal(format!("Failed to parse vCard: {e}")))?; | |
| @@ -204,7 +237,7 @@ | |||
| 204 | 237 | .find_by_external_id("vcf", &ext_id, DESKTOP_USER_ID) | |
| 205 | 238 | { | |
| 206 | 239 | Ok(Some(contact)) => Some(contact), | |
| 207 | - | _ => find_duplicate(&state, &card), | |
| 240 | + | _ => find_duplicate(state, &card), | |
| 208 | 241 | }; | |
| 209 | 242 | ||
| 210 | 243 | if let Some(existing) = existing { | |
| @@ -214,7 +247,7 @@ | |||
| 214 | 247 | continue; | |
| 215 | 248 | } | |
| 216 | 249 | DuplicateStrategy::Merge => { | |
| 217 | - | match merge_card_into(&state, &existing, &card) { | |
| 250 | + | match merge_card_into(state, &existing, &card) { | |
| 218 | 251 | Ok(()) => merged += 1, | |
| 219 | 252 | Err(e) => errors.push(format!("{}: {}", card.display_name, e)), | |
| 220 | 253 | } | |
| @@ -385,7 +418,7 @@ | |||
| 385 | 418 | /// create path: a phone number that will not insert should not cost the user | |
| 386 | 419 | /// the rest of the merge. | |
| 387 | 420 | fn merge_card_into( | |
| 388 | - | state: &Arc<AppState>, | |
| 421 | + | state: &AppState, | |
| 389 | 422 | existing: &goingson_core::Contact, | |
| 390 | 423 | card: &vcard::ParsedVCard, | |
| 391 | 424 | ) -> Result<(), String> { | |
| @@ -499,7 +532,15 @@ | |||
| 499 | 532 | state: State<'_, Arc<AppState>>, | |
| 500 | 533 | file_path: String, | |
| 501 | 534 | ) -> Result<ImportResult, ApiError> { | |
| 502 | - | let content = read_import_file(&file_path)?; | |
| 535 | + | import_ics_at(&state, &file_path) | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | /// Import an iCalendar file, skipping events already seen by UID. | |
| 539 | + | /// | |
| 540 | + | /// The body of [`import_ics`], lifted with [`preview_ics_at`] and for the same | |
| 541 | + | /// reason. | |
| 542 | + | pub(crate) fn import_ics_at(state: &AppState, file_path: &str) -> Result<ImportResult, ApiError> { | |
| 543 | + | let content = read_import_file(file_path)?; | |
| 503 | 544 | ||
| 504 | 545 | let parsed_events = ical::parse_ics(&content) | |
| 505 | 546 | .map_err(|e| ApiError::internal(format!("Failed to parse ICS: {e}")))?; |
| @@ -28,11 +28,11 @@ | |||
| 28 | 28 | mod email_sync; | |
| 29 | 29 | pub mod error; | |
| 30 | 30 | mod event; | |
| 31 | - | mod export; | |
| 31 | + | pub(crate) mod export; | |
| 32 | 32 | mod form; | |
| 33 | 33 | mod group; | |
| 34 | - | mod import; | |
| 35 | - | mod import_external; | |
| 34 | + | pub(crate) mod import; | |
| 35 | + | pub(crate) mod import_external; | |
| 36 | 36 | mod milestone; | |
| 37 | 37 | mod monthly_review; | |
| 38 | 38 | mod oauth; |
| @@ -41,7 +41,7 @@ | |||
| 41 | 41 | //! exist and both are counted. The count starts falling at the flip. Progress is | |
| 42 | 42 | //! the first list, not the number. | |
| 43 | 43 | //! | |
| 44 | - | //! ## Described, and retires at the flip (27 files, 207 sites) | |
| 44 | + | //! ## Described, and retires at the flip (30 files, 225 sites) | |
| 45 | 45 | //! | |
| 46 | 46 | //! | Module | JS counterpart | Sites | | |
| 47 | 47 | //! |---|---|---| | |
| @@ -56,9 +56,10 @@ | |||
| 56 | 56 | //! | [`settings`] | `settings.js` | 6 | | |
| 57 | 57 | //! | [`board`] | `tasks-kanban.js` 3, `task-board.js` 1 | 4 | | |
| 58 | 58 | //! | [`task_list`] | `tasks.js` 2, `tasks-render.js` 8, `tasks-filter.js` 2, `task-forms.js` 1, `saved-views.js` 1 | 14 | | |
| 59 | + | //! | [`data`] | `import-external.js` 11, `import.js` 5, `export.js` 2 | 18 | | |
| 59 | 60 | //! | |
| 60 | - | //! [`projects`] carries its dashboard as a submodule, which is the twelfth | |
| 61 | - | //! described screen against eleven modules here. | |
| 61 | + | //! [`projects`] carries its dashboard as a submodule, which is the thirteenth | |
| 62 | + | //! described screen against twelve modules here. | |
| 62 | 63 | //! | |
| 63 | 64 | //! ## Stays JavaScript, by decision (4 files, 50 sites) | |
| 64 | 65 | //! | |
| @@ -70,7 +71,7 @@ | |||
| 70 | 71 | //! These 50 never reach zero by porting. Retiring `escape.js` means giving them | |
| 71 | 72 | //! typed escaping some other way, or accepting that four files keep an escaper. | |
| 72 | 73 | //! | |
| 73 | - | //! ## Un-ported screens (7 files, 43 sites) | |
| 74 | + | //! ## Un-ported screens (4 files, 25 sites) | |
| 74 | 75 | //! | |
| 75 | 76 | //! A described counterpart could exist and does not. This is the candidate list, | |
| 76 | 77 | //! and it is the only place to look for what is portable next. | |
| @@ -78,9 +79,14 @@ | |||
| 78 | 79 | //! The task list left this list on 2026-08-15 and is [`task_list`] now. It was | |
| 79 | 80 | //! the largest candidate here and the one this table had to name twice, because | |
| 80 | 81 | //! it is neither [`tasks`] (the single-task drawer at `GET /tasks/{id}`) nor | |
| 81 | - | //! [`board`] (the kanban). | |
| 82 | + | //! [`board`] (the kanban). The three import and export files left it on | |
| 83 | + | //! 2026-08-16 and are [`data`] now — with a caveat this table cannot carry in a | |
| 84 | + | //! count: three exports and the on-demand backup are described nowhere, because | |
| 85 | + | //! a save destination has no word in the vocabulary and a described write cannot | |
| 86 | + | //! be offloaded. Their `esc()` sites are inside files that do retire, so the | |
| 87 | + | //! arithmetic here is honest and the screen is not yet whole. [`data`]'s findings | |
| 88 | + | //! 1 and 2 are what the flip waits on. | |
| 82 | 89 | //! | |
| 83 | - | //! - `import-external.js` 11, `import.js` 5, `export.js` 2. 18 sites. | |
| 84 | 90 | //! - `events.js` 10. Its grid rendering is bespoke; whether the CRUD/list half | |
| 85 | 91 | //! is a real candidate is unchecked, and per this table that is a claim to | |
| 86 | 92 | //! measure rather than assert. | |
| @@ -146,6 +152,7 @@ | |||
| 146 | 152 | ||
| 147 | 153 | pub mod board; | |
| 148 | 154 | pub mod contacts; | |
| 155 | + | pub mod data; | |
| 149 | 156 | pub mod day_planning; | |
| 150 | 157 | pub mod emails; | |
| 151 | 158 | pub mod monthly_review; | |
| @@ -331,6 +338,7 @@ | |||
| 331 | 338 | let router = day_planning::routes(router); | |
| 332 | 339 | let router = board::routes(router); | |
| 333 | 340 | let router = task_list::routes(router); | |
| 341 | + | let router = data::routes(router); | |
| 334 | 342 | emails::routes(router) | |
| 335 | 343 | } | |
| 336 | 344 |
| @@ -33,8 +33,17 @@ | |||
| 33 | 33 | //! about the app.** About is nothing else: the version comes from | |
| 34 | 34 | //! `window.__TAURI__.app.getVersion()`, the platform from `navigator`, and | |
| 35 | 35 | //! whether to offer the app-lock switch at all from asking the OS whether | |
| 36 | - | //! biometry is enrolled. Import & Export is native file dialogs. Sync and | |
| 37 | - | //! Sharing reach a network client through commands that take an `AppHandle`. | |
| 36 | + | //! biometry is enrolled. Sync and Sharing reach a network client through | |
| 37 | + | //! commands that take an `AppHandle`. | |
| 38 | + | //! | |
| 39 | + | //! Import & Export was in that sentence as "native file dialogs" and is half out | |
| 40 | + | //! of it as of 2026-08-16. It is [`data`](super::data), a screen of its own: the | |
| 41 | + | //! imports describe fine, because picking a file to submit is | |
| 42 | + | //! [`FieldKind::File`](makeover_layout::FieldKind::File) and the value travels. | |
| 43 | + | //! It is the *export* half the sentence was right about — a save dialog asks the | |
| 44 | + | //! host where to put something, and nothing in the vocabulary names that. This | |
| 45 | + | //! sidebar still does not offer the section, because what is described lives at | |
| 46 | + | //! `/data` rather than under this screen's addresses. | |
| 38 | 47 | //! | |
| 39 | 48 | //! Half of that is the app's own doing and is fixed here: the theme list also | |
| 40 | 49 | //! needed an `AppHandle`, because the search path is built from the resource and |
| @@ -109,7 +109,17 @@ | |||
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | 111 | /// Options controlling how an import file is parsed. | |
| 112 | - | #[derive(Debug, Clone, Serialize, Deserialize, Default)] | |
| 112 | + | /// | |
| 113 | + | /// `Default` is written out rather than derived, because the derived one said | |
| 114 | + | /// the opposite of what deserializing says. `has_header` deserializes to `true` | |
| 115 | + | /// when the caller leaves it out, which is what every caller does — the JS sends | |
| 116 | + | /// `options: {}` — while a derived `Default` gave `false`, so the same "no | |
| 117 | + | /// options" meant a header row to serde and a data row to Rust. Nothing hit it | |
| 118 | + | /// until a Rust caller wanted the default: the described Import & Export screen, | |
| 119 | + | /// 2026-08-16, whose CSV preview read the header as a task called | |
| 120 | + | /// "description". A default that two paths disagree about is one of them being | |
| 121 | + | /// wrong, and the serde one is the one every file on disk was written against. | |
| 122 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 113 | 123 | #[serde(rename_all = "camelCase")] | |
| 114 | 124 | pub struct ImportOptions { | |
| 115 | 125 | /// Whether the file has a header row. | |
| @@ -124,6 +134,17 @@ | |||
| 124 | 134 | pub extra: std::collections::HashMap<String, String>, | |
| 125 | 135 | } | |
| 126 | 136 | ||
| 137 | + | impl Default for ImportOptions { | |
| 138 | + | fn default() -> Self { | |
| 139 | + | Self { | |
| 140 | + | has_header: default_true(), | |
| 141 | + | delimiter: None, | |
| 142 | + | date_format: None, | |
| 143 | + | extra: std::collections::HashMap::new(), | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 127 | 148 | fn default_true() -> bool { | |
| 128 | 149 | true | |
| 129 | 150 | } | |
| @@ -152,3 +173,19 @@ | |||
| 152 | 173 | /// Error message. | |
| 153 | 174 | pub message: String, | |
| 154 | 175 | } | |
| 176 | + | ||
| 177 | + | #[cfg(test)] | |
| 178 | + | mod tests { | |
| 179 | + | use super::*; | |
| 180 | + | ||
| 181 | + | #[test] | |
| 182 | + | fn the_two_defaults_agree_about_the_header_row() { | |
| 183 | + | // They did not until 2026-08-16: `Default` was derived and said `false` | |
| 184 | + | // where deserializing an absent field says `true`, so "no options" | |
| 185 | + | // meant one thing to a JS caller and the other to a Rust one. Asserted | |
| 186 | + | // together because separately each looks correct. | |
| 187 | + | let deserialized: ImportOptions = serde_json::from_str("{}").unwrap(); | |
| 188 | + | assert!(deserialized.has_header); | |
| 189 | + | assert_eq!(ImportOptions::default().has_header, deserialized.has_header); | |
| 190 | + | } | |
| 191 | + | } |
| @@ -1,0 +1,969 @@ | |||
| 1 | + | //! Import, export and backups, described rather than built. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! The thirteenth screen ported. The shipped counterpart is the Data section of | |
| 6 | + | //! `frontend/js/settings.js` and the three files it opens — `import.js`, | |
| 7 | + | //! `import-external.js` and `export.js`, 18 `esc()` sites between them — exactly | |
| 8 | + | //! as before; see [the module above](super) for why both exist at once. | |
| 9 | + | //! | |
| 10 | + | //! It is a screen of its own rather than a fourth [`settings`](super::settings) | |
| 11 | + | //! section, because the shipped Data section is a menu of five modal wizards and | |
| 12 | + | //! a described screen has no modals to open: what the wizards do inline here is | |
| 13 | + | //! the screen. The settings sidebar does not offer it, which is the same | |
| 14 | + | //! omission it already makes for the other four sections it cannot draw. | |
| 15 | + | //! | |
| 16 | + | //! # What is here, and what the port had to build | |
| 17 | + | //! | |
| 18 | + | //! Every write on this screen already existed as a Tauri command whose body was | |
| 19 | + | //! sync inside an `async fn`. A `quasi_router` handler is a plain | |
| 20 | + | //! `fn(&S, Request)`, so each body was lifted out of its command the way | |
| 21 | + | //! `attach_path` was in 2026-08-09 and both callers now share it: | |
| 22 | + | //! [`preview_csv_at`](crate::commands::import::preview_csv_at), | |
| 23 | + | //! [`execute_csv_at`](crate::commands::import::execute_csv_at), | |
| 24 | + | //! [`preview_vcf_at`](crate::commands::import_external::preview_vcf_at), | |
| 25 | + | //! [`import_vcf_at`](crate::commands::import_external::import_vcf_at), | |
| 26 | + | //! [`preview_ics_at`](crate::commands::import_external::preview_ics_at), | |
| 27 | + | //! [`import_ics_at`](crate::commands::import_external::import_ics_at), | |
| 28 | + | //! [`list_backups_in`](crate::commands::export::list_backups_in) and | |
| 29 | + | //! [`delete_backup_at`](crate::commands::export::delete_backup_at). | |
| 30 | + | //! | |
| 31 | + | //! The backup half needed one more thing. `list_backups`, `delete_backup`, the | |
| 32 | + | //! scheduler and the on-demand path each built | |
| 33 | + | //! `app.path().app_data_dir().join("backups")` for themselves, so where a backup | |
| 34 | + | //! lives was a fact only a Tauri command could ask for. It is | |
| 35 | + | //! [`backup_dir`](crate::backup_scheduler::backup_dir) now, off | |
| 36 | + | //! [`AppState::data_dir`](crate::state::AppState::data_dir), which already held | |
| 37 | + | //! the same directory. That is [`settings`](super::settings)' general answer | |
| 38 | + | //! applied a second time: a host fact a described screen needs is a host fact | |
| 39 | + | //! the app has to put in `S`. | |
| 40 | + | //! | |
| 41 | + | //! # The shape | |
| 42 | + | //! | |
| 43 | + | //! - `GET /data` — the screen. | |
| 44 | + | //! - `POST /data/import/{kind}/preview` — parse the picked file, change nothing. | |
| 45 | + | //! - `POST /data/import/{kind}` — do it. | |
| 46 | + | //! - `POST /data/backups/{name}/restore` — merge a backup back in. | |
| 47 | + | //! - `POST /data/backups/{name}/delete` — remove one. | |
| 48 | + | //! - `POST /data/backups/automatic` — the automatic-backup settings. | |
| 49 | + | //! | |
| 50 | + | //! `{kind}` is `csv`, `contacts` or `calendar`, which is the entity the file | |
| 51 | + | //! holds rather than its extension: the CSV importer detects task/project/event | |
| 52 | + | //! from the header itself, so the address cannot name what is in the file and | |
| 53 | + | //! does not pretend to. | |
| 54 | + | //! | |
| 55 | + | //! A backup is addressed by **file name**, never by the absolute path the | |
| 56 | + | //! shipped screen puts in a `data-a1` attribute on every Restore and Delete | |
| 57 | + | //! button. The name is resolved against [`backup_dir`] here, so the only paths | |
| 58 | + | //! this screen can name are the ones inside it, and [`safe_name`] refuses | |
| 59 | + | //! anything with a separator in it before the resolution happens. | |
| 60 | + | //! `delete_backup_at`'s own canonicalisation check stays where it is: it guards | |
| 61 | + | //! the command as well, and a check moved up to one caller is a check the other | |
| 62 | + | //! caller lost. | |
| 63 | + | //! | |
| 64 | + | //! # Findings | |
| 65 | + | //! | |
| 66 | + | //! **1. A destination cannot be described, so the three exports are absent.** | |
| 67 | + | //! "Export All (JSON)", "Export Tasks (CSV)" and "Export Calendar (ICS)" each | |
| 68 | + | //! open a native save dialog and then call a command with the path that came | |
| 69 | + | //! back. [`FieldKind::File`](makeover_layout::FieldKind::File) covers picking a | |
| 70 | + | //! file to *submit*, which is the import half and is what the three import forms | |
| 71 | + | //! here use; nothing in the vocabulary covers a control that asks the host where | |
| 72 | + | //! to put something and then acts. [`settings`](super::settings) found the same | |
| 73 | + | //! wall from the other side and left its Import & Export section out entirely | |
| 74 | + | //! for it. Left out rather than dangled, to the standard the contacts port set, | |
| 75 | + | //! and filed on quasicoherent. | |
| 76 | + | //! | |
| 77 | + | //! **2. A described write cannot be long-running, so "Create Backup" is | |
| 78 | + | //! absent.** `create_backup` is the one write on this screen that is genuinely | |
| 79 | + | //! async: the gzip write goes to the blocking pool because it takes seconds on a | |
| 80 | + | //! large database, and freezing the UI on it is a fixed performance finding | |
| 81 | + | //! (Perf S6). A handler is sync and has no runtime, so a described Create Backup | |
| 82 | + | //! could only be that freeze, restored. The automatic backups the scheduler | |
| 83 | + | //! takes are unaffected, and the list, the restore and the delete are all here — | |
| 84 | + | //! it is only the "now" button that has nowhere to go. Filed with finding 1. | |
| 85 | + | //! | |
| 86 | + | //! This is quasicoherent `82273265` (a write with a best-effort remote half) | |
| 87 | + | //! from a different direction: there the second half was remote, here it is | |
| 88 | + | //! slow, and both are the same fact that a described write is one synchronous | |
| 89 | + | //! call that answers. | |
| 90 | + | //! | |
| 91 | + | //! **3. A preview and the write it precedes are two requests, and the file may | |
| 92 | + | //! change between them.** The preview parses the path and shows what it holds; | |
| 93 | + | //! the import re-reads the same path. The shipped wizards have the identical | |
| 94 | + | //! hole — they hold `selectedFilePath` in module scope and re-send it — so this | |
| 95 | + | //! is inherited rather than introduced, and worth naming because the described | |
| 96 | + | //! version makes it visible: the path travels in a | |
| 97 | + | //! [`Hidden`](makeover_layout::FieldKind::Hidden) field, in the open, where the | |
| 98 | + | //! JS kept it in a closure. Re-reading is the right answer either way. A backup | |
| 99 | + | //! of the parse would answer for a file that is no longer there. | |
| 100 | + | //! | |
| 101 | + | //! **4. A default that two paths disagree about, found by writing the tests.** | |
| 102 | + | //! `ImportOptions` derived `Default`, so `has_header` was `false` in Rust, while | |
| 103 | + | //! the same field deserializes to `true` when a caller leaves it out — which is | |
| 104 | + | //! what every caller does, since `api.js` sends `options: {}`. The first Rust | |
| 105 | + | //! caller to want the default was this screen, and its CSV preview read the | |
| 106 | + | //! header row as a task called "description". The derive is a hand-written impl | |
| 107 | + | //! now, in `goingson_core`, agreeing with serde. Nothing shipped was wrong: the | |
| 108 | + | //! JS path went through serde and got `true`. What was wrong was that the two | |
| 109 | + | //! answers existed, and the port is what asked the question in Rust for the | |
| 110 | + | //! first time. | |
| 111 | + | //! | |
| 112 | + | //! **5. The duplicate strategy is a choice about the whole import, and it is | |
| 113 | + | //! only offered when it applies.** `import-external.js` draws the three radios | |
| 114 | + | //! only when the file contains cards that already exist, and defaults to Merge | |
| 115 | + | //! when the control is absent. That reads as a workaround and is not one: the | |
| 116 | + | //! question is meaningless with no duplicates, and a control that is always | |
| 117 | + | //! drawn would ask it anyway. The description says the same thing by putting the | |
| 118 | + | //! radio in the preview fragment rather than in the screen. | |
| 119 | + | ||
| 120 | + | // Handlers take their request by value because `quasi_router::Handler` is a | |
| 121 | + | // plain `fn(&S, Request)` pointer, so the signature is the router's and not a | |
| 122 | + | // choice made here. Same allow, for the same reason, as quasi-axum's tests. | |
| 123 | + | #![allow(clippy::needless_pass_by_value)] | |
| 124 | + | ||
| 125 | + | use goingson_core::ImportOptions; | |
| 126 | + | use makeover_layout::{FieldKind, Tone}; | |
| 127 | + | use quasi_router::screen::{Act, Cells, Choice, Column, Field, Row}; | |
| 128 | + | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 129 | + | ||
| 130 | + | use crate::backup_scheduler::backup_dir; | |
| 131 | + | use crate::commands::export::{BackupInfoResponse, RestoreOptions, list_backups_in}; | |
| 132 | + | use crate::commands::import_external::DuplicateStrategy; | |
| 133 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 134 | + | ||
| 135 | + | #[cfg(test)] | |
| 136 | + | mod tests; | |
| 137 | + | ||
| 138 | + | /// How many rows of a parsed file the preview shows. | |
| 139 | + | /// | |
| 140 | + | /// 25, which is what all three shipped wizards slice to, each with its own copy | |
| 141 | + | /// of the number and its own "...and N more" line. | |
| 142 | + | const PREVIEW_ROWS: usize = 25; | |
| 143 | + | ||
| 144 | + | /// The region a preview lands in, and the one an import empties. | |
| 145 | + | const PREVIEW: &str = "data-preview"; | |
| 146 | + | ||
| 147 | + | /// The region holding the list of backups. | |
| 148 | + | const BACKUPS: &str = "data-backups"; | |
| 149 | + | ||
| 150 | + | /// The region holding the automatic-backup settings. | |
| 151 | + | const AUTOMATIC: &str = "data-automatic"; | |
| 152 | + | ||
| 153 | + | /// A file the user picked, as it arrived. | |
| 154 | + | /// | |
| 155 | + | /// The name is `file` because that is what a [`FieldKind::File`] submits under, | |
| 156 | + | /// which is the name the project dashboard's attach route already reads. Blank | |
| 157 | + | /// is what an untouched control sends and is refused rather than passed to the | |
| 158 | + | /// importer, which would answer "Failed to open file: No such file". | |
| 159 | + | fn picked(request: &quasi_router::Request) -> Result<String, RouteError> { | |
| 160 | + | let path = request.payload.get("file").unwrap_or_default().trim(); | |
| 161 | + | if path.is_empty() { | |
| 162 | + | return Err(RouteError::not_found("no file was picked")); | |
| 163 | + | } | |
| 164 | + | Ok(path.to_owned()) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// The three kinds of file this screen imports. | |
| 168 | + | #[derive(Clone, Copy, PartialEq, Eq)] | |
| 169 | + | enum Kind { | |
| 170 | + | /// Tasks, projects or events, whichever the header says. | |
| 171 | + | Csv, | |
| 172 | + | /// Contacts, from a vCard. | |
| 173 | + | Contacts, | |
| 174 | + | /// Events, from an iCalendar file. | |
| 175 | + | Calendar, | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | impl Kind { | |
| 179 | + | /// The kind under this address segment, or 404. | |
| 180 | + | fn of(slug: &str) -> Result<Self, RouteError> { | |
| 181 | + | match slug { | |
| 182 | + | "csv" => Ok(Self::Csv), | |
| 183 | + | "contacts" => Ok(Self::Contacts), | |
| 184 | + | "calendar" => Ok(Self::Calendar), | |
| 185 | + | _ => Err(RouteError::not_found("nothing imports that")), | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// The segment it travels as. | |
| 190 | + | const fn slug(self) -> &'static str { | |
| 191 | + | match self { | |
| 192 | + | Self::Csv => "csv", | |
| 193 | + | Self::Contacts => "contacts", | |
| 194 | + | Self::Calendar => "calendar", | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// What the field asks for. | |
| 199 | + | const fn label(self) -> &'static str { | |
| 200 | + | match self { | |
| 201 | + | Self::Csv => "CSV or TSV file", | |
| 202 | + | Self::Contacts => "vCard file", | |
| 203 | + | Self::Calendar => "iCalendar file", | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// Standing help under the field, which is where the shipped wizard's | |
| 208 | + | /// paragraph of column names belongs once there is no modal to head. | |
| 209 | + | const fn hint(self) -> &'static str { | |
| 210 | + | match self { | |
| 211 | + | Self::Csv => { | |
| 212 | + | "Columns are matched by name: description, due, priority, project and tags for \ | |
| 213 | + | tasks; start and end for events; name and type for projects. The kind is read \ | |
| 214 | + | from the header." | |
| 215 | + | } | |
| 216 | + | Self::Contacts => "Cards already here are matched by email address.", | |
| 217 | + | Self::Calendar => "Events already here are matched by their UID.", | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /// The kind under the request's `{kind}` capture, or 404. | |
| 222 | + | fn from(request: &quasi_router::Request) -> Result<Self, RouteError> { | |
| 223 | + | Self::of( | |
| 224 | + | request | |
| 225 | + | .captures | |
| 226 | + | .get("kind") | |
| 227 | + | .ok_or_else(|| RouteError::not_found("no kind"))?, | |
| 228 | + | ) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | /// One import's form: pick a file, see what is in it. | |
| 233 | + | fn import_form(kind: Kind) -> Node { | |
| 234 | + | Node::Form { | |
| 235 | + | action: Action::post(format!("/data/import/{}/preview", kind.slug())), | |
| 236 | + | submit: "Preview".to_owned(), | |
| 237 | + | fields: vec![ | |
| 238 | + | Field::new(FieldKind::File, "file", kind.label()) | |
| 239 | + | .required() | |
| 240 | + | .hint(kind.hint()), | |
| 241 | + | ], | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /// The import half of the screen. | |
| 246 | + | fn import_region() -> Slot { | |
| 247 | + | Slot::new("data-import", RegionKind::Pane) | |
| 248 | + | .with(Node::section("Import")) | |
| 249 | + | .with(Node::text( | |
| 250 | + | "Nothing is created until the preview is confirmed.", | |
| 251 | + | )) | |
| 252 | + | .with(import_form(Kind::Csv)) | |
| 253 | + | .with(import_form(Kind::Contacts)) | |
| 254 | + | .with(import_form(Kind::Calendar)) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// The form that commits a previewed import. | |
| 258 | + | /// | |
| 259 | + | /// The path travels in a hidden field rather than in the address, so the two | |
| 260 | + | /// requests agree about which file without the screen holding state between | |
| 261 | + | /// them. See finding 3 for what that does and does not guarantee. | |
| 262 | + | fn confirm_form(kind: Kind, path: &str, submit: String, extra: Vec<Field>) -> Node { | |
| 263 | + | let mut fields = vec![Field::new(FieldKind::Hidden, "file", "File").value(path)]; | |
| 264 | + | fields.extend(extra); | |
| 265 | + | Node::Form { | |
| 266 | + | action: Action::post(format!("/data/import/{}", kind.slug())), | |
| 267 | + | submit, | |
| 268 | + | fields, | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// The choice offered when a vCard holds cards that are already here. | |
| 273 | + | /// | |
| 274 | + | /// Absent when there are none, which is finding 5. The values are the words | |
| 275 | + | /// [`DuplicateStrategy`] deserialises from, so the control and the enum cannot | |
| 276 | + | /// drift apart. | |
| 277 | + | fn duplicate_choice(duplicates: usize) -> Vec<Field> { | |
| 278 | + | if duplicates == 0 { | |
| 279 | + | return Vec::new(); | |
| 280 | + | } | |
| 281 | + | let label = if duplicates == 1 { | |
| 282 | + | "1 contact is already here".to_owned() | |
| 283 | + | } else { | |
| 284 | + | format!("{duplicates} contacts are already here") | |
| 285 | + | }; | |
| 286 | + | vec![ | |
| 287 | + | Field::radio( | |
| 288 | + | "duplicates", | |
| 289 | + | label, | |
| 290 | + | vec![ | |
| 291 | + | Choice::new( | |
| 292 | + | "merge", | |
| 293 | + | "Merge into the existing contact: fill blank fields, add new emails and \ | |
| 294 | + | phones, never overwrite", | |
| 295 | + | ), | |
| 296 | + | Choice::new("skip", "Skip them"), | |
| 297 | + | Choice::new("importAsNew", "Import them as new contacts"), | |
| 298 | + | ], | |
| 299 | + | ) | |
| 300 | + | .value("merge") | |
| 301 | + | .hint("One choice for the whole import."), | |
| 302 | + | ] | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// The preview table, and the line saying what it left out. | |
| 306 | + | fn preview_table(columns: &[&str], rows: Vec<Cells>, total: usize) -> Vec<Node> { | |
| 307 | + | let mut out = vec![Node::Table { | |
| 308 | + | columns: columns.iter().map(|name| Column::new(*name)).collect(), | |
| 309 | + | rows, | |
| 310 | + | // A parsed file is not a page of a query: every row is already in hand, | |
| 311 | + | // and the 25 shown are a reading convenience rather than a window that | |
| 312 | + | // could be widened. `Rest` would describe an address that fetches more, | |
| 313 | + | // and there is none. | |
| 314 | + | more: None, | |
| 315 | + | }]; | |
| 316 | + | if total > PREVIEW_ROWS { | |
| 317 | + | out.push(Node::text(format!( | |
| 318 | + | "Showing the first {PREVIEW_ROWS} of {total}.", | |
| 319 | + | ))); | |
| 320 | + | } | |
| 321 | + | out | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | /// A parsed file, said back to the user before anything is written. | |
| 325 | + | fn preview_region(nodes: Vec<Node>) -> Node { | |
| 326 | + | Node::Region(Slot::new(PREVIEW, RegionKind::Pane).extend(nodes)) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | /// The empty preview, which is what the screen opens with and what an import | |
| 330 | + | /// leaves behind. | |
| 331 | + | fn no_preview() -> Node { | |
| 332 | + | preview_region(vec![Node::empty( | |
| 333 | + | "Pick a file above to see what importing it would do.", | |
| 334 | + | )]) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | /// One value in a preview cell, shortened the way the shipped table shortens it. | |
| 338 | + | /// | |
| 339 | + | /// The shipped row puts the whole value in a `title=` and the first 50 | |
| 340 | + | /// characters in the cell. A description has no word for text that appears on | |
| 341 | + | /// hover — and should not grow one, since hover is absent on a touch screen and | |
| 342 | + | /// on a keyboard — so the same rule the problems port followed applies: the | |
| 343 | + | /// truncation stays and the tooltip does not come back as anything. | |
| 344 | + | fn short(value: &str) -> String { | |
| 345 | + | if value.chars().count() > 50 { | |
| 346 | + | let kept: String = value.chars().take(50).collect(); | |
| 347 | + | format!("{kept}...") | |
| 348 | + | } else { | |
| 349 | + | value.to_owned() | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | /// What a CSV file holds. | |
| 354 | + | /// | |
| 355 | + | /// Takes no state: the CSV preview is a parse and nothing else, and the project | |
| 356 | + | /// names a task row might resolve against are looked up by the write rather than | |
| 357 | + | /// by the dry run. | |
| 358 | + | fn csv_preview(path: &str) -> Result<Node, RouteError> { | |
| 359 | + | let parsed = crate::commands::import::preview_csv_at(path, &ImportOptions::default()) | |
| 360 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 361 | + | ||
| 362 | + | let mut nodes = Vec::new(); | |
| 363 | + | if parsed.items.is_empty() { | |
| 364 | + | nodes.push(Node::empty("No rows in that file.")); | |
| 365 | + | } else { | |
| 366 | + | let kind = match parsed.entity_type { | |
| 367 | + | goingson_core::ImportEntityType::Task => "task", | |
| 368 | + | goingson_core::ImportEntityType::Project => "project", | |
| 369 | + | goingson_core::ImportEntityType::Event => "event", | |
| 370 | + | }; | |
| 371 | + | let total = parsed.items.len(); | |
| 372 | + | nodes.push(Node::section(if total == 1 { | |
| 373 | + | format!("1 {kind}") | |
| 374 | + | } else { | |
| 375 | + | format!("{total} {kind}s") | |
| 376 | + | })); | |
| 377 | + | ||
| 378 | + | let (columns, rows) = csv_rows(&parsed); | |
| 379 | + | nodes.extend(preview_table(&columns, rows, total)); | |
| 380 | + | nodes.push(confirm_form( | |
| 381 | + | Kind::Csv, | |
| 382 | + | path, | |
| 383 | + | format!("Import {total} {kind}{}", if total == 1 { "" } else { "s" }), | |
| 384 | + | Vec::new(), | |
| 385 | + | )); | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // Warnings after the table: they are about rows that will not arrive, which | |
| 389 | + | // is only readable once it is clear what will. | |
| 390 | + | for warning in &parsed.warnings { | |
| 391 | + | nodes.push(Node::banner(Tone::Warning, warning)); | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | Ok(preview_region(nodes)) | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | /// The columns a parsed CSV shows, and its rows in that order. | |
| 398 | + | /// | |
| 399 | + | /// The shipped `getColumnsForEntityType` keys into the item's camelCase `data` | |
| 400 | + | /// object; here the parse is already typed, so a column is a match arm rather | |
| 401 | + | /// than a string key that can miss. | |
| 402 | + | fn csv_rows(parsed: &goingson_core::ImportParseResult) -> (Vec<&'static str>, Vec<Cells>) { | |
| 403 | + | use goingson_core::ImportItemData; | |
| 404 | + | ||
| 405 | + | let columns = match parsed.entity_type { | |
| 406 | + | goingson_core::ImportEntityType::Task => vec!["Description", "Project", "Priority", "Due"], | |
| 407 | + | goingson_core::ImportEntityType::Project => vec!["Name", "Description", "Type", "Status"], | |
| 408 | + | goingson_core::ImportEntityType::Event => vec!["Title", "Start", "End", "Location"], | |
| 409 | + | }; | |
| 410 | + | ||
| 411 | + | let blank = String::new(); | |
| 412 | + | let rows = parsed | |
| 413 | + | .items | |
| 414 | + | .iter() | |
| 415 | + | .take(PREVIEW_ROWS) | |
| 416 | + | .map(|item| match &item.data { | |
| 417 | + | ImportItemData::Task(task) => Cells::new([ | |
| 418 | + | short(&task.description), | |
| 419 | + | short(task.project_name.as_ref().unwrap_or(&blank)), | |
| 420 | + | short(task.priority.as_ref().unwrap_or(&blank)), | |
| 421 | + | short(task.due.as_ref().unwrap_or(&blank)), | |
| 422 | + | ]), | |
| 423 | + | ImportItemData::Project(project) => Cells::new([ | |
| 424 | + | short(&project.name), | |
| 425 | + | short(project.description.as_ref().unwrap_or(&blank)), | |
| 426 | + | short(project.project_type.as_ref().unwrap_or(&blank)), | |
| 427 | + | short(project.status.as_ref().unwrap_or(&blank)), | |
| 428 | + | ]), | |
| 429 | + | ImportItemData::Event(event) => Cells::new([ | |
| 430 | + | short(&event.title), | |
| 431 | + | short(&event.start), | |
| 432 | + | short(event.end.as_ref().unwrap_or(&blank)), | |
| 433 | + | short(event.location.as_ref().unwrap_or(&blank)), | |
| 434 | + | ]), | |
| 435 | + | }) | |
| 436 | + | .collect(); | |
| 437 | + | ||
| 438 | + | (columns, rows) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | /// What a vCard file holds. | |
| 442 | + | fn contacts_preview(state: &AppState, path: &str) -> Result<Node, RouteError> { | |
| 443 | + | let cards = crate::commands::import_external::preview_vcf_at(state, path) | |
| 444 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 445 | + | ||
| 446 | + | if cards.is_empty() { | |
| 447 | + | return Ok(preview_region(vec![Node::empty( | |
| 448 | + | "No contacts in that file.", | |
| 449 | + | )])); | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | let total = cards.len(); | |
| 453 | + | let duplicates = cards | |
| 454 | + | .iter() | |
| 455 | + | .filter(|card| card.duplicate_of.is_some()) | |
| 456 | + | .count(); | |
| 457 | + | ||
| 458 | + | let rows = cards | |
| 459 | + | .iter() | |
| 460 | + | .take(PREVIEW_ROWS) | |
| 461 | + | .map(|card| { | |
| 462 | + | let mut status = Cells::new([ | |
| 463 | + | short(&card.display_name), | |
| 464 | + | short(card.company.as_deref().unwrap_or_default()), | |
| 465 | + | card.email_count.to_string(), | |
| 466 | + | card.phone_count.to_string(), | |
| 467 | + | ]); | |
| 468 | + | // The shipped cell says "Already exists" and hides which contact it | |
| 469 | + | // matched in a `title=`. The name is the useful half and it is a | |
| 470 | + | // fact, so it is said. | |
| 471 | + | status.values.push( | |
| 472 | + | card.duplicate_of | |
| 473 | + | .as_ref() | |
| 474 | + | .map_or_else(quasi_router::screen::Cell::default, |existing| { | |
| 475 | + | quasi_router::screen::Cell::new(format!("Matches {existing}")) | |
| 476 | + | }), | |
| 477 | + | ); | |
| 478 | + | status | |
| 479 | + | }) | |
| 480 | + | .collect(); | |
| 481 | + | ||
| 482 | + | let mut nodes = vec![Node::section(if total == 1 { | |
| 483 | + | "1 contact".to_owned() | |
| 484 | + | } else { | |
| 485 | + | format!("{total} contacts") | |
| 486 | + | })]; | |
| 487 | + | nodes.extend(preview_table( | |
| 488 | + | &["Name", "Company", "Emails", "Phones", "Status"], | |
| 489 | + | rows, | |
| 490 | + | total, | |
| 491 | + | )); | |
| 492 | + | nodes.push(confirm_form( | |
| 493 | + | Kind::Contacts, | |
| 494 | + | path, | |
| 495 | + | format!( | |
| 496 | + | "Import {total} contact{}", | |
| 497 | + | if total == 1 { "" } else { "s" } | |
| 498 | + | ), | |
| 499 | + | duplicate_choice(duplicates), | |
| 500 | + | )); |
Lines truncated
| @@ -1,0 +1,583 @@ | |||
| 1 | + | //! Import, export and backups, driven through the router against a real | |
| 2 | + | //! database and real files on disk. | |
| 3 | + | //! | |
| 4 | + | //! Same standard as the screens before it: no Tauri runtime and no window, the | |
| 5 | + | //! description asserted, and the markup only where the markup is the point. | |
| 6 | + | //! Every finding this port recorded is asserted here rather than left to be | |
| 7 | + | //! noticed, so closing one is a test that has to change. | |
| 8 | + | //! | |
| 9 | + | //! Files are real because the writes are: every route on this screen reads a | |
| 10 | + | //! path off disk, and a test that faked the read would be testing a different | |
| 11 | + | //! function than the one that runs. | |
| 12 | + | ||
| 13 | + | use std::sync::Arc; | |
| 14 | + | ||
| 15 | + | use quasi_http::Serves as _; | |
| 16 | + | use quasi_router::{Outcome, Params, Request, Response}; | |
| 17 | + | use tempfile::TempDir; | |
| 18 | + | ||
| 19 | + | use super::super::router; | |
| 20 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 21 | + | ||
| 22 | + | /// A state whose data directory is this test's own, so the backups one test | |
| 23 | + | /// writes are invisible to the next. | |
| 24 | + | /// | |
| 25 | + | /// `test_utils` hands out `/tmp/goingson-test` for every test at once, which is | |
| 26 | + | /// fine for a path nothing reads and wrong for this screen: the backups list is | |
| 27 | + | /// a directory walk. | |
| 28 | + | async fn state() -> (Arc<AppState>, TempDir) { | |
| 29 | + | let (mut state, _) = crate::test_utils::setup_test_state().await; | |
| 30 | + | let dir = tempfile::tempdir().unwrap(); | |
| 31 | + | ||
| 32 | + | let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); | |
| 33 | + | state | |
| 34 | + | .db | |
| 35 | + | .conn() | |
| 36 | + | .unwrap() | |
| 37 | + | .execute( | |
| 38 | + | "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ | |
| 39 | + | VALUES (?, ?, ?, ?, ?)", | |
| 40 | + | rusqlite::params![ | |
| 41 | + | DESKTOP_USER_ID.to_string(), | |
| 42 | + | "desktop@localhost", | |
| 43 | + | "x", | |
| 44 | + | "Desktop User", | |
| 45 | + | &now, | |
| 46 | + | ], | |
| 47 | + | ) | |
| 48 | + | .unwrap(); | |
| 49 | + | ||
| 50 | + | Arc::get_mut(&mut state).expect("sole owner").data_dir = dir.path().to_path_buf(); | |
| 51 | + | (state, dir) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | fn get(state: &AppState, path: &str) -> Response { | |
| 55 | + | router() | |
| 56 | + | .handle(state, Request::get(path)) | |
| 57 | + | .expect("the route answers") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | fn post(state: &AppState, path: &str, params: Params) -> Response { | |
| 61 | + | router() | |
| 62 | + | .handle(state, Request::post(path).sending(params)) | |
| 63 | + | .expect("the route answers") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | fn html(response: Response) -> String { | |
| 67 | + | match response.outcome { | |
| 68 | + | Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen), | |
| 69 | + | Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node), | |
| 70 | + | Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), | |
| 71 | + | Outcome::Over(_) => panic!("expected content, got a screen drawn over it"), | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// What the response says in a toast, if it says anything. | |
| 76 | + | fn said(response: &Response) -> String { | |
| 77 | + | response | |
| 78 | + | .notice | |
| 79 | + | .as_ref() | |
| 80 | + | .map(|notice| notice.text.clone()) | |
| 81 | + | .unwrap_or_default() | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Write a file into this test's directory and answer its path. | |
| 85 | + | fn file(dir: &TempDir, name: &str, content: &str) -> String { | |
| 86 | + | let path = dir.path().join(name); | |
| 87 | + | std::fs::write(&path, content).unwrap(); | |
| 88 | + | path.to_string_lossy().into_owned() | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// A picked file, as the `FieldKind::File` control submits one. | |
| 92 | + | fn picked(path: &str) -> Params { | |
| 93 | + | Params::new().with("file", path) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | const TASKS_CSV: &str = "description,priority,project\n\ | |
| 97 | + | Describe the import screens,High,GoingsOn\n\ | |
| 98 | + | Write the tests,Medium,GoingsOn\n"; | |
| 99 | + | ||
| 100 | + | const ONE_CARD: &str = "BEGIN:VCARD\r\n\ | |
| 101 | + | VERSION:3.0\r\n\ | |
| 102 | + | FN:Jane Smith\r\n\ | |
| 103 | + | EMAIL;TYPE=WORK:jane@example.com\r\n\ | |
| 104 | + | ORG:Acme Corp\r\n\ | |
| 105 | + | END:VCARD\r\n"; | |
| 106 | + | ||
| 107 | + | const ONE_EVENT: &str = "BEGIN:VCALENDAR\r\n\ | |
| 108 | + | VERSION:2.0\r\n\ | |
| 109 | + | BEGIN:VEVENT\r\n\ | |
| 110 | + | UID:one@example.com\r\n\ | |
| 111 | + | SUMMARY:Team Meeting\r\n\ | |
| 112 | + | DTSTART:20260415T100000Z\r\n\ | |
| 113 | + | DTEND:20260415T110000Z\r\n\ | |
| 114 | + | LOCATION:Conference Room A\r\n\ | |
| 115 | + | END:VEVENT\r\n\ | |
| 116 | + | END:VCALENDAR\r\n"; | |
| 117 | + | ||
| 118 | + | #[tokio::test] | |
| 119 | + | async fn the_screen_offers_the_three_imports_and_says_nothing_it_cannot_do() { | |
| 120 | + | let (state, _dir) = state().await; | |
| 121 | + | let page = html(get(&state, "/data")); | |
| 122 | + | ||
| 123 | + | assert!(page.contains("CSV or TSV file")); | |
| 124 | + | assert!(page.contains("vCard file")); | |
| 125 | + | assert!(page.contains("iCalendar file")); | |
| 126 | + | assert!(page.contains("/data/import/csv/preview")); | |
| 127 | + | ||
| 128 | + | // Findings 1 and 2. The three exports and Create Backup are absent rather | |
| 129 | + | // than drawn as controls that do nothing: a destination has no word in the | |
| 130 | + | // vocabulary, and a described write cannot be offloaded. A control that is | |
| 131 | + | // drawn and does nothing is worse than one that is not drawn. | |
| 132 | + | for missing in [ | |
| 133 | + | "Export All", | |
| 134 | + | "Export Tasks", | |
| 135 | + | "Export Calendar", | |
| 136 | + | "Create Backup", | |
| 137 | + | ] { | |
| 138 | + | assert!(!page.contains(missing), "should not offer: {missing}"); | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[tokio::test] | |
| 143 | + | async fn a_csv_preview_says_what_is_in_the_file_and_creates_nothing() { | |
| 144 | + | let (state, dir) = state().await; | |
| 145 | + | let path = file(&dir, "tasks.csv", TASKS_CSV); | |
| 146 | + | ||
| 147 | + | let page = html(post(&state, "/data/import/csv/preview", picked(&path))); | |
| 148 | + | ||
| 149 | + | assert!(page.contains("2 tasks")); | |
| 150 | + | assert!(page.contains("Describe the import screens")); | |
| 151 | + | assert!(page.contains("Write the tests")); | |
| 152 | + | // The kind is read off the header, not off the extension, so the confirm | |
| 153 | + | // control can name what it is about to make. | |
| 154 | + | assert!(page.contains("Import 2 tasks")); | |
| 155 | + | // A dry run. Nothing exists yet. | |
| 156 | + | assert_eq!(state.tasks.list_all(DESKTOP_USER_ID).unwrap().len(), 0); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[tokio::test] | |
| 160 | + | async fn importing_a_csv_creates_the_rows_and_clears_the_preview() { | |
| 161 | + | let (state, dir) = state().await; | |
| 162 | + | let path = file(&dir, "tasks.csv", TASKS_CSV); | |
| 163 | + | ||
| 164 | + | let response = post(&state, "/data/import/csv", picked(&path)); | |
| 165 | + | assert!(said(&response).contains("Imported 2")); | |
| 166 | + | ||
| 167 | + | let tasks = state.tasks.list_all(DESKTOP_USER_ID).unwrap(); | |
| 168 | + | assert_eq!(tasks.len(), 2); | |
| 169 | + | ||
| 170 | + | // The preview region is answered emptied: the rows have gone somewhere else, | |
| 171 | + | // and a preview of a file that has been imported is a stale answer. | |
| 172 | + | let page = html(response); | |
| 173 | + | assert!(page.contains("Pick a file above")); | |
| 174 | + | assert!(!page.contains("Describe the import screens")); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[tokio::test] | |
| 178 | + | async fn a_vcard_preview_offers_the_duplicate_choice_only_when_there_are_duplicates() { | |
| 179 | + | // Finding 5, asserted from both sides. | |
| 180 | + | let (state, dir) = state().await; | |
| 181 | + | let path = file(&dir, "one.vcf", ONE_CARD); | |
| 182 | + | ||
| 183 | + | let page = html(post(&state, "/data/import/contacts/preview", picked(&path))); | |
| 184 | + | assert!(page.contains("1 contact")); | |
| 185 | + | assert!(page.contains("Jane Smith")); | |
| 186 | + | assert!(!page.contains("already here"), "nothing to ask about yet"); | |
| 187 | + | ||
| 188 | + | // Import it, then preview the same file again: now every card matches. | |
| 189 | + | post(&state, "/data/import/contacts", picked(&path)); | |
| 190 | + | let page = html(post(&state, "/data/import/contacts/preview", picked(&path))); | |
| 191 | + | ||
| 192 | + | assert!(page.contains("1 contact is already here")); | |
| 193 | + | assert!(page.contains("Merge into the existing contact")); | |
| 194 | + | assert!(page.contains("Skip them")); | |
| 195 | + | assert!(page.contains("Import them as new contacts")); | |
| 196 | + | // And it says which contact was matched, which the shipped table hides in a | |
| 197 | + | // title attribute. | |
| 198 | + | assert!(page.contains("Matches Jane Smith")); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[tokio::test] | |
| 202 | + | async fn the_duplicate_choice_is_what_the_import_does() { | |
| 203 | + | let (state, dir) = state().await; | |
| 204 | + | let path = file(&dir, "one.vcf", ONE_CARD); | |
| 205 | + | ||
| 206 | + | post(&state, "/data/import/contacts", picked(&path)); | |
| 207 | + | assert_eq!(state.contacts.list_all(DESKTOP_USER_ID).unwrap().len(), 1); | |
| 208 | + | ||
| 209 | + | // Skip leaves the contact alone. | |
| 210 | + | let response = post( | |
| 211 | + | &state, | |
| 212 | + | "/data/import/contacts", | |
| 213 | + | picked(&path).with("duplicates", "skip"), | |
| 214 | + | ); | |
| 215 | + | assert!(said(&response).contains("1 already here")); | |
| 216 | + | assert_eq!(state.contacts.list_all(DESKTOP_USER_ID).unwrap().len(), 1); | |
| 217 | + | ||
| 218 | + | // Import as new makes a second one. | |
| 219 | + | post( | |
| 220 | + | &state, | |
| 221 | + | "/data/import/contacts", | |
| 222 | + | picked(&path).with("duplicates", "importAsNew"), | |
| 223 | + | ); | |
| 224 | + | assert_eq!(state.contacts.list_all(DESKTOP_USER_ID).unwrap().len(), 2); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | #[tokio::test] | |
| 228 | + | async fn a_calendar_file_previews_and_imports() { | |
| 229 | + | let (state, dir) = state().await; | |
| 230 | + | let path = file(&dir, "one.ics", ONE_EVENT); | |
| 231 | + | ||
| 232 | + | let page = html(post(&state, "/data/import/calendar/preview", picked(&path))); | |
| 233 | + | assert!(page.contains("1 event")); | |
| 234 | + | assert!(page.contains("Team Meeting")); | |
| 235 | + | assert!(page.contains("Conference Room A")); | |
| 236 | + | ||
| 237 | + | let response = post(&state, "/data/import/calendar", picked(&path)); | |
| 238 | + | assert!(said(&response).contains("1 imported")); | |
| 239 | + | assert_eq!(state.events.list_all(DESKTOP_USER_ID).unwrap().len(), 1); | |
| 240 | + | ||
| 241 | + | // Twice is once: the UID is the dedup key, and the sentence says so rather | |
| 242 | + | // than claiming another import happened. | |
| 243 | + | let response = post(&state, "/data/import/calendar", picked(&path)); | |
| 244 | + | assert!(said(&response).contains("1 already here")); | |
| 245 | + | assert_eq!(state.events.list_all(DESKTOP_USER_ID).unwrap().len(), 1); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[tokio::test] | |
| 249 | + | async fn an_empty_file_says_so_and_offers_no_way_to_import_it() { | |
| 250 | + | let (state, dir) = state().await; | |
| 251 | + | let path = file(&dir, "empty.vcf", ""); | |
| 252 | + | ||
| 253 | + | let page = html(post(&state, "/data/import/contacts/preview", picked(&path))); | |
| 254 | + | assert!(page.contains("No contacts in that file.")); | |
| 255 | + | assert!(!page.contains("/data/import/contacts\"")); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[tokio::test] | |
| 259 | + | async fn a_csv_the_parser_complains_about_keeps_its_warnings() { | |
| 260 | + | let (state, dir) = state().await; | |
| 261 | + | // A row with no description is a row the task importer cannot use. | |
| 262 | + | let path = file( | |
| 263 | + | &dir, | |
| 264 | + | "partial.csv", | |
| 265 | + | "description,priority\nA real task,High\n,Low\n", | |
| 266 | + | ); | |
| 267 | + | ||
| 268 | + | let page = html(post(&state, "/data/import/csv/preview", picked(&path))); | |
| 269 | + | assert!(page.contains("A real task")); | |
| 270 | + | assert!( | |
| 271 | + | page.contains("Row"), | |
| 272 | + | "the parser's warning is carried: {page}" | |
| 273 | + | ); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[tokio::test] | |
| 277 | + | async fn a_request_with_no_file_on_it_is_refused() { | |
| 278 | + | let (state, _dir) = state().await; | |
| 279 | + | ||
| 280 | + | let error = router() | |
| 281 | + | .handle( | |
| 282 | + | &state, | |
| 283 | + | Request::post("/data/import/csv/preview").sending(Params::new().with("file", " ")), | |
| 284 | + | ) | |
| 285 | + | .expect_err("an untouched control sends nothing"); | |
| 286 | + | assert_eq!(error.class.http_status(), 404); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | #[tokio::test] | |
| 290 | + | async fn a_kind_nothing_imports_is_a_not_found() { | |
| 291 | + | let (state, _dir) = state().await; | |
| 292 | + | ||
| 293 | + | let error = router() | |
| 294 | + | .handle( | |
| 295 | + | &state, | |
| 296 | + | Request::post("/data/import/spreadsheet/preview").sending(picked("/tmp/x")), | |
| 297 | + | ) | |
| 298 | + | .expect_err("three kinds and no others"); | |
| 299 | + | assert_eq!(error.class.http_status(), 404); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | #[tokio::test] | |
| 303 | + | async fn a_file_that_is_not_there_is_an_error_rather_than_an_empty_preview() { | |
| 304 | + | let (state, dir) = state().await; | |
| 305 | + | let missing = dir.path().join("nothing.csv"); | |
| 306 | + | ||
| 307 | + | let error = router() | |
| 308 | + | .handle( | |
| 309 | + | &state, | |
| 310 | + | Request::post("/data/import/csv/preview").sending(picked(&missing.to_string_lossy())), | |
| 311 | + | ) | |
| 312 | + | .expect_err("the importer cannot open it"); | |
| 313 | + | assert_eq!(error.class.http_status(), 500); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | #[tokio::test] | |
| 317 | + | async fn with_no_backups_the_list_says_so_rather_than_being_blank() { | |
| 318 | + | let (state, _dir) = state().await; | |
| 319 | + | let page = html(get(&state, "/data")); | |
| 320 | + | ||
| 321 | + | assert!(page.contains("No backups yet")); | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | /// Put a file in the backup directory that looks like a backup. | |
| 325 | + | /// | |
| 326 | + | /// Enough for the list, the delete and the addressing. The restore test writes a | |
| 327 | + | /// real one, because that is the only route that reads the contents. | |
| 328 | + | fn seed_backup(state: &AppState, name: &str) -> std::path::PathBuf { | |
| 329 | + | let dir = crate::backup_scheduler::backup_dir(state); | |
| 330 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 331 | + | let path = dir.join(name); | |
| 332 | + | std::fs::write(&path, b"not really gzip").unwrap(); | |
| 333 | + | path | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | #[tokio::test] | |
| 337 | + | async fn a_backup_is_listed_with_what_it_is_and_what_can_be_done_to_it() { | |
| 338 | + | let (state, _dir) = state().await; | |
| 339 | + | seed_backup(&state, "goingson-backup-20260816-120000-abcd1234.json.gz"); | |
| 340 | + | ||
| 341 | + | let page = html(get(&state, "/data")); | |
| 342 | + | ||
| 343 | + | assert!(page.contains("goingson-backup-20260816-120000-abcd1234.json.gz")); | |
| 344 | + | assert!(page.contains("bytes")); | |
| 345 | + | // Both destructive controls carry their question, which is Act::confirm | |
| 346 | + | // rather than a JS helper at the call site. | |
| 347 | + | assert!(page.contains("Restore from this backup?")); | |
| 348 | + | assert!(page.contains("Delete this backup?")); | |
| 349 | + | // Addressed by name. The absolute path the shipped screen puts on every | |
| 350 | + | // button is never in the markup. | |
| 351 | + | assert!(!page.contains(&state.data_dir.to_string_lossy().into_owned())); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | #[tokio::test] | |
| 355 | + | async fn deleting_a_backup_removes_it_and_answers_with_the_list() { | |
| 356 | + | let (state, _dir) = state().await; | |
| 357 | + | let name = "goingson-backup-20260816-120000-abcd1234.json.gz"; | |
| 358 | + | let path = seed_backup(&state, name); | |
| 359 | + | ||
| 360 | + | let response = post( | |
| 361 | + | &state, | |
| 362 | + | &format!("/data/backups/{name}/delete"), | |
| 363 | + | Params::new(), | |
| 364 | + | ); | |
| 365 | + | assert!(said(&response).contains("Deleted")); | |
| 366 | + | assert!(!path.exists()); | |
| 367 | + | ||
| 368 | + | let page = html(response); | |
| 369 | + | assert!(page.contains("No backups yet")); | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | #[tokio::test] | |
| 373 | + | async fn a_backup_that_is_already_gone_is_said_rather_than_claimed() { | |
| 374 | + | let (state, _dir) = state().await; | |
| 375 | + | let name = "goingson-backup-20260816-120000-abcd1234.json.gz"; | |
| 376 | + | ||
| 377 | + | let response = post( | |
| 378 | + | &state, | |
| 379 | + | &format!("/data/backups/{name}/delete"), | |
| 380 | + | Params::new(), | |
| 381 | + | ); | |
| 382 | + | assert!(said(&response).contains("already gone")); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | #[tokio::test] | |
| 386 | + | async fn a_name_that_is_not_a_backup_in_the_backup_directory_is_refused() { | |
| 387 | + | let (state, _dir) = state().await; | |
| 388 | + | // A neighbour of the backup directory, reached the way a hand-typed request | |
| 389 | + | // would reach it. Both halves of safe_name: the traversal and the suffix. | |
| 390 | + | let outside = state.data_dir.join("goingson.db"); | |
| 391 | + | std::fs::write(&outside, b"the database").unwrap(); | |
| 392 | + | ||
| 393 | + | for name in [ | |
| 394 | + | "../goingson.db", | |
| 395 | + | "..%2Fgoingson.db", | |
| 396 | + | "goingson.db", | |
| 397 | + | "notes.txt", | |
| 398 | + | ] { | |
| 399 | + | let error = router() | |
| 400 | + | .handle( | |
| 401 | + | &state, | |
| 402 | + | Request::post(format!("/data/backups/{name}/delete")), | |
| 403 | + | ) | |
| 404 | + | .expect_err("only backups, only in the backup directory"); | |
| 405 | + | assert_eq!(error.class.http_status(), 404, "should refuse {name}"); | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | assert!( | |
| 409 | + | outside.exists(), | |
| 410 | + | "nothing outside the directory was touched" | |
| 411 | + | ); | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | #[tokio::test(flavor = "multi_thread")] | |
| 415 | + | async fn a_real_backup_restores_and_says_how_much_came_back() { | |
| 416 | + | // Multi-thread because the writer bridges the fetches onto the blocking | |
| 417 | + | // pool, which is the same reason the scheduler's own round-trip test does. | |
| 418 | + | let (state, _dir) = state().await; | |
| 419 | + | let project = state | |
| 420 | + | .projects | |
| 421 | + | .create( | |
| 422 | + | DESKTOP_USER_ID, | |
| 423 | + | goingson_core::NewProject { | |
| 424 | + | name: "Restored project".into(), | |
| 425 | + | description: String::new(), | |
| 426 | + | project_type: goingson_core::ProjectType::SideProject, | |
| 427 | + | status: goingson_core::ProjectStatus::Active, | |
| 428 | + | }, | |
| 429 | + | ) | |
| 430 | + | .unwrap(); | |
| 431 | + | ||
| 432 | + | let dir = crate::backup_scheduler::backup_dir(&state); | |
| 433 | + | let name = "goingson-backup-20260816-130000-beefcafe.json.gz"; | |
| 434 | + | crate::backup_scheduler::write_streaming_backup( | |
| 435 | + | &state, | |
| 436 | + | DESKTOP_USER_ID, | |
| 437 | + | dir.clone(), | |
| 438 | + | dir.join(name), | |
| 439 | + | chrono::Utc::now(), | |
| 440 | + | ) | |
| 441 | + | .await | |
| 442 | + | .expect("the backup writes"); | |
| 443 | + | ||
| 444 | + | // Take the project away, so the restore has something to put back. | |
| 445 | + | state.projects.delete(project.id, DESKTOP_USER_ID).unwrap(); | |
| 446 | + | assert!( | |
| 447 | + | state | |
| 448 | + | .projects | |
| 449 | + | .get_by_id(project.id, DESKTOP_USER_ID) | |
| 450 | + | .unwrap() | |
| 451 | + | .is_none() | |
| 452 | + | ); | |
| 453 | + | ||
| 454 | + | let response = post( | |
| 455 | + | &state, | |
| 456 | + | &format!("/data/backups/{name}/restore"), | |
| 457 | + | Params::new(), | |
| 458 | + | ); | |
| 459 | + | assert!(said(&response).contains("Restored"), "{}", said(&response)); | |
| 460 | + | assert!( | |
| 461 | + | state | |
| 462 | + | .projects | |
| 463 | + | .get_by_id(project.id, DESKTOP_USER_ID) | |
| 464 | + | .unwrap() | |
| 465 | + | .is_some() | |
| 466 | + | ); | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | #[tokio::test] | |
| 470 | + | async fn restoring_a_backup_that_is_not_there_is_a_not_found() { | |
| 471 | + | let (state, _dir) = state().await; | |
| 472 | + | ||
| 473 | + | let error = router() | |
| 474 | + | .handle( | |
| 475 | + | &state, | |
| 476 | + | Request::post("/data/backups/goingson-backup-nope.json.gz/restore"), | |
| 477 | + | ) | |
| 478 | + | .expect_err("no such backup"); | |
| 479 | + | assert_eq!(error.class.http_status(), 404); | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | #[tokio::test] | |
| 483 | + | async fn the_automatic_settings_show_what_is_in_force_and_write_what_is_chosen() { | |
| 484 | + | let (state, _dir) = state().await; | |
| 485 | + | let page = html(get(&state, "/data")); | |
| 486 | + | ||
| 487 | + | // The defaults the app falls back to when nobody has chosen: on, every 15 | |
| 488 | + | // minutes, keep one. | |
| 489 | + | assert!(page.contains("Take backups automatically")); | |
| 490 | + | assert!(page.contains("Every 15 minutes (recommended)")); | |
| 491 | + | assert!(page.contains("No backups yet.")); | |
| 492 | + | ||
| 493 | + | let response = post( | |
| 494 | + | &state, | |
| 495 | + | "/data/backups/automatic", | |
| 496 | + | Params::new() | |
| 497 | + | .with("enabled", "on") | |
| 498 | + | .with("frequency", "60") | |
| 499 | + | .with("retention", "7"), | |
| 500 | + | ); |
Lines truncated