max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
21 files changed,
+448 insertions,
-122 deletions
| @@ -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" |
| @@ -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 | //! |