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
@@ -1,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
@@ -1,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
@@ -1,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
@@ -1,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" }
Binary file
A LICENSE +118
@@ -1,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.
@@ -1,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 }
@@ -1,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 + }
@@ -1,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 + }
@@ -1,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 + }
@@ -1,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 + }
@@ -1,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 + }
@@ -1,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 + }
@@ -1,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 + }
@@ -1,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 + }
@@ -1,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>;
A docs/about.md +142
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file