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