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 #9 from suri-codes/refac

feat: Index + Kasten rewrite

authored by

Surendra Jammishetti and committed by
GitHub
faceeffe 01291483

+729 -797
+11 -13
.config/config.ron
··· 1 1 ( 2 - directory: "./ZettelKasten", 2 + directory: "/Users/suri/dev/projects/filaments/ZettelKasten", 3 3 global_key_binds: { 4 - "ctrl-c": Quit, 5 - "ctrl-z": Suspend, 4 + "down": MoveDown, 6 5 "up": MoveUp, 7 - "down": MoveDown, 6 + "ctrl-z": Suspend, 7 + "ctrl-c": Quit, 8 8 }, 9 9 zk: ( 10 10 keybinds: { 11 + "tab": SwitchTo( 12 + region: Todo, 13 + ), 11 14 "<Ctrl-n>": NewZettel, 12 15 "enter": OpenZettel, 13 - "tab": SwitchTo ( 14 - region: Todo 15 - ), 16 - 17 16 }, 18 17 ), 19 18 todo: ( 20 19 keybinds: { 21 20 "j": MoveDown, 22 21 "k": MoveUp, 23 - "tab": SwitchTo ( 24 - region: Zk 25 - ), 26 - 22 + "tab": SwitchTo( 23 + region: Zk, 24 + ), 27 25 }, 28 26 ), 29 - ) 27 + )
+27
.config/default_config.ron
··· 1 + ( 2 + directory: "{INSERT_ROOT_HERE}", 3 + global_key_binds: { 4 + "up": MoveUp, 5 + "ctrl-c": Quit, 6 + "ctrl-z": Suspend, 7 + "down": MoveDown, 8 + }, 9 + zk: ( 10 + keybinds: { 11 + "enter": OpenZettel, 12 + "tab": SwitchTo( 13 + region: Todo, 14 + ), 15 + "<Ctrl-n>": NewZettel, 16 + }, 17 + ), 18 + todo: ( 19 + keybinds: { 20 + "k": MoveUp, 21 + "j": MoveDown, 22 + "tab": SwitchTo( 23 + region: Zk, 24 + ), 25 + }, 26 + ), 27 + )
+1
.harper-dictionary.txt
··· 6 6 dir 7 7 env 8 8 eyre 9 + zettel
+2
crates/dto/migration/src/lib.rs
··· 7 7 mod m20260323_002518_zettel_table; 8 8 mod m20260327_175853_tag_table; 9 9 mod m20260327_180618_zettel_tag_table; 10 + mod m20260406_200424_remove_path_from_zettel; 10 11 11 12 pub struct Migrator; 12 13 ··· 19 20 Box::new(m20260323_002518_zettel_table::Migration), 20 21 Box::new(m20260327_175853_tag_table::Migration), 21 22 Box::new(m20260327_180618_zettel_tag_table::Migration), 23 + Box::new(m20260406_200424_remove_path_from_zettel::Migration), 22 24 ] 23 25 } 24 26 }
+31
crates/dto/migration/src/m20260406_200424_remove_path_from_zettel.rs
··· 1 + use sea_orm_migration::{prelude::*, schema::*}; 2 + 3 + use crate::m20260323_002518_zettel_table::Zettel; 4 + 5 + #[derive(DeriveMigrationName)] 6 + pub struct Migration; 7 + 8 + #[async_trait::async_trait] 9 + impl MigrationTrait for Migration { 10 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 11 + manager 12 + .alter_table( 13 + Table::alter() 14 + .table(Zettel::Table) 15 + .drop_column(Zettel::FilePath) 16 + .to_owned(), 17 + ) 18 + .await 19 + } 20 + 21 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 22 + manager 23 + .alter_table( 24 + Table::alter() 25 + .table(Zettel::Table) 26 + .add_column(string(Zettel::FilePath).not_null()) 27 + .to_owned(), 28 + ) 29 + .await 30 + } 31 + }
-1
crates/dto/src/entity/zettel.rs
··· 15 15 #[sea_orm(unique)] 16 16 pub nano_id: NanoId, 17 17 pub title: String, 18 - pub file_path: String, 19 18 pub created_at: DateTime, 20 19 pub modified_at: DateTime, 21 20 #[sea_orm(has_one)]
-2
crates/dto/tests/task.rs
··· 13 13 14 14 let group_zettel: ZettelModel = ZettelActiveModel { 15 15 title: Set("Something".to_owned()), 16 - file_path: Set("/voo/doo".to_owned()), 17 16 ..Default::default() 18 17 } 19 18 .insert(&db) ··· 33 32 let task_zettel: ZettelModel = ZettelActiveModel { 34 33 // nano_id: Set(NanoId::default()), 35 34 title: Set("nomething".to_owned()), 36 - file_path: Set("/voo/doo".to_owned()), 37 35 ..Default::default() 38 36 } 39 37 .insert(&db)
-3
crates/dto/tests/zettel.rs
··· 22 22 let _: ZettelModel = ZettelActiveModel { 23 23 // nano_id: Set(NanoId::default()), 24 24 title: Set("something1".to_owned()), 25 - file_path: Set("/voo/doo".to_owned()), 26 25 ..Default::default() 27 26 } 28 27 .insert(&db) ··· 31 30 32 31 let x = ZettelActiveModel::builder() 33 32 .set_title("Hello") 34 - .set_file_path("/voo/doo") 35 33 // .add_tag( 36 34 // TagActiveModel::builder() 37 35 // .set_name("Hi") ··· 46 44 let _: ZettelModel = ZettelActiveModel { 47 45 // nano_id: Set(NanoId::default()), 48 46 title: Set("nomething2".to_owned()), 49 - file_path: Set("/voo/doo".to_owned()), 50 47 ..Default::default() 51 48 } 52 49 .insert(&db)
+4 -4
src/cli/process.rs
··· 9 9 use crate::{ 10 10 cli::{Commands, ZettelSubcommand}, 11 11 config::{Config, get_config_dir}, 12 - types::{Workspace, Zettel}, 12 + types::{Kasten, Zettel}, 13 13 }; 14 14 15 15 impl Commands { ··· 21 21 .context("Failed to get current directory")? 22 22 .join(&name); 23 23 24 - Workspace::initialize(dir.clone()).await?; 24 + Kasten::initialize(dir.clone()).await?; 25 25 26 26 // write config that sets the filaments directory to current dir! 27 27 let config_str = dbg! {Config::generate(&dir)}?; ··· 44 44 45 45 Self::Zettel(zettel_sub_command) => { 46 46 let conf = Config::parse()?; 47 - let ws = Workspace::instansiate(conf.fil_dir).await?; 47 + let mut kt = Kasten::instansiate(conf.fil_dir).await?; 48 48 49 49 match zettel_sub_command { 50 50 ZettelSubcommand::New { title } => { 51 - let zettel = Zettel::new(title, &ws).await?; 51 + let zettel = Zettel::new(title, &mut kt).await?; 52 52 println!("Zettel Created! {zettel:#?}"); 53 53 } 54 54 ZettelSubcommand::List { by_tag: _by_tag } => {}
+6 -2
src/config/mod.rs
··· 7 7 8 8 use color_eyre::eyre::Result; 9 9 use directories::ProjectDirs; 10 + use ron::ser::PrettyConfig; 10 11 11 12 use crate::config::{file::RonConfig, keymap::KeyMap}; 12 13 ··· 31 32 .map(PathBuf::from) 32 33 }); 33 34 34 - const DEFAULT_CONFIG: &str = include_str!("../../.config/config.ron"); 35 + const DEFAULT_CONFIG: &str = include_str!("../../.config/default_config.ron"); 35 36 36 37 #[derive(Debug, Clone)] 37 38 pub struct Config { ··· 47 48 48 49 default_conf.directory = fil_dir.canonicalize()?; 49 50 50 - Ok(ron::to_string(&default_conf)?) 51 + Ok(ron::ser::to_string_pretty( 52 + &default_conf, 53 + PrettyConfig::default(), 54 + )?) 51 55 } 52 56 /// Parse the config from `~/.config/filaments`, but will prioritize 53 57 /// `FIL_CONFIG_DIR`.
+4 -3
src/main.rs
··· 9 9 config::Config, 10 10 gui::FilViz, 11 11 tui::TuiApp, 12 - types::{Kasten, KastenHandle, Workspace}, 12 + types::{Kasten, KastenHandle}, 13 13 }; 14 14 use clap::Parser; 15 15 use tokio::sync::RwLock; ··· 41 41 // create the kasten handle 42 42 let kh: KastenHandle = rt.block_on(async { 43 43 let cfg = Config::parse()?; 44 - let ws = Workspace::instansiate(cfg.fil_dir).await?; 45 - Ok::<KastenHandle, color_eyre::Report>(Arc::new(RwLock::new(Kasten::index(ws).await?))) 44 + Ok::<KastenHandle, color_eyre::Report>(Arc::new(RwLock::new( 45 + Kasten::instansiate(cfg.fil_dir).await?, 46 + ))) 46 47 })?; 47 48 48 49 debug!("Kasten Handle: {kh:#?}");
+15 -2
src/tui/app.rs
··· 1 1 use std::{process::Command, thread::spawn}; 2 2 3 - use color_eyre::eyre::Result; 3 + use color_eyre::eyre::{Context, Result}; 4 4 use crossterm::event::KeyEvent; 5 5 use ratatui::layout::Rect; 6 6 use serde::{Deserialize, Serialize}; ··· 192 192 // once we get out of the edit, we need to update the zettel for this 193 193 // path and then update the db and the kasten for this stuff 194 194 195 - self.kh.write().await.process_path(&path).await?; 195 + self.kh 196 + .write() 197 + .await 198 + .process_path(&path) 199 + .await 200 + .with_context(|| { 201 + format!( 202 + "Failed to process the path 203 + for this zettel: {}", 204 + path.display() 205 + ) 206 + })?; 207 + 208 + debug!("successfully processed path: {}", path.display()); 196 209 197 210 self.signal_tx.send(Signal::ClosedZettel)?; 198 211
+41 -68
src/tui/components/zk/mod.rs
··· 1 1 use async_trait::async_trait; 2 - use color_eyre::eyre::Result; 2 + use color_eyre::eyre::{Context as _, ContextCompat, Result}; 3 3 use crossterm::event::KeyEvent; 4 4 use dto::{QueryOrder, TagEntity, ZettelColumns, ZettelEntity}; 5 5 use ratatui::{prelude::*, widgets::ListState}; ··· 58 58 59 59 impl Zk<'_> { 60 60 pub async fn new(kh: KastenHandle) -> Result<Self> { 61 - let kt = kh.read().await; 62 - let ws = kt.ws.clone(); 63 - 64 61 let fetch_all = async || -> Result<Vec<Zettel>> { 65 62 Ok(ZettelEntity::load() 66 63 .with(TagEntity) 67 64 .order_by_desc(ZettelColumns::ModifiedAt) 68 - .all(&ws.db) 65 + .all(&kh.read().await.db) 69 66 .await? 70 67 .into_iter() 71 68 .map(Into::into) ··· 74 71 75 72 let mut zettels: Vec<Zettel> = fetch_all().await?; 76 73 77 - drop(kt); 78 74 if zettels.is_empty() { 79 - let z = Zettel::new("Welcome!", &ws).await?; 80 - kh.write().await.process_path(&z.absolute_path(&ws)).await?; 75 + let _ = Zettel::new("Welcome!", &mut *kh.write().await).await?; 81 76 zettels = fetch_all().await?; 82 77 } 83 78 ··· 85 80 // stuff inside the init function 86 81 let mut l_state = ListState::default(); 87 82 l_state.select_first(); 88 - let zettel_list = ZettelList::new(zettels, l_state, 0); 83 + let zettel_list = ZettelList::new(zettels.clone(), l_state, 0); 89 84 90 85 let selected_zettel = zettel_list 91 86 .id_list ··· 103 98 info!("{selected_zettel:#?}"); 104 99 info!("{kt:#?}"); 105 100 106 - let zettel = kt 107 - .get_node_by_zettel_id(selected_zettel) 108 - .expect("kasten should have the selected zettel") 109 - .payload(); 101 + let zettel = zettels 102 + .iter() 103 + .find(|&z| &z.id == selected_zettel) 104 + .expect("we selected it out of the list so it must exist"); 110 105 111 - let preview = Preview::from( 112 - zettel 113 - .content(&kt.ws) 114 - .await 115 - .expect("This thing cannot be parsed properly..."), 116 - ); 117 - 118 - // okay now that we have the zettel we need to construct the zettel out of this id 119 - let zettel_view: ZettelView = kt 120 - .get_node_by_zettel_id(selected_zettel) 121 - .expect("must exist, handle case where it doesnt later...") 122 - .payload() 123 - .into(); 124 - 125 - let ws = kt.ws.clone(); 106 + let preview = Preview::from(zettel.content(&kt.index).clone()); 126 107 127 108 drop(kt); 128 109 129 110 Ok(Self { 130 111 signal_tx: None, 112 + search: Search::new(kh.clone()), 131 113 kh, 132 114 layouts: Layouts::default(), 133 115 zettel_list, 134 - zettel_view, 116 + zettel_view: zettel.into(), 135 117 preview, 136 - search: Search::new(ws), 137 118 }) 138 119 } 139 120 ··· 146 127 147 128 // sometimes the selection we get is over the length of the thing, so its 148 129 // actually fine if this is none, just means we reached the end of the list 149 - let Some(z_id) = self.zettel_list.id_list.get(selection_idx) else { 130 + let Some(zid) = self.zettel_list.id_list.get(selection_idx) else { 150 131 return Ok(()); 151 132 }; 152 133 153 134 let kh = self.kh.read().await; 154 135 155 - self.zettel_view = kh 156 - .get_node_by_zettel_id(z_id) 157 - .expect("this should be valid unless the kasten changed out underneath us") 158 - .payload() 159 - .into(); 136 + let zettel = &Zettel::fetch_from_db(zid, &kh.db) 137 + .await? 138 + .context("Unknown Behaviour, A selected zettel got deleted somehow.")?; 160 139 161 - self.preview = kh 162 - .get_node_by_zettel_id(z_id) 163 - .expect("this should be valid unless the kasten changed out underneath us") 164 - .payload() 165 - .content(&kh.ws) 166 - .await? 167 - .into(); 140 + self.preview = zettel.content(&kh.index).clone().into(); 168 141 drop(kh); 142 + 143 + self.zettel_view = zettel.into(); 169 144 170 145 Ok(()) 171 146 } ··· 175 150 let models = ZettelEntity::load() 176 151 .with(TagEntity) 177 152 .order_by_desc(ZettelColumns::ModifiedAt) 178 - .all(&kt.ws.db) 153 + .all(&kt.db) 179 154 .await?; 180 155 181 156 // im being a good boy and dropping this as soon as im done with the db ··· 250 225 }; 251 226 252 227 let kh = self.kh.read().await; 253 - let path = kh 254 - .get_node_by_zettel_id(zid) 255 - .expect( 256 - "This should not have 257 - change dout underneath us", 258 - ) 259 - .payload() 260 - .absolute_path(&kh.ws); 228 + 229 + let path = kh.index.get_zod(zid).path.clone(); 261 230 262 231 drop(kh); 263 232 ··· 267 236 Signal::NewZettel => { 268 237 // what the fuck am i going to do in here 269 238 270 - let ws = &self.kh.read().await.ws; 239 + let mut kt = self.kh.write().await; 271 240 272 241 // we create the zettel with the query as the 273 - let z = Zettel::new(self.search.query(), ws).await?; 242 + let z = Zettel::new(self.search.query(), &mut kt) 243 + .await 244 + .with_context(|| "Failed to create a new Zettel!")?; 274 245 275 - let path = z.absolute_path(ws); 246 + let path = z.absolute_path(&kt.index).to_path_buf(); 247 + 248 + drop(kt); 276 249 277 250 return Ok(Some(Signal::Helix { path })); 278 251 } ··· 284 257 .selected() 285 258 .expect("This must be the zettel we just edited"); 286 259 287 - let Some(id) = self.zettel_list.id_list.get(selected) else { 260 + // regenerate a fresh zettel list 261 + self.zettel_list = ZettelList::new( 262 + self.get_zettels_by_current_query().await?, 263 + self.zettel_list.state, 264 + self.zettel_list.width, 265 + ); 266 + 267 + let Some(zid) = self.zettel_list.id_list.get(selected) else { 288 268 return Ok(None); 289 269 }; 290 270 291 271 let kt = self.kh.read().await; 292 272 293 - let node = kt 294 - .get_node_by_zettel_id(id) 295 - .expect("Invariant broken, this must exist."); 273 + let zettel = Zettel::fetch_from_db(zid, &kt.db) 274 + .await? 275 + .expect("invariant broken, we just closed this zettel"); 296 276 297 277 // reset the state of the component 298 278 self.search.clear_query(); 299 279 self.zettel_list.state.select_first(); 300 280 301 - // regenerate a fresh zettel list 302 - self.zettel_list = ZettelList::new( 303 - self.get_zettels_by_current_query().await?, 304 - self.zettel_list.state, 305 - self.zettel_list.width, 306 - ); 307 - 308 - self.zettel_view = ZettelView::from(node.payload()); 309 - self.preview = Preview::from(node.payload().content(&kt.ws).await?); 281 + self.zettel_view = ZettelView::from(&zettel); 282 + self.preview = Preview::from(zettel.content(&kt.index).clone()); 310 283 drop(kt); 311 284 } 312 285
+8 -7
src/tui/components/zk/search.rs
··· 10 10 }; 11 11 use ratatui_textarea::TextArea; 12 12 13 - use crate::types::{Workspace, Zettel}; 13 + use crate::types::{KastenHandle, Zettel}; 14 14 15 15 #[derive(Clone)] 16 16 pub struct Search<'text> { 17 17 pub query: TextArea<'text>, 18 18 layouts: Layouts, 19 19 matcher: Matcher, 20 - ws: Workspace, 20 + kh: KastenHandle, 21 21 } 22 22 23 23 impl Search<'_> { 24 - pub fn new(ws: Workspace) -> Self { 24 + pub fn new(kh: KastenHandle) -> Self { 25 25 let mut tag = TextArea::default(); 26 26 tag.set_style(Style::default()); 27 27 tag.set_block( ··· 34 34 Self { 35 35 matcher: Matcher::default(), 36 36 query: Self::new_query(), 37 - ws, 37 + kh, 38 38 layouts: Layouts::default(), 39 39 } 40 40 } ··· 76 76 let read_tasks = zettels 77 77 .into_iter() 78 78 .map(|z| { 79 - let ws = self.ws.clone(); 79 + let kh = self.kh.clone(); 80 80 tokio::spawn(async move { 81 - let content = z.content(&ws).await?; 82 - let front_matter = z.front_matter(&ws).await?; 81 + let index = &kh.read().await.index; 82 + let content = z.content(index); 83 + let front_matter = z.front_matter(index); 83 84 Ok::<(Zettel, String), Error>((z, format!("{content}\n{front_matter}"))) 84 85 }) 85 86 })
+45
src/types/filaments.rs
··· 1 + #![expect(dead_code)] 2 + use std::{cmp::max, collections::HashMap}; 3 + 4 + use egui_graphs::{ 5 + Graph, 6 + petgraph::{Directed, graph::NodeIndex, prelude::StableGraph}, 7 + }; 8 + 9 + use crate::types::{Index, Link, ZettelId}; 10 + 11 + pub type ZkGraph = Graph<ZettelId, Link, Directed>; 12 + 13 + /// Minimum number of nodes in our graph. 14 + const GRAPH_MIN_NODES: usize = 128; 15 + /// Arbitrarily chosen minimum number of edges 16 + const GRAPH_MIN_EDGES: usize = GRAPH_MIN_NODES * 3; 17 + 18 + pub struct Filaments { 19 + graph: ZkGraph, 20 + /// simple conversions 21 + zid_to_gid: HashMap<ZettelId, NodeIndex>, 22 + } 23 + 24 + // pub type FilamentsHandle = Arc<RwLock> 25 + // 26 + 27 + // impl Filaments { 28 + // pub fn construct() -> Result<Self> {} 29 + // } 30 + 31 + impl From<&Index> for Filaments { 32 + fn from(value: &Index) -> Self { 33 + let number_of_zettels = value.zods().len(); 34 + 35 + let mut _graph: ZkGraph = ZkGraph::from(&StableGraph::with_capacity( 36 + max(number_of_zettels * 2, GRAPH_MIN_EDGES), 37 + max(number_of_zettels * 3, GRAPH_MIN_EDGES), 38 + )); 39 + 40 + #[expect(clippy::for_kv_map)] 41 + for (_id, _zod) in value.zods() {} 42 + 43 + todo!() 44 + } 45 + }
+5 -4
src/types/frontmatter.rs
··· 4 4 use color_eyre::eyre::{Result, eyre}; 5 5 use egui_graphs::Node; 6 6 use serde::{Deserialize, Serialize}; 7 - use tokio::fs; 8 7 9 8 use dto::DateTime; 10 9 ··· 18 17 pub created_at: DateTime, 19 18 pub tag_strings: Vec<String>, 20 19 } 20 + 21 + pub type Body = String; 21 22 22 23 impl FrontMatter { 23 24 pub fn new( ··· 51 52 /// Tags: Daily barber 52 53 /// --- 53 54 /// ``` 54 - pub async fn extract_from_file(path: impl AsRef<Path>) -> Result<(Self, String)> { 55 + pub fn extract_from_file(path: impl AsRef<Path>) -> Result<(Self, Body)> { 55 56 let path = path.as_ref(); 56 - let string = fs::read_to_string(path).await?; 57 + let string = std::fs::read_to_string(path)?; 57 58 Self::extract_from_str(&string) 58 59 } 59 60 ··· 66 67 /// Tags: Daily barber 67 68 /// --- 68 69 /// ``` 69 - pub fn extract_from_str(string: impl Into<String>) -> Result<(Self, String)> { 70 + pub fn extract_from_str(string: impl Into<String>) -> Result<(Self, Body)> { 70 71 let string: String = string.into(); 71 72 // we just want to strictly match this, else we error 72 73
+191
src/types/index.rs
··· 1 + use std::{ 2 + collections::HashMap, 3 + path::{Path, PathBuf}, 4 + }; 5 + 6 + use color_eyre::eyre::Result; 7 + use dto::{ 8 + ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, 9 + QueryFilter, TagActiveModel, TagEntity, ZettelEntity, ZettelModelEx, ZettelTagActiveModel, 10 + ZettelTagColumns, ZettelTagEntity, 11 + }; 12 + use rayon::iter::{ParallelBridge, ParallelIterator}; 13 + use tracing::info; 14 + 15 + use crate::types::{FrontMatter, ZettelId, frontmatter::Body}; 16 + 17 + #[derive(Debug, Clone)] 18 + pub struct Index { 19 + pub(super) zods: HashMap<ZettelId, ZettelOnDisk>, 20 + } 21 + 22 + #[derive(Debug, Clone)] 23 + pub struct ZettelOnDisk { 24 + pub fm: FrontMatter, 25 + pub body: Body, 26 + pub path: PathBuf, 27 + } 28 + 29 + impl Index { 30 + /// Parses the `root` path to construct an `Index`. 31 + pub fn tabulate(root: &Path) -> Result<Self> { 32 + let root = root.canonicalize()?; 33 + 34 + let mut zods = HashMap::new(); 35 + 36 + std::fs::read_dir(root)? 37 + .par_bridge() 38 + .flatten() 39 + .filter(|entry| { 40 + entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) 41 + && entry 42 + .path() 43 + .extension() 44 + .and_then(|ext| ext.to_str()) 45 + .is_some_and(|ext| ext == "md") 46 + }) 47 + .map(|entry| -> Result<(ZettelId, ZettelOnDisk)> { 48 + let path = entry.path(); 49 + let id: ZettelId = path.as_path().try_into()?; 50 + let (fm, body) = FrontMatter::extract_from_file(&path)?; 51 + 52 + Ok(( 53 + id, 54 + ZettelOnDisk { 55 + fm, 56 + body, 57 + path: path.canonicalize()?, 58 + }, 59 + )) 60 + }) 61 + .collect::<Result<Vec<_>>>()? 62 + .into_iter() 63 + // .par_bridge() 64 + .for_each(|(id, zod)| { 65 + zods.insert(id, zod); 66 + }); 67 + 68 + Ok(Self { zods }) 69 + } 70 + 71 + pub fn update_path_for_zid(&mut self, zid: &ZettelId, new_path: PathBuf) { 72 + self.get_zod_mut(zid).path = new_path; 73 + } 74 + 75 + /// Updates the interal state of the `Index` for the provided `Zid`. 76 + pub fn process_zid(&mut self, zid: &ZettelId) -> Result<()> { 77 + let zod = self.get_zod_mut(zid); 78 + 79 + let (fm, body) = FrontMatter::extract_from_file(&zod.path)?; 80 + 81 + zod.fm = fm; 82 + zod.body = body; 83 + 84 + Ok(()) 85 + } 86 + 87 + /// Sync's the curren title of the `Zettel` with the 88 + /// provided `zid` with the `DB` 89 + pub async fn sync_title_with_db( 90 + &mut self, 91 + zid: &ZettelId, 92 + db: &DatabaseConnection, 93 + ) -> Result<()> { 94 + let fm = &mut self.get_zod_mut(zid).fm; 95 + 96 + let mut model = ZettelEntity::find_by_nano_id(zid.clone()) 97 + .one(db) 98 + .await? 99 + .expect("this must exist") 100 + .into_active_model(); 101 + 102 + model.title = ActiveValue::Set(fm.title.clone()); 103 + 104 + model.update(db).await?; 105 + 106 + info!("We updated the zettel: {zid:#?}"); 107 + 108 + Ok(()) 109 + } 110 + 111 + /// Sync's `Tag`'s that are present in the frontmatter of this 112 + /// `Zettel` to the database. 113 + pub async fn sync_tags_with_db( 114 + &mut self, 115 + zid: &ZettelId, 116 + db: &DatabaseConnection, 117 + ) -> Result<()> { 118 + let fm = &mut self.get_zod_mut(zid).fm; 119 + 120 + let mut tag_strings = fm.tag_strings.clone(); 121 + 122 + tag_strings.sort(); 123 + 124 + let db_zettel: ZettelModelEx = ZettelEntity::load() 125 + .with(TagEntity) 126 + .filter_by_nano_id(zid.clone()) 127 + .one(db) 128 + .await? 129 + .expect("Invariant broken, zettel should not be deleted"); 130 + 131 + for db_tag in db_zettel.tags { 132 + if let Ok(idx) = tag_strings.binary_search(&db_tag.name) { 133 + // we remove tags we have already processed 134 + tag_strings.remove(idx); 135 + } else { 136 + // the db says the file has tag `x`, but that tag is missing from the 137 + // front matter, we can assume its gone, lets delete that link 138 + let to_remove = ZettelTagEntity::find() 139 + .filter(ZettelTagColumns::ZettelNanoId.eq(zid.to_string())) 140 + .filter(ZettelTagColumns::TagNanoId.eq(db_tag.nano_id)) 141 + .one(db) 142 + .await? 143 + .expect("this link must exist"); 144 + 145 + to_remove.into_active_model().delete(db).await?; 146 + } 147 + } 148 + 149 + // now any tags that are left inside zettel_tag_strings, 150 + // we have to look up the tags in the db and then reset them? 151 + for tag_str in tag_strings { 152 + // this is the tag that either already exists with this name, or we just created this new one 153 + let tag = 154 + if let Some(existing) = TagEntity::load().filter_by_name(&tag_str).one(db).await? { 155 + existing 156 + } else { 157 + let am = TagActiveModel { 158 + name: ActiveValue::Set(tag_str.clone()), 159 + ..Default::default() 160 + }; 161 + 162 + am.insert(db).await?.into() 163 + }; 164 + 165 + // this zettel has this tag now 166 + let _ = ZettelTagActiveModel { 167 + zettel_nano_id: ActiveValue::Set(zid.to_string()), 168 + tag_nano_id: ActiveValue::Set(tag.nano_id.to_string()), 169 + } 170 + .insert(db) 171 + .await?; 172 + } 173 + Ok(()) 174 + } 175 + 176 + pub fn get_zod(&self, zid: &ZettelId) -> &ZettelOnDisk { 177 + self.zods.get(zid).expect("Invariant broken. Any zid we lookup must exist in the index, otherwise the db is corrupt or not sync'd.") 178 + } 179 + 180 + fn get_zod_mut(&mut self, zid: &ZettelId) -> &mut ZettelOnDisk { 181 + self.zods.get_mut(zid).expect("Invariant broken. Any zid we lookup must exist in the index, otherwise the db is corrupt or not sync'd.") 182 + } 183 + 184 + pub const fn zods(&self) -> &HashMap<ZettelId, ZettelOnDisk> { 185 + &self.zods 186 + } 187 + 188 + pub fn sync_with_db(&self, _db: &DatabaseConnection) { 189 + todo!() 190 + } 191 + }
+78 -176
src/types/kasten.rs
··· 1 - use crate::types::{Link, Zettel, ZettelId}; 2 - use color_eyre::eyre::Result; 3 - use dto::{TagEntity, ZettelEntity}; 4 - use eframe::emath; 5 - use egui_graphs::{ 6 - Graph, Node, 7 - petgraph::{Directed, Direction, graph::NodeIndex, prelude::StableGraph, visit::EdgeRef}, 1 + use std::{ 2 + path::{Path, PathBuf}, 3 + sync::Arc, 8 4 }; 9 - use rayon::iter::{ParallelBridge as _, ParallelIterator as _}; 10 - use std::{cmp::max, collections::HashMap, path::Path, sync::Arc}; 11 - use tokio::sync::RwLock; 12 - use tracing::{debug, error}; 13 5 14 - use crate::types::Workspace; 6 + use color_eyre::eyre::{Context, Result}; 7 + use dto::{Database, DatabaseConnection, Migrator, MigratorTrait}; 8 + use tokio::{ 9 + fs::{File, create_dir_all}, 10 + sync::RwLock, 11 + }; 12 + use tracing::debug; 13 + 14 + use crate::types::{FrontMatter, Index, ZettelId, index::ZettelOnDisk}; 15 15 16 16 #[derive(Debug, Clone)] 17 - #[expect(dead_code)] 18 17 pub struct Kasten { 19 18 /// Private field so it can only be instantiated from a `Path` 20 19 _private: (), 21 20 22 - /// The workspace this `Kasten` is in 23 - pub ws: Workspace, 24 - 25 - /// the graph of `Zettel`s and the `Links` between them 26 - pub graph: ZkGraph, 21 + pub root: PathBuf, 27 22 28 - /// simple conversions 29 - zid_to_gid: HashMap<ZettelId, NodeIndex>, 23 + pub index: Index, 30 24 31 - pub most_recently_edited: Option<NodeIndex>, 25 + pub db: DatabaseConnection, 32 26 } 33 27 34 - pub type ZkGraph = Graph<Zettel, Link, Directed>; 35 - 36 - /// Minimum number of nodes in our graph. 37 - const GRAPH_MIN_NODES: usize = 128; 38 - /// Arbitrarily chosen minimum number of edges 39 - const GRAPH_MIN_EDGES: usize = GRAPH_MIN_NODES * 3; 40 - 41 28 pub type KastenHandle = Arc<RwLock<Kasten>>; 42 29 43 30 impl Kasten { 44 - /// Indexes the `Workspace` and constructs a `Kasten` 45 - pub async fn index(ws: Workspace) -> Result<Self> { 46 - let paths = std::fs::read_dir(&ws.root)? 47 - .par_bridge() 48 - .flatten() 49 - .filter(|entry| { 50 - entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) 51 - && entry 52 - .path() 53 - .extension() 54 - .and_then(|ext| ext.to_str()) 55 - .is_some_and(|ext| ext == "md") 56 - }) 57 - .map(|entry| entry.path()) 58 - .collect::<Vec<_>>(); 59 - 60 - debug!( 61 - "indexing the following paths {paths:#?} at root {:#?}", 62 - ws.root 31 + /// Given a path, try to construct a `Kasten` based on its contents. 32 + /// 33 + /// Note: this means that there should already exist a valid `Kasten` 34 + /// at that path. 35 + pub async fn instansiate(root: impl Into<PathBuf>) -> Result<Self> { 36 + let root = root.into(); 37 + let db_conn_string = format!( 38 + "sqlite://{}", 39 + root.clone() 40 + .join(".filaments/filaments.db") 41 + .canonicalize() 42 + .context("Invalid Filaments workspace!!")? 43 + .to_string_lossy() 63 44 ); 64 45 65 - let zettel_tasks = paths 66 - .into_iter() 67 - .map(|path| { 68 - let ws = ws.clone(); 69 - tokio::spawn(async move { Zettel::from_path(path, &ws).await }) 70 - }) 71 - .collect::<Vec<_>>(); 46 + debug!("connecting to {db_conn_string}"); 72 47 73 - // await all of them 74 - let zettels = futures::future::join_all(zettel_tasks) 48 + let conn = Database::connect(db_conn_string) 75 49 .await 76 - .into_iter() 77 - .filter_map(|result| { 78 - result 79 - .inspect_err(|e| error!("Failed to join on zettel task parsing: {e:#?}")) 80 - .ok()? 81 - .inspect_err(|e| error!("Failed to parse file into zettel: {e:#?}")) 82 - .ok() 83 - }) 84 - .collect::<Vec<Zettel>>(); 50 + .context("Failed to connect to the database in the filaments workspace!")?; 85 51 86 - debug!("parsed zettels: {zettels:#?}"); 87 - 88 - // capacity! 89 - let mut graph: ZkGraph = ZkGraph::from(&StableGraph::with_capacity( 90 - max(zettels.len() * 2, GRAPH_MIN_EDGES), 91 - max(zettels.len() * 3, GRAPH_MIN_EDGES), 92 - )); 52 + let index = Index::tabulate(&root)?; 93 53 94 - let mut zid_to_gid = HashMap::new(); 95 - for zettel in &zettels { 96 - let fm = zettel.front_matter(&ws).await?; 97 - let id = graph.add_node_custom(zettel.clone(), |node| { 98 - fm.apply_node_transform(node); 99 - let x = rand::random_range(0.0..=100.0); 100 - let y = rand::random_range(0.0..=100.0); 101 - node.set_location(emath::Pos2 { x, y }); 102 - }); 103 - zid_to_gid.insert(zettel.id.clone(), id); 104 - } 105 - 106 - for zettel in &zettels { 107 - let src = zid_to_gid.get(&zettel.id).expect("must exist"); 108 - for link in &zettel.links(&ws).await? { 109 - let dst = zid_to_gid.get(&link.dest).expect("must exist"); 110 - graph.add_edge(*src, *dst, link.clone()); 111 - } 112 - } 113 - 114 - debug!("parsed graph: {graph:#?}"); 54 + // run da migrations every time we connect, just in case 55 + Migrator::up(&conn, None).await?; 115 56 116 57 Ok(Self { 117 58 _private: (), 118 - ws, 119 - graph, 120 - zid_to_gid, 121 - most_recently_edited: None, 59 + db: conn, 60 + root, 61 + index, 122 62 }) 123 63 } 124 64 125 - /// processes the `Zettel` for the provided `ZettelId`, 126 - /// meaning it updates the internal state of the `Kasten` 127 - /// with the changes in `Zettel`. 128 - pub async fn process_path(&mut self, path: &Path) -> Result<()> { 129 - //NOTE: need to clone to get around borrowing rules but 130 - // ideally we dont have to do this, kind of cringe imo. 131 - let ws = self.ws.clone(); 65 + /// Create a new `Kasten` at the provided `path`. 66 + pub async fn initialize(path: impl Into<PathBuf>) -> Result<Self> { 67 + let path = path.into(); 132 68 133 - let zid = ZettelId::try_from(path)?; 69 + let filaments_dir = path.join(".filaments"); 134 70 135 - let mut gid = self.zid_to_gid.get(&zid).copied(); 136 - // sometimes this zid is new, so it wont be in the kasten 137 - let zettel = if let Some(existing) = self.get_node_by_zettel_id_mut(&zid) { 138 - existing.payload_mut() 139 - } else { 140 - // this should aleady be in the database though so lets get it from there first 141 - let zettel: Zettel = ZettelEntity::load() 142 - .filter_by_nano_id(zid) 143 - .with(TagEntity) 144 - .one(&ws.db) 145 - .await? 146 - .expect("This should be in the database already") 147 - .into(); 71 + // create the dir 72 + create_dir_all(&filaments_dir) 73 + .await 74 + .context("Failed to create the filaments directory!")?; 148 75 149 - let zid = zettel.id.clone(); 150 - let idx = self.graph.add_node(zettel); 76 + let filaments_dir = filaments_dir.canonicalize()?; 151 77 152 - self.zid_to_gid.insert(zid.clone(), idx); 78 + File::create(filaments_dir.join("filaments.db")).await?; 153 79 154 - gid = Some(idx); 80 + Ok(Self::instansiate(&path).await.expect( 81 + "Invariant broken. This instantiation call must always work \ 82 + since we just initialized the workspace.", 83 + )) 84 + } 155 85 156 - self.get_node_by_zettel_id_mut(&zid) 157 - .expect("we just inserted it") 158 - .payload_mut() 159 - }; 86 + /// processes the `Zettel` for the provided `ZettelId`, 87 + /// meaning it updates the internal state of the `Kasten` 88 + /// with the changes in `Zettel`. 89 + pub async fn process_path(&mut self, path: impl AsRef<Path>) -> Result<()> { 90 + //NOTE: need to clone to get around borrowing rules but 91 + // ideally we dont have to do this, kind of cringe imo. 160 92 161 - // and then we sync with the file 162 - zettel.sync_with_file(&ws).await?; 93 + let path = path.as_ref().canonicalize()?; 94 + let zid = ZettelId::try_from(path.as_path())?; 163 95 164 - // to get past borrowchecker rules 165 - let zettel = zettel.clone(); 166 - 167 - // gid must be set 168 - let gid = gid.unwrap(); 169 - 170 - // and now we manage the links going out of the file 96 + if !self.index.zods.contains_key(&zid) { 97 + let (fm, body) = FrontMatter::extract_from_file(&path)?; 98 + self.index.zods.insert( 99 + zid.clone(), 100 + ZettelOnDisk { 101 + fm, 102 + body, 103 + path: path.clone(), 104 + }, 105 + ); 106 + } 171 107 172 - // remove all the old shit 173 - self.graph 174 - .edges_directed(gid, Direction::Outgoing) 175 - .map(|e| e.id()) 176 - .collect::<Vec<_>>() 177 - .into_iter() 178 - .for_each(|e| { 179 - let _ = self.graph.remove_edge(e); 180 - }); 181 - 182 - // add the links that actually exist 183 - zettel.links(&ws).await?.into_iter().for_each(|link| { 184 - // this is an option because a user c 185 - let dest = self 186 - .zid_to_gid 187 - .get(&link.dest) 188 - .expect("Links should be valid"); 189 - 190 - self.graph.add_edge(gid, *dest, link); 191 - }); 108 + // incase the path of the zettel changed 109 + self.index.update_path_for_zid(&zid, path.clone()); 110 + // let the index process the zettel, basically update the internal state of the zod 111 + self.index.process_zid(&zid)?; 112 + // and then we sync tags 113 + self.index.sync_tags_with_db(&zid, &self.db).await?; 114 + self.index.sync_title_with_db(&zid, &self.db).await?; 192 115 193 116 Ok(()) 194 - } 195 - 196 - pub fn get_node_by_zettel_id(&self, id: &ZettelId) -> Option<&Node<Zettel, Link>> { 197 - let idx = self.zid_to_gid.get(id)?; 198 - 199 - let node = self.graph.node(*idx).expect( 200 - "invariant broken if internal hashmap is not uptodate with 201 - the state of the graph...", 202 - ); 203 - Some(node) 204 - } 205 - 206 - pub fn get_node_by_zettel_id_mut(&mut self, id: &ZettelId) -> Option<&mut Node<Zettel, Link>> { 207 - let idx = self.zid_to_gid.get(id)?; 208 - 209 - let node = self.graph.node_mut(*idx).expect( 210 - "invariant broken if internal hashmap is not uptodate with the 211 - state of the graph...", 212 - ); 213 - 214 - Some(node) 215 117 } 216 118 }
+7 -3
src/types/mod.rs
··· 18 18 #[expect(unused_imports)] 19 19 pub use task::Task; 20 20 21 - mod workspace; 22 - pub use workspace::Workspace; 23 - 24 21 mod link; 25 22 pub use link::Link; 23 + 24 + mod filaments; 25 + #[expect(unused_imports)] 26 + pub use filaments::Filaments; 27 + 28 + mod index; 29 + pub use index::Index; 26 30 27 31 mod kasten; 28 32 pub use kasten::Kasten;
-95
src/types/workspace.rs
··· 1 - use std::path::PathBuf; 2 - 3 - use color_eyre::eyre::{Context, Result}; 4 - use dto::{Database, DatabaseConnection, Migrator, MigratorTrait}; 5 - use tokio::fs::{File, create_dir_all}; 6 - use tracing::debug; 7 - 8 - /// The `Workspace` in which the filaments exist. 9 - #[derive(Debug, Clone)] 10 - pub struct Workspace { 11 - /// Private field so it can only be instantiated from a `Path` 12 - _private: (), 13 - /// Connection to the sqlite database inside the `Workspace` 14 - pub db: DatabaseConnection, 15 - /// The path to the root of this workspace 16 - pub root: PathBuf, 17 - } 18 - 19 - impl Workspace { 20 - /// Given a path, try to construct a `Workspace` based on its contents. 21 - /// 22 - /// Note: this means that there should already exist a valid `Workspace` 23 - /// at that path. 24 - pub async fn instansiate(path: impl Into<PathBuf>) -> Result<Self> { 25 - let path = path.into(); 26 - 27 - let db_conn_string = format!( 28 - "sqlite://{}", 29 - path.clone() 30 - .join(".filaments/filaments.db") 31 - .canonicalize() 32 - .context("Invalid Filaments workspace!!")? 33 - .to_string_lossy() 34 - ); 35 - 36 - debug!("connecting to {db_conn_string}"); 37 - 38 - let conn = Database::connect(db_conn_string) 39 - .await 40 - .context("Failed to connect to the database in the filaments workspace!")?; 41 - 42 - // run da migrations every time we connect, just in case 43 - Migrator::up(&conn, None).await?; 44 - 45 - Ok(Self { 46 - _private: (), 47 - db: conn, 48 - root: path, 49 - }) 50 - } 51 - 52 - pub async fn initialize(path: impl Into<PathBuf>) -> Result<Self> { 53 - let path = path.into(); 54 - 55 - let filaments_dir = path.join(".filaments"); 56 - 57 - // create the dir 58 - create_dir_all(&filaments_dir) 59 - .await 60 - .context("Failed to create the filaments directory!")?; 61 - 62 - let filaments_dir = filaments_dir.canonicalize()?; 63 - 64 - File::create(filaments_dir.join("filaments.db")).await?; 65 - 66 - Ok(Self::instansiate(&path).await.expect( 67 - "Invariant broken. This instantiation call must always work \ 68 - since we just initialized the workspace.", 69 - )) 70 - } 71 - } 72 - 73 - #[cfg(test)] 74 - mod tests { 75 - 76 - use crate::types::Workspace; 77 - 78 - #[tokio::test] 79 - async fn test_instantiation() { 80 - let tmp = tempfile::tempdir().unwrap(); 81 - let filaments_dir = tmp.path().join(".filaments"); 82 - std::fs::create_dir_all(&filaments_dir).unwrap(); 83 - std::fs::File::create(filaments_dir.join("filaments.db")).unwrap(); 84 - let _ws = Workspace::instansiate(tmp.path()).await.unwrap(); 85 - } 86 - 87 - #[tokio::test] 88 - async fn test_initialization() { 89 - let tmp = tempfile::tempdir().unwrap(); 90 - let path = tmp.path().join("workspace"); 91 - Workspace::initialize(path) 92 - .await 93 - .expect("Should initialize just fine"); 94 - } 95 - }
-414
src/types/zettel.rs
··· 1 - use dto::{ 2 - ActiveModelTrait, ActiveValue, ColumnTrait, DateTime, EntityTrait as _, IntoActiveModel, 3 - QueryFilter, TagActiveModel, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModel, 4 - ZettelModelEx, ZettelTagActiveModel, ZettelTagColumns, ZettelTagEntity, 5 - }; 6 - use pulldown_cmark::{Event, Parser, Tag as MkTag}; 7 - use serde::{Deserialize, Serialize}; 8 - use std::{ 9 - fmt::Display, 10 - path::{Path, PathBuf}, 11 - }; 12 - use tracing::{error, info}; 13 - 14 - use color_eyre::eyre::{Error, Result, eyre}; 15 - use dto::NanoId; 16 - use tokio::{fs::File, io::AsyncWriteExt}; 17 - 18 - use crate::types::{FrontMatter, Link, Tag, Workspace, frontmatter}; 19 - 20 - /// A `Zettel` is a note about a single idea. 21 - /// It can have many `Tag`s, just meaning it can fall under many 22 - /// categories. 23 - #[derive(Debug, Clone)] 24 - pub struct Zettel { 25 - /// Should only be constructed from models. 26 - _private: (), 27 - pub id: ZettelId, 28 - pub title: String, 29 - /// a workspace-local file path, needs to be canonicalized before usage 30 - pub file_path: PathBuf, 31 - pub created_at: DateTime, 32 - pub modified_at: DateTime, 33 - pub tags: Vec<Tag>, 34 - } 35 - 36 - /// A `ZettelId` is essentially a `NanoId`, 37 - /// with some `Zettel` specific helpers written 38 - /// onto it 39 - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] 40 - pub struct ZettelId(NanoId); 41 - 42 - impl Zettel { 43 - pub async fn new(title: impl Into<String>, ws: &Workspace) -> Result<Self> { 44 - // fn new(title: impl Into<String>) -> Result<Self> { 45 - let title = title.into(); 46 - 47 - // make a file that has a random identifier, and then 48 - // also has the name "title" 49 - let nano_id = NanoId::default(); 50 - 51 - let local_file_path = format!("{nano_id}.md"); 52 - 53 - // now we have to create the file 54 - let mut file = File::create_new(ws.root.clone().join(&local_file_path)).await?; 55 - 56 - let inserted = ZettelActiveModel::builder() 57 - .set_title(title.clone()) 58 - .set_file_path(local_file_path) 59 - .set_nano_id(nano_id) 60 - .insert(&ws.db) 61 - .await?; 62 - 63 - // need to load tags... 64 - let zettel = ZettelEntity::load() 65 - .filter_by_nano_id(inserted.nano_id) 66 - .with(TagEntity) 67 - .one(&ws.db) 68 - .await? 69 - .expect("This must exist since we just inserted it"); 70 - 71 - let front_matter = FrontMatter::new( 72 - title, 73 - zettel.created_at, 74 - zettel.tags.iter().map(|t| t.name.clone()).collect(), 75 - ); 76 - 77 - file.write_all(front_matter.to_string().as_bytes()).await?; 78 - 79 - Ok(zettel.into()) 80 - } 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 - 182 - /// Returns the most up-to-date `FrontMatter` for this 183 - /// `Zettel` 184 - pub async fn front_matter(&self, ws: &Workspace) -> Result<FrontMatter> { 185 - let path = self.absolute_path(ws); 186 - let (fm, _) = FrontMatter::extract_from_file(path).await?; 187 - Ok(fm) 188 - } 189 - 190 - /// Returns the content of this `Zettel`, which is everything 191 - /// but the `FrontMatter` 192 - pub async fn content(&self, ws: &Workspace) -> Result<String> { 193 - let path = self.absolute_path(ws); 194 - let (_, content) = FrontMatter::extract_from_file(path).await?; 195 - Ok(content) 196 - } 197 - 198 - #[expect(dead_code)] 199 - async fn open_file(&self, ws: &Workspace) -> Result<File> { 200 - let path = self.absolute_path(ws); 201 - Ok(File::open(path).await?) 202 - } 203 - 204 - pub fn absolute_path(&self, ws: &Workspace) -> PathBuf { 205 - ws.root.clone().join(&self.file_path) 206 - } 207 - 208 - /// uses the id and root to parse out of the root directory 209 - pub async fn from_id(id: &ZettelId, ws: &Workspace) -> Result<Self> { 210 - let mut path = ws.root.clone(); 211 - path.push(id.0.to_string()); 212 - Self::from_path(path, ws).await 213 - } 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 - 227 - pub async fn from_path(path: impl Into<PathBuf>, ws: &Workspace) -> Result<Self> { 228 - let path: PathBuf = path.into(); 229 - 230 - let id = ZettelId::try_from(path.as_path())?; 231 - 232 - let (front_matter, _) = FrontMatter::extract_from_file(&ws.root.clone().join(path)).await?; 233 - 234 - // get the zettel from the db 235 - let db_zettel: ZettelModelEx = if let Some(existing_zettel) = ZettelEntity::load() 236 - .with(TagEntity) 237 - .filter_by_nano_id(id.clone()) 238 - .one(&ws.db) 239 - .await? 240 - { 241 - existing_zettel 242 - } else { 243 - // if zettel is missing from db, we just add it here 244 - info!("adding zettel to db"); 245 - let am = ZettelActiveModel { 246 - nano_id: ActiveValue::Set(id.clone().into()), 247 - title: ActiveValue::Set(front_matter.title.clone()), 248 - ..Default::default() 249 - }; 250 - 251 - am.insert(&ws.db).await?; 252 - 253 - ZettelEntity::load() 254 - .with(TagEntity) 255 - .filter_by_nano_id(id.clone()) 256 - .one(&ws.db) 257 - .await? 258 - .expect("we just inserted the zettel") 259 - }; 260 - 261 - let mut temp_zettel: Self = db_zettel.clone().into(); 262 - temp_zettel.sync_tags(ws).await?; 263 - 264 - if front_matter.title != db_zettel.title { 265 - let mut am = db_zettel.into_active_model(); 266 - am.title = ActiveValue::Set(front_matter.title.clone()); 267 - am.update(&ws.db).await?; 268 - } 269 - 270 - Ok(ZettelEntity::load() 271 - .with(TagEntity) 272 - .filter_by_nano_id(id.clone()) 273 - .one(&ws.db) 274 - .await? 275 - .expect("We just inserted it right above") 276 - .into()) 277 - } 278 - 279 - /// The `Link`s that are going out of this `Zettel` 280 - pub async fn links(&self, ws: &Workspace) -> Result<Vec<Link>> { 281 - let content = self.content(ws).await?; 282 - let parsed = Parser::new(&content); 283 - 284 - let mut links = vec![]; 285 - 286 - for event in parsed { 287 - if let Event::Start(MkTag::Link { dest_url, .. }) = event { 288 - info!("Found dest_url: {dest_url:#?}"); 289 - 290 - let dest_path = { 291 - // remove leading "./" 292 - let without_prefix = dest_url.strip_prefix("./").unwrap_or(&dest_url); 293 - 294 - // remove "#" and everything after it 295 - let without_anchor = without_prefix.split('#').next().unwrap(); 296 - 297 - // add .md if not present 298 - let normalized = if std::path::Path::new(without_anchor) 299 - .extension() 300 - .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) 301 - { 302 - without_anchor.to_string() 303 - } else { 304 - format!("{without_anchor}.md") 305 - }; 306 - 307 - let mut tmp_root = ws.root.clone(); 308 - tmp_root.push(normalized); 309 - tmp_root 310 - }; 311 - // simplest way to validate that the path exists 312 - let Ok(canon_url) = dest_path.canonicalize() else { 313 - error!("Link not found!: {dest_path:?}"); 314 - continue; 315 - }; 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 319 - let dst_id = ZettelId::try_from(canon_url)?; 320 - 321 - let link = Link::new(self.id.clone(), dst_id); 322 - 323 - links.push(link); 324 - } 325 - } 326 - 327 - Ok(links) 328 - } 329 - } 330 - 331 - impl From<ZettelModelEx> for Zettel { 332 - fn from(value: ZettelModelEx) -> Self { 333 - assert!( 334 - !value.tags.is_unloaded(), 335 - "When fetching a Zettel from the database, we expect 336 - to always have the tags loaded!!" 337 - ); 338 - 339 - Self { 340 - _private: (), 341 - id: value.nano_id.into(), 342 - title: value.title, 343 - file_path: value.file_path.into(), 344 - created_at: value.created_at, 345 - modified_at: value.modified_at, 346 - tags: value.tags.into_iter().map(Into::into).collect(), 347 - } 348 - } 349 - } 350 - 351 - impl From<&str> for ZettelId { 352 - fn from(value: &str) -> Self { 353 - Self(NanoId::from(value)) 354 - } 355 - } 356 - 357 - impl From<&NanoId> for ZettelId { 358 - fn from(value: &NanoId) -> Self { 359 - value.clone().into() 360 - } 361 - } 362 - 363 - impl From<NanoId> for ZettelId { 364 - fn from(value: NanoId) -> Self { 365 - Self(value) 366 - } 367 - } 368 - 369 - impl TryFrom<PathBuf> for ZettelId { 370 - type Error = Error; 371 - 372 - fn try_from(value: PathBuf) -> Result<Self, Self::Error> { 373 - let path = value.as_path(); 374 - path.try_into() 375 - } 376 - } 377 - 378 - impl TryFrom<&Path> for ZettelId { 379 - type Error = Error; 380 - 381 - fn try_from(value: &Path) -> Result<Self, Self::Error> { 382 - let extension = value 383 - .extension() 384 - .and_then(|ext| ext.to_str()) 385 - .ok_or_else(|| eyre!("Unable to turn file extension into string".to_owned(),))?; 386 - 387 - if extension != "md" { 388 - return Err(eyre!(format!("Wrong extension: {extension}, expected .md"))); 389 - } 390 - 391 - let id: Self = value 392 - .file_name() 393 - .ok_or_else(|| eyre!("Invalid File Name!".to_owned()))? 394 - .to_str() 395 - .ok_or_else(|| eyre!("File Name cannot be translated into str!".to_owned(),))? 396 - .strip_suffix(".md") 397 - .expect("we statically verify this right above") 398 - .into(); 399 - 400 - Ok(id) 401 - } 402 - } 403 - 404 - impl Display for ZettelId { 405 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 406 - f.write_str(&self.0.to_string()) 407 - } 408 - } 409 - 410 - impl From<ZettelId> for NanoId { 411 - fn from(value: ZettelId) -> Self { 412 - value.0 413 - } 414 - }
+108
src/types/zettel/id.rs
··· 1 + use std::{ 2 + fmt::Display, 3 + path::{Path, PathBuf}, 4 + }; 5 + 6 + use color_eyre::eyre::{Error, eyre}; 7 + use dto::NanoId; 8 + use serde::{Deserialize, Serialize}; 9 + 10 + /// A `ZettelId` is essentially a `NanoId`, 11 + /// with some `Zettel` specific helpers written 12 + /// onto it 13 + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] 14 + pub struct ZettelId(pub(super) NanoId); 15 + 16 + impl From<&str> for ZettelId { 17 + fn from(value: &str) -> Self { 18 + Self(NanoId::from(value)) 19 + } 20 + } 21 + 22 + impl From<&NanoId> for ZettelId { 23 + fn from(value: &NanoId) -> Self { 24 + value.clone().into() 25 + } 26 + } 27 + 28 + impl From<NanoId> for ZettelId { 29 + fn from(value: NanoId) -> Self { 30 + Self(value) 31 + } 32 + } 33 + 34 + impl TryFrom<PathBuf> for ZettelId { 35 + type Error = Error; 36 + 37 + fn try_from(value: PathBuf) -> Result<Self, Self::Error> { 38 + let path = value.as_path(); 39 + path.try_into() 40 + } 41 + } 42 + 43 + impl TryFrom<&Path> for ZettelId { 44 + type Error = Error; 45 + 46 + fn try_from(value: &Path) -> Result<Self, Self::Error> { 47 + let extension = value 48 + .extension() 49 + .and_then(|ext| ext.to_str()) 50 + .ok_or_else(|| eyre!("Unable to turn file extension into string".to_owned(),))?; 51 + 52 + if extension != "md" { 53 + return Err(eyre!(format!("Wrong extension: {extension}, expected .md"))); 54 + } 55 + 56 + let id: Self = (value 57 + .file_name() 58 + .ok_or_else(|| eyre!("Invalid File Name!".to_owned()))? 59 + .to_str() 60 + .ok_or_else(|| eyre!("File Name cannot be translated into str!".to_owned(),))? 61 + .strip_suffix(".md") 62 + .expect("we statically verify this right above") 63 + .split('-')) 64 + .next() 65 + .ok_or_else(|| eyre!("Unable to get the first part of the file name!"))? 66 + .into(); 67 + 68 + Ok(id) 69 + } 70 + } 71 + 72 + impl Display for ZettelId { 73 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 74 + f.write_str(&self.0.to_string()) 75 + } 76 + } 77 + 78 + impl From<ZettelId> for NanoId { 79 + fn from(value: ZettelId) -> Self { 80 + value.0 81 + } 82 + } 83 + 84 + #[cfg(test)] 85 + mod tests { 86 + use std::path::PathBuf; 87 + 88 + use super::*; 89 + 90 + #[tokio::test] 91 + async fn test_zettel_id_parsing_from_path() { 92 + let path = PathBuf::from("/what/the/fuck/are/you/abcdef-doing-monkey.md"); 93 + 94 + let id: ZettelId = path 95 + .try_into() 96 + .expect("Should be able to parse the test path just file"); 97 + 98 + assert_eq!(id.0, "abcdef".into()); 99 + 100 + let path = PathBuf::from("/what/the/fuck/are/you/abcdef.md"); 101 + 102 + let id: ZettelId = path 103 + .try_into() 104 + .expect("Should be able to parse the test path just file"); 105 + 106 + assert_eq!(id.0, "abcdef".into()); 107 + } 108 + }
+145
src/types/zettel/mod.rs
··· 1 + use std::path::Path; 2 + 3 + use dto::{ 4 + DatabaseConnection, DateTime, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModelEx, 5 + }; 6 + 7 + use color_eyre::eyre::{Context, Result}; 8 + use dto::NanoId; 9 + use tokio::{fs::File, io::AsyncWriteExt}; 10 + 11 + use crate::types::{ 12 + FrontMatter, Index, Kasten, Tag, 13 + frontmatter::{self, Body}, 14 + }; 15 + 16 + mod id; 17 + pub use id::ZettelId; 18 + 19 + /// A `Zettel` is a note about a single idea. 20 + /// It can have many `Tag`s, just meaning it can fall under many 21 + /// categories. 22 + #[derive(Debug, Clone)] 23 + pub struct Zettel { 24 + /// Should only be constructed from models. 25 + _private: (), 26 + pub id: ZettelId, 27 + pub title: String, 28 + pub created_at: DateTime, 29 + pub modified_at: DateTime, 30 + pub tags: Vec<Tag>, 31 + } 32 + 33 + impl Zettel { 34 + /// fetches the `Zettel` with the provided `ZettelId`, returning `None` if not found. 35 + pub async fn fetch_from_db(zid: &ZettelId, db: &DatabaseConnection) -> Result<Option<Self>> { 36 + Ok(ZettelEntity::load() 37 + .filter_by_nano_id(zid.0.clone()) 38 + .with(TagEntity) 39 + .one(db) 40 + .await? 41 + .map(Into::into)) 42 + } 43 + 44 + pub async fn new(title: impl Into<String>, kt: &mut Kasten) -> Result<Self> { 45 + // fn new(title: impl Into<String>) -> Result<Self> { 46 + let title = title.into(); 47 + 48 + // make a file that has a random identifier, and then 49 + // also has the name "title" 50 + let nano_id = NanoId::default(); 51 + 52 + let local_file_path = format!("{nano_id}.md"); 53 + 54 + let absolute_file_path = kt.root.clone().join(&local_file_path); 55 + 56 + // now we have to create the file 57 + let mut file = File::create_new(&absolute_file_path) 58 + .await 59 + .with_context(|| { 60 + format!("Failed to create file at local file path: {local_file_path}") 61 + })?; 62 + 63 + let inserted = ZettelActiveModel::builder() 64 + .set_title(title.clone()) 65 + .set_nano_id(nano_id) 66 + .insert(&kt.db) 67 + .await?; 68 + 69 + // need to load tags... 70 + let zettel = ZettelEntity::load() 71 + .filter_by_nano_id(inserted.nano_id) 72 + .with(TagEntity) 73 + .one(&kt.db) 74 + .await? 75 + .expect("This must exist since we just inserted it"); 76 + 77 + let front_matter = FrontMatter::new( 78 + title, 79 + zettel.created_at, 80 + zettel.tags.iter().map(|t| t.name.clone()).collect(), 81 + ); 82 + 83 + file.write_all(front_matter.to_string().as_bytes()).await?; 84 + 85 + kt.process_path(&absolute_file_path) 86 + .await 87 + .with_context(|| { 88 + format!( 89 + "Kasten fails to process new Zettel at path: {}", 90 + absolute_file_path.display(), 91 + ) 92 + })?; 93 + 94 + Ok(zettel.into()) 95 + } 96 + 97 + /// Returns the most up-to-date `FrontMatter` for this 98 + /// `Zettel` 99 + pub fn front_matter<'index>(&self, idx: &'index Index) -> &'index FrontMatter { 100 + &idx.get_zod(&self.id).fm 101 + } 102 + 103 + /// Returns the content of this `Zettel`, which is everything 104 + /// but the `FrontMatter` 105 + pub fn content<'index>(&self, idx: &'index Index) -> &'index Body { 106 + &idx.get_zod(&self.id).body 107 + } 108 + /// Get the absolute path to this `Zettel` 109 + pub fn absolute_path<'index>(&self, idx: &'index Index) -> &'index Path { 110 + &idx.get_zod(&self.id).path 111 + } 112 + 113 + /// Get the formatted creation datetime for this `Zettel` 114 + pub fn created_at(&self) -> String { 115 + self.created_at 116 + .format(frontmatter::DATE_FMT_STR) 117 + .to_string() 118 + } 119 + 120 + /// Get the formatted modified datetime for this `Zettel` 121 + pub fn modified_at(&self) -> String { 122 + self.modified_at 123 + .format(frontmatter::DATE_FMT_STR) 124 + .to_string() 125 + } 126 + } 127 + 128 + impl From<ZettelModelEx> for Zettel { 129 + fn from(value: ZettelModelEx) -> Self { 130 + assert!( 131 + !value.tags.is_unloaded(), 132 + "When fetching a Zettel from the database, we expect 133 + to always have the tags loaded!!" 134 + ); 135 + 136 + Self { 137 + _private: (), 138 + id: value.nano_id.into(), 139 + title: value.title, 140 + created_at: value.created_at, 141 + modified_at: value.modified_at, 142 + tags: value.tags.into_iter().map(Into::into).collect(), 143 + } 144 + } 145 + }