Skip to main content

max / goingson

Initial commit
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-05 18:51 UTC
Commit: 91ffe7508e0786f6707e67644807212afda02c0b
327 files changed, +79626 insertions, -0 deletions
A .build.yml +37
@@ -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
A .gitignore +21
@@ -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/
A Cargo.lock +500
@@ -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
A Cargo.toml +95
@@ -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" }
A LICENSE +118
@@ -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 + }
A docs/about.md +142