max / goingson
13 files changed,
+298 insertions,
-38 deletions
| @@ -63,6 +63,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 63 | 63 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 64 | 64 | ||
| 65 | 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 | + | ||
| 78 | + | [[package]] | |
| 66 | 79 | name = "android_system_properties" | |
| 67 | 80 | version = "0.1.5" | |
| 68 | 81 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -875,6 +888,19 @@ dependencies = [ | |||
| 875 | 888 | ] | |
| 876 | 889 | ||
| 877 | 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 | + | ||
| 903 | + | [[package]] | |
| 878 | 904 | name = "cssparser-macros" | |
| 879 | 905 | version = "0.6.1" | |
| 880 | 906 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1803,6 +1829,7 @@ dependencies = [ | |||
| 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 @@ checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" | |||
| 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 @@ version = "0.8.8-speedreader" | |||
| 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 | ] | |
| @@ -2766,6 +2804,12 @@ dependencies = [ | |||
| 2766 | 2804 | ] | |
| 2767 | 2805 | ||
| 2768 | 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 | + | ||
| 2812 | + | [[package]] | |
| 2769 | 2813 | name = "markup5ever" | |
| 2770 | 2814 | version = "0.14.1" | |
| 2771 | 2815 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2780,6 +2824,17 @@ dependencies = [ | |||
| 2780 | 2824 | ] | |
| 2781 | 2825 | ||
| 2782 | 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 | + | ||
| 2837 | + | [[package]] | |
| 2783 | 2838 | name = "match_token" | |
| 2784 | 2839 | version = "0.1.0" | |
| 2785 | 2840 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2791,6 +2846,17 @@ dependencies = [ | |||
| 2791 | 2846 | ] | |
| 2792 | 2847 | ||
| 2793 | 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 | + | ||
| 2859 | + | [[package]] | |
| 2794 | 2860 | name = "matchers" | |
| 2795 | 2861 | version = "0.2.0" | |
| 2796 | 2862 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4622,7 +4688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 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 @@ dependencies = [ | |||
| 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 @@ dependencies = [ | |||
| 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", | |
| @@ -6651,6 +6717,18 @@ dependencies = [ | |||
| 6651 | 6717 | ] | |
| 6652 | 6718 | ||
| 6653 | 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 | + | ||
| 6731 | + | [[package]] | |
| 6654 | 6732 | name = "webkit2gtk" | |
| 6655 | 6733 | version = "2.0.2" | |
| 6656 | 6734 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -7306,7 +7384,7 @@ dependencies = [ | |||
| 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", |
| @@ -82,6 +82,9 @@ flate2 = "1.0" | |||
| 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 |
| @@ -8,12 +8,12 @@ use crate::constants::{ | |||
| 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 @@ impl Validate for NewProject { | |||
| 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 @@ impl Validate for NewEvent { | |||
| 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 @@ mod tests { | |||
| 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 @@ pub mod migrations; | |||
| 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 @@ pub async fn init_pool(database_path: Option<&str>) -> Result<SqlitePool, sqlx:: | |||
| 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 @@ impl PluginApiContext { | |||
| 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 @@ impl PluginApi { | |||
| 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 | } |
| @@ -73,6 +73,9 @@ dirs = { workspace = true } | |||
| 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"> |
| @@ -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> |
| @@ -31,7 +31,7 @@ | |||
| 31 | 31 | </p> | |
| 32 | 32 | </div> | |
| 33 | 33 | <div class="form-actions"> | |
| 34 | - | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button> | |
| 34 | + | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button> | |
| 35 | 35 | </div> | |
| 36 | 36 | `; | |
| 37 | 37 | } else if (!status.authenticated) { | |
| @@ -46,7 +46,7 @@ | |||
| 46 | 46 | </button> | |
| 47 | 47 | </div> | |
| 48 | 48 | <div class="form-actions"> | |
| 49 | - | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button> | |
| 49 | + | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button> | |
| 50 | 50 | </div> | |
| 51 | 51 | `; | |
| 52 | 52 | } else if (!status.encryptionReady) { | |
| @@ -75,7 +75,7 @@ | |||
| 75 | 75 | <div id="sync-encryption-error" style="color: var(--accent-red); font-size: 0.875rem; margin-top: 0.5rem; display: none;"></div> | |
| 76 | 76 | </div> | |
| 77 | 77 | <div class="form-actions"> | |
| 78 | - | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button> | |
| 78 | + | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button> | |
| 79 | 79 | <button type="button" class="btn btn-primary" onclick="GoingsOn.settings.submitEncryption(${isNewDevice})"> | |
| 80 | 80 | ${GoingsOn.utils.escapeHtml(heading)} | |
| 81 | 81 | </button> | |
| @@ -137,7 +137,7 @@ | |||
| 137 | 137 | Disconnect | |
| 138 | 138 | </button> | |
| 139 | 139 | <div style="flex: 1;"></div> | |
| 140 | - | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button> | |
| 140 | + | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button> | |
| 141 | 141 | </div> | |
| 142 | 142 | `; | |
| 143 | 143 | } |
| @@ -92,11 +92,17 @@ | |||
| 92 | 92 | </div> | |
| 93 | 93 | ||
| 94 | 94 | <div class="settings-section" style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 2px solid var(--border-color);"> | |
| 95 | - | <h3 style="margin-bottom: 1rem; font-family: var(--font-heading);">Export & Backup</h3> | |
| 95 | + | <h3 style="margin-bottom: 1rem; font-family: var(--font-heading);">Import & Export</h3> | |
| 96 | 96 | <p style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 1rem;"> | |
| 97 | - | Export your data or create backups for safekeeping. | |
| 97 | + | Import data from external sources or export for safekeeping. | |
| 98 | 98 | </p> | |
| 99 | 99 | ||
| 100 | + | <div style="margin-bottom: 1rem;"> | |
| 101 | + | <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.import.openModal();"> | |
| 102 | + | Import Data | |
| 103 | + | </button> | |
| 104 | + | </div> | |
| 105 | + | ||
| 100 | 106 | <div style="display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem;"> | |
| 101 | 107 | <button class="btn btn-secondary" onclick="GoingsOn.export.exportJSON()"> | |
| 102 | 108 | Export All (JSON) | |
| @@ -215,7 +221,7 @@ async function openLlmSettingsModal() { | |||
| 215 | 221 | <div class="form-actions" style="margin-top: 1.5rem;"> | |
| 216 | 222 | <button type="button" class="btn btn-secondary" onclick="GoingsOn.settings.testLlm()">Test Connection</button> | |
| 217 | 223 | <div style="flex: 1;"></div> | |
| 218 | - | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button> | |
| 224 | + | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button> | |
| 219 | 225 | <button type="submit" class="btn btn-primary">Save Settings</button> | |
| 220 | 226 | </div> | |
| 221 | 227 |
| @@ -291,6 +291,7 @@ pub async fn open_email_in_browser(state: State<'_, Arc<AppState>>, id: EmailId) | |||
| 291 | 291 | .or_not_found("email", id)?; | |
| 292 | 292 | ||
| 293 | 293 | let html_content = if let Some(ref html) = email.html_body { | |
| 294 | + | let sanitized_body = ammonia::clean(html); | |
| 294 | 295 | format!( | |
| 295 | 296 | r#"<!DOCTYPE html> | |
| 296 | 297 | <html> | |
| @@ -322,7 +323,7 @@ pub async fn open_email_in_browser(state: State<'_, Arc<AppState>>, id: EmailId) | |||
| 322 | 323 | html_escape(&email.to), | |
| 323 | 324 | html_escape(&email.subject), | |
| 324 | 325 | email.received_at.format("%Y-%m-%d %H:%M:%S UTC"), | |
| 325 | - | html | |
| 326 | + | sanitized_body | |
| 326 | 327 | ) | |
| 327 | 328 | } else { | |
| 328 | 329 | let body_html = html_escape(&email.body).replace('\n', "<br>\n"); |
| @@ -256,17 +256,6 @@ pub async fn create_event(state: State<'_, Arc<AppState>>, input: EventInput) -> | |||
| 256 | 256 | #[tauri::command] | |
| 257 | 257 | #[instrument(skip_all)] | |
| 258 | 258 | pub async fn update_event(state: State<'_, Arc<AppState>>, id: EventId, input: EventInput) -> Result<EventResponse, ApiError> { | |
| 259 | - | if input.title.trim().is_empty() { | |
| 260 | - | return Err(ApiError::validation("title", "Title is required")); | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | // Validate end_time > start_time if end_time is provided | |
| 264 | - | if let Some(end_time) = input.end_time { | |
| 265 | - | if end_time <= input.start_time { | |
| 266 | - | return Err(ApiError::validation("endTime", "End time must be after start time")); | |
| 267 | - | } | |
| 268 | - | } | |
| 269 | - | ||
| 270 | 259 | // Get existing event to preserve linked_task_id | |
| 271 | 260 | let existing = state.events | |
| 272 | 261 | .get_by_id(id, DESKTOP_USER_ID) | |
| @@ -293,6 +282,8 @@ pub async fn update_event(state: State<'_, Arc<AppState>>, id: EventId, input: E | |||
| 293 | 282 | block_type, | |
| 294 | 283 | }; | |
| 295 | 284 | ||
| 285 | + | update_event.validate()?; | |
| 286 | + | ||
| 296 | 287 | let event = state.events | |
| 297 | 288 | .update(id, DESKTOP_USER_ID, update_event) | |
| 298 | 289 | .await? |
| @@ -136,6 +136,8 @@ pub async fn update_project(state: State<'_, Arc<AppState>>, id: ProjectId, inpu | |||
| 136 | 136 | status: ProjectStatus::from_str_or_default(&input.status), | |
| 137 | 137 | }; | |
| 138 | 138 | ||
| 139 | + | update.validate()?; | |
| 140 | + | ||
| 139 | 141 | let project = state.projects | |
| 140 | 142 | .update(id, DESKTOP_USER_ID, update) | |
| 141 | 143 | .await? |