Skip to main content

max / goingson

TagTree + DocEngine integration, API key setup UI, sync client refactor - Tag validation delegated to tagtree::validate_with (depth 3, length 60) - ammonia replaced with docengine for HTML sanitization - Sync settings: API key test/save flow instead of env-var-only setup - sync_client changed to RwLock<Option<Arc<SyncKitClient>>> for runtime init - API key persisted to data_dir/sync_api_key across launches
Co-Authored-By
Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-22 05:29 UTC
Commit: 11ac0c3674878e7ba158d3ff70c930c758920732
Parent: 8ac7c48
21 files changed, +293 insertions, -113 deletions
M Cargo.lock +56 -1
@@ -1112,6 +1112,15 @@
1112 1112 "syn 2.0.114",
1113 1113 ]
1114 1114
1115 + [[package]]
1116 + name = "docengine"
1117 + version = "0.3.0"
1118 + dependencies = [
1119 + "ammonia",
1120 + "pulldown-cmark",
1121 + "serde",
1122 + ]
1123 +
1115 1124 [[package]]
1116 1125 name = "dotenvy"
1117 1126 version = "0.15.7"
@@ -1676,6 +1685,15 @@
1676 1685 "version_check",
1677 1686 ]
1678 1687
1688 + [[package]]
1689 + name = "getopts"
1690 + version = "0.2.24"
1691 + source = "registry+https://github.com/rust-lang/crates.io-index"
1692 + checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
1693 + dependencies = [
1694 + "unicode-width",
1695 + ]
1696 +
1679 1697 [[package]]
1680 1698 name = "getrandom"
1681 1699 version = "0.1.16"
@@ -1817,6 +1835,7 @@
1817 1835 "sqlx",
1818 1836 "strum",
1819 1837 "strum_macros",
1838 + "tagtree",
1820 1839 "thiserror 1.0.69",
1821 1840 "uuid",
1822 1841 ]
@@ -1840,13 +1859,13 @@
1840 1859 name = "goingson-desktop"
1841 1860 version = "0.3.0"
1842 1861 dependencies = [
1843 - "ammonia",
1844 1862 "async-imap",
1845 1863 "async-trait",
1846 1864 "base64 0.22.1",
1847 1865 "chrono",
1848 1866 "csv",
1849 1867 "dirs",
1868 + "docengine",
1850 1869 "flate2",
1851 1870 "futures",
1852 1871 "goingson-core",
@@ -1859,6 +1878,7 @@
1859 1878 "notify",
1860 1879 "notify-debouncer-mini",
1861 1880 "open",
1881 + "parking_lot",
1862 1882 "rand 0.8.5",
1863 1883 "reqwest 0.12.28",
1864 1884 "serde",
@@ -4034,6 +4054,25 @@
4034 4054 "cc",
4035 4055 ]
4036 4056
4057 + [[package]]
4058 + name = "pulldown-cmark"
4059 + version = "0.12.2"
4060 + source = "registry+https://github.com/rust-lang/crates.io-index"
4061 + checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
4062 + dependencies = [
4063 + "bitflags 2.10.0",
4064 + "getopts",
4065 + "memchr",
4066 + "pulldown-cmark-escape",
4067 + "unicase",
4068 + ]
4069 +
4070 + [[package]]
4071 + name = "pulldown-cmark-escape"
4072 + version = "0.11.0"
4073 + source = "registry+https://github.com/rust-lang/crates.io-index"
4074 + checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
4075 +
4037 4076 [[package]]
4038 4077 name = "pxfm"
4039 4078 version = "0.1.27"
@@ -5531,6 +5570,10 @@
5531 5570 "version-compare",
5532 5571 ]
5533 5572
5573 + [[package]]
5574 + name = "tagtree"
5575 + version = "0.3.0"
5576 +
5534 5577 [[package]]
5535 5578 name = "tao"
5536 5579 version = "0.34.5"
@@ -6485,6 +6528,12 @@
6485 6528 "unic-common",
6486 6529 ]
6487 6530
6531 + [[package]]
6532 + name = "unicase"
6533 + version = "2.9.0"
6534 + source = "registry+https://github.com/rust-lang/crates.io-index"
6535 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
6536 +
6488 6537 [[package]]
6489 6538 name = "unicode-bidi"
6490 6539 version = "0.3.18"
@@ -6518,6 +6567,12 @@
6518 6567 source = "registry+https://github.com/rust-lang/crates.io-index"
6519 6568 checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
6520 6569
6570 + [[package]]
6571 + name = "unicode-width"
6572 + version = "0.2.2"
6573 + source = "registry+https://github.com/rust-lang/crates.io-index"
6574 + checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
6575 +
6521 6576 [[package]]
6522 6577 name = "universal-hash"
6523 6578 version = "0.5.1"
M Cargo.toml +5 -2
@@ -83,8 +83,8 @@
83 83 # Browser opening
84 84 open = "5"
85 85
86 - # HTML sanitization
87 - ammonia = "4"
86 + # Markdown rendering + HTML sanitization
87 + docengine = { path = "../docengine" }
88 88
89 89 # Filesystem
90 90 dirs = "6"
@@ -93,6 +93,9 @@
93 93 tracing = "0.1"
94 94 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
95 95
96 + # Tag standard
97 + tagtree = { path = "../tagtree" }
98 +
96 99 # Internal crates
97 100 goingson-core = { path = "crates/core" }
98 101 goingson-db-sqlite = { path = "crates/db-sqlite" }
@@ -73,11 +73,12 @@
73 73 # Browser opening
74 74 open = { workspace = true }
75 75
76 - # HTML sanitization
77 - ammonia = { workspace = true }
76 + # Markdown rendering + HTML sanitization
77 + docengine = { workspace = true }
78 78
79 79 # Secure credential storage
80 80 keyring = { workspace = true }
81 + parking_lot = "0.12.5"
81 82
82 83 # === Desktop-only dependencies (not available on iOS/Android) ===
83 84
@@ -16,3 +16,4 @@
16 16 strum = { workspace = true }
17 17 strum_macros = { workspace = true }
18 18 sqlx = { workspace = true, features = ["sqlite", "uuid"], optional = true }
19 + tagtree = { workspace = true }
@@ -513,6 +513,8 @@
513 513 commands::set_vacation_days,
514 514 commands::check_weekly_review_nudge,
515 515 // Sync
516 + commands::sync_test_api_key,
517 + commands::sync_save_api_key,
516 518 commands::sync_status,
517 519 commands::sync_start_auth,
518 520 commands::sync_complete_auth,
@@ -14,11 +14,16 @@
14 14 SqliteTaskRepository, SqliteWeeklyReviewRepository,
15 15 };
16 16 use sqlx::SqlitePool;
17 + use std::path::PathBuf;
17 18 use std::sync::Arc;
19 + use parking_lot::RwLock;
18 20 use synckit_client::{SyncKitClient, SyncKitConfig};
19 21 use tauri::{AppHandle, Manager};
20 22 use tracing::{debug, info, instrument, warn};
21 23
24 + /// Default SyncKit server URL.
25 + pub const SYNC_SERVER_URL: &str = "https://makenot.work";
26 +
22 27 /// Application state holding database connections and repositories
23 28 pub struct AppState {
24 29 #[allow(dead_code)]
@@ -37,7 +42,8 @@
37 42 pub saved_views: Arc<dyn SavedViewRepository>,
38 43 pub weekly_reviews: Arc<dyn WeeklyReviewRepository>,
39 44 pub backup_settings: Arc<dyn BackupSettingsRepository>,
40 - pub sync_client: Option<SyncKitClient>,
45 + pub sync_client: RwLock<Option<Arc<SyncKitClient>>>,
46 + pub data_dir: PathBuf,
41 47 }
42 48
43 49 impl AppState {
@@ -108,39 +114,8 @@
108 114 let weekly_reviews = Arc::new(SqliteWeeklyReviewRepository::new(pool.clone()));
109 115 let backup_settings = Arc::new(SqliteBackupSettingsRepository::new(pool.clone()));
110 116
111 - // Initialize SyncKit client from env vars (optional)
112 - let sync_client = match (
113 - std::env::var("GOINGSON_SYNC_SERVER_URL"),
114 - std::env::var("GOINGSON_SYNC_API_KEY"),
115 - ) {
116 - (Ok(server_url), Ok(api_key)) => {
117 - info!(%server_url, "SyncKit client configured");
118 - let client = SyncKitClient::new(SyncKitConfig { server_url, api_key });
119 -
120 - // Try to restore session from keychain
121 - if let Some(creds) = crate::oauth::CredentialStore::get_sync_token() {
122 - if let Err(e) = client.restore_session(&creds.token, creds.user_id, creds.app_id) {
123 - warn!("Failed to restore sync session: {}", e);
124 - } else if client.is_token_expired().unwrap_or(true) {
125 - warn!("Stored sync token is expired, clearing session");
126 - let _ = client.clear_session();
127 - } else {
128 - // Try to load encryption key from keychain
129 - match client.try_load_key_from_keychain() {
130 - Ok(true) => info!("Sync encryption key loaded from keychain"),
131 - Ok(false) => debug!("No sync encryption key in keychain"),
132 - Err(e) => warn!("Failed to load sync encryption key: {}", e),
133 - }
134 - }
135 - }
136 -
137 - Some(client)
138 - }
139 - _ => {
140 - debug!("SyncKit not configured (GOINGSON_SYNC_SERVER_URL / GOINGSON_SYNC_API_KEY not set)");
141 - None
142 - }
143 - };
117 + // Initialize SyncKit client from saved key or env vars (optional)
118 + let sync_client = load_sync_client(&app_data_dir);
144 119
145 120 Ok(Self {
146 121 pool,
@@ -158,11 +133,60 @@
158 133 saved_views,
159 134 weekly_reviews,
160 135 backup_settings,
161 - sync_client,
136 + sync_client: RwLock::new(sync_client.map(Arc::new)),
137 + data_dir: app_data_dir,
162 138 })
163 139 }
164 140 }
165 141
142 + /// Load a SyncKit API key from the data directory, falling back to env vars.
143 + fn load_api_key(data_dir: &std::path::Path) -> Option<String> {
144 + let key_path = data_dir.join("sync_api_key");
145 + if let Ok(key) = std::fs::read_to_string(&key_path) {
146 + let key = key.trim().to_string();
147 + if !key.is_empty() {
148 + info!("Loaded SyncKit API key from {}", key_path.display());
149 + return Some(key);
150 + }
151 + }
152 + std::env::var("GOINGSON_SYNC_API_KEY").ok()
153 + }
154 +
155 + /// Create a SyncKitClient from a saved or env-provided API key.
156 + fn load_sync_client(data_dir: &std::path::Path) -> Option<SyncKitClient> {
157 + let api_key = load_api_key(data_dir)?;
158 + let server_url = std::env::var("GOINGSON_SYNC_SERVER_URL")
159 + .unwrap_or_else(|_| SYNC_SERVER_URL.to_string());
160 + info!(%server_url, "SyncKit client configured");
161 + let client = SyncKitClient::new(SyncKitConfig { server_url, api_key });
162 +
163 + // Try to restore session from keychain
164 + if let Some(creds) = crate::oauth::CredentialStore::get_sync_token() {
165 + if let Err(e) = client.restore_session(&creds.token, creds.user_id, creds.app_id) {
166 + warn!("Failed to restore sync session: {}", e);
167 + } else if client.is_token_expired().unwrap_or(true) {
168 + warn!("Stored sync token is expired, clearing session");
169 + let _ = client.clear_session();
170 + } else {
171 + match client.try_load_key_from_keychain() {
172 + Ok(true) => info!("Sync encryption key loaded from keychain"),
173 + Ok(false) => debug!("No sync encryption key in keychain"),
174 + Err(e) => warn!("Failed to load sync encryption key: {}", e),
175 + }
176 + }
177 + }
178 +
179 + Some(client)
180 + }
181 +
182 + /// Save an API key to the data directory for future launches.
183 + pub fn save_api_key(data_dir: &std::path::Path, api_key: &str) {
184 + let key_path = data_dir.join("sync_api_key");
185 + if let Err(e) = std::fs::write(&key_path, api_key) {
186 + tracing::error!("Failed to save API key to {}: {e}", key_path.display());
187 + }
188 + }
189 +
166 190 /// Fixed user ID for single-user desktop app
167 191 pub const DESKTOP_USER_ID: goingson_core::UserId = goingson_core::UserId::from_uuid(uuid::Uuid::from_u128(1));
168 192
@@ -41,7 +41,7 @@
41 41 }
42 42 };
43 43
44 - let client = match &state.sync_client {
44 + let client: Arc<synckit_client::SyncKitClient> = match state.sync_client.read().clone() {
45 45 Some(c) => c,
46 46 None => continue,
47 47 };
@@ -119,7 +119,7 @@
119 119
120 120 // Perform sync
121 121 let _ = app.emit("sync:status-changed", "syncing");
122 - match sync_service::perform_sync(&state.pool, client).await {
122 + match sync_service::perform_sync(&state.pool, &client).await {
123 123 Ok(result) => {
124 124 consecutive_failures = 0;
125 125 backoff_until = None;
@@ -69,7 +69,8 @@
69 69 saved_views: Arc::new(SqliteSavedViewRepository::new(pool.clone())),
70 70 weekly_reviews: Arc::new(SqliteWeeklyReviewRepository::new(pool.clone())),
71 71 backup_settings: Arc::new(SqliteBackupSettingsRepository::new(pool.clone())),
72 - sync_client: None,
72 + sync_client: parking_lot::RwLock::new(None),
73 + data_dir: std::path::PathBuf::from("/tmp/goingson-test"),
73 74 };
74 75
75 76 (Arc::new(state), user_id)
@@ -59,9 +59,6 @@
59 59 /// Maximum length for event titles.
60 60 pub const MAX_EVENT_TITLE_LENGTH: usize = 255;
61 61
62 - /// Maximum length for tags.
63 - pub const MAX_TAG_LENGTH: usize = 50;
64 -
65 62 /// Maximum length for contact display names.
66 63 pub const MAX_CONTACT_DISPLAY_NAME_LENGTH: usize = 255;
67 64
@@ -78,12 +75,6 @@
78 75 const { assert!(URGENCY_MEDIUM_THRESHOLD > 0.0) };
79 76 }
80 77
81 - #[test]
82 - fn max_tag_length_is_reasonable() {
83 - const { assert!(MAX_TAG_LENGTH >= 10) };
84 - const { assert!(MAX_TAG_LENGTH <= 255) };
85 - }
86 -
87 78 #[test]
88 79 fn max_scheduled_duration_is_24_hours() {
89 80 assert_eq!(MAX_SCHEDULED_DURATION_MINUTES, 1440);
@@ -5,12 +5,25 @@
5 5
6 6 use crate::constants::{
7 7 MAX_CONTACT_DISPLAY_NAME_LENGTH, MAX_EVENT_TITLE_LENGTH, MAX_PROJECT_NAME_LENGTH,
8 - MAX_SCHEDULED_DURATION_MINUTES, MAX_TAG_LENGTH, MAX_TASK_DESCRIPTION_LENGTH,
8 + MAX_SCHEDULED_DURATION_MINUTES, MAX_TASK_DESCRIPTION_LENGTH,
9 9 };
10 10 use crate::error::CoreError;
11 11 use crate::contact::{NewContact, UpdateContact};
12 12 use crate::models::{NewEvent, NewProject, NewTask, UpdateEvent, UpdateProject, UpdateTask};
13 13
14 + /// Tag rules for GoingsOn: shallow hierarchy, no required semantic prefix.
15 + const GO_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
16 + max_depth: 3,
17 + max_length: 60,
18 + semantic_depth: 0,
19 + };
20 +
21 + /// Validate a single tag against the GoingsOn config.
22 + fn validate_tag(tag: &str) -> Result<(), CoreError> {
23 + tagtree::validate_with(tag, &GO_TAG_CONFIG)
24 + .map_err(|e| CoreError::validation("tags", e.0))
25 + }
26 +
14 27 /// A trait for types that can validate their own data before persistence.
15 28 ///
16 29 /// Call `.validate()` on DTOs (`NewTask`, `NewProject`, `NewEvent`, `UpdateTask`,
@@ -67,7 +80,7 @@
67 80 }
68 81
69 82 // Task validation: non-empty description, valid duration (positive, ≤24h),
70 - // non-empty tags within MAX_TAG_LENGTH.
83 + // tags validated via tagtree (lowercase, dot-separated, max 3 levels, 60 chars).
71 84 impl Validate for NewTask {
72 85 fn validate(&self) -> Result<(), CoreError> {
73 86 if self.description.trim().is_empty() {
@@ -88,15 +101,7 @@
88 101 }
89 102 }
90 103 for tag in &self.tags {
91 - if tag.trim().is_empty() {
92 - return Err(CoreError::validation("tags", "tags cannot be empty strings"));
93 - }
94 - if tag.len() > MAX_TAG_LENGTH {
95 - return Err(CoreError::validation(
96 - "tags",
97 - format!("each tag must be {} characters or less", MAX_TAG_LENGTH),
98 - ));
99 - }
104 + validate_tag(tag)?;
100 105 }
101 106 Ok(())
102 107 }
@@ -123,15 +128,7 @@
123 128 }
124 129 }
125 130 for tag in &self.tags {
126 - if tag.trim().is_empty() {
127 - return Err(CoreError::validation("tags", "tags cannot be empty strings"));
128 - }
129 - if tag.len() > MAX_TAG_LENGTH {
130 - return Err(CoreError::validation(
131 - "tags",
132 - format!("each tag must be {} characters or less", MAX_TAG_LENGTH),
133 - ));
134 - }
131 + validate_tag(tag)?;
135 132 }
136 133 Ok(())
137 134 }
@@ -194,15 +191,7 @@
194 191 ));
195 192 }
196 193 for tag in tags {
197 - if tag.trim().is_empty() {
198 - return Err(CoreError::validation("tags", "tags cannot be empty strings"));
199 - }
200 - if tag.len() > MAX_TAG_LENGTH {
201 - return Err(CoreError::validation(
202 - "tags",
203 - format!("each tag must be {} characters or less", MAX_TAG_LENGTH),
204 - ));
205 - }
194 + validate_tag(tag)?;
206 195 }
207 196 Ok(())
208 197 }
@@ -393,7 +382,7 @@
393 382 description: "Task with long tag".to_string(),
394 383 priority: Priority::Medium,
395 384 due: None,
396 - tags: vec!["a".repeat(MAX_TAG_LENGTH + 1)],
385 + tags: vec!["a".repeat(61)], // exceeds tagtree max_length of 60
397 386 recurrence: Recurrence::None,
398 387 urgency: 5.0,
399 388 source_email_id: None,
@@ -556,6 +556,24 @@
556 556 margin-bottom: 1rem;
557 557 }
558 558
559 + .markdown-content {
560 + font-size: 0.9rem;
561 + color: var(--text-secondary);
562 + line-height: 1.5;
563 + }
564 + .markdown-content p { margin: 0 0 0.5em 0; }
565 + .markdown-content p:last-child { margin-bottom: 0; }
566 + .markdown-content ul, .markdown-content ol { margin: 0 0 0.5em 1.5em; padding: 0; }
567 + .markdown-content code { background: var(--bg-tertiary); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
568 + .markdown-content pre { background: var(--bg-tertiary); padding: 0.5em; border-radius: 4px; overflow-x: auto; margin: 0 0 0.5em 0; }
569 + .markdown-content pre code { background: none; padding: 0; }
570 + .markdown-content a { color: var(--accent-color); }
571 + .markdown-content blockquote { border-left: 3px solid var(--border-color); margin: 0 0 0.5em 0; padding-left: 0.75em; color: var(--text-secondary); }
572 + .markdown-content h1, .markdown-content h2, .markdown-content h3 { margin: 0.5em 0 0.25em 0; font-size: 1em; font-weight: 600; color: var(--text-primary); }
573 + .markdown-content table { border-collapse: collapse; margin: 0.5em 0; }
574 + .markdown-content th, .markdown-content td { border: 1px solid var(--border-color); padding: 0.25em 0.5em; }
575 + .markdown-content img { max-width: 100%; }
576 +
559 577 .card-meta {
560 578 display: flex;
561 579 gap: 0.5rem;