Skip to main content

max / goingson

UX navigation fixes, input validation hardening, plugin API improvements Navigation: Contacts promoted to main tab bar, g+c and g+w keyboard shortcuts added, Import button added to Settings, all sub-modal Cancel buttons changed to Back with return-to-Settings behavior. Validation: extensive input sanitization for task/project/event/contact fields. Plugin API: tightened runtime API surface. DB: minor schema adjustments. Email/event commands: improved error handling.
Co-Authored-By
Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-15 02:33 UTC
Commit: 92cd9cc699436ab9d5a213ea7b028882f0e72985
Parent: 7efdd78
13 files changed, +298 insertions, -38 deletions
M Cargo.lock +86 -8
@@ -62,6 +62,19 @@
62 62 source = "registry+https://github.com/rust-lang/crates.io-index"
63 63 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
64 64
65 + [[package]]
66 + name = "ammonia"
67 + version = "4.1.2"
68 + source = "registry+https://github.com/rust-lang/crates.io-index"
69 + checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
70 + dependencies = [
71 + "cssparser 0.35.0",
72 + "html5ever 0.35.0",
73 + "maplit",
74 + "tendril",
75 + "url",
76 + ]
77 +
65 78 [[package]]
66 79 name = "android_system_properties"
67 80 version = "0.1.5"
@@ -874,6 +887,19 @@
874 887 "syn 1.0.109",
875 888 ]
876 889
890 + [[package]]
891 + name = "cssparser"
892 + version = "0.35.0"
893 + source = "registry+https://github.com/rust-lang/crates.io-index"
894 + checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
895 + dependencies = [
896 + "cssparser-macros",
897 + "dtoa-short",
898 + "itoa",
899 + "phf 0.11.3",
900 + "smallvec",
901 + ]
902 +
877 903 [[package]]
878 904 name = "cssparser-macros"
879 905 version = "0.6.1"
@@ -1803,6 +1829,7 @@
1803 1829 name = "goingson-desktop"
1804 1830 version = "0.2.1"
1805 1831 dependencies = [
1832 + "ammonia",
1806 1833 "async-imap",
1807 1834 "async-trait",
1808 1835 "base64 0.22.1",
@@ -2059,8 +2086,19 @@
2059 2086 dependencies = [
2060 2087 "log",
2061 2088 "mac",
2062 - "markup5ever",
2063 - "match_token",
2089 + "markup5ever 0.14.1",
2090 + "match_token 0.1.0",
2091 + ]
2092 +
2093 + [[package]]
2094 + name = "html5ever"
2095 + version = "0.35.0"
2096 + source = "registry+https://github.com/rust-lang/crates.io-index"
2097 + checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
2098 + dependencies = [
2099 + "log",
2100 + "markup5ever 0.35.0",
2101 + "match_token 0.35.0",
2064 2102 ]
2065 2103
2066 2104 [[package]]
@@ -2599,8 +2637,8 @@
2599 2637 source = "registry+https://github.com/rust-lang/crates.io-index"
2600 2638 checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
2601 2639 dependencies = [
2602 - "cssparser",
2603 - "html5ever",
2640 + "cssparser 0.29.6",
2641 + "html5ever 0.29.1",
2604 2642 "indexmap 2.13.0",
2605 2643 "selectors",
2606 2644 ]
@@ -2765,6 +2803,12 @@
2765 2803 "quoted_printable",
2766 2804 ]
2767 2805
2806 + [[package]]
2807 + name = "maplit"
2808 + version = "1.0.2"
2809 + source = "registry+https://github.com/rust-lang/crates.io-index"
2810 + checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2811 +
2768 2812 [[package]]
2769 2813 name = "markup5ever"
2770 2814 version = "0.14.1"
@@ -2779,6 +2823,17 @@
2779 2823 "tendril",
2780 2824 ]
2781 2825
2826 + [[package]]
2827 + name = "markup5ever"
2828 + version = "0.35.0"
2829 + source = "registry+https://github.com/rust-lang/crates.io-index"
2830 + checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
2831 + dependencies = [
2832 + "log",
2833 + "tendril",
2834 + "web_atoms",
2835 + ]
2836 +
2782 2837 [[package]]
2783 2838 name = "match_token"
2784 2839 version = "0.1.0"
@@ -2790,6 +2845,17 @@
2790 2845 "syn 2.0.114",
2791 2846 ]
2792 2847
2848 + [[package]]
2849 + name = "match_token"
2850 + version = "0.35.0"
2851 + source = "registry+https://github.com/rust-lang/crates.io-index"
2852 + checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
2853 + dependencies = [
2854 + "proc-macro2",
2855 + "quote",
2856 + "syn 2.0.114",
2857 + ]
2858 +
2793 2859 [[package]]
2794 2860 name = "matchers"
2795 2861 version = "0.2.0"
@@ -4622,7 +4688,7 @@
4622 4688 checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416"
4623 4689 dependencies = [
4624 4690 "bitflags 1.3.2",
4625 - "cssparser",
4691 + "cssparser 0.29.6",
4626 4692 "derive_more",
4627 4693 "fxhash",
4628 4694 "log",
@@ -5400,7 +5466,7 @@
5400 5466 "serde",
5401 5467 "serde_json",
5402 5468 "sha2",
5403 - "thiserror 1.0.69",
5469 + "thiserror 2.0.18",
5404 5470 "tokio",
5405 5471 "tracing",
5406 5472 "unicode-normalization",
@@ -5801,7 +5867,7 @@
5801 5867 "ctor",
5802 5868 "dunce",
5803 5869 "glob",
5804 - "html5ever",
5870 + "html5ever 0.29.1",
5805 5871 "http",
5806 5872 "infer",
5807 5873 "json-patch",
@@ -6650,6 +6716,18 @@
6650 6716 "wasm-bindgen",
6651 6717 ]
6652 6718
6719 + [[package]]
6720 + name = "web_atoms"
6721 + version = "0.1.3"
6722 + source = "registry+https://github.com/rust-lang/crates.io-index"
6723 + checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
6724 + dependencies = [
6725 + "phf 0.11.3",
6726 + "phf_codegen 0.11.3",
6727 + "string_cache",
6728 + "string_cache_codegen",
6729 + ]
6730 +
6653 6731 [[package]]
6654 6732 name = "webkit2gtk"
6655 6733 version = "2.0.2"
@@ -7306,7 +7384,7 @@
7306 7384 "dunce",
7307 7385 "gdkx11",
7308 7386 "gtk",
7309 - "html5ever",
7387 + "html5ever 0.29.1",
7310 7388 "http",
7311 7389 "javascriptcore-rs",
7312 7390 "jni",
M Cargo.toml +3
@@ -82,6 +82,9 @@
82 82 # Browser opening
83 83 open = "5"
84 84
85 + # HTML sanitization
86 + ammonia = "4"
87 +
85 88 # Filesystem
86 89 dirs = "6"
87 90
@@ -73,6 +73,9 @@
73 73 # Browser opening
74 74 open = { workspace = true }
75 75
76 + # HTML sanitization
77 + ammonia = { workspace = true }
78 +
76 79 # Secure credential storage
77 80 keyring = { workspace = true }
78 81
@@ -20,7 +20,6 @@
20 20 <button class="sync-indicator" id="sync-indicator" onclick="GoingsOn.settings.openCloudSync()" title="Cloud Sync" aria-label="Cloud sync status" style="display:none;">
21 21 <span class="sync-dot" id="sync-dot"></span>
22 22 </button>
23 - <button class="settings-btn" onclick="GoingsOn.navigation.switchView('contacts')" title="Contacts" aria-label="Open contacts">Contacts</button>
24 23 <button class="settings-btn" onclick="GoingsOn.settings.open()" title="Settings" aria-label="Open settings">Settings</button>
25 24 <button class="settings-btn shortcut-hint-btn" onclick="GoingsOn.keyboard.showShortcuts()" title="Keyboard shortcuts (?)" aria-label="Show keyboard shortcuts">?</button>
26 25 </div>
@@ -45,6 +44,9 @@
45 44 <a href="#" class="tab" data-view="events" role="tab" aria-selected="false" aria-controls="events-view">
46 45 <span class="tab-label">Events</span>
47 46 </a>
47 + <a href="#" class="tab" data-view="contacts" role="tab" aria-selected="false" aria-controls="contacts-view">
48 + <span class="tab-label">Contacts</span>
49 + </a>
48 50 </nav>
49 51
50 52 <div class="app-body">
@@ -8,12 +8,12 @@
8 8 MAX_TAG_LENGTH, MAX_TASK_DESCRIPTION_LENGTH,
9 9 };
10 10 use crate::error::CoreError;
11 - use crate::models::{NewEvent, NewProject, NewTask, UpdateTask};
11 + use crate::models::{NewEvent, NewProject, NewTask, UpdateEvent, UpdateProject, UpdateTask};
12 12
13 13 /// A trait for types that can validate their own data before persistence.
14 14 ///
15 - /// Call `.validate()` on DTOs (`NewTask`, `NewProject`, `NewEvent`, `UpdateTask`)
16 - /// before passing them to a repository. This centralizes business rules
15 + /// Call `.validate()` on DTOs (`NewTask`, `NewProject`, `NewEvent`, `UpdateTask`,
16 + /// `UpdateProject`, `UpdateEvent`) before passing them to a repository. This centralizes business rules
17 17 /// (length limits, required fields, range checks) in the core crate so
18 18 /// they're enforced regardless of whether the caller is a Tauri command,
19 19 /// a plugin import, or a test.
@@ -49,6 +49,22 @@
49 49 }
50 50 }
51 51
52 + // Same rules as NewProject — updates must also pass validation.
53 + impl Validate for UpdateProject {
54 + fn validate(&self) -> Result<(), CoreError> {
55 + if self.name.trim().is_empty() {
56 + return Err(CoreError::validation("name", "cannot be empty"));
57 + }
58 + if self.name.len() > MAX_PROJECT_NAME_LENGTH {
59 + return Err(CoreError::validation(
60 + "name",
61 + format!("must be {} characters or less", MAX_PROJECT_NAME_LENGTH),
62 + ));
63 + }
64 + Ok(())
65 + }
66 + }
67 +
52 68 // Task validation: non-empty description, valid duration (positive, ≤24h),
53 69 // non-empty tags within MAX_TAG_LENGTH.
54 70 impl Validate for NewTask {
@@ -141,10 +157,31 @@
141 157 }
142 158 }
143 159
160 + // Same rules as NewEvent — updates must also pass validation.
161 + impl Validate for UpdateEvent {
162 + fn validate(&self) -> Result<(), CoreError> {
163 + if self.title.trim().is_empty() {
164 + return Err(CoreError::validation("title", "cannot be empty"));
165 + }
166 + if self.title.len() > MAX_EVENT_TITLE_LENGTH {
167 + return Err(CoreError::validation(
168 + "title",
169 + format!("must be {} characters or less", MAX_EVENT_TITLE_LENGTH),
170 + ));
171 + }
172 + if let Some(end) = self.end_time {
173 + if end <= self.start_time {
174 + return Err(CoreError::validation("end_time", "must be after start_time"));
175 + }
176 + }
177 + Ok(())
178 + }
179 + }
180 +
144 181 #[cfg(test)]
145 182 mod tests {
146 183 use super::*;
147 - use crate::models::{Priority, ProjectStatus, ProjectType, Recurrence};
184 + use crate::models::{BlockType, Priority, ProjectStatus, ProjectType, Recurrence, UpdateEvent, UpdateProject};
148 185 use chrono::{Duration, Utc};
149 186
150 187 #[test]
@@ -483,4 +520,137 @@
483 520 };
484 521 assert!(event.validate().is_err());
485 522 }
523 +
524 + // ---- UpdateProject validation tests ----
525 +
526 + #[test]
527 + fn test_update_project_valid() {
528 + let project = UpdateProject {
529 + name: "Renamed Project".to_string(),
530 + description: "Updated description".to_string(),
531 + project_type: ProjectType::Job,
532 + status: ProjectStatus::Active,
533 + };
534 + assert!(project.validate().is_ok());
535 + }
536 +
537 + #[test]
538 + fn test_update_project_empty_name() {
539 + let project = UpdateProject {
540 + name: " ".to_string(),
541 + description: "".to_string(),
542 + project_type: ProjectType::SideProject,
543 + status: ProjectStatus::Active,
544 + };
545 + let err = project.validate().unwrap_err();
546 + assert!(matches!(err, CoreError::Validation { field: "name", .. }));
547 + }
548 +
549 + #[test]
550 + fn test_update_project_name_too_long() {
551 + let project = UpdateProject {
552 + name: "a".repeat(MAX_PROJECT_NAME_LENGTH + 1),
553 + description: "".to_string(),
554 + project_type: ProjectType::SideProject,
555 + status: ProjectStatus::Active,
556 + };
557 + let err = project.validate().unwrap_err();
558 + assert!(matches!(err, CoreError::Validation { field: "name", .. }));
559 + }
560 +
561 + // ---- UpdateEvent validation tests ----
562 +
563 + #[test]
564 + fn test_update_event_valid() {
565 + let now = Utc::now();
566 + let event = UpdateEvent {
567 + project_id: None,
568 + contact_id: None,
569 + title: "Updated Meeting".to_string(),
570 + description: "New notes".to_string(),
571 + start_time: now,
572 + end_time: Some(now + Duration::hours(2)),
573 + location: Some("Room B".to_string()),
574 + linked_task_id: None,
575 + recurrence: Recurrence::None,
576 + block_type: Some(BlockType::Focus),
577 + };
578 + assert!(event.validate().is_ok());
579 + }
580 +
581 + #[test]
582 + fn test_update_event_empty_title() {
583 + let now = Utc::now();
584 + let event = UpdateEvent {
585 + project_id: None,
586 + contact_id: None,
587 + title: "".to_string(),
588 + description: "".to_string(),
589 + start_time: now,
590 + end_time: None,
591 + location: None,
592 + linked_task_id: None,
593 + recurrence: Recurrence::None,
594 + block_type: None,
595 + };
596 + let err = event.validate().unwrap_err();
597 + assert!(matches!(err, CoreError::Validation { field: "title", .. }));
598 + }
599 +
600 + #[test]
601 + fn test_update_event_title_too_long() {
602 + let now = Utc::now();
603 + let event = UpdateEvent {
604 + project_id: None,
605 + contact_id: None,
606 + title: "a".repeat(MAX_EVENT_TITLE_LENGTH + 1),
607 + description: "".to_string(),
608 + start_time: now,
609 + end_time: None,
610 + location: None,
611 + linked_task_id: None,
612 + recurrence: Recurrence::None,
613 + block_type: None,
614 + };
615 + let err = event.validate().unwrap_err();
616 + assert!(matches!(err, CoreError::Validation { field: "title", .. }));
617 + }
618 +
619 + #[test]
620 + fn test_update_event_end_before_start() {
621 + let now = Utc::now();
622 + let event = UpdateEvent {
623 + project_id: None,
624 + contact_id: None,
625 + title: "Meeting".to_string(),
626 + description: "".to_string(),
627 + start_time: now,
628 + end_time: Some(now - Duration::hours(1)),
629 + location: None,
630 + linked_task_id: None,
631 + recurrence: Recurrence::None,
632 + block_type: None,
633 + };
634 + let err = event.validate().unwrap_err();
635 + assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
636 + }
637 +
638 + #[test]
639 + fn test_update_event_end_equals_start() {
640 + let now = Utc::now();
641 + let event = UpdateEvent {
642 + project_id: None,
643 + contact_id: None,
644 + title: "Meeting".to_string(),
645 + description: "".to_string(),
646 + start_time: now,
647 + end_time: Some(now),
648 + location: None,
649 + linked_task_id: None,
650 + recurrence: Recurrence::None,
651 + block_type: None,
652 + };
653 + let err = event.validate().unwrap_err();
654 + assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
655 + }
486 656 }
@@ -15,7 +15,7 @@
15 15 pub mod repository;
16 16 pub mod utils;
17 17
18 - use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
18 + use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
19 19 use sqlx::SqlitePool;
20 20 use std::str::FromStr;
21 21 use std::time::Duration;
@@ -27,7 +27,8 @@
27 27
28 28 let options = SqliteConnectOptions::from_str(&format!("sqlite:{}", path))?
29 29 .create_if_missing(true)
30 - .foreign_keys(true);
30 + .foreign_keys(true)
31 + .journal_mode(SqliteJournalMode::Wal);
31 32
32 33 SqlitePoolOptions::new()
33 34 .max_connections(5)
@@ -44,8 +44,8 @@
44 44 CONTEXT.with(|c| *c.borrow_mut() = ctx);
45 45 }
46 46
47 - /// Gets a copy of the current context.
48 - #[allow(dead_code)]
47 + /// Gets a copy of the current context (used in tests).
48 + #[cfg(test)]
49 49 pub fn get() -> PluginApiContext {
50 50 CONTEXT.with(|c| {
51 51 let ctx = c.borrow();
@@ -98,8 +98,7 @@
98 98 CONTEXT.with(|c| c.borrow().logs.clone())
99 99 }
100 100
101 - /// Gets current progress.
102 - #[allow(dead_code)]
101 + /// Gets current progress (used by plugin host for progress reporting).
103 102 pub fn get_progress() -> Option<(usize, usize, String)> {
104 103 CONTEXT.with(|c| c.borrow().progress.clone())
105 104 }
@@ -41,6 +41,8 @@
41 41 'p': { action: () => GoingsOn.navigation.switchView('projects'), description: 'Go to Projects' },
42 42 'v': { action: () => GoingsOn.navigation.switchView('events'), description: 'Go to Events' },
43 43 'd': { action: () => GoingsOn.navigation.switchView('day-plan'), description: 'Go to Day Plan' },
44 + 'c': { action: () => GoingsOn.navigation.switchView('contacts'), description: 'Go to Contacts' },
45 + 'w': { action: () => GoingsOn.navigation.switchView('weekly-review'), description: 'Go to Weekly Review' },
44 46 }
45 47 };
46 48
@@ -149,6 +151,8 @@
149 151 <div class="shortcut-row"><kbd>g</kbd> <kbd>p</kbd> <span>Go to Projects</span></div>
150 152 <div class="shortcut-row"><kbd>g</kbd> <kbd>v</kbd> <span>Go to Events</span></div>
151 153 <div class="shortcut-row"><kbd>g</kbd> <kbd>d</kbd> <span>Go to Day Plan</span></div>
154 + <div class="shortcut-row"><kbd>g</kbd> <kbd>c</kbd> <span>Go to Contacts</span></div>
155 + <div class="shortcut-row"><kbd>g</kbd> <kbd>w</kbd> <span>Go to Weekly</span></div>
152 156 <div class="shortcut-row"><kbd>[</kbd> <span>Previous day</span></div>
153 157 <div class="shortcut-row"><kbd>]</kbd> <span>Next day</span></div>
154 158 <div class="shortcut-row"><kbd>j</kbd> <span>Next item</span></div>