My personal-knowledge-system, with deeply integrated task tracking and long term goal planning capabilities.
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #7 from suri-codes/zk

fZk

authored by

Surendra Jammishetti and committed by
GitHub
b8477948 75011e20

+1054 -124
+4 -2
.config/config.kdl
··· 4 4 keymap { 5 5 6 6 Home { 7 - q Quit // Quit the application 8 7 <Ctrl-c> Quit // Another way to quit 9 8 <Ctrl-z> Suspend // Suspend the application 10 - h Helix 9 + up MoveUp 10 + down MoveDown 11 + enter OpenZettel 12 + <Ctrl-n> NewZettel 11 13 } 12 14 }
+25
Cargo.lock
··· 2303 2303 "futures", 2304 2304 "human-panic", 2305 2305 "kdl", 2306 + "nucleo-matcher", 2306 2307 "pulldown-cmark", 2307 2308 "rand 0.10.0", 2308 2309 "ratatui", 2310 + "ratatui-textarea", 2309 2311 "rayon", 2310 2312 "serde", 2311 2313 "signal-hook 0.4.3", ··· 4392 4394 dependencies = [ 4393 4395 "async-std", 4394 4396 "nanoid", 4397 + "rand 0.10.0", 4395 4398 "rgb", 4396 4399 "sea-orm", 4397 4400 "sea-orm-migration", ··· 4546 4549 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 4547 4550 dependencies = [ 4548 4551 "windows-sys 0.61.2", 4552 + ] 4553 + 4554 + [[package]] 4555 + name = "nucleo-matcher" 4556 + version = "0.3.1" 4557 + source = "registry+https://github.com/rust-lang/crates.io-index" 4558 + checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" 4559 + dependencies = [ 4560 + "memchr", 4561 + "unicode-segmentation", 4549 4562 ] 4550 4563 4551 4564 [[package]] ··· 5802 5815 dependencies = [ 5803 5816 "ratatui-core", 5804 5817 "termwiz", 5818 + ] 5819 + 5820 + [[package]] 5821 + name = "ratatui-textarea" 5822 + version = "0.8.0" 5823 + source = "registry+https://github.com/rust-lang/crates.io-index" 5824 + checksum = "de236b7cc74b3f7dea227b3fbad97bf459cddf552b6503d888fb9a106eda59ab" 5825 + dependencies = [ 5826 + "ratatui-core", 5827 + "ratatui-crossterm", 5828 + "ratatui-widgets", 5829 + "unicode-width 0.2.2", 5805 5830 ] 5806 5831 5807 5832 [[package]]
+2
Cargo.toml
··· 79 79 rayon = "1.11.0" 80 80 rand = "0.10.0" 81 81 pulldown-cmark = { version = "0.13.3", features = ["simd"] } 82 + ratatui-textarea = "0.8.0" 83 + nucleo-matcher = "0.3.1" 82 84 83 85 [build-dependencies] 84 86 anyhow = "1.0.102"
+2
crates/dto/migration/Cargo.toml
··· 13 13 nanoid = "0.4.0" 14 14 sea-orm = "2.0.0-rc" 15 15 rgb = "0.8.53" 16 + 16 17 serde = {workspace = true} 18 + rand = "0.10.0" 17 19 18 20 [dependencies.sea-orm-migration] 19 21 version = "2.0.0-rc"
+1 -1
crates/dto/migration/src/m20260327_175853_tag_table.rs
··· 20 20 .unique_key() 21 21 .not_null(), 22 22 ) 23 - .col(string(Tag::Name).not_null()) 23 + .col(string(Tag::Name).unique_key().not_null()) 24 24 .col(integer(Tag::Color).not_null()) 25 25 .to_owned(), 26 26 )
+12
crates/dto/migration/src/types/color.rs
··· 1 + use rand::RngExt; 1 2 use rgb::RGB8; 2 3 use sea_orm::DeriveValueType; 3 4 use std::fmt::{Debug, Display}; ··· 49 50 c.to_rgb8() 50 51 } 51 52 } 53 + 54 + impl Default for Color { 55 + fn default() -> Self { 56 + let mut rng = rand::rng(); 57 + let r = rng.random_range(0..=255); 58 + let g = rng.random_range(0..=255); 59 + let b = rng.random_range(0..=255); 60 + 61 + Self::new(r, g, b) 62 + } 63 + }
+1 -1
crates/dto/src/entity/prelude.rs
··· 1 1 //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 2 - 3 2 #![expect(unused_imports)] 3 + 4 4 pub use super::group::Entity as Group; 5 5 pub use super::tag::Entity as Tag; 6 6 pub use super::task::Entity as Task;
+4
crates/dto/src/entity/tag.rs
··· 13 13 pub id: i64, 14 14 #[sea_orm(unique)] 15 15 pub nano_id: NanoId, 16 + #[sea_orm(unique)] 16 17 pub name: String, 17 18 pub color: Color, 18 19 #[sea_orm(has_many, via = "zettel_tag")] ··· 32 33 { 33 34 if insert && self.nano_id.is_not_set() { 34 35 self.nano_id = Set(NanoId::default()); 36 + } 37 + if insert && self.color.is_not_set() { 38 + self.color = Set(Color::default()); 35 39 } 36 40 Box::pin(ready(Ok(self))) 37 41 }
+1
crates/dto/src/lib.rs
··· 12 12 pub use sea_orm::EntityTrait; 13 13 pub use sea_orm::IntoActiveModel; 14 14 pub use sea_orm::QueryFilter; 15 + pub use sea_orm::QueryOrder; 15 16 16 17 /// Exporting this as a generic NanoId. 17 18 pub use migration::types::NanoId;
+3 -1
crates/dto/tests/zettel.rs
··· 2 2 ActiveModelTrait, ActiveValue::Set, ColorDTO, TagActiveModel, TagEntity, TagModel, 3 3 ZettelActiveModel, ZettelEntity, ZettelModel, 4 4 }; 5 - use sea_orm::IntoActiveModel; 5 + use sea_orm::{IntoActiveModel, QueryOrder}; 6 6 7 7 mod common; 8 8 ··· 56 56 let zettels_for_tag = TagEntity::load() 57 57 .filter_by_nano_id(tag.nano_id.clone()) 58 58 .with(ZettelEntity) 59 + 59 60 .all(&db) 61 + 60 62 .await 61 63 .unwrap(); 62 64
+11 -11
flake.lock
··· 8 8 "rust-analyzer-src": "rust-analyzer-src" 9 9 }, 10 10 "locked": { 11 - "lastModified": 1774682177, 12 - "narHash": "sha256-OVbuJnJLlbHE28eRMudjtA6NXz/ifuXSho79gvh6GHY=", 11 + "lastModified": 1775115015, 12 + "narHash": "sha256-XO7jmyFupI82Sr1M2tLfsSxslIJwUOjzhFqeffaWyNw=", 13 13 "owner": "nix-community", 14 14 "repo": "fenix", 15 - "rev": "e0f515387df77b9fdbaaf81e7f866f0365474c18", 15 + "rev": "45f82ed61800d52e27390b70823426045d982c84", 16 16 "type": "github" 17 17 }, 18 18 "original": { ··· 23 23 }, 24 24 "nixpkgs": { 25 25 "locked": { 26 - "lastModified": 1774386573, 27 - "narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=", 28 - "rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9", 29 - "revCount": 969196, 26 + "lastModified": 1775036866, 27 + "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", 28 + "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", 29 + "revCount": 972949, 30 30 "type": "tarball", 31 - "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.969196%2Brev-46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9/019d279e-af65-79ce-92be-5dee7b1e36d4/source.tar.gz" 31 + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.972949%2Brev-6201e203d09599479a3b3450ed24fa81537ebc4e/019d4b63-29c4-7e95-9524-45503c792ef3/source.tar.gz" 32 32 }, 33 33 "original": { 34 34 "type": "tarball", ··· 44 44 "rust-analyzer-src": { 45 45 "flake": false, 46 46 "locked": { 47 - "lastModified": 1774569884, 48 - "narHash": "sha256-E8iWEPzg7OnE0XXXjo75CX7xFauqzJuGZ5wSO9KS8Ek=", 47 + "lastModified": 1775045117, 48 + "narHash": "sha256-PLZYhcg3HUZ+lUMUV+JbXs9ExOAYpZC0PAtOVHCgYss=", 49 49 "owner": "rust-lang", 50 50 "repo": "rust-analyzer", 51 - "rev": "443ddcddd0c73b07b799d052f5ef3b448c2f3508", 51 + "rev": "e599ad4fc8861e0401906e4d730f74bfcc530e07", 52 52 "type": "github" 53 53 }, 54 54 "original": {
+19 -2
src/main.rs
··· 4 4 5 5 use std::{process, sync::Arc}; 6 6 7 - use crate::{cli::Cli, gui::FilViz, tui::TuiApp}; 7 + use crate::{ 8 + cli::Cli, 9 + config::Config, 10 + gui::FilViz, 11 + tui::TuiApp, 12 + types::{Kasten, KastenHandle, Workspace}, 13 + }; 8 14 use clap::Parser; 15 + use tokio::sync::RwLock; 16 + use tracing::debug; 9 17 10 18 mod cli; 11 19 mod gui; ··· 30 38 return rt.block_on(async { command.process().await }); 31 39 } 32 40 41 + // create the kasten handle 42 + let kh: KastenHandle = rt.block_on(async { 43 + let cfg = Config::parse()?; 44 + let ws = Workspace::instansiate(cfg.app_config.workspace).await?; 45 + Ok::<KastenHandle, color_eyre::Report>(Arc::new(RwLock::new(Kasten::index(ws).await?))) 46 + })?; 47 + 48 + debug!("{kh:#?}"); 49 + 33 50 // then we spawn the tui on its own thread 34 51 let tui_handle = std::thread::spawn({ 35 52 // arc stuff ··· 39 56 move || -> color_eyre::Result<()> { 40 57 // block the tui on the same runtime as above 41 58 tui_rt.block_on(async { 42 - let mut tui = TuiApp::new(args.tick_rate, args.frame_rate)?; 59 + let mut tui = TuiApp::new(args.tick_rate, args.frame_rate, kh).await?; 43 60 tui.run().await?; 44 61 // just close everything as soon as the tui is done running 45 62 process::exit(0);
+26 -13
src/tui/app.rs
··· 11 11 use crate::{ 12 12 config::Config, 13 13 tui::{Event, Tui, components::Zk}, 14 + types::KastenHandle, 14 15 }; 15 16 16 17 use super::{components::Component, signal::Signal}; ··· 25 26 #[allow(dead_code)] 26 27 region: Region, 27 28 last_tick_key_events: Vec<KeyEvent>, 29 + kh: KastenHandle, 28 30 signal_tx: UnboundedSender<Signal>, 29 31 signal_rx: UnboundedReceiver<Signal>, 30 32 } ··· 43 45 44 46 impl App { 45 47 /// Construct a new `App` instance. 46 - pub fn new(tick_rate: f64, frame_rate: f64) -> Result<Self> { 48 + pub async fn new(tick_rate: f64, frame_rate: f64, kh: KastenHandle) -> Result<Self> { 47 49 let (signal_tx, signal_rx) = mpsc::unbounded_channel(); 48 50 49 51 Ok(Self { 50 52 tick_rate, 51 53 frame_rate, 52 - components: vec![Box::new(Zk::new())], 54 + components: vec![Box::new(Zk::new(kh.clone()).await?)], 53 55 should_quit: false, 54 56 should_suspend: false, 55 57 config: Config::parse()?, 56 58 region: Region::default(), 57 59 last_tick_key_events: Vec::new(), 60 + kh, 58 61 signal_tx, 59 62 signal_rx, 60 63 }) ··· 74 77 } 75 78 76 79 for component in &mut self.components { 77 - component.init(tui.size()?)?; 80 + component.init(tui.size()?).await?; 78 81 } 79 82 80 83 let signal_tx = self.signal_tx.clone(); ··· 121 124 } 122 125 123 126 for component in &mut self.components { 124 - if let Some(signal) = component.handle_events(Some(event.clone()))? { 127 + if let Some(signal) = component.handle_events(Some(event.clone())).await? { 125 128 signal_tx.send(signal)?; 126 129 } 127 130 } ··· 157 160 debug!("handling signal: {signal:?}"); 158 161 } 159 162 160 - match signal { 163 + match signal.clone() { 161 164 Signal::Tick => { 162 165 self.last_tick_key_events.drain(..); 163 166 } 164 167 165 168 Signal::Quit => self.should_quit = true, 166 169 167 - Signal::Helix => { 170 + Signal::Helix { path } => { 168 171 tui.exit()?; 169 172 170 - let hx = spawn(move || -> Result<()> { 171 - Command::new("hx") 172 - .stdin(std::process::Stdio::inherit()) 173 - .stdout(std::process::Stdio::inherit()) 174 - .stderr(std::process::Stdio::inherit()) 175 - .status()?; 173 + let hx = spawn({ 174 + let path = path.clone(); 175 + move || -> Result<()> { 176 + Command::new("hx") 177 + .stdin(std::process::Stdio::inherit()) 178 + .stdout(std::process::Stdio::inherit()) 179 + .stderr(std::process::Stdio::inherit()) 180 + .arg(path) 181 + .status()?; 176 182 177 - Ok(()) 183 + Ok(()) 184 + } 178 185 }); 179 186 180 187 hx.join().unwrap().unwrap(); 188 + // once we get out of the edit, we need to update the zettel for this 189 + // path and then update the db and the kasten for this stuff 190 + 191 + self.kh.write().await.process_path(&path).await?; 192 + 193 + self.signal_tx.send(Signal::ClosedZettel)?; 181 194 182 195 tui.terminal.clear()?; 183 196 tui.enter()?;
+4 -4
src/tui/components/mod.rs
··· 58 58 /// # Returns 59 59 /// 60 60 /// * [`color_eyre::Result<()>`] - An Ok result or an error. 61 - fn init(&mut self, area: Size) -> color_eyre::Result<()> { 61 + async fn init(&mut self, area: Size) -> color_eyre::Result<()> { 62 62 let _ = area; // to appease clippy 63 63 Ok(()) 64 64 } ··· 71 71 /// # Returns 72 72 /// 73 73 /// * [`color_eyre::Result<Option<signal>>`] - A signal to be processed or none. 74 - fn handle_events(&mut self, event: Option<Event>) -> color_eyre::Result<Option<Signal>> { 74 + async fn handle_events(&mut self, event: Option<Event>) -> color_eyre::Result<Option<Signal>> { 75 75 let signal = match event { 76 - Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, 76 + Some(Event::Key(key_event)) => self.handle_key_event(key_event).await?, 77 77 Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, 78 78 _ => None, 79 79 }; ··· 88 88 /// # Returns 89 89 /// 90 90 /// * [`color_eyre::Result<Option<signal>>`] - A signal to be processed or none. 91 - fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<Option<Signal>> { 91 + async fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<Option<Signal>> { 92 92 let _ = key; // to appease clippy 93 93 Ok(None) 94 94 }
+323 -23
src/tui/components/zk/mod.rs
··· 1 1 use async_trait::async_trait; 2 2 use color_eyre::eyre::Result; 3 - use ratatui::widgets::Paragraph; 3 + use crossterm::event::KeyEvent; 4 + use dto::{QueryOrder, TagEntity, ZettelColumns, ZettelEntity}; 5 + use ratatui::{prelude::*, widgets::ListState}; 4 6 use tokio::sync::mpsc::UnboundedSender; 7 + use tracing::info; 5 8 6 - use crate::tui::{Signal, components::Component}; 9 + use crate::{ 10 + tui::{Signal, components::Component}, 11 + types::{KastenHandle, Zettel}, 12 + }; 7 13 8 - mod zettel; 14 + mod preview; 15 + mod search; 16 + mod zettel_list; 17 + mod zettel_view; 9 18 10 - pub struct Zk { 19 + use preview::Preview; 20 + use search::Search; 21 + use zettel_list::ZettelList; 22 + use zettel_view::ZettelView; 23 + 24 + /// in theory we could do some fancy `type_state` encoding stuff 25 + /// to make this work cleanly (so we know when the widgets are properly 26 + /// initialized) 27 + /// The tui interface for interacting with a `ZettelKasten`. 28 + /// Has `Search` functionality and `Preview` to view each `Zettel`. 29 + pub struct Zk<'text> { 11 30 signal_tx: Option<UnboundedSender<Signal>>, 31 + kh: KastenHandle, 32 + layouts: Layouts, 33 + search: Search<'text>, 34 + zettel_list: ZettelList<'text>, 35 + zettel_view: ZettelView<'text>, 36 + preview: Preview<'text>, 12 37 } 13 38 14 - impl Zk { 15 - pub const fn new() -> Self { 16 - Self { signal_tx: None } 39 + struct Layouts { 40 + left_right: Layout, 41 + search_zl: Layout, 42 + z_preview: Layout, 43 + } 44 + 45 + impl Default for Layouts { 46 + fn default() -> Self { 47 + Self { 48 + left_right: Layout::horizontal(vec![ 49 + Constraint::Fill(51), 50 + Constraint::Min(1), 51 + Constraint::Fill(50), 52 + ]), 53 + search_zl: Layout::vertical(vec![Constraint::Min(3), Constraint::Fill(95)]), 54 + z_preview: Layout::vertical(vec![Constraint::Min(6), Constraint::Fill(95)]), 55 + } 56 + } 57 + } 58 + 59 + impl Zk<'_> { 60 + pub async fn new(kh: KastenHandle) -> Result<Self> { 61 + let kt = kh.read().await; 62 + 63 + let fetch_all = async || -> Result<Vec<Zettel>> { 64 + Ok(ZettelEntity::load() 65 + .with(TagEntity) 66 + .order_by_desc(ZettelColumns::ModifiedAt) 67 + .all(&kt.ws.db) 68 + .await? 69 + .into_iter() 70 + .map(Into::into) 71 + .collect()) 72 + }; 73 + 74 + let mut zettels: Vec<Zettel> = fetch_all().await?; 75 + 76 + if zettels.is_empty() { 77 + // we should have a welcome zettel 78 + Zettel::new("Welcome!", &kt.ws).await?; 79 + zettels = fetch_all().await?; 80 + } 81 + 82 + // in theory this is wasted compute, we should be initializing all our 83 + // stuff inside the init function 84 + let mut l_state = ListState::default(); 85 + l_state.select_first(); 86 + let zettel_list = ZettelList::new(zettels, l_state, 0); 87 + 88 + let selected_zettel = zettel_list 89 + .id_list 90 + .get( 91 + zettel_list 92 + .state 93 + .selected() 94 + .expect("We explicitly select the first item"), 95 + ) 96 + // so technically this might not exist 97 + .expect("There must always be one atleast one zettel"); 98 + 99 + let zettel = kt 100 + .get_node_by_zettel_id(selected_zettel) 101 + .expect("") 102 + .payload(); 103 + 104 + let preview = Preview::from( 105 + zettel 106 + .content(&kt.ws) 107 + .await 108 + .expect("This thing cannot be parsed properly..."), 109 + ); 110 + 111 + // okay now that we have the zettel we need to construct the zettel out of this id 112 + let zettel_view: ZettelView = kt 113 + .get_node_by_zettel_id(selected_zettel) 114 + .expect("must exist, handle case where it doesnt later...") 115 + .payload() 116 + .into(); 117 + 118 + let ws = kt.ws.clone(); 119 + 120 + drop(kt); 121 + 122 + Ok(Self { 123 + signal_tx: None, 124 + kh, 125 + layouts: Layouts::default(), 126 + zettel_list, 127 + zettel_view, 128 + preview, 129 + search: Search::new(ws), 130 + }) 131 + } 132 + 133 + async fn update_views_from_zettel_list_selection(&mut self) -> Result<()> { 134 + let selection_idx = self 135 + .zettel_list 136 + .state 137 + .selected() 138 + .expect("i have no idea what to do if this doesnt exist"); 139 + 140 + // sometimes the selection we get is over the length of the thing, so its 141 + // actually fine if this is none, just means we reached the end of the list 142 + let Some(z_id) = self.zettel_list.id_list.get(selection_idx) else { 143 + return Ok(()); 144 + }; 145 + 146 + let kh = self.kh.read().await; 147 + 148 + self.zettel_view = kh 149 + .get_node_by_zettel_id(z_id) 150 + .expect("this should be valid unless the kasten changed out underneath us") 151 + .payload() 152 + .into(); 153 + 154 + self.preview = kh 155 + .get_node_by_zettel_id(z_id) 156 + .expect("this should be valid unless the kasten changed out underneath us") 157 + .payload() 158 + .content(&kh.ws) 159 + .await? 160 + .into(); 161 + drop(kh); 162 + 163 + Ok(()) 164 + } 165 + 166 + pub async fn get_zettels_by_current_query(&self) -> Result<Vec<Zettel>> { 167 + let kt = self.kh.read().await; 168 + let models = ZettelEntity::load() 169 + .with(TagEntity) 170 + .order_by_desc(ZettelColumns::ModifiedAt) 171 + .all(&kt.ws.db) 172 + .await?; 173 + 174 + // im being a good boy and dropping this as soon as im done with the db 175 + drop(kt); 176 + 177 + // for now we are going to just read that shit every time... 178 + 179 + let zettels: Vec<Zettel> = models.into_iter().map(Into::into).collect(); 180 + 181 + Ok(zettels) 182 + } 183 + 184 + pub async fn update_with_respect_to_query(&mut self) -> Result<()> { 185 + let zettels = self 186 + .search 187 + .rank(self.get_zettels_by_current_query().await?) 188 + .await; 189 + 190 + self.zettel_list = ZettelList::new(zettels, self.zettel_list.state, self.zettel_list.width); 191 + info!("we are moving selection to first"); 192 + self.zettel_list.state.select_first(); 193 + self.update_views_from_zettel_list_selection().await?; 194 + 195 + Ok(()) 17 196 } 18 197 } 19 198 20 199 #[async_trait] 21 - impl Component for Zk { 200 + impl Component for Zk<'_> { 201 + /// this tells us how big the space we have for this is 202 + async fn init(&mut self, area: Size) -> color_eyre::Result<()> { 203 + let total_width = area.width; 204 + 205 + // in theory this is wasted compute, we should be initializing all our 206 + let mut l_state = ListState::default(); 207 + l_state.select_first(); 208 + 209 + let zettel_list = ZettelList::new( 210 + self.get_zettels_by_current_query().await?, 211 + l_state, 212 + total_width / 2, 213 + ); 214 + 215 + self.zettel_list = zettel_list; 216 + 217 + Ok(()) 218 + } 219 + 22 220 fn register_signal_handler(&mut self, tx: UnboundedSender<Signal>) -> Result<()> { 23 221 self.signal_tx = Some(tx); 24 222 Ok(()) 25 223 } 26 224 27 - async fn update(&mut self, _signal: Signal) -> Result<Option<crate::tui::Signal>> { 28 - // match signal { 29 - // Signal::Tick => todo!(), 30 - // Signal::Render => todo!(), 31 - // Signal::Resize(_, _) => todo!(), 32 - // Signal::Suspend => todo!(), 33 - // Signal::Resume => todo!(), 34 - // Signal::Quit => todo!(), 35 - // Signal::ClearScreen => todo!(), 36 - // Signal::Error(_) => todo!(), 37 - // Signal::Help => todo!(), 38 - // Signal::Helix => todo!(), 39 - // } 225 + async fn update(&mut self, signal: Signal) -> Result<Option<crate::tui::Signal>> { 226 + match signal { 227 + Signal::MoveDown => { 228 + self.zettel_list.state.select_next(); 229 + self.update_views_from_zettel_list_selection().await?; 230 + } 231 + Signal::MoveUp => { 232 + self.zettel_list.state.select_previous(); 233 + self.update_views_from_zettel_list_selection().await?; 234 + } 235 + 236 + Signal::OpenZettel => { 237 + let Some(selcted) = self.zettel_list.state.selected() else { 238 + return Ok(None); 239 + }; 240 + 241 + let Some(zid) = self.zettel_list.id_list.get(selcted) else { 242 + return Ok(None); 243 + }; 244 + 245 + let kh = self.kh.read().await; 246 + let path = kh 247 + .get_node_by_zettel_id(zid) 248 + .expect( 249 + "This should not have 250 + change dout underneath us", 251 + ) 252 + .payload() 253 + .absolute_path(&kh.ws); 254 + 255 + drop(kh); 256 + 257 + return Ok(Some(Signal::Helix { path })); 258 + } 259 + 260 + Signal::NewZettel => { 261 + // what the fuck am i going to do in here 262 + 263 + let ws = &self.kh.read().await.ws; 264 + 265 + // we create the zettel with the query as the 266 + let z = Zettel::new(self.search.query(), ws).await?; 267 + 268 + let path = z.absolute_path(ws); 269 + 270 + return Ok(Some(Signal::Helix { path })); 271 + } 272 + 273 + Signal::ClosedZettel => { 274 + let selected = self 275 + .zettel_list 276 + .state 277 + .selected() 278 + .expect("This must be the zettel we just edited"); 279 + 280 + let Some(id) = self.zettel_list.id_list.get(selected) else { 281 + return Ok(None); 282 + }; 283 + 284 + let kt = self.kh.read().await; 285 + 286 + let node = kt 287 + .get_node_by_zettel_id(id) 288 + .expect("Invariant broken, this must exist."); 289 + 290 + // reset the state of the component 291 + self.search.clear_query(); 292 + self.zettel_list.state.select_first(); 293 + 294 + // regenerate a fresh zettel list 295 + self.zettel_list = ZettelList::new( 296 + self.get_zettels_by_current_query().await?, 297 + self.zettel_list.state, 298 + self.zettel_list.width, 299 + ); 300 + 301 + self.zettel_view = ZettelView::from(node.payload()); 302 + self.preview = Preview::from(node.payload().content(&kt.ws).await?); 303 + drop(kt); 304 + } 305 + 306 + _ => {} 307 + } 308 + Ok(None) 309 + } 310 + 311 + async fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<Option<Signal>> { 312 + // NOTE: this is hardcoded for now, but I honestly think people should not 313 + // be able to change these binds, opinionated software or something... 314 + if !(key.code.is_up() || key.code.is_down() || key.code.is_enter()) { 315 + self.search.query.input(key); 316 + self.update_with_respect_to_query().await?; 317 + } 318 + 40 319 Ok(None) 41 320 } 42 321 ··· 45 324 frame: &mut ratatui::Frame, 46 325 area: ratatui::prelude::Rect, 47 326 ) -> color_eyre::Result<()> { 48 - let hello = Paragraph::new("Hello Surendra"); 49 - frame.render_widget(hello, area); 327 + let (search_layout, zettel_list_layout, zettel_layout, preview_layout) = { 328 + let rects = self.layouts.left_right.split(area); 329 + 330 + let (left, right) = (rects[0], rects[2]); 331 + 332 + let l_rects = self.layouts.search_zl.split(left); 333 + 334 + let r_rects = self.layouts.z_preview.split(right); 335 + 336 + (l_rects[0], l_rects[1], r_rects[0], r_rects[1]) 337 + }; 338 + 339 + frame.render_widget(self.search.clone(), search_layout); 340 + 341 + frame.render_stateful_widget( 342 + &self.zettel_list.render_list, 343 + zettel_list_layout, 344 + &mut self.zettel_list.state, 345 + ); 346 + 347 + frame.render_widget(self.zettel_view.clone(), zettel_layout); 348 + frame.render_widget(self.preview.clone(), preview_layout); 349 + 50 350 Ok(()) 51 351 } 52 352 }
+23
src/tui/components/zk/preview.rs
··· 1 + use ratatui::{style::Style, text::Text, widgets::Widget}; 2 + 3 + #[derive(Debug, Clone)] 4 + pub struct Preview<'text> { 5 + content: Text<'text>, 6 + } 7 + 8 + impl From<String> for Preview<'_> { 9 + fn from(value: String) -> Self { 10 + Self { 11 + content: Text::from(value), 12 + } 13 + } 14 + } 15 + 16 + impl Widget for Preview<'_> { 17 + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) 18 + where 19 + Self: Sized, 20 + { 21 + self.content.style(Style::new()).render(area, buf); 22 + } 23 + }
+137
src/tui/components/zk/search.rs
··· 1 + use color_eyre::eyre::Error; 2 + use nucleo_matcher::{ 3 + Matcher, Utf32Str, 4 + pattern::{CaseMatching, Normalization, Pattern}, 5 + }; 6 + use ratatui::{ 7 + layout::{Constraint, Layout}, 8 + style::Style, 9 + widgets::{Block, BorderType, Borders, Widget}, 10 + }; 11 + use ratatui_textarea::TextArea; 12 + 13 + use crate::types::{Workspace, Zettel}; 14 + 15 + #[derive(Clone)] 16 + pub struct Search<'text> { 17 + pub query: TextArea<'text>, 18 + layouts: Layouts, 19 + matcher: Matcher, 20 + ws: Workspace, 21 + } 22 + 23 + impl Search<'_> { 24 + pub fn new(ws: Workspace) -> Self { 25 + let mut tag = TextArea::default(); 26 + tag.set_style(Style::default()); 27 + tag.set_block( 28 + Block::new() 29 + .border_type(BorderType::Plain) 30 + .borders(Borders::all()) 31 + .title("Filter by Tag"), 32 + ); 33 + 34 + Self { 35 + matcher: Matcher::default(), 36 + query: Self::new_query(), 37 + ws, 38 + layouts: Layouts::default(), 39 + } 40 + } 41 + 42 + fn new_query<'a>() -> TextArea<'a> { 43 + let mut query = TextArea::default(); 44 + query.set_style(Style::default()); 45 + query.set_block( 46 + Block::new() 47 + .border_type(BorderType::Plain) 48 + .borders(Borders::all()) 49 + .title("Search"), 50 + ); 51 + 52 + query.set_max_histories(0); 53 + query 54 + } 55 + 56 + /// Clears the query 57 + pub fn clear_query(&mut self) { 58 + self.query = Self::new_query(); 59 + } 60 + 61 + /// Getter method for the current `Query` 62 + pub fn query(&self) -> &str { 63 + self.query.lines()[0].as_str() 64 + } 65 + 66 + /// Sorts the vector of `Zettels` by their relation to the 67 + /// search query. 68 + //TODO: this should really take in some sort of file cache 69 + // so we arent reloading it every single time... 70 + pub async fn rank(&mut self, zettels: Vec<Zettel>) -> Vec<Zettel> { 71 + // if no query, we dont do any ranking 72 + if self.query().is_empty() { 73 + return zettels; 74 + } 75 + 76 + let read_tasks = zettels 77 + .into_iter() 78 + .map(|z| { 79 + let ws = self.ws.clone(); 80 + tokio::spawn(async move { 81 + let content = z.content(&ws).await?; 82 + let front_matter = z.front_matter(&ws).await?; 83 + Ok::<(Zettel, String), Error>((z, format!("{content}\n{front_matter}"))) 84 + }) 85 + }) 86 + .collect::<Vec<_>>(); 87 + 88 + // await all of them 89 + let documents = futures::future::join_all(read_tasks) 90 + .await 91 + .into_iter() 92 + .filter_map(|result| result.ok()?.ok()) 93 + .collect::<Vec<(Zettel, String)>>(); 94 + 95 + let pattern = Pattern::parse(self.query(), CaseMatching::Ignore, Normalization::Smart); 96 + 97 + let mut results: Vec<(Zettel, u32)> = documents 98 + .into_iter() 99 + .filter_map(|(z, doc)| { 100 + let mut buf = Vec::new(); 101 + let score = pattern 102 + .score(Utf32Str::new(doc.as_str(), &mut buf), &mut self.matcher) 103 + .unwrap_or_default(); 104 + 105 + if score > 0 { Some((z, score)) } else { None } 106 + }) 107 + .collect(); 108 + 109 + results.sort_by(|a, b| b.1.cmp(&a.1)); 110 + 111 + results.into_iter().map(|(i, _)| i).collect() 112 + } 113 + } 114 + 115 + #[derive(Clone)] 116 + struct Layouts { 117 + title: Layout, 118 + } 119 + 120 + impl Default for Layouts { 121 + fn default() -> Self { 122 + Self { 123 + title: Layout::vertical(vec![Constraint::Min(3)]), 124 + } 125 + } 126 + } 127 + 128 + impl Widget for Search<'_> { 129 + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) 130 + where 131 + Self: Sized, 132 + { 133 + let rect = { self.layouts.title.split(area)[0] }; 134 + 135 + self.query.render(rect, buf); 136 + } 137 + }
src/tui/components/zk/zettel.rs

This is a binary file and will not be displayed.

+93
src/tui/components/zk/zettel_list.rs
··· 1 + use ratatui::{ 2 + style::{Color, Modifier, Style, Stylize}, 3 + text::{Line, Span, Text}, 4 + widgets::{List, ListState}, 5 + }; 6 + 7 + use crate::types::{Zettel, ZettelId}; 8 + 9 + pub struct ZettelList<'text> { 10 + pub render_list: ratatui::widgets::List<'text>, 11 + pub id_list: Vec<ZettelId>, 12 + pub state: ListState, 13 + pub width: u16, 14 + } 15 + 16 + pub struct ZettelListItem<'text> { 17 + title: Span<'text>, 18 + tags: Vec<Span<'text>>, 19 + date: Span<'text>, 20 + width: u16, 21 + } 22 + 23 + impl From<&Zettel> for ZettelListItem<'_> { 24 + fn from(value: &Zettel) -> Self { 25 + Self { 26 + title: Span::from(value.title.clone()) 27 + .style(Style::new().cyan()) 28 + .add_modifier(Modifier::BOLD), 29 + tags: value 30 + .tags 31 + .iter() 32 + .map(|t| { 33 + Span::from(format!("{} ", t.name)) 34 + .style(Style::new().fg(t.color.into()).italic()) 35 + .add_modifier(Modifier::DIM) 36 + }) 37 + .collect(), 38 + date: Span::from(value.modified_at()).style(Style::new().add_modifier(Modifier::DIM)), 39 + width: 0, 40 + } 41 + } 42 + } 43 + 44 + impl<'item, 'text> From<ZettelListItem<'item>> for Text<'text> 45 + where 46 + 'item: 'text, 47 + { 48 + fn from(value: ZettelListItem<'item>) -> Self { 49 + let title_width: usize = value.title.width(); 50 + let tags_width: usize = value.tags.iter().map(Span::width).sum(); 51 + let date_width: usize = value.date.width() + 2; 52 + 53 + // tags start 2 tabs after the title 54 + let gap_after_title = 2; 55 + let used = title_width + gap_after_title + tags_width + date_width; 56 + let padding = (value.width as usize).saturating_sub(used); 57 + 58 + let mut spans: Vec<Span> = std::iter::once(value.title) 59 + .chain(std::iter::once(Span::raw(" "))) 60 + .chain(value.tags) 61 + .chain(std::iter::once(Span::raw(" ".repeat(padding)))) 62 + .collect(); 63 + 64 + if tags_width + title_width + date_width <= value.width as usize { 65 + spans.push(value.date); 66 + } 67 + 68 + let line = Line::from(spans); 69 + line.into() 70 + } 71 + } 72 + 73 + impl ZettelList<'_> { 74 + pub fn new(zettels: Vec<Zettel>, state: ListState, width: u16) -> Self { 75 + let render_list = List::new(zettels.iter().map(|z| { 76 + let mut zli: ZettelListItem<'_> = z.into(); 77 + zli.width = width; 78 + Text::from(zli) 79 + })) 80 + .style(Color::White) 81 + .highlight_style(Modifier::REVERSED) 82 + .highlight_symbol("> "); 83 + 84 + let id_list = zettels.into_iter().map(|z| z.id).collect::<Vec<_>>(); 85 + 86 + ZettelList { 87 + render_list, 88 + id_list, 89 + state, 90 + width, 91 + } 92 + } 93 + }
+108
src/tui/components/zk/zettel_view.rs
··· 1 + use ratatui::{ 2 + layout::{Constraint, Layout}, 3 + style::Style, 4 + text::{Line, Span}, 5 + widgets::{Block, BorderType, Borders, Paragraph, Widget}, 6 + }; 7 + 8 + use crate::types::Zettel; 9 + 10 + /// A `Widget` that represents a `Zettel` 11 + #[derive(Debug, Clone)] 12 + pub struct ZettelView<'text> { 13 + // title: Text<'text>, 14 + title: Paragraph<'text>, 15 + tags: Paragraph<'text>, 16 + created_at: Paragraph<'text>, 17 + zettel_id: Paragraph<'text>, 18 + layouts: Layouts, 19 + } 20 + 21 + impl From<&Zettel> for ZettelView<'_> { 22 + fn from(value: &Zettel) -> Self { 23 + Self { 24 + title: Paragraph::new(value.title.clone()).block( 25 + Block::default() 26 + .title("Title") 27 + .borders(Borders::all()) 28 + .border_style(Style::new()) 29 + .border_type(BorderType::Plain), 30 + ), 31 + tags: Paragraph::new( 32 + value 33 + .tags 34 + .iter() 35 + .map(|t| { 36 + Span::from(format!("{} ", t.name)).style(Style::new().fg(t.color.into())) 37 + }) 38 + .collect::<Line>(), 39 + ) 40 + .block( 41 + Block::default() 42 + .title("Tags") 43 + .borders(Borders::all()) 44 + .border_style(Style::new()) 45 + .border_type(BorderType::Plain), 46 + ), 47 + created_at: Paragraph::new(value.created_at()).block( 48 + Block::default() 49 + .title("Created At") 50 + .borders(Borders::all()) 51 + .border_style(Style::new()) 52 + .border_type(BorderType::Plain), 53 + ), 54 + zettel_id: Paragraph::new(value.id.to_string()).block( 55 + Block::default() 56 + .title("Id") 57 + .borders(Borders::all()) 58 + .border_style(Style::new()) 59 + .border_type(BorderType::Plain), 60 + ), 61 + 62 + layouts: Layouts::default(), 63 + } 64 + } 65 + } 66 + 67 + #[derive(Debug, Clone)] 68 + struct Layouts { 69 + top_bottom: Layout, 70 + title_created: Layout, 71 + tags_id: Layout, 72 + } 73 + 74 + impl Default for Layouts { 75 + fn default() -> Self { 76 + Self { 77 + top_bottom: Layout::vertical(vec![Constraint::Min(3), Constraint::Min(3)]), 78 + 79 + title_created: Layout::horizontal(vec![Constraint::Fill(80), Constraint::Min(24)]), 80 + 81 + tags_id: Layout::horizontal(vec![Constraint::Fill(95), Constraint::Min(8)]), 82 + } 83 + } 84 + } 85 + 86 + impl Widget for ZettelView<'_> { 87 + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) 88 + where 89 + Self: Sized, 90 + { 91 + let (title_rect, created_rect, tags_rect, modified_rect) = { 92 + let rects = self.layouts.top_bottom.split(area); 93 + 94 + let (left, right) = (rects[0], rects[1]); 95 + 96 + let t_rects = self.layouts.title_created.split(left); 97 + 98 + let b_rects = self.layouts.tags_id.split(right); 99 + 100 + (t_rects[0], t_rects[1], b_rects[0], b_rects[1]) 101 + }; 102 + 103 + self.title.render(title_rect, buf); 104 + self.tags.render(tags_rect, buf); 105 + self.created_at.render(created_rect, buf); 106 + self.zettel_id.render(modified_rect, buf); 107 + } 108 + }
+20 -3
src/tui/signal.rs
··· 1 - use std::str::FromStr; 1 + use std::{path::PathBuf, str::FromStr}; 2 2 3 3 use color_eyre::eyre::eyre; 4 4 use strum::Display; ··· 17 17 ClearScreen, 18 18 Error(String), 19 19 Help, 20 + 21 + // movement 22 + MoveDown, 23 + MoveUp, 24 + 25 + /// New `Zettel` 26 + NewZettel, 27 + /// User asks to open a `Zettel` 28 + OpenZettel, 29 + /// The user is done editing a `Zettel` 30 + ClosedZettel, 31 + 20 32 /// this is fucking temporary 21 - Helix, 33 + Helix { 34 + path: PathBuf, 35 + }, 22 36 } 23 37 24 38 impl FromStr for Signal { ··· 29 43 "suspend" => Self::Suspend, 30 44 "resume" => Self::Resume, 31 45 "quit" => Self::Quit, 32 - "helix" => Self::Helix, 46 + "movedown" => Self::MoveDown, 47 + "moveup" => Self::MoveUp, 48 + "openzettel" => Self::OpenZettel, 49 + "newzettel" => Self::NewZettel, 33 50 _ => { 34 51 return Err(eyre!(format!( 35 52 "Attempt to construct a non-user Signal from str: {s}"
+10 -2
src/types/color.rs
··· 1 1 use dto::ColorDTO; 2 + use ratatui::style::Color as RatColor; 2 3 3 4 /// Agnostic Color type, 4 5 /// internally represented as rgb 5 - #[expect(dead_code)] 6 - #[derive(Debug, Clone)] 6 + #[derive(Debug, Copy, Clone, Default)] 7 7 pub struct Color(ColorDTO); 8 8 9 9 impl From<ColorDTO> for Color { ··· 11 11 Self(value) 12 12 } 13 13 } 14 + 15 + impl From<Color> for RatColor { 16 + fn from(value: Color) -> Self { 17 + let rgb = value.0.to_rgb8(); 18 + 19 + Self::Rgb(rgb.r, rgb.g, rgb.b) 20 + } 21 + }
+1 -1
src/types/frontmatter.rs
··· 10 10 11 11 use crate::types::{Link, Zettel}; 12 12 13 - const DATE_FMT_STR: &str = "%Y-%m-%d %I:%M:%S %p"; 13 + pub(super) const DATE_FMT_STR: &str = "%Y-%m-%d %I:%M:%S %p"; 14 14 15 15 #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)] 16 16 pub struct FrontMatter {
+99 -4
src/types/kasten.rs
··· 1 1 use crate::types::{Link, Zettel, ZettelId}; 2 2 use color_eyre::eyre::Result; 3 + use dto::{TagEntity, ZettelEntity}; 3 4 use eframe::emath; 4 5 use egui_graphs::{ 5 - Graph, 6 - petgraph::{Directed, graph::NodeIndex, prelude::StableGraph}, 6 + Graph, Node, 7 + petgraph::{Directed, Direction, graph::NodeIndex, prelude::StableGraph, visit::EdgeRef}, 7 8 }; 8 9 use rayon::iter::{ParallelBridge as _, ParallelIterator as _}; 9 - use std::{cmp::max, collections::HashMap}; 10 + use std::{cmp::max, collections::HashMap, path::Path, sync::Arc}; 11 + use tokio::sync::RwLock; 10 12 11 13 use crate::types::Workspace; 12 14 ··· 35 37 /// Arbitrarily chosen minimum number of edges 36 38 const GRAPH_MIN_EDGES: usize = GRAPH_MIN_NODES * 3; 37 39 40 + pub type KastenHandle = Arc<RwLock<Kasten>>; 41 + 38 42 impl Kasten { 39 43 /// Indexes the `Workspace` and constructs a `Kasten` 40 - #[expect(dead_code)] 41 44 pub async fn index(ws: Workspace) -> Result<Self> { 42 45 let paths = std::fs::read_dir(&ws.root)? 43 46 .par_bridge() ··· 101 104 zid_to_gid, 102 105 most_recently_edited: None, 103 106 }) 107 + } 108 + 109 + /// processes the `Zettel` for the provided `ZettelId`, 110 + /// meaning it updates the internal state of the `Kasten` 111 + /// with the changes in `Zettel`. 112 + pub async fn process_path(&mut self, path: &Path) -> Result<()> { 113 + //NOTE: need to clone to get around borrowing rules but 114 + // ideally we dont have to do this, kind of cringe imo. 115 + let ws = self.ws.clone(); 116 + 117 + let zid = ZettelId::try_from(path)?; 118 + 119 + let mut gid = self.zid_to_gid.get(&zid).copied(); 120 + // sometimes this zid is new, so it wont be in the kasten 121 + let zettel = if let Some(existing) = self.get_node_by_zettel_id_mut(&zid) { 122 + existing.payload_mut() 123 + } else { 124 + // this should aleady be in the database though so lets get it from there first 125 + let zettel: Zettel = ZettelEntity::load() 126 + .filter_by_nano_id(zid) 127 + .with(TagEntity) 128 + .one(&ws.db) 129 + .await? 130 + .expect("This should be in the database already") 131 + .into(); 132 + 133 + let zid = zettel.id.clone(); 134 + let idx = self.graph.add_node(zettel); 135 + 136 + self.zid_to_gid.insert(zid.clone(), idx); 137 + 138 + gid = Some(idx); 139 + 140 + self.get_node_by_zettel_id_mut(&zid) 141 + .expect("we just inserted it") 142 + .payload_mut() 143 + }; 144 + 145 + // and then we sync with the file 146 + zettel.sync_with_file(&ws).await?; 147 + 148 + // to get past borrowchecker rules 149 + let zettel = zettel.clone(); 150 + 151 + // gid must be set 152 + let gid = gid.unwrap(); 153 + 154 + // and now we manage the links going out of the file 155 + 156 + // remove all the old shit 157 + self.graph 158 + .edges_directed(gid, Direction::Outgoing) 159 + .map(|e| e.id()) 160 + .collect::<Vec<_>>() 161 + .into_iter() 162 + .for_each(|e| { 163 + let _ = self.graph.remove_edge(e); 164 + }); 165 + 166 + // add the links that actually exist 167 + zettel.links(&ws).await?.into_iter().for_each(|link| { 168 + // this is an option because a user c 169 + let dest = self 170 + .zid_to_gid 171 + .get(&link.dest) 172 + .expect("Links should be valid"); 173 + 174 + self.graph.add_edge(gid, *dest, link); 175 + }); 176 + 177 + Ok(()) 178 + } 179 + 180 + pub fn get_node_by_zettel_id(&self, id: &ZettelId) -> Option<&Node<Zettel, Link>> { 181 + let idx = self.zid_to_gid.get(id)?; 182 + 183 + let node = self.graph.node(*idx).expect( 184 + "invariant broken if internal hashmap is not uptodate with 185 + the state of the graph...", 186 + ); 187 + Some(node) 188 + } 189 + 190 + pub fn get_node_by_zettel_id_mut(&mut self, id: &ZettelId) -> Option<&mut Node<Zettel, Link>> { 191 + let idx = self.zid_to_gid.get(id)?; 192 + 193 + let node = self.graph.node_mut(*idx).expect( 194 + "invariant broken if internal hashmap is not uptodate with the 195 + state of the graph...", 196 + ); 197 + 198 + Some(node) 104 199 } 105 200 }
+1 -1
src/types/mod.rs
··· 25 25 pub use link::Link; 26 26 27 27 mod kasten; 28 - #[expect(unused_imports)] 29 28 pub use kasten::Kasten; 29 + pub use kasten::KastenHandle; 30 30 31 31 mod frontmatter; 32 32 pub use frontmatter::FrontMatter;
-1
src/types/tag.rs
··· 4 4 5 5 /// Represents a `Tag` in a `ZettelKasten` note taking method. 6 6 /// Easy way to link multiple notes under one simple word. 7 - #[expect(dead_code)] 8 7 #[derive(Debug, Clone)] 9 8 pub struct Tag { 10 9 /// Should only be constructed from models.
+124 -54
src/types/zettel.rs
··· 1 1 use dto::{ 2 2 ActiveModelTrait, ActiveValue, ColumnTrait, DateTime, EntityTrait as _, IntoActiveModel, 3 - QueryFilter, TagActiveModel, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModelEx, 4 - ZettelTagActiveModel, ZettelTagColumns, ZettelTagEntity, 3 + QueryFilter, TagActiveModel, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModel, 4 + ZettelModelEx, ZettelTagActiveModel, ZettelTagColumns, ZettelTagEntity, 5 5 }; 6 6 use pulldown_cmark::{Event, Parser, Tag as MkTag}; 7 7 use serde::{Deserialize, Serialize}; ··· 15 15 use dto::NanoId; 16 16 use tokio::{fs::File, io::AsyncWriteExt}; 17 17 18 - use crate::types::{FrontMatter, Link, Tag, Workspace}; 18 + use crate::types::{FrontMatter, Link, Tag, Workspace, frontmatter}; 19 19 20 20 /// A `Zettel` is a note about a single idea. 21 21 /// It can have many `Tag`s, just meaning it can fall under many 22 22 /// categories. 23 - #[expect(dead_code)] 24 23 #[derive(Debug, Clone)] 25 24 pub struct Zettel { 26 25 /// Should only be constructed from models. ··· 30 29 /// a workspace-local file path, needs to be canonicalized before usage 31 30 pub file_path: PathBuf, 32 31 pub created_at: DateTime, 32 + pub modified_at: DateTime, 33 33 pub tags: Vec<Tag>, 34 34 } 35 35 ··· 79 79 Ok(zettel.into()) 80 80 } 81 81 82 + pub async fn sync_with_file(&mut self, ws: &Workspace) -> Result<()> { 83 + let (fm, _) = FrontMatter::extract_from_file(self.absolute_path(ws)).await?; 84 + 85 + let mut model = ZettelEntity::find_by_nano_id(self.id.clone()) 86 + .one(&ws.db) 87 + .await? 88 + .expect("this must exist") 89 + .into_active_model(); 90 + 91 + model.title = ActiveValue::Set(fm.title); 92 + 93 + let updated: ZettelModel = model.update(&ws.db).await?; 94 + 95 + self.title = updated.title; 96 + self.modified_at = updated.modified_at; 97 + self.created_at = updated.created_at; 98 + 99 + self.sync_tags(ws).await?; 100 + 101 + Ok(()) 102 + } 103 + 104 + /// Sync's `Tag`'s that are present in the frontmatter of this 105 + /// `Zettel` to the database, and then updates the `Tag`s on the 106 + /// `Zettel` to reflect the changes. 107 + pub async fn sync_tags(&mut self, ws: &Workspace) -> Result<()> { 108 + let mut fm = self.front_matter(ws).await?; 109 + fm.tag_strings.sort(); 110 + 111 + let mut tag_strings = fm.tag_strings; 112 + 113 + let Some(db_zettel): Option<ZettelModelEx> = ZettelEntity::load() 114 + .with(TagEntity) 115 + .filter_by_nano_id(self.id.clone()) 116 + .one(&ws.db) 117 + .await? 118 + else { 119 + panic!("how the fuck was this deleted"); 120 + }; 121 + 122 + for db_tag in db_zettel.tags { 123 + if let Ok(idx) = tag_strings.binary_search(&db_tag.name) { 124 + // we remove tags we have already processed 125 + tag_strings.remove(idx); 126 + } else { 127 + // the db says the file has tag `x`, but that tag is missing from the 128 + // front matter, we can assume its gone, lets delete that link 129 + let to_remove = ZettelTagEntity::find() 130 + .filter(ZettelTagColumns::ZettelNanoId.eq(self.id.0.clone())) 131 + .filter(ZettelTagColumns::TagNanoId.eq(db_tag.nano_id)) 132 + .one(&ws.db) 133 + .await? 134 + .expect("this link must exist"); 135 + 136 + to_remove.into_active_model().delete(&ws.db).await?; 137 + } 138 + } 139 + 140 + // now any tags that are left inside zettel_tag_strings, 141 + // we have to look up the tags in the db and then reset them? 142 + for tag_str in tag_strings { 143 + // this is the tag that either already exists with this name, or we just created this new one 144 + let tag = if let Some(existing) = TagEntity::load() 145 + .filter_by_name(&tag_str) 146 + .one(&ws.db) 147 + .await? 148 + { 149 + existing 150 + } else { 151 + let am = TagActiveModel { 152 + name: ActiveValue::Set(tag_str), 153 + ..Default::default() 154 + }; 155 + 156 + am.insert(&ws.db).await?.into() 157 + }; 158 + 159 + // this zettel has this tag now 160 + let _ = ZettelTagActiveModel { 161 + zettel_nano_id: ActiveValue::Set(self.id.to_string()), 162 + tag_nano_id: ActiveValue::Set(tag.nano_id.to_string()), 163 + } 164 + .insert(&ws.db) 165 + .await?; 166 + } 167 + 168 + let entity = ZettelEntity::load() 169 + .with(TagEntity) 170 + .filter_by_nano_id(self.id.clone()) 171 + .one(&ws.db) 172 + .await? 173 + .expect("this exists"); 174 + 175 + let temp_zettel: Self = entity.into(); 176 + 177 + self.tags = temp_zettel.tags; 178 + 179 + Ok(()) 180 + } 181 + 82 182 /// Returns the most up-to-date `FrontMatter` for this 83 183 /// `Zettel` 84 184 pub async fn front_matter(&self, ws: &Workspace) -> Result<FrontMatter> { ··· 101 201 Ok(File::open(path).await?) 102 202 } 103 203 104 - fn absolute_path(&self, ws: &Workspace) -> PathBuf { 204 + pub fn absolute_path(&self, ws: &Workspace) -> PathBuf { 105 205 ws.root.clone().join(&self.file_path) 106 206 } 107 207 108 208 /// uses the id and root to parse out of the root directory 109 - #[expect(dead_code)] 110 209 pub async fn from_id(id: &ZettelId, ws: &Workspace) -> Result<Self> { 111 210 let mut path = ws.root.clone(); 112 211 path.push(id.0.to_string()); 113 212 Self::from_path(path, ws).await 114 213 } 115 214 215 + pub fn created_at(&self) -> String { 216 + self.created_at 217 + .format(frontmatter::DATE_FMT_STR) 218 + .to_string() 219 + } 220 + 221 + pub fn modified_at(&self) -> String { 222 + self.modified_at 223 + .format(frontmatter::DATE_FMT_STR) 224 + .to_string() 225 + } 226 + 116 227 pub async fn from_path(path: impl Into<PathBuf>, ws: &Workspace) -> Result<Self> { 117 228 let path: PathBuf = path.into(); 118 229 119 230 let id = ZettelId::try_from(path.as_path())?; 120 231 121 232 let (front_matter, _) = FrontMatter::extract_from_file(&ws.root.clone().join(path)).await?; 122 - 123 - let mut zettel_tag_strings = front_matter.tag_strings.clone(); 124 - 125 - zettel_tag_strings.sort(); 126 233 127 234 // get the zettel from the db 128 235 let db_zettel: ZettelModelEx = if let Some(existing_zettel) = ZettelEntity::load() ··· 151 258 .expect("we just inserted the zettel") 152 259 }; 153 260 154 - // get the tags for it 155 - for db_tag in db_zettel.tags { 156 - if let Ok(idx) = zettel_tag_strings.binary_search(&db_tag.name) { 157 - // we remove tags we have already processed 158 - zettel_tag_strings.remove(idx); 159 - } else { 160 - // the db says the file has tag `x`, but that tag is missing from the 161 - // front matter, we can assume its gone, lets delete that link 162 - let to_remove = ZettelTagEntity::find() 163 - .filter(ZettelTagColumns::ZettelNanoId.eq(id.0.clone())) 164 - .filter(ZettelTagColumns::TagNanoId.eq(db_tag.nano_id)) 165 - .one(&ws.db) 166 - .await? 167 - .expect("this link must exist"); 168 - 169 - to_remove.into_active_model().delete(&ws.db).await?; 170 - } 171 - } 172 - 173 - // now any tags that are left inside zettel_tag_strings, 174 - // we have to put them inside the db 175 - for new_tag in zettel_tag_strings { 176 - // create a new tag 177 - let tag = TagActiveModel { 178 - name: ActiveValue::Set(new_tag), 179 - ..Default::default() 180 - } 181 - .insert(&ws.db) 182 - .await?; 183 - 184 - // this zettel has this tag now 185 - let _ = ZettelTagActiveModel { 186 - zettel_nano_id: ActiveValue::Set(id.to_string()), 187 - tag_nano_id: ActiveValue::Set(tag.nano_id.to_string()), 188 - } 189 - .insert(&ws.db) 190 - .await?; 191 - } 261 + let mut temp_zettel: Self = db_zettel.clone().into(); 262 + temp_zettel.sync_tags(ws).await?; 192 263 193 264 if front_matter.title != db_zettel.title { 194 - let am = ZettelActiveModel { 195 - id: ActiveValue::Unchanged(db_zettel.id), 196 - title: ActiveValue::Set(front_matter.title.clone()), 197 - ..Default::default() 198 - }; 199 - 265 + let mut am = db_zettel.into_active_model(); 266 + am.title = ActiveValue::Set(front_matter.title.clone()); 200 267 am.update(&ws.db).await?; 201 268 } 202 269 ··· 247 314 continue; 248 315 }; 249 316 317 + // TODO: check that the thing actually exists inside the ws.db 318 + // instead of just seeing if we can turn it into a ZettelId 250 319 let dst_id = ZettelId::try_from(canon_url)?; 251 320 252 321 let link = Link::new(self.id.clone(), dst_id); ··· 273 342 title: value.title, 274 343 file_path: value.file_path.into(), 275 344 created_at: value.created_at, 345 + modified_at: value.modified_at, 276 346 tags: value.tags.into_iter().map(Into::into).collect(), 277 347 } 278 348 }