max / goingson
327 files changed,
+79626 insertions,
-0 deletions
| @@ -0,0 +1,37 @@ | |||
| 1 | + | image: archlinux | |
| 2 | + | packages: | |
| 3 | + | - rust | |
| 4 | + | - cmake | |
| 5 | + | - clang | |
| 6 | + | - git | |
| 7 | + | - pkg-config | |
| 8 | + | - perl | |
| 9 | + | - webkit2gtk-4.1 | |
| 10 | + | - gtk3 | |
| 11 | + | - openssl | |
| 12 | + | - libayatana-appindicator | |
| 13 | + | sources: | |
| 14 | + | - https://git.sr.ht/~maxmj/goingson | |
| 15 | + | - https://git.sr.ht/~maxmj/synckit-client | |
| 16 | + | environment: | |
| 17 | + | CARGO_INCREMENTAL: "0" | |
| 18 | + | RUST_BACKTRACE: "1" | |
| 19 | + | tasks: | |
| 20 | + | - setup: | | |
| 21 | + | # Arrange directory structure for path dependencies | |
| 22 | + | mkdir -p project/active | |
| 23 | + | mv goingson project/active/ | |
| 24 | + | mv synckit-client project/ | |
| 25 | + | - check: | | |
| 26 | + | cd project/active/goingson | |
| 27 | + | cargo check --workspace 2>&1 | |
| 28 | + | - test: | | |
| 29 | + | cd project/active/goingson | |
| 30 | + | cargo test --workspace 2>&1 | |
| 31 | + | - clippy: | | |
| 32 | + | cd project/active/goingson | |
| 33 | + | cargo clippy --workspace --all-targets -- -D warnings 2>&1 | |
| 34 | + | - audit: | | |
| 35 | + | cargo install --locked cargo-audit | |
| 36 | + | cd project/active/goingson | |
| 37 | + | cargo audit 2>&1 |
| @@ -0,0 +1,21 @@ | |||
| 1 | + | # Build | |
| 2 | + | /target/ | |
| 3 | + | ||
| 4 | + | # Environment | |
| 5 | + | .env | |
| 6 | + | .env.* | |
| 7 | + | ||
| 8 | + | # macOS | |
| 9 | + | .DS_Store | |
| 10 | + | ||
| 11 | + | # IDE / tooling | |
| 12 | + | .vscode/ | |
| 13 | + | .idea/ | |
| 14 | + | .claude/ | |
| 15 | + | .mcp.json | |
| 16 | + | ||
| 17 | + | # Release artifacts | |
| 18 | + | dist/ | |
| 19 | + | ||
| 20 | + | # Archive | |
| 21 | + | _archive/ |
| @@ -0,0 +1,7564 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "adler2" | |
| 7 | + | version = "2.0.1" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 10 | + | ||
| 11 | + | [[package]] | |
| 12 | + | name = "aead" | |
| 13 | + | version = "0.5.2" | |
| 14 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | + | checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" | |
| 16 | + | dependencies = [ | |
| 17 | + | "crypto-common", | |
| 18 | + | "generic-array", | |
| 19 | + | ] | |
| 20 | + | ||
| 21 | + | [[package]] | |
| 22 | + | name = "ahash" | |
| 23 | + | version = "0.8.12" | |
| 24 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 25 | + | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 26 | + | dependencies = [ | |
| 27 | + | "cfg-if", | |
| 28 | + | "const-random", | |
| 29 | + | "getrandom 0.3.4", | |
| 30 | + | "once_cell", | |
| 31 | + | "version_check", | |
| 32 | + | "zerocopy", | |
| 33 | + | ] | |
| 34 | + | ||
| 35 | + | [[package]] | |
| 36 | + | name = "aho-corasick" | |
| 37 | + | version = "1.1.4" | |
| 38 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 39 | + | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 40 | + | dependencies = [ | |
| 41 | + | "memchr", | |
| 42 | + | ] | |
| 43 | + | ||
| 44 | + | [[package]] | |
| 45 | + | name = "alloc-no-stdlib" | |
| 46 | + | version = "2.0.4" | |
| 47 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 48 | + | checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" | |
| 49 | + | ||
| 50 | + | [[package]] | |
| 51 | + | name = "alloc-stdlib" | |
| 52 | + | version = "0.2.2" | |
| 53 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 54 | + | checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" | |
| 55 | + | dependencies = [ | |
| 56 | + | "alloc-no-stdlib", | |
| 57 | + | ] | |
| 58 | + | ||
| 59 | + | [[package]] | |
| 60 | + | name = "allocator-api2" | |
| 61 | + | version = "0.2.21" | |
| 62 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 63 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 64 | + | ||
| 65 | + | [[package]] | |
| 66 | + | name = "android_system_properties" | |
| 67 | + | version = "0.1.5" | |
| 68 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 69 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 70 | + | dependencies = [ | |
| 71 | + | "libc", | |
| 72 | + | ] | |
| 73 | + | ||
| 74 | + | [[package]] | |
| 75 | + | name = "anyhow" | |
| 76 | + | version = "1.0.101" | |
| 77 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 78 | + | checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" | |
| 79 | + | ||
| 80 | + | [[package]] | |
| 81 | + | name = "ar_archive_writer" | |
| 82 | + | version = "0.5.1" | |
| 83 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 84 | + | checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" | |
| 85 | + | dependencies = [ | |
| 86 | + | "object", | |
| 87 | + | ] | |
| 88 | + | ||
| 89 | + | [[package]] | |
| 90 | + | name = "argon2" | |
| 91 | + | version = "0.5.3" | |
| 92 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 93 | + | checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" | |
| 94 | + | dependencies = [ | |
| 95 | + | "base64ct", | |
| 96 | + | "blake2", | |
| 97 | + | "cpufeatures", | |
| 98 | + | "password-hash", | |
| 99 | + | ] | |
| 100 | + | ||
| 101 | + | [[package]] | |
| 102 | + | name = "async-broadcast" | |
| 103 | + | version = "0.7.2" | |
| 104 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 105 | + | checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" | |
| 106 | + | dependencies = [ | |
| 107 | + | "event-listener 5.4.1", | |
| 108 | + | "event-listener-strategy", | |
| 109 | + | "futures-core", | |
| 110 | + | "pin-project-lite", | |
| 111 | + | ] | |
| 112 | + | ||
| 113 | + | [[package]] | |
| 114 | + | name = "async-channel" | |
| 115 | + | version = "1.9.0" | |
| 116 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 117 | + | checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" | |
| 118 | + | dependencies = [ | |
| 119 | + | "concurrent-queue", | |
| 120 | + | "event-listener 2.5.3", | |
| 121 | + | "futures-core", | |
| 122 | + | ] | |
| 123 | + | ||
| 124 | + | [[package]] | |
| 125 | + | name = "async-channel" | |
| 126 | + | version = "2.5.0" | |
| 127 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 128 | + | checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" | |
| 129 | + | dependencies = [ | |
| 130 | + | "concurrent-queue", | |
| 131 | + | "event-listener-strategy", | |
| 132 | + | "futures-core", | |
| 133 | + | "pin-project-lite", | |
| 134 | + | ] | |
| 135 | + | ||
| 136 | + | [[package]] | |
| 137 | + | name = "async-compression" | |
| 138 | + | version = "0.4.37" | |
| 139 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 140 | + | checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" | |
| 141 | + | dependencies = [ | |
| 142 | + | "compression-codecs", | |
| 143 | + | "compression-core", | |
| 144 | + | "futures-io", | |
| 145 | + | "pin-project-lite", | |
| 146 | + | ] | |
| 147 | + | ||
| 148 | + | [[package]] | |
| 149 | + | name = "async-executor" | |
| 150 | + | version = "1.13.3" | |
| 151 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 152 | + | checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" | |
| 153 | + | dependencies = [ | |
| 154 | + | "async-task", | |
| 155 | + | "concurrent-queue", | |
| 156 | + | "fastrand", | |
| 157 | + | "futures-lite", | |
| 158 | + | "pin-project-lite", | |
| 159 | + | "slab", | |
| 160 | + | ] | |
| 161 | + | ||
| 162 | + | [[package]] | |
| 163 | + | name = "async-imap" | |
| 164 | + | version = "0.11.2" | |
| 165 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 166 | + | checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66" | |
| 167 | + | dependencies = [ | |
| 168 | + | "async-channel 2.5.0", | |
| 169 | + | "async-compression", | |
| 170 | + | "async-std", | |
| 171 | + | "base64 0.22.1", | |
| 172 | + | "bytes", | |
| 173 | + | "chrono", | |
| 174 | + | "futures", | |
| 175 | + | "imap-proto", | |
| 176 | + | "log", | |
| 177 | + | "nom 7.1.3", | |
| 178 | + | "pin-project", | |
| 179 | + | "pin-utils", | |
| 180 | + | "self_cell", | |
| 181 | + | "stop-token", | |
| 182 | + | "thiserror 1.0.69", | |
| 183 | + | ] | |
| 184 | + | ||
| 185 | + | [[package]] | |
| 186 | + | name = "async-io" | |
| 187 | + | version = "2.6.0" | |
| 188 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 189 | + | checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" | |
| 190 | + | dependencies = [ | |
| 191 | + | "autocfg", | |
| 192 | + | "cfg-if", | |
| 193 | + | "concurrent-queue", | |
| 194 | + | "futures-io", | |
| 195 | + | "futures-lite", | |
| 196 | + | "parking", | |
| 197 | + | "polling", | |
| 198 | + | "rustix", | |
| 199 | + | "slab", | |
| 200 | + | "windows-sys 0.61.2", | |
| 201 | + | ] | |
| 202 | + | ||
| 203 | + | [[package]] | |
| 204 | + | name = "async-lock" | |
| 205 | + | version = "3.4.2" | |
| 206 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 207 | + | checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" | |
| 208 | + | dependencies = [ | |
| 209 | + | "event-listener 5.4.1", | |
| 210 | + | "event-listener-strategy", | |
| 211 | + | "pin-project-lite", | |
| 212 | + | ] | |
| 213 | + | ||
| 214 | + | [[package]] | |
| 215 | + | name = "async-process" | |
| 216 | + | version = "2.5.0" | |
| 217 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 218 | + | checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" | |
| 219 | + | dependencies = [ | |
| 220 | + | "async-channel 2.5.0", | |
| 221 | + | "async-io", | |
| 222 | + | "async-lock", | |
| 223 | + | "async-signal", | |
| 224 | + | "async-task", | |
| 225 | + | "blocking", | |
| 226 | + | "cfg-if", | |
| 227 | + | "event-listener 5.4.1", | |
| 228 | + | "futures-lite", | |
| 229 | + | "rustix", | |
| 230 | + | ] | |
| 231 | + | ||
| 232 | + | [[package]] | |
| 233 | + | name = "async-recursion" | |
| 234 | + | version = "1.1.1" | |
| 235 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 236 | + | checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" | |
| 237 | + | dependencies = [ | |
| 238 | + | "proc-macro2", | |
| 239 | + | "quote", | |
| 240 | + | "syn 2.0.114", | |
| 241 | + | ] | |
| 242 | + | ||
| 243 | + | [[package]] | |
| 244 | + | name = "async-signal" | |
| 245 | + | version = "0.2.13" | |
| 246 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 247 | + | checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" | |
| 248 | + | dependencies = [ | |
| 249 | + | "async-io", | |
| 250 | + | "async-lock", | |
| 251 | + | "atomic-waker", | |
| 252 | + | "cfg-if", | |
| 253 | + | "futures-core", | |
| 254 | + | "futures-io", | |
| 255 | + | "rustix", | |
| 256 | + | "signal-hook-registry", | |
| 257 | + | "slab", | |
| 258 | + | "windows-sys 0.61.2", | |
| 259 | + | ] | |
| 260 | + | ||
| 261 | + | [[package]] | |
| 262 | + | name = "async-std" | |
| 263 | + | version = "1.13.2" | |
| 264 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 265 | + | checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" | |
| 266 | + | dependencies = [ | |
| 267 | + | "async-channel 1.9.0", | |
| 268 | + | "async-io", | |
| 269 | + | "async-lock", | |
| 270 | + | "async-process", | |
| 271 | + | "crossbeam-utils", | |
| 272 | + | "futures-channel", | |
| 273 | + | "futures-core", | |
| 274 | + | "futures-io", | |
| 275 | + | "memchr", | |
| 276 | + | "once_cell", | |
| 277 | + | "pin-project-lite", | |
| 278 | + | "pin-utils", | |
| 279 | + | "slab", | |
| 280 | + | "wasm-bindgen-futures", | |
| 281 | + | ] | |
| 282 | + | ||
| 283 | + | [[package]] | |
| 284 | + | name = "async-task" | |
| 285 | + | version = "4.7.1" | |
| 286 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 287 | + | checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" | |
| 288 | + | ||
| 289 | + | [[package]] | |
| 290 | + | name = "async-trait" | |
| 291 | + | version = "0.1.89" | |
| 292 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 293 | + | checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" | |
| 294 | + | dependencies = [ | |
| 295 | + | "proc-macro2", | |
| 296 | + | "quote", | |
| 297 | + | "syn 2.0.114", | |
| 298 | + | ] | |
| 299 | + | ||
| 300 | + | [[package]] | |
| 301 | + | name = "atk" | |
| 302 | + | version = "0.18.2" | |
| 303 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 304 | + | checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" | |
| 305 | + | dependencies = [ | |
| 306 | + | "atk-sys", | |
| 307 | + | "glib", | |
| 308 | + | "libc", | |
| 309 | + | ] | |
| 310 | + | ||
| 311 | + | [[package]] | |
| 312 | + | name = "atk-sys" | |
| 313 | + | version = "0.18.2" | |
| 314 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 315 | + | checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" | |
| 316 | + | dependencies = [ | |
| 317 | + | "glib-sys", | |
| 318 | + | "gobject-sys", | |
| 319 | + | "libc", | |
| 320 | + | "system-deps", | |
| 321 | + | ] | |
| 322 | + | ||
| 323 | + | [[package]] | |
| 324 | + | name = "atoi" | |
| 325 | + | version = "2.0.0" | |
| 326 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 327 | + | checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" | |
| 328 | + | dependencies = [ | |
| 329 | + | "num-traits", | |
| 330 | + | ] | |
| 331 | + | ||
| 332 | + | [[package]] | |
| 333 | + | name = "atomic-waker" | |
| 334 | + | version = "1.1.2" | |
| 335 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 336 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 337 | + | ||
| 338 | + | [[package]] | |
| 339 | + | name = "autocfg" | |
| 340 | + | version = "1.5.0" | |
| 341 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 342 | + | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" | |
| 343 | + | ||
| 344 | + | [[package]] | |
| 345 | + | name = "base64" | |
| 346 | + | version = "0.21.7" | |
| 347 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 348 | + | checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" | |
| 349 | + | ||
| 350 | + | [[package]] | |
| 351 | + | name = "base64" | |
| 352 | + | version = "0.22.1" | |
| 353 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 354 | + | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 355 | + | ||
| 356 | + | [[package]] | |
| 357 | + | name = "base64ct" | |
| 358 | + | version = "1.8.3" | |
| 359 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 360 | + | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | |
| 361 | + | ||
| 362 | + | [[package]] | |
| 363 | + | name = "bitflags" | |
| 364 | + | version = "1.3.2" | |
| 365 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 366 | + | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" | |
| 367 | + | ||
| 368 | + | [[package]] | |
| 369 | + | name = "bitflags" | |
| 370 | + | version = "2.10.0" | |
| 371 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 372 | + | checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" | |
| 373 | + | dependencies = [ | |
| 374 | + | "serde_core", | |
| 375 | + | ] | |
| 376 | + | ||
| 377 | + | [[package]] | |
| 378 | + | name = "blake2" | |
| 379 | + | version = "0.10.6" | |
| 380 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 381 | + | checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" | |
| 382 | + | dependencies = [ | |
| 383 | + | "digest", | |
| 384 | + | ] | |
| 385 | + | ||
| 386 | + | [[package]] | |
| 387 | + | name = "block-buffer" | |
| 388 | + | version = "0.10.4" | |
| 389 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 390 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 391 | + | dependencies = [ | |
| 392 | + | "generic-array", | |
| 393 | + | ] | |
| 394 | + | ||
| 395 | + | [[package]] | |
| 396 | + | name = "block2" | |
| 397 | + | version = "0.5.1" | |
| 398 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 399 | + | checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" | |
| 400 | + | dependencies = [ | |
| 401 | + | "objc2 0.5.2", | |
| 402 | + | ] | |
| 403 | + | ||
| 404 | + | [[package]] | |
| 405 | + | name = "block2" | |
| 406 | + | version = "0.6.2" | |
| 407 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 408 | + | checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" | |
| 409 | + | dependencies = [ | |
| 410 | + | "objc2 0.6.3", | |
| 411 | + | ] | |
| 412 | + | ||
| 413 | + | [[package]] | |
| 414 | + | name = "blocking" | |
| 415 | + | version = "1.6.2" | |
| 416 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 417 | + | checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" | |
| 418 | + | dependencies = [ | |
| 419 | + | "async-channel 2.5.0", | |
| 420 | + | "async-task", | |
| 421 | + | "futures-io", | |
| 422 | + | "futures-lite", | |
| 423 | + | "piper", | |
| 424 | + | ] | |
| 425 | + | ||
| 426 | + | [[package]] | |
| 427 | + | name = "brotli" | |
| 428 | + | version = "8.0.2" | |
| 429 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 430 | + | checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" | |
| 431 | + | dependencies = [ | |
| 432 | + | "alloc-no-stdlib", | |
| 433 | + | "alloc-stdlib", | |
| 434 | + | "brotli-decompressor", | |
| 435 | + | ] | |
| 436 | + | ||
| 437 | + | [[package]] | |
| 438 | + | name = "brotli-decompressor" | |
| 439 | + | version = "5.0.0" | |
| 440 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 441 | + | checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" | |
| 442 | + | dependencies = [ | |
| 443 | + | "alloc-no-stdlib", | |
| 444 | + | "alloc-stdlib", | |
| 445 | + | ] | |
| 446 | + | ||
| 447 | + | [[package]] | |
| 448 | + | name = "bumpalo" | |
| 449 | + | version = "3.19.1" | |
| 450 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 451 | + | checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" | |
| 452 | + | ||
| 453 | + | [[package]] | |
| 454 | + | name = "bytemuck" | |
| 455 | + | version = "1.25.0" | |
| 456 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 457 | + | checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" | |
| 458 | + | ||
| 459 | + | [[package]] | |
| 460 | + | name = "byteorder" | |
| 461 | + | version = "1.5.0" | |
| 462 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 463 | + | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 464 | + | ||
| 465 | + | [[package]] | |
| 466 | + | name = "byteorder-lite" | |
| 467 | + | version = "0.1.0" | |
| 468 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 469 | + | checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" | |
| 470 | + | ||
| 471 | + | [[package]] | |
| 472 | + | name = "bytes" | |
| 473 | + | version = "1.11.1" | |
| 474 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 475 | + | checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" | |
| 476 | + | dependencies = [ | |
| 477 | + | "serde", | |
| 478 | + | ] | |
| 479 | + | ||
| 480 | + | [[package]] | |
| 481 | + | name = "cairo-rs" | |
| 482 | + | version = "0.18.5" | |
| 483 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 484 | + | checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" | |
| 485 | + | dependencies = [ | |
| 486 | + | "bitflags 2.10.0", | |
| 487 | + | "cairo-sys-rs", | |
| 488 | + | "glib", | |
| 489 | + | "libc", | |
| 490 | + | "once_cell", | |
| 491 | + | "thiserror 1.0.69", | |
| 492 | + | ] | |
| 493 | + | ||
| 494 | + | [[package]] | |
| 495 | + | name = "cairo-sys-rs" | |
| 496 | + | version = "0.18.2" | |
| 497 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 498 | + | checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" | |
| 499 | + | dependencies = [ | |
| 500 | + | "glib-sys", |
Lines truncated
| @@ -0,0 +1,95 @@ | |||
| 1 | + | [workspace] | |
| 2 | + | members = [ | |
| 3 | + | "crates/core", | |
| 4 | + | "crates/db-sqlite", | |
| 5 | + | "crates/goingson-mcp", | |
| 6 | + | "crates/plugin-runtime", | |
| 7 | + | "src-tauri", | |
| 8 | + | ] | |
| 9 | + | default-members = ["src-tauri"] | |
| 10 | + | resolver = "2" | |
| 11 | + | ||
| 12 | + | [workspace.package] | |
| 13 | + | version = "0.1.0" | |
| 14 | + | edition = "2021" | |
| 15 | + | license-file = "LICENSE" | |
| 16 | + | ||
| 17 | + | [workspace.dependencies] | |
| 18 | + | # Core dependencies | |
| 19 | + | chrono = { version = "0.4.43", features = ["serde"] } | |
| 20 | + | uuid = { version = "1.16", features = ["v4", "v5", "serde"] } | |
| 21 | + | serde = { version = "1.0.228", features = ["derive"] } | |
| 22 | + | serde_json = "1.0.149" | |
| 23 | + | async-trait = "0.1" | |
| 24 | + | thiserror = "1" | |
| 25 | + | ||
| 26 | + | # Async runtime | |
| 27 | + | tokio = { version = "1.49", features = ["rt-multi-thread", "macros"] } | |
| 28 | + | ||
| 29 | + | # Database - SQLx | |
| 30 | + | sqlx = { version = "0.8", features = ["runtime-tokio"] } | |
| 31 | + | ||
| 32 | + | # Authentication | |
| 33 | + | argon2 = "0.5" | |
| 34 | + | ||
| 35 | + | # Email integration | |
| 36 | + | async-imap = "0.11" | |
| 37 | + | tokio-native-tls = "0.3" | |
| 38 | + | tokio-util = { version = "0.7", features = ["compat"] } | |
| 39 | + | futures = "0.3" | |
| 40 | + | mailparse = "0.16" | |
| 41 | + | lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-native-tls", "smtp-transport", "builder"] } | |
| 42 | + | ||
| 43 | + | # HTTP client | |
| 44 | + | reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } | |
| 45 | + | ||
| 46 | + | # Security / OAuth | |
| 47 | + | rand = "0.8" | |
| 48 | + | base64 = "0.22" | |
| 49 | + | sha2 = "0.10" | |
| 50 | + | ||
| 51 | + | # Secure credential storage | |
| 52 | + | keyring = "3" | |
| 53 | + | ||
| 54 | + | # Config | |
| 55 | + | dotenvy = "0.15" | |
| 56 | + | ||
| 57 | + | # Tauri | |
| 58 | + | tauri = "2.10.2" | |
| 59 | + | tauri-build = "2.5.5" | |
| 60 | + | tauri-plugin-dialog = "2.6.0" | |
| 61 | + | tauri-plugin-shell = "2.3.5" | |
| 62 | + | tauri-plugin-notification = "2.3.3" | |
| 63 | + | tauri-plugin-window-state = "2.4.1" | |
| 64 | + | ||
| 65 | + | # Plugin system | |
| 66 | + | rhai = { version = "1.17", features = ["sync", "serde"] } | |
| 67 | + | notify = "6.0" | |
| 68 | + | notify-debouncer-mini = "0.4" | |
| 69 | + | toml = "0.8" | |
| 70 | + | ||
| 71 | + | # Enums | |
| 72 | + | strum = "0.26" | |
| 73 | + | strum_macros = "0.26" | |
| 74 | + | ||
| 75 | + | # CSV | |
| 76 | + | csv = "1.3" | |
| 77 | + | ||
| 78 | + | # Export/Backup | |
| 79 | + | icalendar = "0.16" | |
| 80 | + | flate2 = "1.0" | |
| 81 | + | ||
| 82 | + | # Browser opening | |
| 83 | + | open = "5" | |
| 84 | + | ||
| 85 | + | # Filesystem | |
| 86 | + | dirs = "6" | |
| 87 | + | ||
| 88 | + | # Logging | |
| 89 | + | tracing = "0.1" | |
| 90 | + | tracing-subscriber = { version = "0.3", features = ["env-filter"] } | |
| 91 | + | ||
| 92 | + | # Internal crates | |
| 93 | + | goingson-core = { path = "crates/core" } | |
| 94 | + | goingson-db-sqlite = { path = "crates/db-sqlite" } | |
| 95 | + | goingson-plugin-runtime = { path = "crates/plugin-runtime" } |
| @@ -0,0 +1,118 @@ | |||
| 1 | + | PolyForm Noncommercial License 1.0.0 | |
| 2 | + | ||
| 3 | + | <https://polyformproject.org/licenses/noncommercial/1.0.0> | |
| 4 | + | ||
| 5 | + | Acceptance | |
| 6 | + | ||
| 7 | + | In order to get any license under these terms, you must agree to them as | |
| 8 | + | both strict obligations and conditions to all your licenses. | |
| 9 | + | ||
| 10 | + | Copyright License | |
| 11 | + | ||
| 12 | + | The licensor grants you a copyright license for the software to do | |
| 13 | + | everything you might do with the software that would otherwise infringe | |
| 14 | + | the licensor's copyright in it for any permitted purpose. However, you | |
| 15 | + | may only distribute the software according to Distribution License and | |
| 16 | + | make changes or new works based on the software according to Changes and | |
| 17 | + | New Works License. | |
| 18 | + | ||
| 19 | + | Distribution License | |
| 20 | + | ||
| 21 | + | The licensor grants you an additional copyright license to distribute | |
| 22 | + | copies of the software. Your license to distribute covers distributing | |
| 23 | + | the software with changes and new works permitted by Changes and New | |
| 24 | + | Works License. | |
| 25 | + | ||
| 26 | + | Notices | |
| 27 | + | ||
| 28 | + | You must ensure that anyone who gets a copy of any part of the software | |
| 29 | + | from you also gets a copy of these terms or the URL for them above, as | |
| 30 | + | well as copies of any plain-text lines beginning with "Required Notice:" | |
| 31 | + | that the licensor provided with the software. For example: | |
| 32 | + | ||
| 33 | + | Required Notice: Copyright Yoyodyne, Inc. (http://example.com) | |
| 34 | + | ||
| 35 | + | Changes and New Works License | |
| 36 | + | ||
| 37 | + | The licensor grants you an additional copyright license to make changes | |
| 38 | + | and new works based on the software for any permitted purpose. | |
| 39 | + | ||
| 40 | + | Patent License | |
| 41 | + | ||
| 42 | + | The licensor grants you a patent license for the software that covers | |
| 43 | + | patent claims the licensor can license, or becomes able to license, that | |
| 44 | + | you would infringe by using the software. | |
| 45 | + | ||
| 46 | + | Noncommercial Purposes | |
| 47 | + | ||
| 48 | + | Any noncommercial purpose is a permitted purpose. | |
| 49 | + | ||
| 50 | + | Personal Uses | |
| 51 | + | ||
| 52 | + | Personal use for research, experiment, and testing for the benefit of | |
| 53 | + | public knowledge, personal study, private entertainment, hobby projects, | |
| 54 | + | amateur pursuits, or religious observance, without any anticipated | |
| 55 | + | commercial application, is use for a permitted purpose. | |
| 56 | + | ||
| 57 | + | Noncommercial Organizations | |
| 58 | + | ||
| 59 | + | Use by any charitable organization, educational institution, public | |
| 60 | + | research organization, public safety or health organization, | |
| 61 | + | environmental protection organization, or government institution is use | |
| 62 | + | for a permitted purpose regardless of the source of funding or | |
| 63 | + | obligations resulting from the funding. | |
| 64 | + | ||
| 65 | + | Fair Use | |
| 66 | + | ||
| 67 | + | You may have "fair use" rights for the software under the law. These | |
| 68 | + | terms do not limit them. | |
| 69 | + | ||
| 70 | + | No Other Rights | |
| 71 | + | ||
| 72 | + | These terms do not allow you to sublicense or transfer any of your | |
| 73 | + | licenses to anyone else, or prevent the licensor from granting licenses | |
| 74 | + | to anyone else. These terms do not imply any other licenses. | |
| 75 | + | ||
| 76 | + | Patent Defense | |
| 77 | + | ||
| 78 | + | If you make any written claim that the software infringes or contributes | |
| 79 | + | to infringement of any patent, your patent license for the software | |
| 80 | + | granted under these terms ends immediately. If your company makes such a | |
| 81 | + | claim, your patent license ends immediately for work on behalf of your | |
| 82 | + | company. | |
| 83 | + | ||
| 84 | + | Violations | |
| 85 | + | ||
| 86 | + | The first time you are notified in writing that you have violated any of | |
| 87 | + | these terms, or done anything with the software not covered by your | |
| 88 | + | licenses, your licenses can nonetheless continue if you come into full | |
| 89 | + | compliance with these terms, and take practical steps to correct past | |
| 90 | + | violations, within 32 days of receiving notice. Otherwise, all your | |
| 91 | + | licenses end immediately. | |
| 92 | + | ||
| 93 | + | No Liability | |
| 94 | + | ||
| 95 | + | As far as the law allows, the software comes as is, without any warranty | |
| 96 | + | or condition, and the licensor will not be liable to you for any damages | |
| 97 | + | arising out of these terms or the use or nature of the software, under | |
| 98 | + | any kind of legal claim. | |
| 99 | + | ||
| 100 | + | Definitions | |
| 101 | + | ||
| 102 | + | The licensor is the individual or entity offering these terms, and the | |
| 103 | + | software is the software the licensor makes available under these terms. | |
| 104 | + | ||
| 105 | + | You refers to the individual or entity agreeing to these terms. | |
| 106 | + | ||
| 107 | + | Your company is any legal entity, sole proprietorship, or other kind of | |
| 108 | + | organization that you work for, plus all organizations that have control | |
| 109 | + | over, are under the control of, or are under common control with that | |
| 110 | + | organization. Control means ownership of substantially all the assets of | |
| 111 | + | an entity, or the power to direct its management and policies by vote, | |
| 112 | + | contract, or otherwise. Control can be direct or indirect. | |
| 113 | + | ||
| 114 | + | Your licenses are all the licenses granted to you for the software under | |
| 115 | + | these terms. | |
| 116 | + | ||
| 117 | + | Use means anything you do with the software requiring one of your | |
| 118 | + | licenses. |
| @@ -0,0 +1,18 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "goingson-core" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | ||
| 6 | + | [features] | |
| 7 | + | sqlx-sqlite = ["dep:sqlx"] | |
| 8 | + | ||
| 9 | + | [dependencies] | |
| 10 | + | chrono = { workspace = true } | |
| 11 | + | uuid = { workspace = true } | |
| 12 | + | serde = { workspace = true } | |
| 13 | + | serde_json = { workspace = true } | |
| 14 | + | async-trait = { workspace = true } | |
| 15 | + | thiserror = { workspace = true } | |
| 16 | + | strum = { workspace = true } | |
| 17 | + | strum_macros = { workspace = true } | |
| 18 | + | sqlx = { workspace = true, features = ["sqlite", "uuid"], optional = true } |
| @@ -0,0 +1,172 @@ | |||
| 1 | + | //! Backup restore orchestration logic. | |
| 2 | + | //! | |
| 3 | + | //! Contains the entity iteration and existence-check-before-create pattern | |
| 4 | + | //! for restoring data from backups. The command layer handles file I/O; | |
| 5 | + | //! this module handles the restore logic using repository trait objects. | |
| 6 | + | ||
| 7 | + | use crate::id_types::UserId; | |
| 8 | + | ||
| 9 | + | use crate::error::CoreError; | |
| 10 | + | use crate::models::{ | |
| 11 | + | Email, Event, NewEmail, NewEvent, NewProject, Project, Task, | |
| 12 | + | }; | |
| 13 | + | use crate::repository::{EmailRepository, EventRepository, ProjectRepository, TaskRepository}; | |
| 14 | + | ||
| 15 | + | /// Result of a restore operation. | |
| 16 | + | #[derive(Debug, Default)] | |
| 17 | + | pub struct RestoreResult { | |
| 18 | + | /// Number of projects restored. | |
| 19 | + | pub projects_restored: usize, | |
| 20 | + | /// Number of tasks restored. | |
| 21 | + | pub tasks_restored: usize, | |
| 22 | + | /// Number of events restored. | |
| 23 | + | pub events_restored: usize, | |
| 24 | + | /// Number of emails restored. | |
| 25 | + | pub emails_restored: usize, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// Pre-parsed backup data for restoration. | |
| 29 | + | pub struct RestoreInput { | |
| 30 | + | pub projects: Vec<Project>, | |
| 31 | + | pub tasks: Vec<Task>, | |
| 32 | + | pub events: Vec<Event>, | |
| 33 | + | pub emails: Vec<Email>, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Restores entities from backup data, skipping those that already exist. | |
| 37 | + | /// | |
| 38 | + | /// Uses existence checks (by ID or message_id) to avoid duplicates. | |
| 39 | + | /// This is a merge operation — existing data is preserved. | |
| 40 | + | pub async fn restore_from_backup( | |
| 41 | + | user_id: UserId, | |
| 42 | + | input: &RestoreInput, | |
| 43 | + | projects: &dyn ProjectRepository, | |
| 44 | + | tasks: &dyn TaskRepository, | |
| 45 | + | events: &dyn EventRepository, | |
| 46 | + | emails: &dyn EmailRepository, | |
| 47 | + | ) -> Result<RestoreResult, CoreError> { | |
| 48 | + | let mut result = RestoreResult::default(); | |
| 49 | + | ||
| 50 | + | // Import projects | |
| 51 | + | for project in &input.projects { | |
| 52 | + | if projects.get_by_id(project.id, user_id).await?.is_none() { | |
| 53 | + | let new_project = NewProject { | |
| 54 | + | name: project.name.clone(), | |
| 55 | + | description: project.description.clone(), | |
| 56 | + | project_type: project.project_type.clone(), | |
| 57 | + | status: project.status.clone(), | |
| 58 | + | }; | |
| 59 | + | projects.create(user_id, new_project).await?; | |
| 60 | + | result.projects_restored += 1; | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Import tasks | |
| 65 | + | for task in &input.tasks { | |
| 66 | + | if tasks.get_by_id(task.id, user_id).await?.is_none() { | |
| 67 | + | let new_task = crate::models::NewTask::builder(&task.description) | |
| 68 | + | .priority(task.priority.clone()) | |
| 69 | + | .tags(task.tags.clone()) | |
| 70 | + | .recurrence(task.recurrence.clone()) | |
| 71 | + | .urgency(task.urgency); | |
| 72 | + | ||
| 73 | + | let new_task = if let Some(due) = task.due { | |
| 74 | + | new_task.due(due) | |
| 75 | + | } else { | |
| 76 | + | new_task | |
| 77 | + | }; | |
| 78 | + | ||
| 79 | + | let new_task = if let Some(pid) = task.project_id { | |
| 80 | + | new_task.project_id(pid) | |
| 81 | + | } else { | |
| 82 | + | new_task | |
| 83 | + | }; | |
| 84 | + | ||
| 85 | + | tasks.create(user_id, new_task.build()).await?; | |
| 86 | + | result.tasks_restored += 1; | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Import events | |
| 91 | + | for event in &input.events { | |
| 92 | + | if events.get_by_id(event.id, user_id).await?.is_none() { | |
| 93 | + | let new_event = NewEvent::builder(&event.title, event.start_time) | |
| 94 | + | .description(&event.description) | |
| 95 | + | .recurrence(event.recurrence.clone()); | |
| 96 | + | ||
| 97 | + | let new_event = if let Some(end) = event.end_time { | |
| 98 | + | new_event.end_time(end) | |
| 99 | + | } else { | |
| 100 | + | new_event | |
| 101 | + | }; | |
| 102 | + | ||
| 103 | + | let new_event = if let Some(ref loc) = event.location { | |
| 104 | + | new_event.location(loc) | |
| 105 | + | } else { | |
| 106 | + | new_event | |
| 107 | + | }; | |
| 108 | + | ||
| 109 | + | let new_event = if let Some(pid) = event.project_id { | |
| 110 | + | new_event.project_id(pid) | |
| 111 | + | } else { | |
| 112 | + | new_event | |
| 113 | + | }; | |
| 114 | + | ||
| 115 | + | events.create(user_id, new_event.build()).await?; | |
| 116 | + | result.events_restored += 1; | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Import emails | |
| 121 | + | for email in &input.emails { | |
| 122 | + | let exists = if let Some(ref msg_id) = email.message_id { | |
| 123 | + | emails.exists_by_message_id(user_id, msg_id).await? | |
| 124 | + | } else { | |
| 125 | + | emails.get_by_id(email.id, user_id).await?.is_some() | |
| 126 | + | }; | |
| 127 | + | ||
| 128 | + | if !exists { | |
| 129 | + | let new_email = NewEmail { | |
| 130 | + | project_id: email.project_id, | |
| 131 | + | from_address: email.from.clone(), | |
| 132 | + | to_address: email.to.clone(), | |
| 133 | + | subject: email.subject.clone(), | |
| 134 | + | body: email.body.clone(), | |
| 135 | + | is_read: email.is_read, | |
| 136 | + | received_at: Some(email.received_at), | |
| 137 | + | }; | |
| 138 | + | emails.create(user_id, new_email).await?; | |
| 139 | + | result.emails_restored += 1; | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | Ok(result) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | #[cfg(test)] | |
| 147 | + | mod tests { | |
| 148 | + | use super::*; | |
| 149 | + | ||
| 150 | + | #[test] | |
| 151 | + | fn restore_result_default_all_zeros() { | |
| 152 | + | let r = RestoreResult::default(); | |
| 153 | + | assert_eq!(r.projects_restored, 0); | |
| 154 | + | assert_eq!(r.tasks_restored, 0); | |
| 155 | + | assert_eq!(r.events_restored, 0); | |
| 156 | + | assert_eq!(r.emails_restored, 0); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[test] | |
| 160 | + | fn restore_input_accepts_empty_vecs() { | |
| 161 | + | let input = RestoreInput { | |
| 162 | + | projects: vec![], | |
| 163 | + | tasks: vec![], | |
| 164 | + | events: vec![], | |
| 165 | + | emails: vec![], | |
| 166 | + | }; | |
| 167 | + | assert!(input.projects.is_empty()); | |
| 168 | + | assert!(input.tasks.is_empty()); | |
| 169 | + | assert!(input.events.is_empty()); | |
| 170 | + | assert!(input.emails.is_empty()); | |
| 171 | + | } | |
| 172 | + | } |
| @@ -0,0 +1,88 @@ | |||
| 1 | + | //! Named constants for the GoingsOn application. | |
| 2 | + | //! | |
| 3 | + | //! This module centralizes magic numbers and configuration values | |
| 4 | + | //! to improve maintainability and documentation. | |
| 5 | + | ||
| 6 | + | // ============ Time Constants ============ | |
| 7 | + | ||
| 8 | + | /// Hours in a day. | |
| 9 | + | pub const HOURS_PER_DAY: f64 = 24.0; | |
| 10 | + | ||
| 11 | + | /// Days in a week. | |
| 12 | + | pub const DAYS_PER_WEEK: i64 = 7; | |
| 13 | + | ||
| 14 | + | /// Approximate days in a month (for relative date calculations). | |
| 15 | + | pub const APPROXIMATE_DAYS_PER_MONTH: i64 = 30; | |
| 16 | + | ||
| 17 | + | // ============ Parser Defaults ============ | |
| 18 | + | ||
| 19 | + | /// Default hour for parsed dates (9:00 AM). | |
| 20 | + | pub const DEFAULT_PARSE_HOUR: u32 = 9; | |
| 21 | + | ||
| 22 | + | /// Default minute for parsed dates. | |
| 23 | + | pub const DEFAULT_PARSE_MINUTE: u32 = 0; | |
| 24 | + | ||
| 25 | + | // ============ Urgency Thresholds ============ | |
| 26 | + | ||
| 27 | + | /// Urgency score threshold for "high" classification (on a 0–10 scale). | |
| 28 | + | /// Scores are computed by `calculate_urgency()` from priority, due date | |
| 29 | + | /// proximity, task age, and tags. Tasks above 8.0 get the "urgency-high" | |
| 30 | + | /// CSS class and sort to the top of the list. | |
| 31 | + | pub const URGENCY_HIGH_THRESHOLD: f64 = 8.0; | |
| 32 | + | ||
| 33 | + | /// Urgency score threshold for "medium" classification (on the same 0–10 scale). | |
| 34 | + | /// Tasks between 5.0 and 8.0 get "urgency-medium"; below 5.0 gets "urgency-low". | |
| 35 | + | pub const URGENCY_MEDIUM_THRESHOLD: f64 = 5.0; | |
| 36 | + | ||
| 37 | + | // ============ Display Thresholds ============ | |
| 38 | + | ||
| 39 | + | /// Number of days for short-form due date display (e.g., "+3d" instead of "Mar 15"). | |
| 40 | + | /// Used by `TaskResponse::from()` and `GoingsOn.utils.formatDue()` in the frontend | |
| 41 | + | /// to decide between relative ("today", "+3d") and absolute ("Mar 15") formats. | |
| 42 | + | pub const DAYS_THRESHOLD_SHORT_FORMAT: i64 = 7; | |
| 43 | + | ||
| 44 | + | // ============ Preview Lengths ============ | |
| 45 | + | ||
| 46 | + | /// Maximum length for email body preview snippets in the email list view. | |
| 47 | + | /// Truncates the plain-text body to this many characters for the compact | |
| 48 | + | /// thread listing, with an ellipsis appended if truncated. | |
| 49 | + | pub const EMAIL_BODY_PREVIEW_LENGTH: usize = 100; | |
| 50 | + | ||
| 51 | + | // ============ Validation Limits ============ | |
| 52 | + | ||
| 53 | + | /// Maximum length for project names. | |
| 54 | + | pub const MAX_PROJECT_NAME_LENGTH: usize = 255; | |
| 55 | + | ||
| 56 | + | /// Maximum length for task descriptions. | |
| 57 | + | pub const MAX_TASK_DESCRIPTION_LENGTH: usize = 2000; | |
| 58 | + | ||
| 59 | + | /// Maximum length for event titles. | |
| 60 | + | pub const MAX_EVENT_TITLE_LENGTH: usize = 255; | |
| 61 | + | ||
| 62 | + | /// Maximum length for tags. | |
| 63 | + | pub const MAX_TAG_LENGTH: usize = 50; | |
| 64 | + | ||
| 65 | + | /// Maximum scheduled duration in minutes (24 hours). | |
| 66 | + | pub const MAX_SCHEDULED_DURATION_MINUTES: i32 = 24 * 60; | |
| 67 | + | ||
| 68 | + | #[cfg(test)] | |
| 69 | + | mod tests { | |
| 70 | + | use super::*; | |
| 71 | + | ||
| 72 | + | #[test] | |
| 73 | + | fn urgency_thresholds_are_ordered() { | |
| 74 | + | const { assert!(URGENCY_HIGH_THRESHOLD > URGENCY_MEDIUM_THRESHOLD) }; | |
| 75 | + | const { assert!(URGENCY_MEDIUM_THRESHOLD > 0.0) }; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[test] | |
| 79 | + | fn max_tag_length_is_reasonable() { | |
| 80 | + | const { assert!(MAX_TAG_LENGTH >= 10) }; | |
| 81 | + | const { assert!(MAX_TAG_LENGTH <= 255) }; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | #[test] | |
| 85 | + | fn max_scheduled_duration_is_24_hours() { | |
| 86 | + | assert_eq!(MAX_SCHEDULED_DURATION_MINUTES, 1440); | |
| 87 | + | } | |
| 88 | + | } |
| @@ -0,0 +1,269 @@ | |||
| 1 | + | //! Contact domain model. | |
| 2 | + | //! | |
| 3 | + | //! Contacts represent people with multiple email addresses, phone numbers, | |
| 4 | + | //! and social handles. Sub-collections are stored in separate tables to | |
| 5 | + | //! enable querying by email address for future integration features. | |
| 6 | + | ||
| 7 | + | use chrono::{DateTime, NaiveDate, Utc}; | |
| 8 | + | use serde::{Deserialize, Serialize}; | |
| 9 | + | use crate::id_types::{ContactId, ContactEmailId, ContactPhoneId, SocialHandleId, CustomFieldId}; | |
| 10 | + | ||
| 11 | + | // ============ Main Entity ============ | |
| 12 | + | ||
| 13 | + | /// A contact (person) with optional sub-collections. | |
| 14 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 15 | + | #[serde(rename_all = "camelCase")] | |
| 16 | + | pub struct Contact { | |
| 17 | + | pub id: ContactId, | |
| 18 | + | pub display_name: String, | |
| 19 | + | pub nickname: Option<String>, | |
| 20 | + | pub company: Option<String>, | |
| 21 | + | pub title: Option<String>, | |
| 22 | + | pub notes: String, | |
| 23 | + | pub tags: Vec<String>, | |
| 24 | + | pub birthday: Option<NaiveDate>, | |
| 25 | + | pub timezone: Option<String>, | |
| 26 | + | pub emails: Vec<ContactEmail>, | |
| 27 | + | pub phones: Vec<ContactPhone>, | |
| 28 | + | pub social_handles: Vec<SocialHandle>, | |
| 29 | + | pub custom_fields: Vec<ContactCustomField>, | |
| 30 | + | pub created_at: DateTime<Utc>, | |
| 31 | + | pub updated_at: DateTime<Utc>, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl Contact { | |
| 35 | + | /// Returns the primary email address, or the first email if none is marked primary. | |
| 36 | + | pub fn primary_email(&self) -> Option<&str> { | |
| 37 | + | self.emails | |
| 38 | + | .iter() | |
| 39 | + | .find(|e| e.is_primary) | |
| 40 | + | .or_else(|| self.emails.first()) | |
| 41 | + | .map(|e| e.address.as_str()) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Returns display initials (e.g., "JS" from "Jane Smith"). | |
| 45 | + | pub fn display_initials(&self) -> String { | |
| 46 | + | self.display_name | |
| 47 | + | .split_whitespace() | |
| 48 | + | .filter_map(|w| w.chars().next()) | |
| 49 | + | .take(2) | |
| 50 | + | .collect::<String>() | |
| 51 | + | .to_uppercase() | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Returns the number of email addresses. | |
| 55 | + | pub fn email_count(&self) -> usize { | |
| 56 | + | self.emails.len() | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Returns true if the contact has any social handles. | |
| 60 | + | pub fn has_social(&self) -> bool { | |
| 61 | + | !self.social_handles.is_empty() | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Returns true if the contact has a company set. | |
| 65 | + | pub fn has_company(&self) -> bool { | |
| 66 | + | self.company.as_ref().is_some_and(|c| !c.is_empty()) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// Returns the company name or an empty string. | |
| 70 | + | pub fn company_or_empty(&self) -> &str { | |
| 71 | + | self.company.as_deref().unwrap_or("") | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // ============ Sub-collection Entities ============ | |
| 76 | + | ||
| 77 | + | /// An email address belonging to a contact. | |
| 78 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 79 | + | #[serde(rename_all = "camelCase")] | |
| 80 | + | pub struct ContactEmail { | |
| 81 | + | pub id: ContactEmailId, | |
| 82 | + | #[serde(skip_serializing)] | |
| 83 | + | pub contact_id: ContactId, | |
| 84 | + | pub address: String, | |
| 85 | + | pub label: String, | |
| 86 | + | pub is_primary: bool, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// A phone number belonging to a contact. | |
| 90 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 91 | + | #[serde(rename_all = "camelCase")] | |
| 92 | + | pub struct ContactPhone { | |
| 93 | + | pub id: ContactPhoneId, | |
| 94 | + | #[serde(skip_serializing)] | |
| 95 | + | pub contact_id: ContactId, | |
| 96 | + | pub number: String, | |
| 97 | + | pub label: String, | |
| 98 | + | pub is_primary: bool, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// A social media handle belonging to a contact. | |
| 102 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 103 | + | #[serde(rename_all = "camelCase")] | |
| 104 | + | pub struct SocialHandle { | |
| 105 | + | pub id: SocialHandleId, | |
| 106 | + | #[serde(skip_serializing)] | |
| 107 | + | pub contact_id: ContactId, | |
| 108 | + | pub platform: String, | |
| 109 | + | pub handle: String, | |
| 110 | + | pub url: Option<String>, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// An arbitrary custom field on a contact (label + value + optional URL). | |
| 114 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 115 | + | #[serde(rename_all = "camelCase")] | |
| 116 | + | pub struct ContactCustomField { | |
| 117 | + | pub id: CustomFieldId, | |
| 118 | + | #[serde(skip_serializing)] | |
| 119 | + | pub contact_id: ContactId, | |
| 120 | + | pub label: String, | |
| 121 | + | pub value: String, | |
| 122 | + | pub url: Option<String>, | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // ============ DTOs ============ | |
| 126 | + | ||
| 127 | + | /// Data for creating a new contact. | |
| 128 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 129 | + | pub struct NewContact { | |
| 130 | + | pub display_name: String, | |
| 131 | + | pub nickname: Option<String>, | |
| 132 | + | pub company: Option<String>, | |
| 133 | + | pub title: Option<String>, | |
| 134 | + | pub notes: String, | |
| 135 | + | pub tags: Vec<String>, | |
| 136 | + | pub birthday: Option<NaiveDate>, | |
| 137 | + | pub timezone: Option<String>, | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// Data for updating an existing contact. | |
| 141 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 142 | + | pub struct UpdateContact { | |
| 143 | + | pub display_name: String, | |
| 144 | + | pub nickname: Option<String>, | |
| 145 | + | pub company: Option<String>, | |
| 146 | + | pub title: Option<String>, | |
| 147 | + | pub notes: String, | |
| 148 | + | pub tags: Vec<String>, | |
| 149 | + | pub birthday: Option<NaiveDate>, | |
| 150 | + | pub timezone: Option<String>, | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /// Data for adding an email to a contact. | |
| 154 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 155 | + | pub struct NewContactEmail { | |
| 156 | + | pub address: String, | |
| 157 | + | pub label: String, | |
| 158 | + | pub is_primary: bool, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// Data for adding a phone number to a contact. | |
| 162 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 163 | + | pub struct NewContactPhone { | |
| 164 | + | pub number: String, | |
| 165 | + | pub label: String, | |
| 166 | + | pub is_primary: bool, | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /// Data for adding a social handle to a contact. | |
| 170 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 171 | + | pub struct NewSocialHandle { | |
| 172 | + | pub platform: String, | |
| 173 | + | pub handle: String, | |
| 174 | + | pub url: Option<String>, | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /// Data for adding a custom field to a contact. | |
| 178 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 179 | + | pub struct NewContactCustomField { | |
| 180 | + | pub label: String, | |
| 181 | + | pub value: String, | |
| 182 | + | pub url: Option<String>, | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | #[cfg(test)] | |
| 186 | + | mod tests { | |
| 187 | + | use super::*; | |
| 188 | + | ||
| 189 | + | fn make_contact(name: &str) -> Contact { | |
| 190 | + | Contact { | |
| 191 | + | id: ContactId::new(), | |
| 192 | + | display_name: name.to_string(), | |
| 193 | + | nickname: None, | |
| 194 | + | company: None, | |
| 195 | + | title: None, | |
| 196 | + | notes: String::new(), | |
| 197 | + | tags: vec![], | |
| 198 | + | birthday: None, | |
| 199 | + | timezone: None, | |
| 200 | + | emails: vec![], | |
| 201 | + | phones: vec![], | |
| 202 | + | social_handles: vec![], | |
| 203 | + | custom_fields: vec![], | |
| 204 | + | created_at: Utc::now(), | |
| 205 | + | updated_at: Utc::now(), | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | #[test] | |
| 210 | + | fn test_display_initials() { | |
| 211 | + | let c = make_contact("Jane Smith"); | |
| 212 | + | assert_eq!(c.display_initials(), "JS"); | |
| 213 | + | ||
| 214 | + | let c = make_contact("Madonna"); | |
| 215 | + | assert_eq!(c.display_initials(), "M"); | |
| 216 | + | ||
| 217 | + | let c = make_contact("John Jacob Jingleheimer Schmidt"); | |
| 218 | + | assert_eq!(c.display_initials(), "JJ"); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | #[test] | |
| 222 | + | fn test_primary_email() { | |
| 223 | + | let mut c = make_contact("Test"); | |
| 224 | + | assert_eq!(c.primary_email(), None); | |
| 225 | + | ||
| 226 | + | c.emails.push(ContactEmail { | |
| 227 | + | id: ContactEmailId::new(), | |
| 228 | + | contact_id: c.id, | |
| 229 | + | address: "first@example.com".to_string(), | |
| 230 | + | label: "Work".to_string(), | |
| 231 | + | is_primary: false, | |
| 232 | + | }); | |
| 233 | + | c.emails.push(ContactEmail { | |
| 234 | + | id: ContactEmailId::new(), | |
| 235 | + | contact_id: c.id, | |
| 236 | + | address: "primary@example.com".to_string(), | |
| 237 | + | label: "Personal".to_string(), | |
| 238 | + | is_primary: true, | |
| 239 | + | }); | |
| 240 | + | ||
| 241 | + | assert_eq!(c.primary_email(), Some("primary@example.com")); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[test] | |
| 245 | + | fn test_primary_email_fallback_to_first() { | |
| 246 | + | let mut c = make_contact("Test"); | |
| 247 | + | c.emails.push(ContactEmail { | |
| 248 | + | id: ContactEmailId::new(), | |
| 249 | + | contact_id: c.id, | |
| 250 | + | address: "only@example.com".to_string(), | |
| 251 | + | label: String::new(), | |
| 252 | + | is_primary: false, | |
| 253 | + | }); | |
| 254 | + | ||
| 255 | + | assert_eq!(c.primary_email(), Some("only@example.com")); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn test_has_company() { | |
| 260 | + | let mut c = make_contact("Test"); | |
| 261 | + | assert!(!c.has_company()); | |
| 262 | + | ||
| 263 | + | c.company = Some("Acme Corp".to_string()); | |
| 264 | + | assert!(c.has_company()); | |
| 265 | + | ||
| 266 | + | c.company = Some(String::new()); | |
| 267 | + | assert!(!c.has_company()); | |
| 268 | + | } | |
| 269 | + | } |
| @@ -0,0 +1,209 @@ | |||
| 1 | + | //! Day planning business logic. | |
| 2 | + | //! | |
| 3 | + | //! Contains pure functions for timeline conflict detection. | |
| 4 | + | //! These are used by the command layer but are pure logic with no I/O. | |
| 5 | + | ||
| 6 | + | use chrono::{DateTime, Utc}; | |
| 7 | + | use serde::Serialize; | |
| 8 | + | use uuid::Uuid; | |
| 9 | + | ||
| 10 | + | /// A scheduled item on the day timeline. | |
| 11 | + | #[derive(Debug, Serialize)] | |
| 12 | + | #[serde(rename_all = "camelCase")] | |
| 13 | + | pub struct TimelineItem { | |
| 14 | + | pub id: Uuid, | |
| 15 | + | pub item_type: String, | |
| 16 | + | pub title: String, | |
| 17 | + | pub start_time: DateTime<Utc>, | |
| 18 | + | pub end_time: Option<DateTime<Utc>>, | |
| 19 | + | pub duration: Option<i32>, | |
| 20 | + | pub project_id: Option<Uuid>, | |
| 21 | + | pub project_name: Option<String>, | |
| 22 | + | pub priority: Option<String>, | |
| 23 | + | pub status: Option<String>, | |
| 24 | + | pub block_type: Option<String>, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// A detected overlap between two timeline items. | |
| 28 | + | #[derive(Debug, Serialize)] | |
| 29 | + | #[serde(rename_all = "camelCase")] | |
| 30 | + | pub struct Conflict { | |
| 31 | + | pub item1_id: Uuid, | |
| 32 | + | pub item2_id: Uuid, | |
| 33 | + | pub overlap_start: DateTime<Utc>, | |
| 34 | + | pub overlap_end: DateTime<Utc>, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Detects scheduling conflicts between timeline items. | |
| 38 | + | /// | |
| 39 | + | /// Compares all pairs of items and returns any overlapping time ranges. | |
| 40 | + | /// Items without an explicit end time use their duration (defaulting to 30 minutes). | |
| 41 | + | pub fn detect_conflicts(items: &[TimelineItem]) -> Vec<Conflict> { | |
| 42 | + | let mut conflicts = Vec::new(); | |
| 43 | + | ||
| 44 | + | for (i, item1) in items.iter().enumerate() { | |
| 45 | + | let end1 = item1.end_time.unwrap_or_else(|| { | |
| 46 | + | item1.start_time + chrono::Duration::minutes(item1.duration.unwrap_or(30) as i64) | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | for item2 in items.iter().skip(i + 1) { | |
| 50 | + | let end2 = item2.end_time.unwrap_or_else(|| { | |
| 51 | + | item2.start_time + chrono::Duration::minutes(item2.duration.unwrap_or(30) as i64) | |
| 52 | + | }); | |
| 53 | + | ||
| 54 | + | // Standard interval overlap test: two intervals [s1,e1) and [s2,e2) | |
| 55 | + | // overlap iff s1 < e2 AND s2 < e1. The overlap region is [max(s1,s2), min(e1,e2)). | |
| 56 | + | if item1.start_time < end2 && item2.start_time < end1 { | |
| 57 | + | let overlap_start = item1.start_time.max(item2.start_time); | |
| 58 | + | let overlap_end = end1.min(end2); | |
| 59 | + | conflicts.push(Conflict { | |
| 60 | + | item1_id: item1.id, | |
| 61 | + | item2_id: item2.id, | |
| 62 | + | overlap_start, | |
| 63 | + | overlap_end, | |
| 64 | + | }); | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | conflicts | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[cfg(test)] | |
| 73 | + | mod tests { | |
| 74 | + | use super::*; | |
| 75 | + | use chrono::{Duration, TimeZone}; | |
| 76 | + | ||
| 77 | + | fn make_timeline_item(hour: u32, minute: u32, duration_mins: i32) -> TimelineItem { | |
| 78 | + | let start = Utc.with_ymd_and_hms(2026, 3, 15, hour, minute, 0).unwrap(); | |
| 79 | + | TimelineItem { | |
| 80 | + | id: Uuid::new_v4(), | |
| 81 | + | item_type: "event".to_string(), | |
| 82 | + | title: format!("Event at {}:{:02}", hour, minute), | |
| 83 | + | start_time: start, | |
| 84 | + | end_time: Some(start + Duration::minutes(duration_mins as i64)), | |
| 85 | + | duration: Some(duration_mins), | |
| 86 | + | project_id: None, | |
| 87 | + | project_name: None, | |
| 88 | + | priority: None, | |
| 89 | + | status: None, | |
| 90 | + | block_type: None, | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | #[test] | |
| 95 | + | fn test_no_conflicts_non_overlapping() { | |
| 96 | + | let items = vec![ | |
| 97 | + | make_timeline_item(9, 0, 60), | |
| 98 | + | make_timeline_item(10, 0, 60), | |
| 99 | + | make_timeline_item(11, 0, 60), | |
| 100 | + | ]; | |
| 101 | + | assert!(detect_conflicts(&items).is_empty()); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | #[test] | |
| 105 | + | fn test_direct_overlap() { | |
| 106 | + | let items = vec![ | |
| 107 | + | make_timeline_item(9, 0, 60), | |
| 108 | + | make_timeline_item(9, 30, 60), | |
| 109 | + | ]; | |
| 110 | + | let conflicts = detect_conflicts(&items); | |
| 111 | + | assert_eq!(conflicts.len(), 1); | |
| 112 | + | let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start; | |
| 113 | + | assert_eq!(overlap_duration.num_minutes(), 30); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | #[test] | |
| 117 | + | fn test_complete_containment() { | |
| 118 | + | let items = vec![ | |
| 119 | + | make_timeline_item(9, 0, 120), | |
| 120 | + | make_timeline_item(9, 30, 30), | |
| 121 | + | ]; | |
| 122 | + | let conflicts = detect_conflicts(&items); | |
| 123 | + | assert_eq!(conflicts.len(), 1); | |
| 124 | + | let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start; | |
| 125 | + | assert_eq!(overlap_duration.num_minutes(), 30); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[test] | |
| 129 | + | fn test_multiple_conflicts() { | |
| 130 | + | let items = vec![ | |
| 131 | + | make_timeline_item(9, 0, 90), | |
| 132 | + | make_timeline_item(9, 30, 60), | |
| 133 | + | make_timeline_item(10, 0, 60), | |
| 134 | + | ]; | |
| 135 | + | assert_eq!(detect_conflicts(&items).len(), 3); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | #[test] | |
| 139 | + | fn test_adjacent_no_conflict() { | |
| 140 | + | let items = vec![ | |
| 141 | + | make_timeline_item(9, 0, 60), | |
| 142 | + | make_timeline_item(10, 0, 60), | |
| 143 | + | ]; | |
| 144 | + | assert!(detect_conflicts(&items).is_empty()); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | #[test] | |
| 148 | + | fn test_default_duration_for_missing_end_time() { | |
| 149 | + | let start1 = Utc.with_ymd_and_hms(2026, 3, 15, 9, 0, 0).unwrap(); | |
| 150 | + | let start2 = Utc.with_ymd_and_hms(2026, 3, 15, 9, 15, 0).unwrap(); | |
| 151 | + | ||
| 152 | + | let items = vec![ | |
| 153 | + | TimelineItem { | |
| 154 | + | id: Uuid::new_v4(), | |
| 155 | + | item_type: "task".to_string(), | |
| 156 | + | title: "Task 1".to_string(), | |
| 157 | + | start_time: start1, | |
| 158 | + | end_time: None, | |
| 159 | + | duration: None, | |
| 160 | + | project_id: None, | |
| 161 | + | project_name: None, | |
| 162 | + | priority: None, | |
| 163 | + | status: None, | |
| 164 | + | block_type: None, | |
| 165 | + | }, | |
| 166 | + | TimelineItem { | |
| 167 | + | id: Uuid::new_v4(), | |
| 168 | + | item_type: "task".to_string(), | |
| 169 | + | title: "Task 2".to_string(), | |
| 170 | + | start_time: start2, | |
| 171 | + | end_time: None, | |
| 172 | + | duration: None, | |
| 173 | + | project_id: None, | |
| 174 | + | project_name: None, | |
| 175 | + | priority: None, | |
| 176 | + | status: None, | |
| 177 | + | block_type: None, | |
| 178 | + | }, | |
| 179 | + | ]; | |
| 180 | + | ||
| 181 | + | let conflicts = detect_conflicts(&items); | |
| 182 | + | assert_eq!(conflicts.len(), 1); | |
| 183 | + | let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start; | |
| 184 | + | assert_eq!(overlap_duration.num_minutes(), 15); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | #[test] | |
| 188 | + | fn test_empty_items() { | |
| 189 | + | assert!(detect_conflicts(&Vec::<TimelineItem>::new()).is_empty()); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | #[test] | |
| 193 | + | fn test_single_item() { | |
| 194 | + | assert!(detect_conflicts(&[make_timeline_item(9, 0, 60)]).is_empty()); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | #[test] | |
| 198 | + | fn test_conflict_ids_correct() { | |
| 199 | + | let item1 = make_timeline_item(9, 0, 60); | |
| 200 | + | let item2 = make_timeline_item(9, 30, 60); | |
| 201 | + | let id1 = item1.id; | |
| 202 | + | let id2 = item2.id; | |
| 203 | + | ||
| 204 | + | let conflicts = detect_conflicts(&[item1, item2]); | |
| 205 | + | assert_eq!(conflicts.len(), 1); | |
| 206 | + | assert_eq!(conflicts[0].item1_id, id1); | |
| 207 | + | assert_eq!(conflicts[0].item2_id, id2); | |
| 208 | + | } | |
| 209 | + | } |
| @@ -0,0 +1,57 @@ | |||
| 1 | + | //! Deterministic email ID generation using UUID v5. | |
| 2 | + | //! | |
| 3 | + | //! Emails synced from IMAP/JMAP get a UUID derived from their Message-ID header, | |
| 4 | + | //! so the same email produces the same ID on every device. Manually created emails | |
| 5 | + | //! (no Message-ID) fall back to random UUIDs. | |
| 6 | + | ||
| 7 | + | use crate::id_types::EmailId; | |
| 8 | + | use uuid::Uuid; | |
| 9 | + | ||
| 10 | + | /// Fixed namespace UUID for GoingsOn email IDs (generated once, never changes). | |
| 11 | + | pub const GOINGSON_EMAIL_NS: Uuid = Uuid::from_bytes([ | |
| 12 | + | 0x7a, 0x3b, 0x8c, 0x2d, 0x4e, 0x5f, 0x6a, 0x1b, | |
| 13 | + | 0x9c, 0x0d, 0x8e, 0x7f, 0xa2, 0xb3, 0xc4, 0xd5, | |
| 14 | + | ]); | |
| 15 | + | ||
| 16 | + | /// Generate a deterministic email ID from a Message-ID header. | |
| 17 | + | /// | |
| 18 | + | /// - `Some(message_id)` → UUID v5 from namespace + message_id bytes | |
| 19 | + | /// - `None` → random UUID v4 (for manually created emails without Message-ID) | |
| 20 | + | pub fn deterministic_email_id(message_id: Option<&str>) -> EmailId { | |
| 21 | + | match message_id { | |
| 22 | + | Some(mid) => EmailId::from(Uuid::new_v5(&GOINGSON_EMAIL_NS, mid.as_bytes())), | |
| 23 | + | None => EmailId::new(), | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | #[cfg(test)] | |
| 28 | + | mod tests { | |
| 29 | + | use super::*; | |
| 30 | + | ||
| 31 | + | #[test] | |
| 32 | + | fn same_message_id_same_uuid() { | |
| 33 | + | let a = deterministic_email_id(Some("<abc@example.com>")); | |
| 34 | + | let b = deterministic_email_id(Some("<abc@example.com>")); | |
| 35 | + | assert_eq!(a, b); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | #[test] | |
| 39 | + | fn different_message_ids_different_uuids() { | |
| 40 | + | let a = deterministic_email_id(Some("<abc@example.com>")); | |
| 41 | + | let b = deterministic_email_id(Some("<def@example.com>")); | |
| 42 | + | assert_ne!(a, b); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | #[test] | |
| 46 | + | fn none_gives_random_uuid() { | |
| 47 | + | let a = deterministic_email_id(None); | |
| 48 | + | let b = deterministic_email_id(None); | |
| 49 | + | assert_ne!(a, b); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | #[test] | |
| 53 | + | fn v5_uuid_version() { | |
| 54 | + | let id = deterministic_email_id(Some("<test@example.com>")); | |
| 55 | + | assert_eq!(id.get_version_num(), 5); | |
| 56 | + | } | |
| 57 | + | } |
| @@ -0,0 +1,151 @@ | |||
| 1 | + | //! Email sync business logic. | |
| 2 | + | //! | |
| 3 | + | //! Provides the dedup-save-clear-waiting loop shared by IMAP and JMAP sync paths. | |
| 4 | + | //! The command layer converts protocol-specific parsed emails into [`FetchedEmail`], | |
| 5 | + | //! then calls [`process_fetched_emails`] to handle deduplication, persistence, | |
| 6 | + | //! and waiting-status clearing. | |
| 7 | + | ||
| 8 | + | use chrono::{DateTime, Utc}; | |
| 9 | + | ||
| 10 | + | use crate::error::CoreError; | |
| 11 | + | use crate::id_types::{EmailAccountId, UserId}; | |
| 12 | + | use crate::models::NewEmailWithTracking; | |
| 13 | + | use crate::repository::EmailRepository; | |
| 14 | + | ||
| 15 | + | /// A protocol-agnostic fetched email, ready for dedup and save. | |
| 16 | + | /// | |
| 17 | + | /// Both IMAP `ParsedEmail` and JMAP `JmapParsedEmail` are converted to this | |
| 18 | + | /// before being processed. | |
| 19 | + | pub struct FetchedEmail { | |
| 20 | + | pub message_id: Option<String>, | |
| 21 | + | pub in_reply_to: Option<String>, | |
| 22 | + | pub from: String, | |
| 23 | + | pub to: String, | |
| 24 | + | pub subject: String, | |
| 25 | + | pub body: String, | |
| 26 | + | pub html_body: Option<String>, | |
| 27 | + | pub is_read: bool, | |
| 28 | + | pub date: DateTime<Utc>, | |
| 29 | + | pub source_folder: String, | |
| 30 | + | pub imap_uid: Option<i64>, | |
| 31 | + | pub is_archived: bool, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Result of processing a batch of fetched emails. | |
| 35 | + | #[derive(Debug, Default)] | |
| 36 | + | pub struct SyncProcessResult { | |
| 37 | + | /// Number of emails that were new and saved. | |
| 38 | + | pub emails_saved: usize, | |
| 39 | + | /// Number of waiting statuses cleared because a reply was received. | |
| 40 | + | pub waiting_cleared: usize, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Deduplicates, saves, and clears waiting status for a batch of fetched emails. | |
| 44 | + | /// | |
| 45 | + | /// This is the shared logic for both IMAP and JMAP sync paths: | |
| 46 | + | /// 1. Batch-check which message IDs already exist | |
| 47 | + | /// 2. For each new email, save it with tracking metadata | |
| 48 | + | /// 3. If it's a reply to a message we're waiting on, clear the waiting status | |
| 49 | + | pub async fn process_fetched_emails( | |
| 50 | + | email_repo: &dyn EmailRepository, | |
| 51 | + | user_id: UserId, | |
| 52 | + | account_id: EmailAccountId, | |
| 53 | + | emails: Vec<FetchedEmail>, | |
| 54 | + | ) -> Result<SyncProcessResult, CoreError> { | |
| 55 | + | let mut result = SyncProcessResult::default(); | |
| 56 | + | ||
| 57 | + | // Batch check existing message IDs | |
| 58 | + | let msg_ids: Vec<&str> = emails.iter() | |
| 59 | + | .filter_map(|e| e.message_id.as_deref()) | |
| 60 | + | .collect(); | |
| 61 | + | ||
| 62 | + | let existing_ids = email_repo | |
| 63 | + | .exists_by_message_ids(user_id, &msg_ids) | |
| 64 | + | .await | |
| 65 | + | .unwrap_or_default(); | |
| 66 | + | ||
| 67 | + | // For each email: (1) skip if already exists, (2) insert with tracking | |
| 68 | + | // metadata, (3) if it's a reply to a message we're waiting on, clear waiting. | |
| 69 | + | for email in emails { | |
| 70 | + | if let Some(ref msg_id) = email.message_id { | |
| 71 | + | if existing_ids.contains(msg_id) { | |
| 72 | + | continue; | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // thread_id groups conversations: use in_reply_to if this is a reply, | |
| 77 | + | // otherwise fall back to message_id (starts a new thread). This means | |
| 78 | + | // the first email in a thread has thread_id == message_id. | |
| 79 | + | let thread_id = email.in_reply_to.clone().or_else(|| email.message_id.clone()); | |
| 80 | + | let in_reply_to_for_waiting = email.in_reply_to.clone(); | |
| 81 | + | ||
| 82 | + | let new_email = NewEmailWithTracking { | |
| 83 | + | project_id: None, | |
| 84 | + | from_address: email.from, | |
| 85 | + | to_address: email.to, | |
| 86 | + | subject: email.subject, | |
| 87 | + | body: email.body, | |
| 88 | + | html_body: email.html_body, | |
| 89 | + | is_read: email.is_read, | |
| 90 | + | is_archived: email.is_archived, | |
| 91 | + | received_at: Some(email.date), | |
| 92 | + | message_id: email.message_id, | |
| 93 | + | in_reply_to: email.in_reply_to, | |
| 94 | + | thread_id, | |
| 95 | + | imap_uid: email.imap_uid, | |
| 96 | + | source_folder: Some(email.source_folder), | |
| 97 | + | email_account_id: Some(account_id), | |
| 98 | + | is_outgoing: false, | |
| 99 | + | }; | |
| 100 | + | ||
| 101 | + | if email_repo.create_with_tracking(user_id, new_email).await.is_ok() { | |
| 102 | + | result.emails_saved += 1; | |
| 103 | + | ||
| 104 | + | // Clear waiting status if this is a reply to a message we're waiting on | |
| 105 | + | if let Some(ref reply_to_msg_id) = in_reply_to_for_waiting { | |
| 106 | + | if let Ok(Some(original)) = email_repo.get_by_message_id(user_id, reply_to_msg_id).await { | |
| 107 | + | if original.waiting_for_response { | |
| 108 | + | let _ = email_repo.clear_waiting(original.id, user_id).await; | |
| 109 | + | result.waiting_cleared += 1; | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | Ok(result) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | #[cfg(test)] | |
| 120 | + | mod tests { | |
| 121 | + | use super::*; | |
| 122 | + | ||
| 123 | + | #[test] | |
| 124 | + | fn sync_process_result_default_all_zeros() { | |
| 125 | + | let r = SyncProcessResult::default(); | |
| 126 | + | assert_eq!(r.emails_saved, 0); | |
| 127 | + | assert_eq!(r.waiting_cleared, 0); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn fetched_email_construction() { | |
| 132 | + | let email = FetchedEmail { | |
| 133 | + | message_id: Some("msg-1@example.com".to_string()), | |
| 134 | + | in_reply_to: None, | |
| 135 | + | from: "sender@example.com".to_string(), | |
| 136 | + | to: "recipient@example.com".to_string(), | |
| 137 | + | subject: "Test".to_string(), | |
| 138 | + | body: "Hello".to_string(), | |
| 139 | + | html_body: None, | |
| 140 | + | is_read: false, | |
| 141 | + | date: Utc::now(), | |
| 142 | + | source_folder: "INBOX".to_string(), | |
| 143 | + | imap_uid: Some(42), | |
| 144 | + | is_archived: false, | |
| 145 | + | }; | |
| 146 | + | assert_eq!(email.from, "sender@example.com"); | |
| 147 | + | assert_eq!(email.imap_uid, Some(42)); | |
| 148 | + | assert!(!email.is_read); | |
| 149 | + | assert!(!email.is_archived); | |
| 150 | + | } | |
| 151 | + | } |
| @@ -0,0 +1,176 @@ | |||
| 1 | + | //! Core error types and structured error handling. | |
| 2 | + | ||
| 3 | + | use thiserror::Error; | |
| 4 | + | ||
| 5 | + | /// Core error type for the application. | |
| 6 | + | /// | |
| 7 | + | /// Provides structured error handling with context preservation and source chaining. | |
| 8 | + | #[derive(Debug, Error)] | |
| 9 | + | pub enum CoreError { | |
| 10 | + | /// Database error with optional source for error chaining. | |
| 11 | + | #[error("Database error: {message}")] | |
| 12 | + | Database { | |
| 13 | + | message: String, | |
| 14 | + | #[source] | |
| 15 | + | source: Option<Box<dyn std::error::Error + Send + Sync>>, | |
| 16 | + | }, | |
| 17 | + | ||
| 18 | + | /// Resource not found with type and identifier context. | |
| 19 | + | #[error("Not found: {resource} with id {id}")] | |
| 20 | + | NotFound { | |
| 21 | + | resource: &'static str, | |
| 22 | + | id: String, | |
| 23 | + | }, | |
| 24 | + | ||
| 25 | + | /// Validation error with field and message context. | |
| 26 | + | #[error("Validation error: {field} - {message}")] | |
| 27 | + | Validation { | |
| 28 | + | field: &'static str, | |
| 29 | + | message: String, | |
| 30 | + | }, | |
| 31 | + | ||
| 32 | + | /// Parse error for malformed input. | |
| 33 | + | #[error("Parse error: {0}")] | |
| 34 | + | Parse(String), | |
| 35 | + | ||
| 36 | + | /// Bad request (generic invalid input). | |
| 37 | + | #[error("Bad request: {0}")] | |
| 38 | + | BadRequest(String), | |
| 39 | + | ||
| 40 | + | /// Internal server error. | |
| 41 | + | #[error("Internal error: {0}")] | |
| 42 | + | Internal(String), | |
| 43 | + | ||
| 44 | + | /// Authentication error. | |
| 45 | + | #[error("Authentication error: {0}")] | |
| 46 | + | Auth(String), | |
| 47 | + | ||
| 48 | + | /// Remote sync operation failed (push, pull, device registration). | |
| 49 | + | #[error("Sync error: {0}")] | |
| 50 | + | Sync(String), | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | impl CoreError { | |
| 54 | + | /// Creates a database error from any error type that implements Error + Send + Sync. | |
| 55 | + | pub fn database(err: impl std::error::Error + Send + Sync + 'static) -> Self { | |
| 56 | + | CoreError::Database { | |
| 57 | + | message: err.to_string(), | |
| 58 | + | source: Some(Box::new(err)), | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Creates a database error from a string message. | |
| 63 | + | pub fn database_msg(msg: impl Into<String>) -> Self { | |
| 64 | + | CoreError::Database { | |
| 65 | + | message: msg.into(), | |
| 66 | + | source: None, | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Creates a not-found error with resource type and identifier. | |
| 71 | + | pub fn not_found(resource: &'static str, id: impl ToString) -> Self { | |
| 72 | + | CoreError::NotFound { | |
| 73 | + | resource, | |
| 74 | + | id: id.to_string(), | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Creates a validation error for a specific field. | |
| 79 | + | pub fn validation(field: &'static str, message: impl Into<String>) -> Self { | |
| 80 | + | CoreError::Validation { | |
| 81 | + | field, | |
| 82 | + | message: message.into(), | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Creates a parse error. | |
| 87 | + | pub fn parse(msg: impl Into<String>) -> Self { | |
| 88 | + | CoreError::Parse(msg.into()) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Creates a bad request error. | |
| 92 | + | pub fn bad_request(msg: impl Into<String>) -> Self { | |
| 93 | + | CoreError::BadRequest(msg.into()) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Creates an internal error. | |
| 97 | + | pub fn internal(msg: impl Into<String>) -> Self { | |
| 98 | + | CoreError::Internal(msg.into()) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// Creates an authentication error. | |
| 102 | + | pub fn auth(msg: impl Into<String>) -> Self { | |
| 103 | + | CoreError::Auth(msg.into()) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Creates a sync error. | |
| 107 | + | pub fn sync(msg: impl Into<String>) -> Self { | |
| 108 | + | CoreError::Sync(msg.into()) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Returns true if this is a not-found error. | |
| 112 | + | pub fn is_not_found(&self) -> bool { | |
| 113 | + | matches!(self, CoreError::NotFound { .. }) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// Returns true if this is a validation error. | |
| 117 | + | pub fn is_validation(&self) -> bool { | |
| 118 | + | matches!(self, CoreError::Validation { .. }) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// Returns true if this is a database error. | |
| 122 | + | pub fn is_database(&self) -> bool { | |
| 123 | + | matches!(self, CoreError::Database { .. }) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | #[cfg(test)] | |
| 128 | + | mod tests { | |
| 129 | + | use super::*; | |
| 130 | + | ||
| 131 | + | #[test] | |
| 132 | + | fn database_constructor_preserves_message() { | |
| 133 | + | let err = CoreError::database_msg("connection refused"); | |
| 134 | + | assert!(err.to_string().contains("connection refused")); | |
| 135 | + | assert!(err.is_database()); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | #[test] | |
| 139 | + | fn not_found_constructor_formats_resource_and_id() { | |
| 140 | + | let err = CoreError::not_found("Task", "abc-123"); | |
| 141 | + | let msg = err.to_string(); | |
| 142 | + | assert!(msg.contains("Task")); | |
| 143 | + | assert!(msg.contains("abc-123")); | |
| 144 | + | assert!(err.is_not_found()); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | #[test] | |
| 148 | + | fn validation_constructor_includes_field_and_message() { | |
| 149 | + | let err = CoreError::validation("name", "too long"); | |
| 150 | + | let msg = err.to_string(); | |
| 151 | + | assert!(msg.contains("name")); | |
| 152 | + | assert!(msg.contains("too long")); | |
| 153 | + | assert!(err.is_validation()); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[test] | |
| 157 | + | fn is_not_found_false_for_other_variants() { | |
| 158 | + | assert!(!CoreError::database_msg("fail").is_not_found()); | |
| 159 | + | assert!(!CoreError::parse("bad").is_not_found()); | |
| 160 | + | assert!(!CoreError::internal("oops").is_not_found()); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[test] | |
| 164 | + | fn is_validation_false_for_other_variants() { | |
| 165 | + | assert!(!CoreError::not_found("Task", "1").is_validation()); | |
| 166 | + | assert!(!CoreError::bad_request("nope").is_validation()); | |
| 167 | + | assert!(!CoreError::auth("denied").is_validation()); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | #[test] | |
| 171 | + | fn is_database_false_for_other_variants() { | |
| 172 | + | assert!(!CoreError::not_found("Task", "1").is_database()); | |
| 173 | + | assert!(!CoreError::validation("x", "y").is_database()); | |
| 174 | + | assert!(!CoreError::internal("fail").is_database()); | |
| 175 | + | } | |
| 176 | + | } |
| @@ -0,0 +1,204 @@ | |||
| 1 | + | //! Strongly-typed entity ID newtypes. | |
| 2 | + | //! | |
| 3 | + | //! Each entity gets its own ID type wrapping `uuid::Uuid`, preventing | |
| 4 | + | //! accidental mixups at compile time while remaining transparent in | |
| 5 | + | //! serialization (JSON, SQLite) via `#[serde(transparent)]`. | |
| 6 | + | ||
| 7 | + | use std::fmt; | |
| 8 | + | use uuid::Uuid; | |
| 9 | + | ||
| 10 | + | /// Generates a strongly-typed UUID newtype for entity IDs. | |
| 11 | + | /// | |
| 12 | + | /// The generated type is `Copy`, serializes transparently as a UUID string, | |
| 13 | + | /// and (with the `sqlx-sqlite` feature) implements sqlx traits for SQLite. | |
| 14 | + | macro_rules! define_uuid_id { | |
| 15 | + | ($($name:ident),+ $(,)?) => { | |
| 16 | + | $( | |
| 17 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 18 | + | #[derive(serde::Serialize, serde::Deserialize)] | |
| 19 | + | #[serde(transparent)] | |
| 20 | + | pub struct $name(Uuid); | |
| 21 | + | ||
| 22 | + | impl $name { | |
| 23 | + | /// Creates a new random ID (UUID v4). | |
| 24 | + | pub fn new() -> Self { | |
| 25 | + | Self(Uuid::new_v4()) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// Wraps an existing UUID (const-compatible). | |
| 29 | + | pub const fn from_uuid(uuid: Uuid) -> Self { | |
| 30 | + | Self(uuid) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Returns the inner UUID. | |
| 34 | + | pub fn as_uuid(&self) -> &Uuid { | |
| 35 | + | &self.0 | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | impl Default for $name { | |
| 40 | + | fn default() -> Self { | |
| 41 | + | Self::new() | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | impl fmt::Display for $name { | |
| 46 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 47 | + | self.0.fmt(f) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | impl From<Uuid> for $name { | |
| 52 | + | fn from(id: Uuid) -> Self { | |
| 53 | + | Self(id) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | impl From<$name> for Uuid { | |
| 58 | + | fn from(id: $name) -> Uuid { | |
| 59 | + | id.0 | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | impl std::ops::Deref for $name { | |
| 64 | + | type Target = Uuid; | |
| 65 | + | fn deref(&self) -> &Uuid { | |
| 66 | + | &self.0 | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | impl AsRef<Uuid> for $name { | |
| 71 | + | fn as_ref(&self) -> &Uuid { | |
| 72 | + | &self.0 | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | #[cfg(feature = "sqlx-sqlite")] | |
| 77 | + | impl sqlx::Type<sqlx::Sqlite> for $name { | |
| 78 | + | fn type_info() -> sqlx::sqlite::SqliteTypeInfo { | |
| 79 | + | <Uuid as sqlx::Type<sqlx::Sqlite>>::type_info() | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool { | |
| 83 | + | <Uuid as sqlx::Type<sqlx::Sqlite>>::compatible(ty) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | #[cfg(feature = "sqlx-sqlite")] | |
| 88 | + | impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for $name { | |
| 89 | + | fn encode_by_ref( | |
| 90 | + | &self, | |
| 91 | + | buf: &mut <sqlx::Sqlite as sqlx::Database>::ArgumentBuffer<'q>, | |
| 92 | + | ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> { | |
| 93 | + | <Uuid as sqlx::Encode<'q, sqlx::Sqlite>>::encode_by_ref(&self.0, buf) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | #[cfg(feature = "sqlx-sqlite")] | |
| 98 | + | impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for $name { | |
| 99 | + | fn decode( | |
| 100 | + | value: <sqlx::Sqlite as sqlx::Database>::ValueRef<'r>, | |
| 101 | + | ) -> Result<Self, sqlx::error::BoxDynError> { | |
| 102 | + | let uuid = <Uuid as sqlx::Decode<'r, sqlx::Sqlite>>::decode(value)?; | |
| 103 | + | Ok(Self(uuid)) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | )+ | |
| 107 | + | }; | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Primary entity IDs | |
| 111 | + | define_uuid_id!( | |
| 112 | + | TaskId, | |
| 113 | + | ProjectId, | |
| 114 | + | EventId, | |
| 115 | + | EmailId, | |
| 116 | + | ContactId, | |
| 117 | + | MilestoneId, | |
| 118 | + | WeeklyReviewId, | |
| 119 | + | SavedViewId, | |
| 120 | + | EmailAccountId, | |
| 121 | + | LlmSettingsId, | |
| 122 | + | UserId, | |
| 123 | + | ); | |
| 124 | + | ||
| 125 | + | // Sub-entity IDs | |
| 126 | + | define_uuid_id!( | |
| 127 | + | AnnotationId, | |
| 128 | + | SubtaskId, | |
| 129 | + | ContactEmailId, | |
| 130 | + | ContactPhoneId, | |
| 131 | + | SocialHandleId, | |
| 132 | + | CustomFieldId, | |
| 133 | + | ); | |
| 134 | + | ||
| 135 | + | #[cfg(test)] | |
| 136 | + | mod tests { | |
| 137 | + | use super::*; | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn new_generates_unique_ids() { | |
| 141 | + | let a = TaskId::new(); | |
| 142 | + | let b = TaskId::new(); | |
| 143 | + | assert_ne!(a, b); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | #[test] | |
| 147 | + | fn from_uuid_roundtrip() { | |
| 148 | + | let uuid = Uuid::new_v4(); | |
| 149 | + | let id = ProjectId::from(uuid); | |
| 150 | + | assert_eq!(*id, uuid); | |
| 151 | + | assert_eq!(Uuid::from(id), uuid); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | #[test] | |
| 155 | + | fn display_matches_uuid() { | |
| 156 | + | let uuid = Uuid::new_v4(); | |
| 157 | + | let id = EventId::from(uuid); | |
| 158 | + | assert_eq!(id.to_string(), uuid.to_string()); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | #[test] | |
| 162 | + | fn serde_transparent_roundtrip() { | |
| 163 | + | let id = TaskId::new(); | |
| 164 | + | let json = serde_json::to_string(&id).unwrap(); | |
| 165 | + | // Should be just a quoted UUID string, no wrapper object | |
| 166 | + | assert!(json.starts_with('"')); | |
| 167 | + | assert!(json.ends_with('"')); | |
| 168 | + | let parsed: TaskId = serde_json::from_str(&json).unwrap(); | |
| 169 | + | assert_eq!(id, parsed); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[test] | |
| 173 | + | fn different_id_types_same_inner_value() { | |
| 174 | + | let uuid = Uuid::new_v4(); | |
| 175 | + | let task_id = TaskId::from(uuid); | |
| 176 | + | let project_id = ProjectId::from(uuid); | |
| 177 | + | // Same inner value but different types — can't accidentally swap them. | |
| 178 | + | assert_eq!(*task_id, *project_id); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | #[test] | |
| 182 | + | fn hash_works_for_collections() { | |
| 183 | + | use std::collections::HashSet; | |
| 184 | + | let mut set = HashSet::new(); | |
| 185 | + | let id = ContactId::new(); | |
| 186 | + | set.insert(id); | |
| 187 | + | assert!(set.contains(&id)); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | #[test] | |
| 191 | + | fn as_uuid_returns_inner() { | |
| 192 | + | let uuid = Uuid::new_v4(); | |
| 193 | + | let id = UserId::from(uuid); | |
| 194 | + | assert_eq!(id.as_uuid(), &uuid); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | #[test] | |
| 198 | + | fn deref_to_uuid() { | |
| 199 | + | let id = MilestoneId::new(); | |
| 200 | + | // Can call Uuid methods directly through Deref | |
| 201 | + | let _s = id.to_string(); | |
| 202 | + | let _hyphenated = id.hyphenated(); | |
| 203 | + | } | |
| 204 | + | } |
| @@ -0,0 +1,84 @@ | |||
| 1 | + | //! Core domain models and business logic for GoingsOn. | |
| 2 | + | //! | |
| 3 | + | //! This crate provides the foundational types and traits for the GoingsOn | |
| 4 | + | //! project management application, including: | |
| 5 | + | //! | |
| 6 | + | //! - **Domain Models**: [`Project`], [`Task`], [`Event`], [`Email`], and related types | |
| 7 | + | //! - **Repository Traits**: Abstract data access layer for persistence | |
| 8 | + | //! - **Business Logic**: Urgency calculation, recurrence handling, quick-add parsing | |
| 9 | + | //! - **Error Handling**: Unified [`CoreError`] type for all operations | |
| 10 | + | //! | |
| 11 | + | //! # Architecture | |
| 12 | + | //! | |
| 13 | + | //! The crate follows a clean architecture pattern where domain models are | |
| 14 | + | //! independent of persistence concerns. Repository traits define the data | |
| 15 | + | //! access contract, allowing different implementations (SQLite, PostgreSQL). | |
| 16 | + | //! | |
| 17 | + | //! # Example | |
| 18 | + | //! | |
| 19 | + | //! ```rust,ignore | |
| 20 | + | //! use goingson_core::{Task, Priority, TaskStatus, calculate_urgency}; | |
| 21 | + | //! use chrono::Utc; | |
| 22 | + | //! | |
| 23 | + | //! // Calculate task urgency based on priority, status, and due date | |
| 24 | + | //! let urgency = calculate_urgency( | |
| 25 | + | //! &Priority::High, | |
| 26 | + | //! &TaskStatus::Pending, | |
| 27 | + | //! None, | |
| 28 | + | //! &Utc::now(), | |
| 29 | + | //! &["urgent"], | |
| 30 | + | //! ); | |
| 31 | + | //! ``` | |
| 32 | + | ||
| 33 | + | pub mod backup_restore; | |
| 34 | + | pub mod constants; | |
| 35 | + | pub mod contact; | |
| 36 | + | pub mod day_planning; | |
| 37 | + | pub mod email_id; | |
| 38 | + | pub mod email_sync; | |
| 39 | + | pub mod error; | |
| 40 | + | pub mod id_types; | |
| 41 | + | pub mod models; | |
| 42 | + | pub mod parser; | |
| 43 | + | pub mod plugin; | |
| 44 | + | pub mod recurrence; | |
| 45 | + | pub mod repository; | |
| 46 | + | pub mod search_parser; | |
| 47 | + | pub mod urgency; | |
| 48 | + | pub mod validation; | |
| 49 | + | pub mod weekly_review; | |
| 50 | + | ||
| 51 | + | pub use contact::{ | |
| 52 | + | Contact, ContactCustomField, ContactEmail, ContactPhone, NewContact, NewContactCustomField, | |
| 53 | + | NewContactEmail, NewContactPhone, NewSocialHandle, SocialHandle, UpdateContact, | |
| 54 | + | }; | |
| 55 | + | pub use error::CoreError; | |
| 56 | + | pub use id_types::{ | |
| 57 | + | AnnotationId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, EmailAccountId, | |
| 58 | + | EmailId, EventId, LlmSettingsId, MilestoneId, ProjectId, SavedViewId, SocialHandleId, | |
| 59 | + | SubtaskId, TaskId, UserId, WeeklyReviewId, | |
| 60 | + | }; | |
| 61 | + | pub use models::{ | |
| 62 | + | Annotation, BackupSettings, BlockType, CssClass, DbValue, Email, EmailAccount, EmailAuthType, | |
| 63 | + | EmailThread, Event, LlmContext, LlmProviderType, LlmSettings, Milestone, MilestoneStatus, | |
| 64 | + | NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder, NewLlmSettings, | |
| 65 | + | NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority, Project, | |
| 66 | + | ProjectStatus, ProjectType, Recurrence, SavedView, SortDirection, SortField, Subtask, Task, | |
| 67 | + | TaskFilterQuery, TaskSortColumn, TaskStatus, UpdateEvent, UpdateProject, UpdateTask, User, | |
| 68 | + | ViewFilters, ViewType, WeeklyReview, | |
| 69 | + | }; | |
| 70 | + | pub use parser::{parse_quick_add, parse_quick_add_with_warnings, ParsedTask, ParseResult}; | |
| 71 | + | pub use day_planning::{Conflict, TimelineItem, detect_conflicts}; | |
| 72 | + | pub use recurrence::{calculate_next_due, should_recur}; | |
| 73 | + | pub use repository::*; | |
| 74 | + | pub use urgency::calculate_urgency; | |
| 75 | + | pub use plugin::{ | |
| 76 | + | ExportPluginConfig, ImportEntityType, ImportExecuteResult, ImportFailure, ImportItem, | |
| 77 | + | ImportItemData, ImportOptions, ImportParseResult, ImportPluginConfig, ImportProgress, | |
| 78 | + | ImportProjectData, ImportTaskData, ImportEventData, PluginCapabilities, PluginMeta, PluginType, | |
| 79 | + | }; | |
| 80 | + | pub use email_id::deterministic_email_id; | |
| 81 | + | pub use validation::Validate; | |
| 82 | + | ||
| 83 | + | /// A specialized `Result` type for core operations. | |
| 84 | + | pub type Result<T> = std::result::Result<T, CoreError>; |
| @@ -0,0 +1,56 @@ | |||
| 1 | + | //! Backup settings types and DTOs. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | use uuid::Uuid; | |
| 6 | + | ||
| 7 | + | // ============ Backup Settings ============ | |
| 8 | + | ||
| 9 | + | /// User's backup configuration. | |
| 10 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 11 | + | #[serde(rename_all = "camelCase")] | |
| 12 | + | pub struct BackupSettings { | |
| 13 | + | /// Unique identifier. | |
| 14 | + | pub id: Uuid, | |
| 15 | + | /// Owner user ID. | |
| 16 | + | pub user_id: Uuid, | |
| 17 | + | /// Whether automatic backups are enabled. | |
| 18 | + | pub auto_backup_enabled: bool, | |
| 19 | + | /// Minutes between automatic backups. | |
| 20 | + | pub backup_frequency_minutes: i32, | |
| 21 | + | /// Maximum number of backups to retain. | |
| 22 | + | pub max_backups_to_keep: i32, | |
| 23 | + | /// When the last backup was created. | |
| 24 | + | pub last_backup_at: Option<DateTime<Utc>>, | |
| 25 | + | /// When settings were created. | |
| 26 | + | pub created_at: DateTime<Utc>, | |
| 27 | + | /// When settings were last modified. | |
| 28 | + | pub updated_at: DateTime<Utc>, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl Default for BackupSettings { | |
| 32 | + | fn default() -> Self { | |
| 33 | + | Self { | |
| 34 | + | id: Uuid::new_v4(), | |
| 35 | + | user_id: Uuid::nil(), | |
| 36 | + | auto_backup_enabled: true, | |
| 37 | + | backup_frequency_minutes: 15, | |
| 38 | + | max_backups_to_keep: 1, | |
| 39 | + | last_backup_at: None, | |
| 40 | + | created_at: Utc::now(), | |
| 41 | + | updated_at: Utc::now(), | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Data for creating or updating backup settings. | |
| 47 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 48 | + | #[serde(rename_all = "camelCase")] | |
| 49 | + | pub struct NewBackupSettings { | |
| 50 | + | /// Whether automatic backups are enabled. | |
| 51 | + | pub auto_backup_enabled: bool, | |
| 52 | + | /// Minutes between automatic backups. | |
| 53 | + | pub backup_frequency_minutes: i32, | |
| 54 | + | /// Maximum number of backups to retain. | |
| 55 | + | pub max_backups_to_keep: i32, | |
| 56 | + | } |
| @@ -0,0 +1,352 @@ | |||
| 1 | + | //! Email domain types and DTOs. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | use crate::constants::{DAYS_THRESHOLD_SHORT_FORMAT, EMAIL_BODY_PREVIEW_LENGTH}; | |
| 6 | + | use crate::id_types::{EmailId, ProjectId, EmailAccountId}; | |
| 7 | + | ||
| 8 | + | // ============ Email ============ | |
| 9 | + | ||
| 10 | + | /// An email message synced from IMAP or sent via SMTP. | |
| 11 | + | /// | |
| 12 | + | /// Emails can be linked to projects, snoozed, and tracked for follow-up responses. | |
| 13 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 14 | + | #[serde(rename_all = "camelCase")] | |
| 15 | + | pub struct Email { | |
| 16 | + | /// Unique identifier. | |
| 17 | + | pub id: EmailId, | |
| 18 | + | /// Associated project, if any. | |
| 19 | + | pub project_id: Option<ProjectId>, | |
| 20 | + | /// Denormalized project name for display. | |
| 21 | + | pub project_name: Option<String>, | |
| 22 | + | /// Sender address. | |
| 23 | + | pub from: String, | |
| 24 | + | /// Recipient address(es). | |
| 25 | + | pub to: String, | |
| 26 | + | /// Email subject line. | |
| 27 | + | pub subject: String, | |
| 28 | + | /// Email body content (plain text or HTML stripped to text). | |
| 29 | + | pub body: String, | |
| 30 | + | /// Original HTML body for "Open in Browser" feature. | |
| 31 | + | pub html_body: Option<String>, | |
| 32 | + | /// Whether the email has been read. | |
| 33 | + | pub is_read: bool, | |
| 34 | + | /// Whether the email is archived. | |
| 35 | + | pub is_archived: bool, | |
| 36 | + | /// When the email was received. | |
| 37 | + | pub received_at: DateTime<Utc>, | |
| 38 | + | /// RFC 2822 Message-ID header for deduplication. | |
| 39 | + | pub message_id: Option<String>, | |
| 40 | + | /// RFC 2822 In-Reply-To header for threading. | |
| 41 | + | pub in_reply_to: Option<String>, | |
| 42 | + | /// Thread ID for grouping related emails (derived from original Message-ID). | |
| 43 | + | pub thread_id: Option<String>, | |
| 44 | + | /// Source email account. | |
| 45 | + | pub email_account_id: Option<EmailAccountId>, | |
| 46 | + | /// True for sent emails, false for received. | |
| 47 | + | pub is_outgoing: bool, | |
| 48 | + | /// IMAP UID for sync operations (internal). | |
| 49 | + | #[serde(skip_serializing)] | |
| 50 | + | pub imap_uid: Option<i64>, | |
| 51 | + | /// IMAP folder name (internal). | |
| 52 | + | #[serde(skip_serializing)] | |
| 53 | + | pub source_folder: Option<String>, | |
| 54 | + | /// If snoozed, when to resurface. | |
| 55 | + | pub snoozed_until: Option<DateTime<Utc>>, | |
| 56 | + | /// Whether waiting for a reply. | |
| 57 | + | pub waiting_for_response: bool, | |
| 58 | + | /// When waiting status was set. | |
| 59 | + | pub waiting_since: Option<DateTime<Utc>>, | |
| 60 | + | /// Expected reply date when waiting. | |
| 61 | + | pub expected_response_date: Option<DateTime<Utc>>, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | impl Email { | |
| 65 | + | /// Returns a human-readable relative time string for when the email was received. | |
| 66 | + | /// | |
| 67 | + | /// Examples: "Just now", "3h ago", "5d ago", or "Jan 15" for older emails. | |
| 68 | + | pub fn received_formatted(&self) -> String { | |
| 69 | + | let now = Utc::now(); | |
| 70 | + | let diff = now.signed_duration_since(self.received_at); | |
| 71 | + | let hours = diff.num_hours(); | |
| 72 | + | let days = diff.num_days(); | |
| 73 | + | ||
| 74 | + | if hours < 1 { | |
| 75 | + | "Just now".to_string() | |
| 76 | + | } else if hours < 24 { | |
| 77 | + | format!("{}h ago", hours) | |
| 78 | + | } else if days < DAYS_THRESHOLD_SHORT_FORMAT { | |
| 79 | + | format!("{}d ago", days) | |
| 80 | + | } else { | |
| 81 | + | self.received_at.format("%b %d").to_string() | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Returns a truncated preview of the email body for list display. | |
| 86 | + | /// | |
| 87 | + | /// Truncates to `EMAIL_BODY_PREVIEW_LENGTH` characters (not bytes) to avoid | |
| 88 | + | /// panicking on multi-byte UTF-8 sequences. | |
| 89 | + | pub fn body_preview(&self) -> String { | |
| 90 | + | if self.body.chars().count() > EMAIL_BODY_PREVIEW_LENGTH { | |
| 91 | + | let truncated: String = self.body.chars().take(EMAIL_BODY_PREVIEW_LENGTH).collect(); | |
| 92 | + | format!("{truncated}...") | |
| 93 | + | } else { | |
| 94 | + | self.body.clone() | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | pub fn has_project(&self) -> bool { | |
| 99 | + | self.project_name.is_some() | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | pub fn project_name_or_empty(&self) -> &str { | |
| 103 | + | self.project_name.as_deref().unwrap_or("") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | pub fn is_read_str(&self) -> &'static str { | |
| 107 | + | if self.is_read { "true" } else { "false" } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | pub fn is_archived_str(&self) -> &'static str { | |
| 111 | + | if self.is_archived { "true" } else { "false" } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// Returns true if the email is currently snoozed (snoozed_until is in the future). | |
| 115 | + | pub fn is_snoozed(&self) -> bool { | |
| 116 | + | self.snoozed_until | |
| 117 | + | .map(|until| until > Utc::now()) | |
| 118 | + | .unwrap_or(false) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// Returns true if the email is waiting for a reply. | |
| 122 | + | pub fn is_waiting(&self) -> bool { | |
| 123 | + | self.waiting_for_response | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Returns true if the email is waiting and the expected response date has passed. | |
| 127 | + | pub fn is_response_overdue(&self) -> bool { | |
| 128 | + | self.waiting_for_response | |
| 129 | + | && self.expected_response_date | |
| 130 | + | .map(|date| date < Utc::now()) | |
| 131 | + | .unwrap_or(false) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// A thread of emails, grouped by thread_id. | |
| 136 | + | /// Contains metadata computed server-side for efficient UI rendering. | |
| 137 | + | #[derive(Debug, Clone)] | |
| 138 | + | pub struct EmailThread { | |
| 139 | + | /// The shared thread identifier | |
| 140 | + | pub thread_id: String, | |
| 141 | + | /// The most recent email in the thread (for display) | |
| 142 | + | pub most_recent_email: Email, | |
| 143 | + | /// Total count of emails in this thread | |
| 144 | + | pub thread_count: usize, | |
| 145 | + | /// True if any email in the thread has is_read = false | |
| 146 | + | pub has_unread: bool, | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // ============ Email DTOs ============ | |
| 150 | + | ||
| 151 | + | #[cfg(test)] | |
| 152 | + | mod tests { | |
| 153 | + | use super::*; | |
| 154 | + | use chrono::Duration; | |
| 155 | + | ||
| 156 | + | fn make_email() -> Email { | |
| 157 | + | Email { | |
| 158 | + | id: EmailId::new(), | |
| 159 | + | project_id: None, | |
| 160 | + | project_name: None, | |
| 161 | + | from: "alice@example.com".into(), | |
| 162 | + | to: "bob@example.com".into(), | |
| 163 | + | subject: "Test".into(), | |
| 164 | + | body: "Hello world".into(), | |
| 165 | + | html_body: None, | |
| 166 | + | is_read: false, | |
| 167 | + | is_archived: false, | |
| 168 | + | received_at: Utc::now(), | |
| 169 | + | message_id: None, | |
| 170 | + | in_reply_to: None, | |
| 171 | + | thread_id: None, | |
| 172 | + | email_account_id: None, | |
| 173 | + | is_outgoing: false, | |
| 174 | + | imap_uid: None, | |
| 175 | + | source_folder: None, | |
| 176 | + | snoozed_until: None, | |
| 177 | + | waiting_for_response: false, | |
| 178 | + | waiting_since: None, | |
| 179 | + | expected_response_date: None, | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[test] | |
| 184 | + | fn body_preview_short_body_unchanged() { | |
| 185 | + | let email = make_email(); | |
| 186 | + | assert_eq!(email.body_preview(), "Hello world"); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | #[test] | |
| 190 | + | fn body_preview_truncates_long_body() { | |
| 191 | + | let mut email = make_email(); | |
| 192 | + | email.body = "a".repeat(200); | |
| 193 | + | let preview = email.body_preview(); | |
| 194 | + | assert_eq!(preview.chars().count(), EMAIL_BODY_PREVIEW_LENGTH + 3); // +3 for "..." | |
| 195 | + | assert!(preview.ends_with("...")); | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | #[test] | |
| 199 | + | fn body_preview_handles_multibyte_utf8() { | |
| 200 | + | let mut email = make_email(); | |
| 201 | + | // Each char is 3 bytes in UTF-8; body is 200 chars = 600 bytes. | |
| 202 | + | // Truncating at byte 100 would land mid-character and panic. | |
| 203 | + | email.body = "\u{00e9}".repeat(200); // 'e' with accent | |
| 204 | + | let preview = email.body_preview(); | |
| 205 | + | assert!(preview.ends_with("...")); | |
| 206 | + | assert_eq!(preview.chars().count(), EMAIL_BODY_PREVIEW_LENGTH + 3); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | #[test] | |
| 210 | + | fn is_read_str_values() { | |
| 211 | + | let mut email = make_email(); | |
| 212 | + | assert_eq!(email.is_read_str(), "false"); | |
| 213 | + | email.is_read = true; | |
| 214 | + | assert_eq!(email.is_read_str(), "true"); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | #[test] | |
| 218 | + | fn is_archived_str_values() { | |
| 219 | + | let mut email = make_email(); | |
| 220 | + | assert_eq!(email.is_archived_str(), "false"); | |
| 221 | + | email.is_archived = true; | |
| 222 | + | assert_eq!(email.is_archived_str(), "true"); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | #[test] | |
| 226 | + | fn has_project_without_project() { | |
| 227 | + | let email = make_email(); | |
| 228 | + | assert!(!email.has_project()); | |
| 229 | + | assert_eq!(email.project_name_or_empty(), ""); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[test] | |
| 233 | + | fn has_project_with_project() { | |
| 234 | + | let mut email = make_email(); | |
| 235 | + | email.project_name = Some("My Project".into()); | |
| 236 | + | assert!(email.has_project()); | |
| 237 | + | assert_eq!(email.project_name_or_empty(), "My Project"); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | #[test] | |
| 241 | + | fn is_snoozed_future() { | |
| 242 | + | let mut email = make_email(); | |
| 243 | + | email.snoozed_until = Some(Utc::now() + Duration::hours(1)); | |
| 244 | + | assert!(email.is_snoozed()); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | #[test] | |
| 248 | + | fn is_snoozed_past() { | |
| 249 | + | let mut email = make_email(); | |
| 250 | + | email.snoozed_until = Some(Utc::now() - Duration::hours(1)); | |
| 251 | + | assert!(!email.is_snoozed()); | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | #[test] | |
| 255 | + | fn is_snoozed_none() { | |
| 256 | + | let email = make_email(); | |
| 257 | + | assert!(!email.is_snoozed()); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | #[test] | |
| 261 | + | fn is_waiting_returns_flag() { | |
| 262 | + | let mut email = make_email(); | |
| 263 | + | assert!(!email.is_waiting()); | |
| 264 | + | email.waiting_for_response = true; | |
| 265 | + | assert!(email.is_waiting()); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[test] | |
| 269 | + | fn is_response_overdue_not_waiting() { | |
| 270 | + | let mut email = make_email(); | |
| 271 | + | email.expected_response_date = Some(Utc::now() - Duration::hours(1)); | |
| 272 | + | assert!(!email.is_response_overdue()); // not waiting | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | #[test] | |
| 276 | + | fn is_response_overdue_waiting_past_date() { | |
| 277 | + | let mut email = make_email(); | |
| 278 | + | email.waiting_for_response = true; | |
| 279 | + | email.expected_response_date = Some(Utc::now() - Duration::hours(1)); | |
| 280 | + | assert!(email.is_response_overdue()); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | #[test] | |
| 284 | + | fn is_response_overdue_waiting_future_date() { | |
| 285 | + | let mut email = make_email(); | |
| 286 | + | email.waiting_for_response = true; | |
| 287 | + | email.expected_response_date = Some(Utc::now() + Duration::hours(1)); | |
| 288 | + | assert!(!email.is_response_overdue()); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | #[test] | |
| 292 | + | fn received_formatted_just_now() { | |
| 293 | + | let email = make_email(); // received_at = now | |
| 294 | + | assert_eq!(email.received_formatted(), "Just now"); | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | #[test] | |
| 298 | + | fn received_formatted_hours_ago() { | |
| 299 | + | let mut email = make_email(); | |
| 300 | + | email.received_at = Utc::now() - Duration::hours(3); | |
| 301 | + | assert_eq!(email.received_formatted(), "3h ago"); | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | #[test] | |
| 305 | + | fn received_formatted_days_ago() { | |
| 306 | + | let mut email = make_email(); | |
| 307 | + | email.received_at = Utc::now() - Duration::days(5); | |
| 308 | + | assert_eq!(email.received_formatted(), "5d ago"); | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | #[test] | |
| 312 | + | fn received_formatted_older_shows_date() { | |
| 313 | + | let mut email = make_email(); | |
| 314 | + | email.received_at = Utc::now() - Duration::days(30); | |
| 315 | + | let formatted = email.received_formatted(); | |
| 316 | + | // Should be like "Jan 26" — not "30d ago" | |
| 317 | + | assert!(!formatted.contains("ago")); | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | /// Data for creating a new email (simple). | |
| 322 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 323 | + | pub struct NewEmail { | |
| 324 | + | pub project_id: Option<ProjectId>, | |
| 325 | + | pub from_address: String, | |
| 326 | + | pub to_address: String, | |
| 327 | + | pub subject: String, | |
| 328 | + | pub body: String, | |
| 329 | + | pub is_read: bool, | |
| 330 | + | pub received_at: Option<DateTime<Utc>>, | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | /// Data for creating an email with full IMAP tracking info. | |
| 334 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 335 | + | pub struct NewEmailWithTracking { | |
| 336 | + | pub project_id: Option<ProjectId>, | |
| 337 | + | pub from_address: String, | |
| 338 | + | pub to_address: String, | |
| 339 | + | pub subject: String, | |
| 340 | + | pub body: String, | |
| 341 | + | pub html_body: Option<String>, | |
| 342 | + | pub is_read: bool, | |
| 343 | + | pub is_archived: bool, | |
| 344 | + | pub received_at: Option<DateTime<Utc>>, | |
| 345 | + | pub message_id: Option<String>, | |
| 346 | + | pub in_reply_to: Option<String>, | |
| 347 | + | pub thread_id: Option<String>, | |
| 348 | + | pub email_account_id: Option<EmailAccountId>, | |
| 349 | + | pub is_outgoing: bool, | |
| 350 | + | pub imap_uid: Option<i64>, | |
| 351 | + | pub source_folder: Option<String>, | |
| 352 | + | } |
| @@ -0,0 +1,299 @@ | |||
| 1 | + | //! Email account domain types. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | use strum_macros::EnumString; | |
| 6 | + | use crate::id_types::{EmailAccountId, UserId}; | |
| 7 | + | ||
| 8 | + | use super::shared::DbValue; | |
| 9 | + | ||
| 10 | + | // ============ Email Account ============ | |
| 11 | + | ||
| 12 | + | /// Authentication method for email accounts. | |
| 13 | + | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)] | |
| 14 | + | pub enum EmailAuthType { | |
| 15 | + | /// Traditional password-based IMAP/SMTP authentication. | |
| 16 | + | #[strum(serialize = "password")] | |
| 17 | + | #[default] | |
| 18 | + | Password, | |
| 19 | + | /// OAuth2 with Fastmail JMAP API. | |
| 20 | + | #[strum(serialize = "oauth2_fastmail")] | |
| 21 | + | OAuth2Fastmail, | |
| 22 | + | /// OAuth2 with Google/Gmail (IMAP + XOAUTH2). | |
| 23 | + | #[strum(serialize = "oauth2_google")] | |
| 24 | + | OAuth2Google, | |
| 25 | + | /// OAuth2 with Microsoft/Outlook (IMAP + XOAUTH2). | |
| 26 | + | #[strum(serialize = "oauth2_microsoft")] | |
| 27 | + | OAuth2Microsoft, | |
| 28 | + | /// OAuth2 with Yahoo Mail (IMAP + XOAUTH2). | |
| 29 | + | #[strum(serialize = "oauth2_yahoo")] | |
| 30 | + | OAuth2Yahoo, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | impl EmailAuthType { | |
| 34 | + | /// Returns a human-readable display string. | |
| 35 | + | pub fn display_name(&self) -> &'static str { | |
| 36 | + | match self { | |
| 37 | + | EmailAuthType::Password => "Password", | |
| 38 | + | EmailAuthType::OAuth2Fastmail => "Fastmail", | |
| 39 | + | EmailAuthType::OAuth2Google => "Google", | |
| 40 | + | EmailAuthType::OAuth2Microsoft => "Microsoft", | |
| 41 | + | EmailAuthType::OAuth2Yahoo => "Yahoo", | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Returns the database/serialization string value. | |
| 46 | + | pub fn as_str(&self) -> &'static str { | |
| 47 | + | match self { | |
| 48 | + | EmailAuthType::Password => "password", | |
| 49 | + | EmailAuthType::OAuth2Fastmail => "oauth2_fastmail", | |
| 50 | + | EmailAuthType::OAuth2Google => "oauth2_google", | |
| 51 | + | EmailAuthType::OAuth2Microsoft => "oauth2_microsoft", | |
| 52 | + | EmailAuthType::OAuth2Yahoo => "oauth2_yahoo", | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Returns true if this auth type uses OAuth2. | |
| 57 | + | pub fn is_oauth(&self) -> bool { | |
| 58 | + | !matches!(self, EmailAuthType::Password) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Returns the provider ID for OAuth providers. | |
| 62 | + | pub fn provider_id(&self) -> Option<&'static str> { | |
| 63 | + | match self { | |
| 64 | + | EmailAuthType::Password => None, | |
| 65 | + | EmailAuthType::OAuth2Fastmail => Some("fastmail"), | |
| 66 | + | EmailAuthType::OAuth2Google => Some("google"), | |
| 67 | + | EmailAuthType::OAuth2Microsoft => Some("microsoft"), | |
| 68 | + | EmailAuthType::OAuth2Yahoo => Some("yahoo"), | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// Creates an EmailAuthType from a provider ID. | |
| 73 | + | pub fn from_provider_id(id: &str) -> Option<Self> { | |
| 74 | + | match id { | |
| 75 | + | "fastmail" => Some(EmailAuthType::OAuth2Fastmail), | |
| 76 | + | "google" => Some(EmailAuthType::OAuth2Google), | |
| 77 | + | "microsoft" => Some(EmailAuthType::OAuth2Microsoft), | |
| 78 | + | "yahoo" => Some(EmailAuthType::OAuth2Yahoo), | |
| 79 | + | _ => None, | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Returns true if this auth type uses JMAP (vs IMAP). | |
| 84 | + | pub fn uses_jmap(&self) -> bool { | |
| 85 | + | matches!(self, EmailAuthType::OAuth2Fastmail) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Parses a string into an EmailAuthType, falling back to `Password` on invalid input. | |
| 89 | + | #[allow(clippy::should_implement_trait)] | |
| 90 | + | pub fn from_str_or_default(s: &str) -> Self { | |
| 91 | + | match s { | |
| 92 | + | "oauth2_fastmail" | "OAuth2Fastmail" => EmailAuthType::OAuth2Fastmail, | |
| 93 | + | "oauth2_google" | "OAuth2Google" => EmailAuthType::OAuth2Google, | |
| 94 | + | "oauth2_microsoft" | "OAuth2Microsoft" => EmailAuthType::OAuth2Microsoft, | |
| 95 | + | "oauth2_yahoo" | "OAuth2Yahoo" => EmailAuthType::OAuth2Yahoo, | |
| 96 | + | _ => EmailAuthType::Password, | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | impl DbValue for EmailAuthType { | |
| 102 | + | fn db_value(&self) -> &'static str { | |
| 103 | + | match self { | |
| 104 | + | EmailAuthType::Password => "password", | |
| 105 | + | EmailAuthType::OAuth2Fastmail => "oauth2_fastmail", | |
| 106 | + | EmailAuthType::OAuth2Google => "oauth2_google", | |
| 107 | + | EmailAuthType::OAuth2Microsoft => "oauth2_microsoft", | |
| 108 | + | EmailAuthType::OAuth2Yahoo => "oauth2_yahoo", | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// IMAP/SMTP or OAuth2 email account configuration. | |
| 114 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 115 | + | #[serde(rename_all = "camelCase")] | |
| 116 | + | pub struct EmailAccount { | |
| 117 | + | /// Unique identifier. | |
| 118 | + | pub id: EmailAccountId, | |
| 119 | + | /// Owner user ID. | |
| 120 | + | pub user_id: UserId, | |
| 121 | + | /// Display name for the account. | |
| 122 | + | pub account_name: String, | |
| 123 | + | /// Email address. | |
| 124 | + | pub email_address: String, | |
| 125 | + | /// IMAP server hostname (password auth only). | |
| 126 | + | pub imap_server: String, | |
| 127 | + | /// IMAP server port (password auth only). | |
| 128 | + | pub imap_port: i32, | |
| 129 | + | /// SMTP server hostname (password auth only). | |
| 130 | + | pub smtp_server: String, | |
| 131 | + | /// SMTP server port (password auth only). | |
| 132 | + | pub smtp_port: i32, | |
| 133 | + | /// Login username (password auth only). | |
| 134 | + | pub username: String, | |
| 135 | + | /// Login password (never serialized, password auth only). | |
| 136 | + | #[serde(skip_serializing)] | |
| 137 | + | pub password: String, | |
| 138 | + | /// Whether to use TLS (password auth only). | |
| 139 | + | pub use_tls: bool, | |
| 140 | + | /// Last successful sync. | |
| 141 | + | pub last_sync_at: Option<DateTime<Utc>>, | |
| 142 | + | /// Account creation timestamp. | |
| 143 | + | pub created_at: DateTime<Utc>, | |
| 144 | + | /// IMAP/JMAP folder name for archived emails. | |
| 145 | + | pub archive_folder_name: Option<String>, | |
| 146 | + | /// Authentication type (password or OAuth2). | |
| 147 | + | pub auth_type: EmailAuthType, | |
| 148 | + | /// OAuth2 access token (never serialized). | |
| 149 | + | #[serde(skip_serializing)] | |
| 150 | + | pub oauth2_access_token: Option<String>, | |
| 151 | + | /// OAuth2 refresh token (never serialized). | |
| 152 | + | #[serde(skip_serializing)] | |
| 153 | + | pub oauth2_refresh_token: Option<String>, | |
| 154 | + | /// OAuth2 token expiration time. | |
| 155 | + | pub oauth2_token_expires_at: Option<DateTime<Utc>>, | |
| 156 | + | /// JMAP session URL (cached from discovery). | |
| 157 | + | pub jmap_session_url: Option<String>, | |
| 158 | + | /// JMAP account ID (from session). | |
| 159 | + | pub jmap_account_id: Option<String>, | |
| 160 | + | /// Auto-sync interval in minutes (None = disabled). | |
| 161 | + | pub sync_interval_minutes: Option<i32>, | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | impl EmailAccount { | |
| 165 | + | /// Returns true if this account uses OAuth2 authentication. | |
| 166 | + | pub fn is_oauth(&self) -> bool { | |
| 167 | + | self.auth_type != EmailAuthType::Password | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Returns true if the OAuth2 token needs refresh (expired or expiring within 5 minutes). | |
| 171 | + | pub fn needs_token_refresh(&self) -> bool { | |
| 172 | + | match self.oauth2_token_expires_at { | |
| 173 | + | Some(expires_at) => { | |
| 174 | + | let buffer = chrono::Duration::minutes(5); | |
| 175 | + | Utc::now() + buffer >= expires_at | |
| 176 | + | } | |
| 177 | + | None => self.is_oauth(), // If no expiry set but is OAuth, assume needs refresh | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[cfg(test)] | |
| 183 | + | mod tests { | |
| 184 | + | use super::*; | |
| 185 | + | ||
| 186 | + | #[test] | |
| 187 | + | fn display_name_all_variants() { | |
| 188 | + | assert_eq!(EmailAuthType::Password.display_name(), "Password"); | |
| 189 | + | assert_eq!(EmailAuthType::OAuth2Fastmail.display_name(), "Fastmail"); | |
| 190 | + | assert_eq!(EmailAuthType::OAuth2Google.display_name(), "Google"); | |
| 191 | + | assert_eq!(EmailAuthType::OAuth2Microsoft.display_name(), "Microsoft"); | |
| 192 | + | assert_eq!(EmailAuthType::OAuth2Yahoo.display_name(), "Yahoo"); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | #[test] | |
| 196 | + | fn as_str_all_variants() { | |
| 197 | + | assert_eq!(EmailAuthType::Password.as_str(), "password"); | |
| 198 | + | assert_eq!(EmailAuthType::OAuth2Fastmail.as_str(), "oauth2_fastmail"); | |
| 199 | + | assert_eq!(EmailAuthType::OAuth2Google.as_str(), "oauth2_google"); | |
| 200 | + | assert_eq!(EmailAuthType::OAuth2Microsoft.as_str(), "oauth2_microsoft"); | |
| 201 | + | assert_eq!(EmailAuthType::OAuth2Yahoo.as_str(), "oauth2_yahoo"); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[test] | |
| 205 | + | fn is_oauth_password_is_false() { | |
| 206 | + | assert!(!EmailAuthType::Password.is_oauth()); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | #[test] | |
| 210 | + | fn is_oauth_all_oauth_variants_are_true() { | |
| 211 | + | assert!(EmailAuthType::OAuth2Fastmail.is_oauth()); | |
| 212 | + | assert!(EmailAuthType::OAuth2Google.is_oauth()); | |
| 213 | + | assert!(EmailAuthType::OAuth2Microsoft.is_oauth()); | |
| 214 | + | assert!(EmailAuthType::OAuth2Yahoo.is_oauth()); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | #[test] | |
| 218 | + | fn provider_id_password_is_none() { | |
| 219 | + | assert!(EmailAuthType::Password.provider_id().is_none()); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | #[test] | |
| 223 | + | fn provider_id_oauth_variants() { | |
| 224 | + | assert_eq!(EmailAuthType::OAuth2Fastmail.provider_id(), Some("fastmail")); | |
| 225 | + | assert_eq!(EmailAuthType::OAuth2Google.provider_id(), Some("google")); | |
| 226 | + | assert_eq!(EmailAuthType::OAuth2Microsoft.provider_id(), Some("microsoft")); | |
| 227 | + | assert_eq!(EmailAuthType::OAuth2Yahoo.provider_id(), Some("yahoo")); | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | #[test] | |
| 231 | + | fn from_provider_id_roundtrip() { | |
| 232 | + | for variant in [ | |
| 233 | + | EmailAuthType::OAuth2Fastmail, | |
| 234 | + | EmailAuthType::OAuth2Google, | |
| 235 | + | EmailAuthType::OAuth2Microsoft, | |
| 236 | + | EmailAuthType::OAuth2Yahoo, | |
| 237 | + | ] { | |
| 238 | + | let id = variant.provider_id().unwrap(); | |
| 239 | + | assert_eq!(EmailAuthType::from_provider_id(id), Some(variant)); | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | #[test] | |
| 244 | + | fn from_provider_id_unknown_returns_none() { | |
| 245 | + | assert!(EmailAuthType::from_provider_id("unknown").is_none()); | |
| 246 | + | assert!(EmailAuthType::from_provider_id("").is_none()); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[test] | |
| 250 | + | fn uses_jmap_only_fastmail() { | |
| 251 | + | assert!(EmailAuthType::OAuth2Fastmail.uses_jmap()); | |
| 252 | + | assert!(!EmailAuthType::Password.uses_jmap()); | |
| 253 | + | assert!(!EmailAuthType::OAuth2Google.uses_jmap()); | |
| 254 | + | assert!(!EmailAuthType::OAuth2Microsoft.uses_jmap()); | |
| 255 | + | assert!(!EmailAuthType::OAuth2Yahoo.uses_jmap()); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn from_str_or_default_valid_inputs() { | |
| 260 | + | assert_eq!( | |
| 261 | + | EmailAuthType::from_str_or_default("oauth2_fastmail"), | |
| 262 | + | EmailAuthType::OAuth2Fastmail | |
| 263 | + | ); | |
| 264 | + | assert_eq!( | |
| 265 | + | EmailAuthType::from_str_or_default("OAuth2Google"), | |
| 266 | + | EmailAuthType::OAuth2Google | |
| 267 | + | ); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn from_str_or_default_invalid_falls_back() { | |
| 272 | + | assert_eq!( | |
| 273 | + | EmailAuthType::from_str_or_default("invalid"), | |
| 274 | + | EmailAuthType::Password | |
| 275 | + | ); | |
| 276 | + | assert_eq!( | |
| 277 | + | EmailAuthType::from_str_or_default(""), | |
| 278 | + | EmailAuthType::Password | |
| 279 | + | ); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn db_value_matches_as_str() { | |
| 284 | + | for variant in [ | |
| 285 | + | EmailAuthType::Password, | |
| 286 | + | EmailAuthType::OAuth2Fastmail, | |
| 287 | + | EmailAuthType::OAuth2Google, | |
| 288 | + | EmailAuthType::OAuth2Microsoft, | |
| 289 | + | EmailAuthType::OAuth2Yahoo, | |
| 290 | + | ] { | |
| 291 | + | assert_eq!(variant.db_value(), variant.as_str()); | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | #[test] | |
| 296 | + | fn default_is_password() { | |
| 297 | + | assert_eq!(EmailAuthType::default(), EmailAuthType::Password); | |
| 298 | + | } | |
| 299 | + | } |
| @@ -0,0 +1,274 @@ | |||
| 1 | + | //! Event domain types and DTOs. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | use crate::constants::DAYS_THRESHOLD_SHORT_FORMAT; | |
| 6 | + | use crate::id_types::{EventId, UserId, ProjectId, ContactId, TaskId}; | |
| 7 | + | use super::shared::{BlockType, Recurrence}; | |
| 8 | + | ||
| 9 | + | // ============ Event ============ | |
| 10 | + | ||
| 11 | + | /// A calendar event with optional time-blocking link to a task. | |
| 12 | + | /// | |
| 13 | + | /// Events can be standalone or linked to a task for time-blocking purposes. | |
| 14 | + | /// When linked, the event represents a scheduled time slot for working on the task. | |
| 15 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 16 | + | #[serde(rename_all = "camelCase")] | |
| 17 | + | pub struct Event { | |
| 18 | + | /// Unique identifier. | |
| 19 | + | pub id: EventId, | |
| 20 | + | /// Owner user ID (internal). | |
| 21 | + | #[serde(skip_serializing)] | |
| 22 | + | pub user_id: Option<UserId>, | |
| 23 | + | /// Associated project, if any. | |
| 24 | + | pub project_id: Option<ProjectId>, | |
| 25 | + | /// Denormalized project name for display. | |
| 26 | + | pub project_name: Option<String>, | |
| 27 | + | /// Associated contact, if any. | |
| 28 | + | pub contact_id: Option<ContactId>, | |
| 29 | + | /// Denormalized contact name for display. | |
| 30 | + | pub contact_name: Option<String>, | |
| 31 | + | /// Event title. | |
| 32 | + | pub title: String, | |
| 33 | + | /// Event description/notes. | |
| 34 | + | pub description: String, | |
| 35 | + | /// When the event starts. | |
| 36 | + | pub start_time: DateTime<Utc>, | |
| 37 | + | /// When the event ends (optional for all-day events). | |
| 38 | + | pub end_time: Option<DateTime<Utc>>, | |
| 39 | + | /// Location (physical address or video link). | |
| 40 | + | pub location: Option<String>, | |
| 41 | + | /// If this is a time-block, the linked task ID. | |
| 42 | + | pub linked_task_id: Option<TaskId>, | |
| 43 | + | /// Recurrence pattern. | |
| 44 | + | pub recurrence: Recurrence, | |
| 45 | + | /// Original event ID if this is a recurrence instance. | |
| 46 | + | pub recurrence_parent_id: Option<EventId>, | |
| 47 | + | /// If this is a time block, the block type. | |
| 48 | + | pub block_type: Option<BlockType>, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | impl Event { | |
| 52 | + | /// Returns a formatted time string (e.g., "Jan 15, 14:00" or "Jan 15, 14:00 - 15:30"). | |
| 53 | + | pub fn time_formatted(&self) -> String { | |
| 54 | + | let start = self.start_time.format("%b %d, %H:%M").to_string(); | |
| 55 | + | match &self.end_time { | |
| 56 | + | Some(end) => format!("{} - {}", start, end.format("%H:%M")), | |
| 57 | + | None => start, | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Returns a relative date string: "Past", "Today", "Tomorrow", day name, or "Mon DD". | |
| 62 | + | pub fn date_formatted(&self) -> String { | |
| 63 | + | let now = Utc::now(); | |
| 64 | + | let diff = self.start_time.signed_duration_since(now); | |
| 65 | + | let days = diff.num_days(); | |
| 66 | + | ||
| 67 | + | if days < 0 { | |
| 68 | + | "Past".to_string() | |
| 69 | + | } else if days == 0 { | |
| 70 | + | "Today".to_string() | |
| 71 | + | } else if days == 1 { | |
| 72 | + | "Tomorrow".to_string() | |
| 73 | + | } else if days < DAYS_THRESHOLD_SHORT_FORMAT { | |
| 74 | + | self.start_time.format("%A").to_string() | |
| 75 | + | } else { | |
| 76 | + | self.start_time.format("%b %d").to_string() | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | pub fn day_number(&self) -> String { | |
| 81 | + | self.start_time.format("%d").to_string() | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | pub fn timestamp(&self) -> i64 { | |
| 85 | + | self.start_time.timestamp() | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | pub fn has_location(&self) -> bool { | |
| 89 | + | self.location.is_some() | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | pub fn location_or_empty(&self) -> &str { | |
| 93 | + | self.location.as_deref().unwrap_or("") | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | pub fn has_project(&self) -> bool { | |
| 97 | + | self.project_name.is_some() | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | pub fn project_name_or_empty(&self) -> &str { | |
| 101 | + | self.project_name.as_deref().unwrap_or("") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | pub fn has_description(&self) -> bool { | |
| 105 | + | !self.description.is_empty() | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | pub fn has_recurrence(&self) -> bool { | |
| 109 | + | self.recurrence != Recurrence::None | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | pub fn is_linked_to_task(&self) -> bool { | |
| 113 | + | self.linked_task_id.is_some() | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // ============ Event DTOs ============ | |
| 118 | + | ||
| 119 | + | /// Data for creating a new event. | |
| 120 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 121 | + | pub struct NewEvent { | |
| 122 | + | pub user_id: Option<UserId>, | |
| 123 | + | pub project_id: Option<ProjectId>, | |
| 124 | + | pub contact_id: Option<ContactId>, | |
| 125 | + | pub title: String, | |
| 126 | + | pub description: String, | |
| 127 | + | pub start_time: DateTime<Utc>, | |
| 128 | + | pub end_time: Option<DateTime<Utc>>, | |
| 129 | + | pub location: Option<String>, | |
| 130 | + | pub linked_task_id: Option<TaskId>, | |
| 131 | + | pub recurrence: Recurrence, | |
| 132 | + | pub block_type: Option<BlockType>, | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Data for updating an existing event. | |
| 136 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 137 | + | pub struct UpdateEvent { | |
| 138 | + | pub project_id: Option<ProjectId>, | |
| 139 | + | pub contact_id: Option<ContactId>, | |
| 140 | + | pub title: String, | |
| 141 | + | pub description: String, | |
| 142 | + | pub start_time: DateTime<Utc>, | |
| 143 | + | pub end_time: Option<DateTime<Utc>>, | |
| 144 | + | pub location: Option<String>, | |
| 145 | + | pub linked_task_id: Option<TaskId>, | |
| 146 | + | pub recurrence: Recurrence, | |
| 147 | + | pub block_type: Option<BlockType>, | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | impl NewEvent { | |
| 151 | + | /// Creates a builder for constructing a new event. | |
| 152 | + | /// | |
| 153 | + | /// # Example | |
| 154 | + | /// | |
| 155 | + | /// ```rust | |
| 156 | + | /// use goingson_core::NewEvent; | |
| 157 | + | /// use chrono::{Duration, Utc}; | |
| 158 | + | /// | |
| 159 | + | /// let start = Utc::now(); | |
| 160 | + | /// let event = NewEvent::builder("Team Meeting", start) | |
| 161 | + | /// .end_time(start + Duration::hours(1)) | |
| 162 | + | /// .location("Conference Room A") | |
| 163 | + | /// .build(); | |
| 164 | + | /// ``` | |
| 165 | + | pub fn builder(title: impl Into<String>, start_time: DateTime<Utc>) -> NewEventBuilder { | |
| 166 | + | NewEventBuilder::new(title, start_time) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Builder for constructing [`NewEvent`] with sensible defaults. | |
| 171 | + | #[derive(Debug, Clone)] | |
| 172 | + | pub struct NewEventBuilder { | |
| 173 | + | title: String, | |
| 174 | + | start_time: DateTime<Utc>, | |
| 175 | + | user_id: Option<UserId>, | |
| 176 | + | project_id: Option<ProjectId>, | |
| 177 | + | contact_id: Option<ContactId>, | |
| 178 | + | description: String, | |
| 179 | + | end_time: Option<DateTime<Utc>>, | |
| 180 | + | location: Option<String>, | |
| 181 | + | linked_task_id: Option<TaskId>, | |
| 182 | + | recurrence: Recurrence, | |
| 183 | + | block_type: Option<BlockType>, | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | impl NewEventBuilder { | |
| 187 | + | /// Creates a new builder with the given title and start time. | |
| 188 | + | pub fn new(title: impl Into<String>, start_time: DateTime<Utc>) -> Self { | |
| 189 | + | Self { | |
| 190 | + | title: title.into(), | |
| 191 | + | start_time, | |
| 192 | + | user_id: None, | |
| 193 | + | project_id: None, | |
| 194 | + | contact_id: None, | |
| 195 | + | description: String::new(), | |
| 196 | + | end_time: None, | |
| 197 | + | location: None, | |
| 198 | + | linked_task_id: None, | |
| 199 | + | recurrence: Recurrence::default(), | |
| 200 | + | block_type: None, | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// Sets the user ID. | |
| 205 | + | pub fn user_id(mut self, user_id: UserId) -> Self { | |
| 206 | + | self.user_id = Some(user_id); | |
| 207 | + | self | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// Sets the project ID. | |
| 211 | + | pub fn project_id(mut self, project_id: ProjectId) -> Self { | |
| 212 | + | self.project_id = Some(project_id); | |
| 213 | + | self | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// Sets the contact ID. | |
| 217 | + | pub fn contact_id(mut self, contact_id: ContactId) -> Self { | |
| 218 | + | self.contact_id = Some(contact_id); | |
| 219 | + | self | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// Sets the description. | |
| 223 | + | pub fn description(mut self, description: impl Into<String>) -> Self { | |
| 224 | + | self.description = description.into(); | |
| 225 | + | self | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// Sets the end time. | |
| 229 | + | pub fn end_time(mut self, end_time: DateTime<Utc>) -> Self { | |
| 230 | + | self.end_time = Some(end_time); | |
| 231 | + | self | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// Sets the location. | |
| 235 | + | pub fn location(mut self, location: impl Into<String>) -> Self { | |
| 236 | + | self.location = Some(location.into()); | |
| 237 | + | self | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /// Sets the linked task ID (for time-blocking). | |
| 241 | + | pub fn linked_task_id(mut self, task_id: TaskId) -> Self { | |
| 242 | + | self.linked_task_id = Some(task_id); | |
| 243 | + | self | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// Sets the recurrence pattern. | |
| 247 | + | pub fn recurrence(mut self, recurrence: Recurrence) -> Self { | |
| 248 | + | self.recurrence = recurrence; | |
| 249 | + | self | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | /// Sets the block type. | |
| 253 | + | pub fn block_type(mut self, block_type: BlockType) -> Self { | |
| 254 | + | self.block_type = Some(block_type); | |
| 255 | + | self | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// Builds the [`NewEvent`]. | |
| 259 | + | pub fn build(self) -> NewEvent { | |
| 260 | + | NewEvent { | |
| 261 | + | user_id: self.user_id, | |
| 262 | + | project_id: self.project_id, | |
| 263 | + | contact_id: self.contact_id, | |
| 264 | + | title: self.title, | |
| 265 | + | description: self.description, | |
| 266 | + | start_time: self.start_time, | |
| 267 | + | end_time: self.end_time, | |
| 268 | + | location: self.location, | |
| 269 | + | linked_task_id: self.linked_task_id, | |
| 270 | + | recurrence: self.recurrence, | |
| 271 | + | block_type: self.block_type, | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | } |