Skip to main content

max / goingson

Describe the import and export screens Thirteenth port: settings.js's Data section and the three files it opens -- import.js, import-external.js and export.js, 18 esc() sites. It is a screen of its own at /data rather than a fourth settings section, because the shipped Data section is a menu of five modal wizards and a described screen has no modals: what the wizards do inline is the screen. Every write here was already a Tauri command whose body was sync inside an async fn, so eight bodies were lifted out the way attach_path was and both callers share them. The backup half needed one more thing: list_backups, delete_backup, the scheduler and the on-demand path each built app.path().app_data_dir().join("backups") for themselves, so where a backup lives was a fact only a Tauri command could ask. It is backup_scheduler:: backup_dir off AppState::data_dir now, which already held the same directory. Two of the five sections are absent rather than half-described, and they are what the flip waits on. The three exports open a native save dialog, and nothing in the vocabulary names a control that asks the host where to put something and then acts -- FieldKind::File covers the picking half, which is what the three import forms use. Create Backup is the one write here that has to be offloaded, and a described handler is sync with no runtime, so a described version could only be the UI freeze that offload exists to prevent. Both filed on quasicoherent. One bug came out of writing the tests, in the class the last two ports found: ImportOptions derived Default, so has_header was false in Rust while the same field deserializes to true when a caller leaves it out -- which is what every caller does, since api.js sends options: {}. Nothing shipped was wrong, because the JS path goes through serde. The first Rust caller to want the default was this screen, whose CSV preview read the header row as a task called "description". The derive is a hand-written impl now, with a test asserting the two agree. Backups are addressed by file name resolved against the backup directory, never by the absolute path the shipped screen puts in a data-a1 attribute on every Restore and Delete button; delete_backup_at's own containment check stays where it is, since it guards the command too. 22 tests; 626 of 627 lib tests pass, clippy clean. The one failure (weekly_review's "the fifth completion survives") is on main and unrelated.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 00:16 UTC
Signed with PGP, not checked
Commit: 320fc3d5d88c949be8af587f1bf0e067e31f2093
Parent: 03e25d7
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