max / goingson
- Co-Authored-By
- Claude Opus 4.6 <noreply@anthropic.com>
21 files changed,
+293 insertions,
-113 deletions
| @@ -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" |
| @@ -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; |