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.

feat: Index + Kasten rewrite

+648 -764
+5 -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 ws = Workspace::instansiate(conf.fil_dir).await?; 48 + let mut kt = Kasten::instansiate(conf.fil_dir).await?; 48 49 49 50 match zettel_sub_command { 50 51 ZettelSubcommand::New { title } => { 51 - let zettel = Zettel::new(title, &ws).await?; 52 + let zettel = Zettel::new(title, &mut kt).await?; 52 53 println!("Zettel Created! {zettel:#?}"); 53 54 } 54 55 ZettelSubcommand::List { by_tag: _by_tag } => {}
+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:#?}");
+32 -58
src/tui/components/zk/mod.rs
··· 1 1 use async_trait::async_trait; 2 - use color_eyre::eyre::Result; 2 + use color_eyre::eyre::{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 - ); 106 + let preview = Preview::from(zettel.content(&kt.index).clone()); 117 107 118 108 // 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(); 109 + let zettel_view: ZettelView = zettel.into(); 126 110 127 111 drop(kt); 128 112 129 113 Ok(Self { 130 114 signal_tx: None, 115 + search: Search::new(kh.clone()), 131 116 kh, 132 117 layouts: Layouts::default(), 133 118 zettel_list, 134 119 zettel_view, 135 120 preview, 136 - search: Search::new(ws), 137 121 }) 138 122 } 139 123 ··· 146 130 147 131 // sometimes the selection we get is over the length of the thing, so its 148 132 // 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 { 133 + let Some(zid) = self.zettel_list.id_list.get(selection_idx) else { 150 134 return Ok(()); 151 135 }; 152 136 153 137 let kh = self.kh.read().await; 154 138 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(); 160 - 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) 139 + let zettel = &Zettel::fetch_from_db(zid, &kh.db) 166 140 .await? 167 - .into(); 141 + .context("Unknown Behaviour, A selected zettel got deleted somehow.")?; 142 + 143 + self.zettel_view = zettel.into(); 144 + 145 + self.preview = zettel.content(&kh.index).clone().into(); 146 + 168 147 drop(kh); 169 148 170 149 Ok(()) ··· 175 154 let models = ZettelEntity::load() 176 155 .with(TagEntity) 177 156 .order_by_desc(ZettelColumns::ModifiedAt) 178 - .all(&kt.ws.db) 157 + .all(&kt.db) 179 158 .await?; 180 159 181 160 // im being a good boy and dropping this as soon as im done with the db ··· 250 229 }; 251 230 252 231 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); 232 + 233 + let path = kh.index.get_zod(zid).path.clone(); 261 234 262 235 drop(kh); 263 236 ··· 267 240 Signal::NewZettel => { 268 241 // what the fuck am i going to do in here 269 242 270 - let ws = &self.kh.read().await.ws; 243 + let mut kt = self.kh.write().await; 271 244 272 245 // we create the zettel with the query as the 273 - let z = Zettel::new(self.search.query(), ws).await?; 246 + let z = Zettel::new(self.search.query(), &mut kt).await?; 247 + let path = z.absolute_path(&kt.index).to_path_buf(); 274 248 275 - let path = z.absolute_path(ws); 249 + drop(kt); 276 250 277 251 return Ok(Some(Signal::Helix { path })); 278 252 } ··· 284 258 .selected() 285 259 .expect("This must be the zettel we just edited"); 286 260 287 - let Some(id) = self.zettel_list.id_list.get(selected) else { 261 + let Some(zid) = self.zettel_list.id_list.get(selected) else { 288 262 return Ok(None); 289 263 }; 290 264 291 265 let kt = self.kh.read().await; 292 266 293 - let node = kt 294 - .get_node_by_zettel_id(id) 295 - .expect("Invariant broken, this must exist."); 267 + let zettel = Zettel::fetch_from_db(zid, &kt.db) 268 + .await? 269 + .expect("invariant broken, we just closed this zettel"); 296 270 297 271 // reset the state of the component 298 272 self.search.clear_query(); ··· 305 279 self.zettel_list.width, 306 280 ); 307 281 308 - self.zettel_view = ZettelView::from(node.payload()); 309 - self.preview = Preview::from(node.payload().content(&kt.ws).await?); 282 + self.zettel_view = ZettelView::from(&zettel); 283 + self.preview = Preview::from(zettel.content(&kt.index).clone()); 310 284 drop(kt); 311 285 } 312 286
+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
+241
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((id, ZettelOnDisk { fm, body, path })) 53 + }) 54 + .collect::<Result<Vec<_>>>()? 55 + .into_iter() 56 + // .par_bridge() 57 + .for_each(|(id, zod)| { 58 + zods.insert(id, zod); 59 + }); 60 + 61 + Ok(Self { zods }) 62 + } 63 + 64 + pub fn update_path_for_zid(&mut self, zid: &ZettelId, new_path: PathBuf) { 65 + self.get_zod_mut(zid).path = new_path; 66 + } 67 + 68 + /// Updates the interal state of the `Index` for the provided `Zid`. 69 + pub fn process_zid(&mut self, zid: &ZettelId) -> Result<()> { 70 + let zod = self.get_zod_mut(zid); 71 + 72 + let (fm, body) = FrontMatter::extract_from_file(&zod.path)?; 73 + 74 + zod.fm = fm; 75 + zod.body = body; 76 + 77 + Ok(()) 78 + } 79 + 80 + /// Sync's the curren title of the `Zettel` with the 81 + /// provided `zid` with the `DB` 82 + pub async fn sync_title_with_db( 83 + &mut self, 84 + zid: &ZettelId, 85 + db: &DatabaseConnection, 86 + ) -> Result<()> { 87 + let fm = &mut self.get_zod_mut(zid).fm; 88 + 89 + let mut model = ZettelEntity::find_by_nano_id(zid.clone()) 90 + .one(db) 91 + .await? 92 + .expect("this must exist") 93 + .into_active_model(); 94 + 95 + model.title = ActiveValue::Set(fm.title.clone()); 96 + 97 + model.update(db).await?; 98 + 99 + info!("We updated the zettel: {zid:#?}"); 100 + 101 + Ok(()) 102 + } 103 + 104 + /// Sync's `Tag`'s that are present in the frontmatter of this 105 + /// `Zettel` to the database. 106 + pub async fn sync_tags_with_db( 107 + &mut self, 108 + zid: &ZettelId, 109 + db: &DatabaseConnection, 110 + ) -> Result<()> { 111 + let fm = &mut self.get_zod_mut(zid).fm; 112 + 113 + let mut tag_strings = fm.tag_strings.clone(); 114 + 115 + tag_strings.sort(); 116 + 117 + let db_zettel: ZettelModelEx = ZettelEntity::load() 118 + .with(TagEntity) 119 + .filter_by_nano_id(zid.clone()) 120 + .one(db) 121 + .await? 122 + .expect("Invariant broken, zettel should not be deleted"); 123 + 124 + for db_tag in db_zettel.tags { 125 + if let Ok(idx) = tag_strings.binary_search(&db_tag.name) { 126 + // we remove tags we have already processed 127 + tag_strings.remove(idx); 128 + } else { 129 + // the db says the file has tag `x`, but that tag is missing from the 130 + // front matter, we can assume its gone, lets delete that link 131 + let to_remove = ZettelTagEntity::find() 132 + .filter(ZettelTagColumns::ZettelNanoId.eq(zid.to_string())) 133 + .filter(ZettelTagColumns::TagNanoId.eq(db_tag.nano_id)) 134 + .one(db) 135 + .await? 136 + .expect("this link must exist"); 137 + 138 + to_remove.into_active_model().delete(db).await?; 139 + } 140 + } 141 + 142 + // now any tags that are left inside zettel_tag_strings, 143 + // we have to look up the tags in the db and then reset them? 144 + for tag_str in tag_strings { 145 + // this is the tag that either already exists with this name, or we just created this new one 146 + let tag = 147 + if let Some(existing) = TagEntity::load().filter_by_name(&tag_str).one(db).await? { 148 + existing 149 + } else { 150 + let am = TagActiveModel { 151 + name: ActiveValue::Set(tag_str.clone()), 152 + ..Default::default() 153 + }; 154 + 155 + am.insert(db).await?.into() 156 + }; 157 + 158 + // this zettel has this tag now 159 + let _ = ZettelTagActiveModel { 160 + zettel_nano_id: ActiveValue::Set(zid.to_string()), 161 + tag_nano_id: ActiveValue::Set(tag.nano_id.to_string()), 162 + } 163 + .insert(db) 164 + .await?; 165 + } 166 + Ok(()) 167 + } 168 + 169 + //TODO:need to process 170 + 171 + pub fn get_zod(&self, zid: &ZettelId) -> &ZettelOnDisk { 172 + 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.") 173 + } 174 + 175 + fn get_zod_mut(&mut self, zid: &ZettelId) -> &mut ZettelOnDisk { 176 + 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.") 177 + } 178 + 179 + pub const fn zods(&self) -> &HashMap<ZettelId, ZettelOnDisk> { 180 + &self.zods 181 + } 182 + 183 + pub fn sync_with_db(&self, _db: &DatabaseConnection) { 184 + todo!() 185 + } 186 + 187 + //NOTE: we dont support links just yet 188 + // fn parse_links(src: &ZettelId, body: Body) -> Result<Vec<Link>> { 189 + // let parsed = Parser::new(&body); 190 + 191 + // let mut links = vec![]; 192 + 193 + // for event in parsed { 194 + // if let Event::Start(MkTag::Link { dest_url, .. }) = event { 195 + // info!("Found dest_url: {dest_url:#?}"); 196 + 197 + // let dest_path = { 198 + // // remove leading "./" 199 + // let without_prefix = dest_url.strip_prefix("./").unwrap_or(&dest_url); 200 + 201 + // // remove "#" and everything after it 202 + // let without_anchor = without_prefix.split('#').next().unwrap(); 203 + 204 + // // add .md if not present 205 + // let normalized = if std::path::Path::new(without_anchor) 206 + // .extension() 207 + // .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) 208 + // { 209 + // without_anchor.to_string() 210 + // } else { 211 + // format!("{without_anchor}.md") 212 + // }; 213 + 214 + // let mut tmp_root = self 215 + // .zods 216 + // .get(src) 217 + // .expect("Invariant Broken! src must exist inside index") 218 + // .path 219 + // .clone(); 220 + // tmp_root.push(normalized); 221 + // tmp_root 222 + // }; 223 + // // simplest way to validate that the path exists 224 + // let Ok(canon_url) = dest_path.canonicalize() else { 225 + // error!("Link not found!: {dest_path:?}"); 226 + // continue; 227 + // }; 228 + 229 + // // TODO: check that the thing actually exists inside the ws.db 230 + // // instead of just seeing if we can turn it into a ZettelId 231 + // let dst_id = ZettelId::try_from(canon_url)?; 232 + 233 + // let link = Link::new(src.clone(), dst_id); 234 + 235 + // links.push(link); 236 + // } 237 + // } 238 + 239 + // Ok(links) 240 + // } 241 + }
+67 -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, 4 + }; 5 + 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, 8 11 }; 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}; 12 + use tracing::debug; 13 13 14 - use crate::types::Workspace; 14 + use crate::types::{Index, ZettelId}; 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, 21 + pub root: PathBuf, 24 22 25 - /// the graph of `Zettel`s and the `Links` between them 26 - pub graph: ZkGraph, 23 + pub index: Index, 27 24 28 - /// simple conversions 29 - zid_to_gid: HashMap<ZettelId, NodeIndex>, 30 - 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<_>>(); 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(); 59 37 60 - debug!( 61 - "indexing the following paths {paths:#?} at root {:#?}", 62 - ws.root 38 + let db_conn_string = format!( 39 + "sqlite://{}", 40 + root.clone() 41 + .join(".filaments/filaments.db") 42 + .canonicalize() 43 + .context("Invalid Filaments workspace!!")? 44 + .to_string_lossy() 63 45 ); 64 46 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<_>>(); 47 + debug!("connecting to {db_conn_string}"); 72 48 73 - // await all of them 74 - let zettels = futures::future::join_all(zettel_tasks) 49 + let conn = Database::connect(db_conn_string) 75 50 .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>>(); 51 + .context("Failed to connect to the database in the filaments workspace!")?; 52 + 53 + let index = Index::tabulate(&root)?; 54 + 55 + // run da migrations every time we connect, just in case 56 + Migrator::up(&conn, None).await?; 57 + 58 + Ok(Self { 59 + _private: (), 60 + db: conn, 61 + root, 62 + index, 63 + }) 64 + } 85 65 86 - debug!("parsed zettels: {zettels:#?}"); 66 + /// Create a new `Kasten` at the provided `path`. 67 + pub async fn initialize(path: impl Into<PathBuf>) -> Result<Self> { 68 + let path = path.into(); 87 69 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 - )); 70 + let filaments_dir = path.join(".filaments"); 93 71 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 - } 72 + // create the dir 73 + create_dir_all(&filaments_dir) 74 + .await 75 + .context("Failed to create the filaments directory!")?; 105 76 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 - } 77 + let filaments_dir = filaments_dir.canonicalize()?; 113 78 114 - debug!("parsed graph: {graph:#?}"); 79 + File::create(filaments_dir.join("filaments.db")).await?; 115 80 116 - Ok(Self { 117 - _private: (), 118 - ws, 119 - graph, 120 - zid_to_gid, 121 - most_recently_edited: None, 122 - }) 81 + Ok(Self::instansiate(&path).await.expect( 82 + "Invariant broken. This instantiation call must always work \ 83 + since we just initialized the workspace.", 84 + )) 123 85 } 124 86 125 87 /// processes the `Zettel` for the provided `ZettelId`, 126 88 /// meaning it updates the internal state of the `Kasten` 127 89 /// with the changes in `Zettel`. 128 - pub async fn process_path(&mut self, path: &Path) -> Result<()> { 90 + pub async fn process_path(&mut self, path: impl AsRef<Path>) -> Result<()> { 129 91 //NOTE: need to clone to get around borrowing rules but 130 92 // ideally we dont have to do this, kind of cringe imo. 131 - let ws = self.ws.clone(); 132 93 94 + let path = path.as_ref(); 133 95 let zid = ZettelId::try_from(path)?; 134 96 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(); 148 - 149 - let zid = zettel.id.clone(); 150 - let idx = self.graph.add_node(zettel); 151 - 152 - self.zid_to_gid.insert(zid.clone(), idx); 153 - 154 - gid = Some(idx); 155 - 156 - self.get_node_by_zettel_id_mut(&zid) 157 - .expect("we just inserted it") 158 - .payload_mut() 159 - }; 160 - 161 - // and then we sync with the file 162 - zettel.sync_with_file(&ws).await?; 163 - 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 171 - 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 - }); 97 + // incase the path of the zettel changed 98 + self.index.update_path_for_zid(&zid, path.to_path_buf()); 99 + // let the index process the zettel, basically update the internal state of the zod 100 + self.index.process_zid(&zid)?; 101 + // and then we sync tags 102 + self.index.sync_tags_with_db(&zid, &self.db).await?; 103 + self.index.sync_title_with_db(&zid, &self.db).await?; 192 104 193 105 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 106 } 216 107 }
+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 - }
+100
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 + }
+134
src/types/zettel/mod.rs
··· 1 + use std::path::{Path, PathBuf}; 2 + 3 + use dto::{ 4 + DatabaseConnection, DateTime, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModelEx, 5 + }; 6 + 7 + use color_eyre::eyre::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 + /// a workspace-local file path, needs to be canonicalized before usage 29 + pub file_path: PathBuf, 30 + pub created_at: DateTime, 31 + pub modified_at: DateTime, 32 + pub tags: Vec<Tag>, 33 + } 34 + 35 + impl Zettel { 36 + /// fetches the `Zettel` with the provided `ZettelId`, returning `None` if not found. 37 + pub async fn fetch_from_db(zid: &ZettelId, db: &DatabaseConnection) -> Result<Option<Self>> { 38 + Ok(ZettelEntity::load() 39 + .filter_by_nano_id(zid.0.clone()) 40 + .with(TagEntity) 41 + .one(db) 42 + .await? 43 + .map(Into::into)) 44 + } 45 + 46 + pub async fn new(title: impl Into<String>, kt: &mut Kasten) -> Result<Self> { 47 + // fn new(title: impl Into<String>) -> Result<Self> { 48 + let title = title.into(); 49 + 50 + // make a file that has a random identifier, and then 51 + // also has the name "title" 52 + let nano_id = NanoId::default(); 53 + 54 + let local_file_path = format!("{nano_id}.md"); 55 + 56 + // now we have to create the file 57 + let mut file = File::create_new(kt.root.clone().join(&local_file_path)).await?; 58 + 59 + let inserted = ZettelActiveModel::builder() 60 + .set_title(title.clone()) 61 + .set_file_path(local_file_path) 62 + .set_nano_id(nano_id) 63 + .insert(&kt.db) 64 + .await?; 65 + 66 + // need to load tags... 67 + let zettel = ZettelEntity::load() 68 + .filter_by_nano_id(inserted.nano_id) 69 + .with(TagEntity) 70 + .one(&kt.db) 71 + .await? 72 + .expect("This must exist since we just inserted it"); 73 + 74 + let front_matter = FrontMatter::new( 75 + title, 76 + zettel.created_at, 77 + zettel.tags.iter().map(|t| t.name.clone()).collect(), 78 + ); 79 + 80 + file.write_all(front_matter.to_string().as_bytes()).await?; 81 + 82 + kt.process_path(zettel.file_path.clone()).await?; 83 + 84 + Ok(zettel.into()) 85 + } 86 + 87 + /// Returns the most up-to-date `FrontMatter` for this 88 + /// `Zettel` 89 + pub fn front_matter<'index>(&self, idx: &'index Index) -> &'index FrontMatter { 90 + &idx.get_zod(&self.id).fm 91 + } 92 + 93 + /// Returns the content of this `Zettel`, which is everything 94 + /// but the `FrontMatter` 95 + pub fn content<'index>(&self, idx: &'index Index) -> &'index Body { 96 + &idx.get_zod(&self.id).body 97 + } 98 + /// Get the absolute path to this `Zettel` 99 + pub fn absolute_path<'index>(&self, idx: &'index Index) -> &'index Path { 100 + &idx.get_zod(&self.id).path 101 + } 102 + 103 + pub fn created_at(&self) -> String { 104 + self.created_at 105 + .format(frontmatter::DATE_FMT_STR) 106 + .to_string() 107 + } 108 + 109 + pub fn modified_at(&self) -> String { 110 + self.modified_at 111 + .format(frontmatter::DATE_FMT_STR) 112 + .to_string() 113 + } 114 + } 115 + 116 + impl From<ZettelModelEx> for Zettel { 117 + fn from(value: ZettelModelEx) -> Self { 118 + assert!( 119 + !value.tags.is_unloaded(), 120 + "When fetching a Zettel from the database, we expect 121 + to always have the tags loaded!!" 122 + ); 123 + 124 + Self { 125 + _private: (), 126 + id: value.nano_id.into(), 127 + title: value.title, 128 + file_path: value.file_path.into(), 129 + created_at: value.created_at, 130 + modified_at: value.modified_at, 131 + tags: value.tags.into_iter().map(Into::into).collect(), 132 + } 133 + } 134 + }