Skip to main content

max / goingson

The three exports are described, so finding 1 is closed quasicoherent 67881a88, ruled 2026-08-21 by Max: the route answers with the file and the host puts it somewhere. quasi 0.50.0 carries Outcome::File, and this is its first consumer. The Data screen's export half was absent because a save destination had no word. It is here now, and the description names no path: Export::stem is what export.js passed as `defaultPath` and Export::kind is what it passed as `filters`, both of which were already facts about the file rather than about where it goes. POST /data/export/{format} answers with the bytes, and the window opens a save dialog for them. The three command bodies are lifted rather than duplicated, the way this module already lifted the eight import halves: export_json_bytes, export_tasks_csv_bytes and export_events_ics_bytes produce the file, and the Tauri commands write it to the path a dialog returned. export_json is the one exception and stays on backup::write_json, which writes through a temporary and renames -- the described route has no path to be atomic about, and the unattended backup path does. No project filter and no past/future toggle: both commands take the argument and export.js never sent one, so a control for either would be a feature rather than a port. Create Backup is still absent and finding 2 is still why -- its gzip write is offloaded and a described write is one synchronous call. That is dc2f2b46 and this ruling did not touch it. The two module headers that recorded the gap are corrected in place rather than left to be read as current: data.rs's finding 1 and settings.rs's paragraph about native file dialogs, which had the save dialog filed as a host fact that could not be resolved at startup. The answer turned out not to be resolving the interaction earlier but to stop having one. Also here, because the same builds surfaced them: commands/form.rs was missing makeover-layout 0.34.0's Field::upper_name, thirteen test helpers needed the new Outcome arm, and the quasi and makeover pins move to what the tree holds.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 03:18 UTC
Signed with PGP, not checked
Commit: 9726e190d7ec61e42d996a6cca4ac8153421cc2e
Parent: 05d69d7
21 files changed, +448 insertions, -122 deletions
M Cargo.lock +36 -42
@@ -1387,7 +1387,7 @@
1387 1387 "libc",
1388 1388 "option-ext",
1389 1389 "redox_users",
1390 - "windows-sys 0.61.2",
1390 + "windows-sys 0.60.2",
1391 1391 ]
1392 1392
1393 1393 [[package]]
@@ -1628,7 +1628,7 @@
1628 1628 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1629 1629 dependencies = [
1630 1630 "libc",
1631 - "windows-sys 0.61.2",
1631 + "windows-sys 0.60.2",
1632 1632 ]
1633 1633
1634 1634 [[package]]
@@ -3434,8 +3434,6 @@
3434 3434 [[package]]
3435 3435 name = "makeover"
3436 3436 version = "3.0.0"
3437 - source = "registry+https://github.com/rust-lang/crates.io-index"
3438 - checksum = "3e6da78c376074e48eb2324394004afc847e674e05ed2ea4bec1331073990b3f"
3439 3437 dependencies = [
3440 3438 "include_dir",
3441 3439 "serde",
@@ -3444,9 +3442,7 @@
3444 3442
3445 3443 [[package]]
3446 3444 name = "makeover-build"
3447 - version = "0.49.0"
3448 - source = "registry+https://github.com/rust-lang/crates.io-index"
3449 - checksum = "08975ffa5588135d21fbfb10ce0a70e5cd4da193e679a5f6a56160049ed0183b"
3445 + version = "0.49.1"
3450 3446 dependencies = [
3451 3447 "makeover",
3452 3448 "makeover-geometry",
@@ -3456,20 +3452,14 @@
3456 3452 [[package]]
3457 3453 name = "makeover-geometry"
3458 3454 version = "0.7.0"
3459 - source = "registry+https://github.com/rust-lang/crates.io-index"
3460 - checksum = "91a82b16c4f5b66dd7e87ed92fff7d22185e4d88e5355de6166dd64450cf6a16"
3461 3455
3462 3456 [[package]]
3463 3457 name = "makeover-layout"
3464 - version = "0.33.0"
3465 - source = "registry+https://github.com/rust-lang/crates.io-index"
3466 - checksum = "7ba9bae28e348ef6df04ddf04222703b5ea680ac2c978f0dce8679e2acbdc5a0"
3458 + version = "0.35.0"
3467 3459
3468 3460 [[package]]
3469 3461 name = "makeover-touch"
3470 - version = "0.23.0"
3471 - source = "registry+https://github.com/rust-lang/crates.io-index"
3472 - checksum = "0f6272302ba50183af43fe35064db861d77b4786ab6f8994ed9bb6c18c57bba0"
3462 + version = "0.24.0"
3473 3463 dependencies = [
3474 3464 "makeover-geometry",
3475 3465 "makeover-layout",
@@ -3477,9 +3467,7 @@
3477 3467
3478 3468 [[package]]
3479 3469 name = "makeover-tui"
3480 - version = "0.32.0"
3481 - source = "registry+https://github.com/rust-lang/crates.io-index"
3482 - checksum = "778fc5d11417d03fa54b43176d63b26d7db397cf05595d1832cbdbdc71a93a48"
3470 + version = "0.33.0"
3483 3471 dependencies = [
3484 3472 "makeover",
3485 3473 "makeover-layout",
@@ -3488,9 +3476,7 @@
3488 3476
3489 3477 [[package]]
3490 3478 name = "makeover-webview"
3491 - version = "0.55.0"
3492 - source = "registry+https://github.com/rust-lang/crates.io-index"
3493 - checksum = "2cd308d279ece9665206044886d0da8762ce9eb81368e0f15a5afb8c0650cb41"
3479 + version = "0.57.0"
3494 3480 dependencies = [
3495 3481 "makeover-geometry",
3496 3482 "makeover-layout",
@@ -3639,7 +3625,7 @@
3639 3625 "png 0.18.1",
3640 3626 "serde",
3641 3627 "thiserror 2.0.20",
3642 - "windows-sys 0.61.2",
3628 + "windows-sys 0.60.2",
3643 3629 ]
3644 3630
3645 3631 [[package]]
@@ -3772,7 +3758,7 @@
3772 3758 source = "registry+https://github.com/rust-lang/crates.io-index"
3773 3759 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3774 3760 dependencies = [
3775 - "windows-sys 0.61.2",
3761 + "windows-sys 0.60.2",
3776 3762 ]
3777 3763
3778 3764 [[package]]
@@ -4706,7 +4692,7 @@
4706 4692
4707 4693 [[package]]
4708 4694 name = "quasi-http"
4709 - version = "0.48.0"
4695 + version = "0.51.0"
4710 4696 dependencies = [
4711 4697 "form_urlencoded",
4712 4698 "http",
@@ -4715,7 +4701,7 @@
4715 4701
4716 4702 [[package]]
4717 4703 name = "quasi-notifs"
4718 - version = "0.48.0"
4704 + version = "0.51.0"
4719 4705 dependencies = [
4720 4706 "quasi-router",
4721 4707 "synckit-config",
@@ -4723,14 +4709,14 @@
4723 4709
4724 4710 [[package]]
4725 4711 name = "quasi-router"
4726 - version = "0.48.0"
4712 + version = "0.51.0"
4727 4713 dependencies = [
4728 4714 "makeover-layout",
4729 4715 ]
4730 4716
4731 4717 [[package]]
4732 4718 name = "quasi-tauri"
4733 - version = "0.48.0"
4719 + version = "0.51.0"
4734 4720 dependencies = [
4735 4721 "http",
4736 4722 "quasi-http",
@@ -4756,7 +4742,7 @@
4756 4742
4757 4743 [[package]]
4758 4744 name = "quasi-webview"
4759 - version = "0.48.0"
4745 + version = "0.51.0"
4760 4746 dependencies = [
4761 4747 "docengine",
4762 4748 "makeover-layout",
@@ -5195,7 +5181,7 @@
5195 5181 "errno",
5196 5182 "libc",
5197 5183 "linux-raw-sys",
5198 - "windows-sys 0.61.2",
5184 + "windows-sys 0.60.2",
5199 5185 ]
5200 5186
5201 5187 [[package]]
@@ -5252,7 +5238,7 @@
5252 5238 "security-framework",
5253 5239 "security-framework-sys",
5254 5240 "webpki-root-certs",
5255 - "windows-sys 0.61.2",
5241 + "windows-sys 0.60.2",
5256 5242 ]
5257 5243
5258 5244 [[package]]
@@ -5792,7 +5778,7 @@
5792 5778 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
5793 5779 dependencies = [
5794 5780 "libc",
5795 - "windows-sys 0.61.2",
5781 + "windows-sys 0.60.2",
5796 5782 ]
5797 5783
5798 5784 [[package]]
@@ -6076,7 +6062,7 @@
6076 6062
6077 6063 [[package]]
6078 6064 name = "tagtree"
6079 - version = "0.4.0"
6065 + version = "0.4.1"
6080 6066
6081 6067 [[package]]
6082 6068 name = "tao"
@@ -6528,7 +6514,7 @@
6528 6514 "serde_with",
6529 6515 "swift-rs",
6530 6516 "thiserror 2.0.20",
6531 - "toml 1.1.4+spec-1.1.0",
6517 + "toml 0.9.12+spec-1.1.0",
6532 6518 "url",
6533 6519 "urlpattern",
6534 6520 "uuid",
@@ -6567,7 +6553,7 @@
6567 6553 "getrandom 0.4.3",
6568 6554 "once_cell",
6569 6555 "rustix",
6570 - "windows-sys 0.61.2",
6556 + "windows-sys 0.60.2",
6571 6557 ]
6572 6558
6573 6559 [[package]]
@@ -6589,7 +6575,7 @@
6589 6575 "parking_lot",
6590 6576 "rustix",
6591 6577 "signal-hook",
6592 - "windows-sys 0.61.2",
6578 + "windows-sys 0.60.2",
6593 6579 ]
6594 6580
6595 6581 [[package]]
@@ -7071,7 +7057,7 @@
7071 7057 "png 0.18.1",
7072 7058 "serde",
7073 7059 "thiserror 2.0.20",
7074 - "windows-sys 0.61.2",
7060 + "windows-sys 0.60.2",
7075 7061 ]
7076 7062
7077 7063 [[package]]
@@ -7106,7 +7092,7 @@
7106 7092 dependencies = [
7107 7093 "memoffset",
7108 7094 "tempfile",
7109 - "windows-sys 0.61.2",
7095 + "windows-sys 0.60.2",
7110 7096 ]
7111 7097
7112 7098 [[package]]
@@ -7636,7 +7622,7 @@
7636 7622 source = "registry+https://github.com/rust-lang/crates.io-index"
7637 7623 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
7638 7624 dependencies = [
7639 - "windows-sys 0.61.2",
7625 + "windows-sys 0.60.2",
7640 7626 ]
7641 7627
7642 7628 [[package]]
@@ -8494,21 +8480,29 @@
8494 8480 ]
8495 8481
8496 8482 [[patch.unused]]
8497 - name = "ops-status"
8483 + name = "makeover-immediate"
8484 + version = "0.33.0"
8485 +
8486 + [[patch.unused]]
8487 + name = "makeover-timing"
8498 8488 version = "0.1.0"
8499 8489
8500 8490 [[patch.unused]]
8501 8491 name = "quasi-axum"
8502 - version = "0.48.0"
8492 + version = "0.51.0"
8503 8493
8504 8494 [[patch.unused]]
8505 8495 name = "quasi-basics"
8506 - version = "0.48.0"
8496 + version = "0.51.0"
8507 8497
8508 8498 [[patch.unused]]
8509 8499 name = "quasi-immediate"
8510 - version = "0.48.0"
8500 + version = "0.51.0"
8511 8501
8512 8502 [[patch.unused]]
8513 8503 name = "quasi-store"
8514 8504 version = "0.1.0"
8505 +
8506 + [[patch.unused]]
8507 + name = "ops-status"
8508 + version = "0.1.0"
M Cargo.toml +1 -1
@@ -105,7 +105,7 @@
105 105 notify = "8.2"
106 106 notify-debouncer-mini = "0.7"
107 107 makeover = "3.0"
108 - makeover-tui = "0.32.0"
108 + makeover-tui = "0.33.0"
109 109 toml = "1.1"
110 110
111 111 # Enums
@@ -28,8 +28,8 @@
28 28 # The table CSS is generated here too: the columns are this app's, so the
29 29 # shared helper cannot know them, but the tracks and the narrowing rules come
30 30 # from the description rather than from hand-written nth-child cuts.
31 - makeover-webview = "0.55.0"
32 - makeover-layout = "0.33.0"
31 + makeover-webview = "0.57.0"
32 + makeover-layout = "0.35.0"
33 33 # Width. Direct rather than through makeover-webview, because the narrow table
34 34 # pass keys off SizeClass::Compact and a boundary reached transitively is a
35 35 # boundary nobody pinned.
@@ -38,7 +38,7 @@
38 38 # `quasi` feature. Optional here as well as below so a default build compiles
39 39 # neither: one `dep:` name covers both tables, which is what lets the feature
40 40 # gate the build script and the crate together.
41 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.48", optional = true }
41 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.51", optional = true }
42 42
43 43 [dependencies]
44 44 goingson-core = { workspace = true }
@@ -56,10 +56,10 @@
56 56 # dependency's source even with the feature off -- so a public clone could not
57 57 # build at all against a URL it could not reach. quasi went public that day and
58 58 # the URL moved here with it.
59 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.48", optional = true }
60 - quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.48", optional = true }
61 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.48", optional = true }
62 - quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.48", optional = true }
59 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.51", optional = true }
60 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.51", optional = true }
61 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.51", optional = true }
62 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.51", optional = true }
63 63
64 64 # The notification declaration, and it is NOT behind the `quasi` feature: the
65 65 # watcher is desktop app behaviour rather than a described screen, and the
@@ -76,7 +76,7 @@
76 76 # before it has ported its screens cannot take it. `notifications::Notifier` is
77 77 # the eight lines meanwhile, and it goes when the flip makes quasi-router
78 78 # unconditional here.
79 - quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.48", features = ["synckit"] }
79 + quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.51", features = ["synckit"] }
80 80
81 81 # Tauri
82 82 tauri = { workspace = true, features = ["image-png", "devtools"] }
@@ -141,8 +141,8 @@
141 141 # The forms emitter and the description it renders. Runtime deps, not build:
142 142 # form markup depends on the value, the error and the options at the moment a
143 143 # modal opens, so it cannot be materialised the way the stylesheet is.
144 - makeover-webview = "0.55.0"
145 - makeover-layout = "0.33.0"
144 + makeover-webview = "0.57.0"
145 + makeover-layout = "0.35.0"
146 146 # Browser opening
147 147 open = { workspace = true }
148 148
@@ -120,6 +120,62 @@
120 120 })
121 121 }
122 122
123 + // The three exports, as bytes
124 +
125 + /// One export: what it holds, and how many things are in it.
126 + ///
127 + /// `67881a88`. A described export answers with the file rather than writing it,
128 + /// so the three bodies below produce bytes and their callers decide where the
129 + /// bytes go. The Tauri command writes them to the path a save dialog returned;
130 + /// the described route hands them to `Outcome::File` and lets the host choose.
131 + ///
132 + /// The payload goes through memory, which is the ruling's stated cost and is
133 + /// fine for a task database. `write_json` below still streams for the backup
134 + /// path, which is the one that runs unattended and on a timer.
135 + pub struct Exported {
136 + /// The file.
137 + pub bytes: Vec<u8>,
138 + /// How many things are in it, for the sentence that says so.
139 + pub item_count: usize,
140 + }
141 +
142 + /// Everything, as pretty-printed JSON.
143 + pub fn export_json_bytes(state: &AppState) -> Result<Exported, ApiError> {
144 + let export = crate::backup_scheduler::collect_full_export(state, DESKTOP_USER_ID)?;
145 + let item_count = export.total_count();
146 + let mut bytes = Vec::new();
147 + serde_json::to_writer_pretty(&mut bytes, &export)
148 + .map_api_err("Failed to serialize JSON export", ApiError::internal)?;
149 + Ok(Exported { bytes, item_count })
150 + }
151 +
152 + /// Tasks, as CSV, optionally narrowed to one project.
153 + pub fn export_tasks_csv_bytes(
154 + state: &AppState,
155 + project_id: Option<ProjectId>,
156 + ) -> Result<Exported, ApiError> {
157 + let tasks = if let Some(pid) = project_id {
158 + state.tasks.list_by_project(DESKTOP_USER_ID, pid)?
159 + } else {
160 + state.tasks.list_all(DESKTOP_USER_ID)?
161 + };
162 + let projects = state.projects.list_all(DESKTOP_USER_ID)?;
163 +
164 + let mut bytes = Vec::new();
165 + let item_count = csv::write_tasks_csv(&tasks, &projects, &mut bytes)
166 + .map_api_err("Failed to write CSV", ApiError::internal)?;
167 + Ok(Exported { bytes, item_count })
168 + }
169 +
170 + /// Events, as iCalendar.
171 + pub fn export_events_ics_bytes(state: &AppState, include_past: bool) -> Result<Exported, ApiError> {
172 + let events = state.events.list_all(DESKTOP_USER_ID)?;
173 + let mut bytes = Vec::new();
174 + let item_count = ics::write_events_ics(&events, include_past, &mut bytes)
175 + .map_api_err("Failed to write ICS", ApiError::internal)?;
176 + Ok(Exported { bytes, item_count })
177 + }
178 +
123 179 /// Exports all data as JSON.
124 180 ///
125 181 /// Creates a human-readable JSON file containing all projects, tasks, events, and emails.
@@ -136,6 +192,10 @@
136 192 ) -> Result<ExportResponse, ApiError> {
137 193 validate_export_path(&file_path)?;
138 194
195 + // Still `write_json` rather than the lifted half above: this path writes
196 + // through a temporary and renames, which is what keeps a half-written export
197 + // from replacing a good one, and it streams rather than holding the whole
198 + // document. The described route has no path to be atomic about.
139 199 let export = crate::backup_scheduler::collect_full_export(&state, DESKTOP_USER_ID)?;
140 200 let item_count = export.total_count();
141 201
@@ -166,28 +226,14 @@
166 226 ) -> Result<ExportResponse, ApiError> {
167 227 validate_export_path(&file_path)?;
168 228
169 - // Fetch tasks (filtered or all)
170 - let tasks = if let Some(pid) = project_id {
171 - state.tasks.list_by_project(DESKTOP_USER_ID, pid)?
172 - } else {
173 - state.tasks.list_all(DESKTOP_USER_ID)?
174 - };
175 -
176 - // Fetch projects for name lookup
177 - let projects = state.projects.list_all(DESKTOP_USER_ID)?;
178 -
179 - // Write CSV
180 - let file = std::fs::File::create(&file_path)
181 - .map_api_err("Failed to create CSV file", ApiError::internal)?;
182 -
183 - let item_count = csv::write_tasks_csv(&tasks, &projects, file)
184 - .map_api_err("Failed to write CSV", ApiError::internal)?;
185 -
186 - let size_bytes = std::fs::metadata(&file_path).map_or(0, |m| m.len());
229 + let exported = export_tasks_csv_bytes(&state, project_id)?;
230 + let size_bytes = exported.bytes.len() as u64;
231 + std::fs::write(&file_path, &exported.bytes)
232 + .map_api_err("Failed to write CSV file", ApiError::internal)?;
187 233
188 234 Ok(ExportResponse {
189 235 file_path,
190 - item_count,
236 + item_count: exported.item_count,
191 237 size_bytes,
192 238 })
193 239 }
@@ -209,21 +255,14 @@
209 255 ) -> Result<ExportResponse, ApiError> {
210 256 validate_export_path(&file_path)?;
211 257
212 - let events = state.events.list_all(DESKTOP_USER_ID)?;
213 - let include_past = include_past.unwrap_or(true);
214 -
215 - // Write ICS
216 - let file = std::fs::File::create(&file_path)
217 - .map_api_err("Failed to create ICS file", ApiError::internal)?;
218 -
219 - let item_count = ics::write_events_ics(&events, include_past, file)
220 - .map_api_err("Failed to write ICS", ApiError::internal)?;
221 -
222 - let size_bytes = std::fs::metadata(&file_path).map_or(0, |m| m.len());
258 + let exported = export_events_ics_bytes(&state, include_past.unwrap_or(true))?;
259 + let size_bytes = exported.bytes.len() as u64;
260 + std::fs::write(&file_path, &exported.bytes)
261 + .map_api_err("Failed to write ICS file", ApiError::internal)?;
223 262
224 263 Ok(ExportResponse {
225 264 file_path,
226 - item_count,
265 + item_count: exported.item_count,
227 266 size_bytes,
228 267 })
229 268 }
@@ -213,6 +213,11 @@
213 213 // form spec has no field for what a number is measured in, so this
214 214 // says nothing rather than parsing one out of the label.
215 215 unit: None,
216 + // makeover-layout 0.34.0's second end, on the same footing again:
217 + // this spec describes one value per field, so an interval is not
218 + // something the JS form model can say and naming an upper end here
219 + // would be inventing a question the app never asked.
220 + upper_name: None,
216 221 extended: spec.extended,
217 222 };
218 223
@@ -43,6 +43,8 @@
43 43 //! - `GET /data` — the screen.
44 44 //! - `POST /data/import/{kind}/preview` — parse the picked file, change nothing.
45 45 //! - `POST /data/import/{kind}` — do it.
46 + //! - `POST /data/export/{format}` — hand back a file. `{format}` is `json`,
47 + //! `tasks` or `calendar`.
46 48 //! - `POST /data/backups/{name}/restore` — merge a backup back in.
47 49 //! - `POST /data/backups/{name}/delete` — remove one.
48 50 //! - `POST /data/backups/automatic` — the automatic-backup settings.
@@ -63,19 +65,27 @@
63 65 //!
64 66 //! # Findings
65 67 //!
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.
68 + //! **1. CLOSED 2026-08-21. A destination could not be described, so the three
69 + //! exports were absent.** "Export All (JSON)", "Export Tasks (CSV)" and "Export
70 + //! Calendar (ICS)" each opened a native save dialog and then called a command
71 + //! with the path that came back. [`FieldKind::File`](makeover_layout::FieldKind::File)
72 + //! covers picking a file to *submit*, which is the import half and is what the
73 + //! three import forms here use; nothing covered a control that asks the host
74 + //! where to put something and then acts.
76 75 //!
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
76 + //! quasicoherent `67881a88` ruled it on 2026-08-21: the route answers with the
77 + //! file and the host puts it somewhere, so the control never asks. The
78 + //! description says the name and the kind, both of which the shipped `save()`
79 + //! call already carried as `defaultPath` and `filters`, and says nothing about
80 + //! where the file goes. All three are on the screen now; see
81 + //! [`export_region`]. [`settings`](super::settings) hit the same wall from the
82 + //! other side and its Import & Export section is still out, which is now a
83 + //! second thing to close rather than a limit.
84 + //!
85 + //! **2. STILL OPEN. A described write cannot be long-running, so "Create Backup"
86 + //! is absent.** Untouched by finding 1's ruling, which is a point that ruling
87 + //! made itself: the on-demand backup takes no path, so it was never the same
88 + //! gap. `create_backup` is the one write on this screen that is genuinely
79 89 //! async: the gzip write goes to the blocking pool because it takes seconds on a
80 90 //! large database, and freezing the UI on it is a fixed performance finding
81 91 //! (Perf S6). A handler is sync and has no runtime, so a described Create Backup
@@ -124,11 +134,14 @@
124 134
125 135 use goingson_core::ImportOptions;
126 136 use makeover_layout::{FieldKind, Tone};
127 - use quasi_router::screen::{Act, Cells, Choice, Column, Field, Row};
137 + use quasi_router::screen::{Accepted, Act, Cells, Choice, Column, Field, Row};
128 138 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
129 139
130 140 use crate::backup_scheduler::backup_dir;
131 - use crate::commands::export::{BackupInfoResponse, RestoreOptions, list_backups_in};
141 + use crate::commands::export::{
142 + BackupInfoResponse, RestoreOptions, export_events_ics_bytes, export_json_bytes,
143 + export_tasks_csv_bytes, list_backups_in,
144 + };
132 145 use crate::commands::import_external::DuplicateStrategy;
133 146 use crate::state::{AppState, DESKTOP_USER_ID};
134 147
@@ -851,12 +864,175 @@
851 864 ))))
852 865 }
853 866
867 + /// The three exports this screen offers.
868 + ///
869 + /// One entry per shipped button in `export.js`, and the two facts each one
870 + /// carried are here rather than in a save dialog: `defaultPath` is
871 + /// [`Export::stem`] and `filters` is [`Export::kind`]. Neither is a path, which
872 + /// is why the ruling could put the whole control in the description.
873 + #[derive(Clone, Copy, PartialEq, Eq)]
874 + enum Export {
875 + /// Everything, as JSON.
876 + Json,
877 + /// Tasks, as CSV.
878 + Tasks,
879 + /// Events, as iCalendar.
880 + Calendar,
881 + }
882 +
883 + impl Export {
884 + /// The export under this address segment, or 404.
885 + fn of(slug: &str) -> Result<Self, RouteError> {
886 + match slug {
887 + "json" => Ok(Self::Json),
888 + "tasks" => Ok(Self::Tasks),
889 + "calendar" => Ok(Self::Calendar),
890 + _ => Err(RouteError::not_found("nothing exports that")),
891 + }
892 + }
893 +
894 + /// The export under the request's `{format}` capture, or 404.
895 + fn from(request: &quasi_router::Request) -> Result<Self, RouteError> {
896 + Self::of(
897 + request
898 + .captures
899 + .get("format")
900 + .ok_or_else(|| RouteError::not_found("no format"))?,
901 + )
902 + }
903 +
904 + /// The segment it travels as.
905 + const fn slug(self) -> &'static str {
906 + match self {
907 + Self::Json => "json",
908 + Self::Tasks => "tasks",
909 + Self::Calendar => "calendar",
910 + }
911 + }
912 +
913 + /// The button's words, verbatim from the shipped screen.
914 + const fn label(self) -> &'static str {
915 + match self {
916 + Self::Json => "Export All (JSON)",
917 + Self::Tasks => "Export Tasks (CSV)",
918 + Self::Calendar => "Export Calendar (ICS)",
919 + }
920 + }
921 +
922 + /// The middle of the suggested file name: `goingson-<stem>-<date>.<suffix>`.
923 + const fn stem(self) -> &'static str {
924 + match self {
925 + Self::Json => "export",
926 + Self::Tasks => "tasks",
927 + Self::Calendar => "calendar",
928 + }
929 + }
930 +
931 + /// What kind of file it is.
932 + ///
933 + /// A media type rather than a suffix, because a media type is the one
934 + /// spelling a host can put on the wire and a suffix says nothing about what
935 + /// is inside. The suffix is in the name already.
936 + fn kind(self) -> Accepted {
937 + Accepted::media_type(match self {
938 + Self::Json => "application/json",
939 + Self::Tasks => "text/csv",
940 + Self::Calendar => "text/calendar",
941 + })
942 + }
943 +
944 + /// The file's suffix, with its leading dot.
945 + const fn suffix(self) -> &'static str {
946 + match self {
947 + Self::Json => ".json",
948 + Self::Tasks => ".csv",
949 + Self::Calendar => ".ics",
950 + }
951 + }
952 +
953 + /// The suggested file name, dated the way the shipped screen dates it.
954 + ///
955 + /// `export.js` takes the date from the browser, so it is the machine's local
956 + /// day; this takes it from `Local` here, which is the same day on the same
957 + /// machine. A suggestion either way -- the host may put a different name on
958 + /// it and nothing here depends on the one it chose.
959 + fn file_name(self) -> String {
960 + format!(
961 + "goingson-{}-{}{}",
962 + self.stem(),
963 + chrono::Local::now().format("%Y-%m-%d"),
964 + self.suffix()
965 + )
966 + }
967 +
968 + /// The sentence the toast says, which is the shipped one's.
969 + fn said(self, count: usize) -> String {
970 + match self {
971 + Self::Json => format!("Exported {count} items to JSON"),
972 + Self::Tasks => format!("Exported {count} tasks to CSV"),
973 + Self::Calendar => format!("Exported {count} events to ICS"),
974 + }
975 + }
976 + }
977 +
978 + /// The export half of the screen.
979 + ///
980 + /// `67881a88`, ruled 2026-08-21. This region is what finding 1 above said could
981 + /// not be described: three controls that ask the host where to put something and
982 + /// then act. The answer was that they do not ask -- the route answers with the
983 + /// file and the host decides where it lands, so a Tauri window opens a save
984 + /// dialog, a browser downloads and a terminal writes beside the process, and
985 + /// none of that is in the description.
986 + ///
987 + /// Create Backup is still absent, and finding 2 is still the reason: its gzip
988 + /// write goes to the blocking pool and a described write is one synchronous
989 + /// call. That is `dc2f2b46`, a different decision, and this ruling did not
990 + /// touch it.
991 + fn export_region() -> Slot {
992 + let mut region = Slot::new("data-export", RegionKind::Pane)
993 + .with(Node::section("Export"))
994 + .with(Node::text(
995 + "Nothing here is removed by exporting it. Where the file lands is up to this \
996 + machine.",
997 + ));
998 + for export in [Export::Json, Export::Tasks, Export::Calendar] {
999 + region = region.with(Node::Act(Act::new(
1000 + export.label(),
1001 + Action::post(format!("/data/export/{}", export.slug())),
1002 + )));
1003 + }
1004 + region
1005 + }
1006 +
1007 + /// Hand one export over as a file.
1008 + ///
1009 + /// The screen the button was pressed on is the screen that stays: the answer is
1010 + /// a file rather than a fragment, so nothing is replaced and the toast is the
1011 + /// only thing that changes.
1012 + fn export(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1013 + let export = Export::from(&request)?;
1014 + let done = match export {
1015 + Export::Json => export_json_bytes(state),
1016 + // No project filter and no past/future toggle, because the shipped
1017 + // buttons offer neither: both commands take the argument and `export.js`
1018 + // has never sent one. A control for either is a feature rather than a
1019 + // port, and this screen is a port.
1020 + Export::Tasks => export_tasks_csv_bytes(state, None),
1021 + Export::Calendar => export_events_ics_bytes(state, true),
1022 + }
1023 + .map_err(|error| RouteError::internal(error.to_string()))?;
1024 +
1025 + let said = export.said(done.item_count);
1026 + Ok(Response::file(export.file_name(), export.kind(), done.bytes).toast(Tone::Success, said))
1027 + }
1028 +
854 1029 /// The whole screen.
855 1030 fn screen(state: &AppState) -> Result<Screen, RouteError> {
856 1031 Ok(Screen::list_detail("Import & Export", false)
857 1032 .with(Slot::new("data-band", RegionKind::Band).with(Node::page("Import & Export")))
858 1033 .with(import_region())
859 1034 .with(Slot::new(PREVIEW, RegionKind::Pane).with(no_preview()))
1035 + .with(export_region())
860 1036 .with(backups_region(state)?)
861 1037 .with(automatic_region(state)?))
862 1038 }
@@ -963,6 +1139,7 @@
963 1139 .get("/data", index)
964 1140 .post("/data/import/{kind}/preview", preview)
965 1141 .post("/data/import/{kind}", import)
1142 + .post("/data/export/{format}", export)
966 1143 .post("/data/backups/{name}/restore", restore)
967 1144 .post("/data/backups/{name}/delete", delete)
968 1145 .post("/data/backups/automatic", save_automatic)
@@ -36,14 +36,17 @@
36 36 //! biometry is enrolled. Sync and Sharing reach a network client through
37 37 //! commands that take an `AppHandle`.
38 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.
39 + //! Import & Export left that sentence entirely. It went half out on 2026-08-16,
40 + //! as [`data`](super::data), a screen of its own: picking a file to submit is
41 + //! [`FieldKind::File`](makeover_layout::FieldKind::File) and the value travels,
42 + //! so the imports described fine. The other half was the *export* one, and the
43 + //! sentence was right about it until quasicoherent `67881a88` was ruled on
44 + //! 2026-08-21: a save dialog asks the host where to put something, and nothing
45 + //! named that. The ruling was that the control does not ask — the route answers
46 + //! with the file and the host decides where it lands — so all three exports are
47 + //! described now too. This sidebar still does not offer the section, and that is
48 + //! now only about addresses: what is described lives at `/data` rather than
49 + //! under this screen's.
47 50 //!
48 51 //! Half of that is the app's own doing and is fixed here: the theme list also
49 52 //! needed an `AppHandle`, because the search path is built from the resource and
@@ -56,8 +59,10 @@
56 59 //!
57 60 //! The other half does not yield to it. A version string would, and so would a
58 61 //! platform name. Whether biometry is enrolled is asked at the moment it is
59 - //! asked, and a native save dialog returns a value from an interaction — neither
60 - //! is a fact that can be resolved at startup and held. That is filed.
62 + //! asked, which is not a fact that can be resolved at startup and held. That is
63 + //! filed. The save dialog was in this paragraph until 2026-08-21 and is out of
64 + //! it: the answer there was not to resolve the interaction earlier but to stop
65 + //! having one, which is what `67881a88` ruled.
61 66 //!
62 67 //! # The shape
63 68 //!