diff --git a/src/cli/process.rs b/src/cli/process.rs index b0492fe..f9a1671 100644 --- a/src/cli/process.rs +++ b/src/cli/process.rs @@ -9,7 +9,7 @@ use color_eyre::eyre::{Context, Result}; use crate::{ cli::{Commands, ZettelSubcommand}, config::{Config, get_config_dir}, - types::{Workspace, Zettel}, + types::{Kasten, Zettel}, }; impl Commands { @@ -21,7 +21,7 @@ impl Commands { .context("Failed to get current directory")? .join(&name); - Workspace::initialize(dir.clone()).await?; + Kasten::initialize(dir.clone()).await?; // write config that sets the filaments directory to current dir! let config_str = dbg! {Config::generate(&dir)}?; @@ -44,11 +44,12 @@ impl Commands { Self::Zettel(zettel_sub_command) => { let conf = Config::parse()?; - let ws = Workspace::instansiate(conf.fil_dir).await?; + // let ws = Workspace::instansiate(conf.fil_dir).await?; + let mut kt = Kasten::instansiate(conf.fil_dir).await?; match zettel_sub_command { ZettelSubcommand::New { title } => { - let zettel = Zettel::new(title, &ws).await?; + let zettel = Zettel::new(title, &mut kt).await?; println!("Zettel Created! {zettel:#?}"); } ZettelSubcommand::List { by_tag: _by_tag } => {} diff --git a/src/main.rs b/src/main.rs index 632f987..3cc62e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use crate::{ config::Config, gui::FilViz, tui::TuiApp, - types::{Kasten, KastenHandle, Workspace}, + types::{Kasten, KastenHandle}, }; use clap::Parser; use tokio::sync::RwLock; @@ -41,8 +41,9 @@ fn main() -> color_eyre::Result<()> { // create the kasten handle let kh: KastenHandle = rt.block_on(async { let cfg = Config::parse()?; - let ws = Workspace::instansiate(cfg.fil_dir).await?; - Ok::(Arc::new(RwLock::new(Kasten::index(ws).await?))) + Ok::(Arc::new(RwLock::new( + Kasten::instansiate(cfg.fil_dir).await?, + ))) })?; debug!("Kasten Handle: {kh:#?}"); diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index c1cbc9c..a53e25b 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use color_eyre::eyre::Result; +use color_eyre::eyre::{ContextCompat, Result}; use crossterm::event::KeyEvent; use dto::{QueryOrder, TagEntity, ZettelColumns, ZettelEntity}; use ratatui::{prelude::*, widgets::ListState}; @@ -58,14 +58,11 @@ impl Default for Layouts { impl Zk<'_> { pub async fn new(kh: KastenHandle) -> Result { - let kt = kh.read().await; - let ws = kt.ws.clone(); - let fetch_all = async || -> Result> { Ok(ZettelEntity::load() .with(TagEntity) .order_by_desc(ZettelColumns::ModifiedAt) - .all(&ws.db) + .all(&kh.read().await.db) .await? .into_iter() .map(Into::into) @@ -74,10 +71,8 @@ impl Zk<'_> { let mut zettels: Vec = fetch_all().await?; - drop(kt); if zettels.is_empty() { - let z = Zettel::new("Welcome!", &ws).await?; - kh.write().await.process_path(&z.absolute_path(&ws)).await?; + let _ = Zettel::new("Welcome!", &mut *kh.write().await).await?; zettels = fetch_all().await?; } @@ -85,7 +80,7 @@ impl Zk<'_> { // stuff inside the init function let mut l_state = ListState::default(); l_state.select_first(); - let zettel_list = ZettelList::new(zettels, l_state, 0); + let zettel_list = ZettelList::new(zettels.clone(), l_state, 0); let selected_zettel = zettel_list .id_list @@ -103,37 +98,26 @@ impl Zk<'_> { info!("{selected_zettel:#?}"); info!("{kt:#?}"); - let zettel = kt - .get_node_by_zettel_id(selected_zettel) - .expect("kasten should have the selected zettel") - .payload(); + let zettel = zettels + .iter() + .find(|&z| &z.id == selected_zettel) + .expect("we selected it out of the list so it must exist"); - let preview = Preview::from( - zettel - .content(&kt.ws) - .await - .expect("This thing cannot be parsed properly..."), - ); + let preview = Preview::from(zettel.content(&kt.index).clone()); // okay now that we have the zettel we need to construct the zettel out of this id - let zettel_view: ZettelView = kt - .get_node_by_zettel_id(selected_zettel) - .expect("must exist, handle case where it doesnt later...") - .payload() - .into(); - - let ws = kt.ws.clone(); + let zettel_view: ZettelView = zettel.into(); drop(kt); Ok(Self { signal_tx: None, + search: Search::new(kh.clone()), kh, layouts: Layouts::default(), zettel_list, zettel_view, preview, - search: Search::new(ws), }) } @@ -146,25 +130,20 @@ impl Zk<'_> { // sometimes the selection we get is over the length of the thing, so its // actually fine if this is none, just means we reached the end of the list - let Some(z_id) = self.zettel_list.id_list.get(selection_idx) else { + let Some(zid) = self.zettel_list.id_list.get(selection_idx) else { return Ok(()); }; let kh = self.kh.read().await; - self.zettel_view = kh - .get_node_by_zettel_id(z_id) - .expect("this should be valid unless the kasten changed out underneath us") - .payload() - .into(); - - self.preview = kh - .get_node_by_zettel_id(z_id) - .expect("this should be valid unless the kasten changed out underneath us") - .payload() - .content(&kh.ws) + let zettel = &Zettel::fetch_from_db(zid, &kh.db) .await? - .into(); + .context("Unknown Behaviour, A selected zettel got deleted somehow.")?; + + self.zettel_view = zettel.into(); + + self.preview = zettel.content(&kh.index).clone().into(); + drop(kh); Ok(()) @@ -175,7 +154,7 @@ impl Zk<'_> { let models = ZettelEntity::load() .with(TagEntity) .order_by_desc(ZettelColumns::ModifiedAt) - .all(&kt.ws.db) + .all(&kt.db) .await?; // im being a good boy and dropping this as soon as im done with the db @@ -250,14 +229,8 @@ impl Component for Zk<'_> { }; let kh = self.kh.read().await; - let path = kh - .get_node_by_zettel_id(zid) - .expect( - "This should not have - change dout underneath us", - ) - .payload() - .absolute_path(&kh.ws); + + let path = kh.index.get_zod(zid).path.clone(); drop(kh); @@ -267,12 +240,13 @@ impl Component for Zk<'_> { Signal::NewZettel => { // what the fuck am i going to do in here - let ws = &self.kh.read().await.ws; + let mut kt = self.kh.write().await; // we create the zettel with the query as the - let z = Zettel::new(self.search.query(), ws).await?; + let z = Zettel::new(self.search.query(), &mut kt).await?; + let path = z.absolute_path(&kt.index).to_path_buf(); - let path = z.absolute_path(ws); + drop(kt); return Ok(Some(Signal::Helix { path })); } @@ -284,15 +258,15 @@ impl Component for Zk<'_> { .selected() .expect("This must be the zettel we just edited"); - let Some(id) = self.zettel_list.id_list.get(selected) else { + let Some(zid) = self.zettel_list.id_list.get(selected) else { return Ok(None); }; let kt = self.kh.read().await; - let node = kt - .get_node_by_zettel_id(id) - .expect("Invariant broken, this must exist."); + let zettel = Zettel::fetch_from_db(zid, &kt.db) + .await? + .expect("invariant broken, we just closed this zettel"); // reset the state of the component self.search.clear_query(); @@ -305,8 +279,8 @@ impl Component for Zk<'_> { self.zettel_list.width, ); - self.zettel_view = ZettelView::from(node.payload()); - self.preview = Preview::from(node.payload().content(&kt.ws).await?); + self.zettel_view = ZettelView::from(&zettel); + self.preview = Preview::from(zettel.content(&kt.index).clone()); drop(kt); } diff --git a/src/tui/components/zk/search.rs b/src/tui/components/zk/search.rs index a4127c7..fa05b95 100644 --- a/src/tui/components/zk/search.rs +++ b/src/tui/components/zk/search.rs @@ -10,18 +10,18 @@ use ratatui::{ }; use ratatui_textarea::TextArea; -use crate::types::{Workspace, Zettel}; +use crate::types::{KastenHandle, Zettel}; #[derive(Clone)] pub struct Search<'text> { pub query: TextArea<'text>, layouts: Layouts, matcher: Matcher, - ws: Workspace, + kh: KastenHandle, } impl Search<'_> { - pub fn new(ws: Workspace) -> Self { + pub fn new(kh: KastenHandle) -> Self { let mut tag = TextArea::default(); tag.set_style(Style::default()); tag.set_block( @@ -34,7 +34,7 @@ impl Search<'_> { Self { matcher: Matcher::default(), query: Self::new_query(), - ws, + kh, layouts: Layouts::default(), } } @@ -76,10 +76,11 @@ impl Search<'_> { let read_tasks = zettels .into_iter() .map(|z| { - let ws = self.ws.clone(); + let kh = self.kh.clone(); tokio::spawn(async move { - let content = z.content(&ws).await?; - let front_matter = z.front_matter(&ws).await?; + let index = &kh.read().await.index; + let content = z.content(index); + let front_matter = z.front_matter(index); Ok::<(Zettel, String), Error>((z, format!("{content}\n{front_matter}"))) }) }) diff --git a/src/types/filaments.rs b/src/types/filaments.rs new file mode 100644 index 0000000..6673837 --- /dev/null +++ b/src/types/filaments.rs @@ -0,0 +1,45 @@ +#![expect(dead_code)] +use std::{cmp::max, collections::HashMap}; + +use egui_graphs::{ + Graph, + petgraph::{Directed, graph::NodeIndex, prelude::StableGraph}, +}; + +use crate::types::{Index, Link, ZettelId}; + +pub type ZkGraph = Graph; + +/// Minimum number of nodes in our graph. +const GRAPH_MIN_NODES: usize = 128; +/// Arbitrarily chosen minimum number of edges +const GRAPH_MIN_EDGES: usize = GRAPH_MIN_NODES * 3; + +pub struct Filaments { + graph: ZkGraph, + /// simple conversions + zid_to_gid: HashMap, +} + +// pub type FilamentsHandle = Arc +// + +// impl Filaments { +// pub fn construct() -> Result {} +// } + +impl From<&Index> for Filaments { + fn from(value: &Index) -> Self { + let number_of_zettels = value.zods().len(); + + let mut _graph: ZkGraph = ZkGraph::from(&StableGraph::with_capacity( + max(number_of_zettels * 2, GRAPH_MIN_EDGES), + max(number_of_zettels * 3, GRAPH_MIN_EDGES), + )); + + #[expect(clippy::for_kv_map)] + for (_id, _zod) in value.zods() {} + + todo!() + } +} diff --git a/src/types/frontmatter.rs b/src/types/frontmatter.rs index 89b7b3b..e8bf941 100644 --- a/src/types/frontmatter.rs +++ b/src/types/frontmatter.rs @@ -4,7 +4,6 @@ use std::{fmt::Display, path::Path}; use color_eyre::eyre::{Result, eyre}; use egui_graphs::Node; use serde::{Deserialize, Serialize}; -use tokio::fs; use dto::DateTime; @@ -19,6 +18,8 @@ pub struct FrontMatter { pub tag_strings: Vec, } +pub type Body = String; + impl FrontMatter { pub fn new( title: impl Into, @@ -51,9 +52,9 @@ impl FrontMatter { /// Tags: Daily barber /// --- /// ``` - pub async fn extract_from_file(path: impl AsRef) -> Result<(Self, String)> { + pub fn extract_from_file(path: impl AsRef) -> Result<(Self, Body)> { let path = path.as_ref(); - let string = fs::read_to_string(path).await?; + let string = std::fs::read_to_string(path)?; Self::extract_from_str(&string) } @@ -66,7 +67,7 @@ impl FrontMatter { /// Tags: Daily barber /// --- /// ``` - pub fn extract_from_str(string: impl Into) -> Result<(Self, String)> { + pub fn extract_from_str(string: impl Into) -> Result<(Self, Body)> { let string: String = string.into(); // we just want to strictly match this, else we error diff --git a/src/types/index.rs b/src/types/index.rs new file mode 100644 index 0000000..e4b8d5d --- /dev/null +++ b/src/types/index.rs @@ -0,0 +1,241 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use color_eyre::eyre::Result; +use dto::{ + ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, + QueryFilter, TagActiveModel, TagEntity, ZettelEntity, ZettelModelEx, ZettelTagActiveModel, + ZettelTagColumns, ZettelTagEntity, +}; +use rayon::iter::{ParallelBridge, ParallelIterator}; +use tracing::info; + +use crate::types::{FrontMatter, ZettelId, frontmatter::Body}; + +#[derive(Debug, Clone)] +pub struct Index { + pub(super) zods: HashMap, +} + +#[derive(Debug, Clone)] +pub struct ZettelOnDisk { + pub fm: FrontMatter, + pub body: Body, + pub path: PathBuf, +} + +impl Index { + /// Parses the `root` path to construct an `Index`. + pub fn tabulate(root: &Path) -> Result { + let root = root.canonicalize()?; + + let mut zods = HashMap::new(); + + std::fs::read_dir(root)? + .par_bridge() + .flatten() + .filter(|entry| { + entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) + && entry + .path() + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "md") + }) + .map(|entry| -> Result<(ZettelId, ZettelOnDisk)> { + let path = entry.path(); + let id: ZettelId = path.as_path().try_into()?; + let (fm, body) = FrontMatter::extract_from_file(&path)?; + + Ok((id, ZettelOnDisk { fm, body, path })) + }) + .collect::>>()? + .into_iter() + // .par_bridge() + .for_each(|(id, zod)| { + zods.insert(id, zod); + }); + + Ok(Self { zods }) + } + + pub fn update_path_for_zid(&mut self, zid: &ZettelId, new_path: PathBuf) { + self.get_zod_mut(zid).path = new_path; + } + + /// Updates the interal state of the `Index` for the provided `Zid`. + pub fn process_zid(&mut self, zid: &ZettelId) -> Result<()> { + let zod = self.get_zod_mut(zid); + + let (fm, body) = FrontMatter::extract_from_file(&zod.path)?; + + zod.fm = fm; + zod.body = body; + + Ok(()) + } + + /// Sync's the curren title of the `Zettel` with the + /// provided `zid` with the `DB` + pub async fn sync_title_with_db( + &mut self, + zid: &ZettelId, + db: &DatabaseConnection, + ) -> Result<()> { + let fm = &mut self.get_zod_mut(zid).fm; + + let mut model = ZettelEntity::find_by_nano_id(zid.clone()) + .one(db) + .await? + .expect("this must exist") + .into_active_model(); + + model.title = ActiveValue::Set(fm.title.clone()); + + model.update(db).await?; + + info!("We updated the zettel: {zid:#?}"); + + Ok(()) + } + + /// Sync's `Tag`'s that are present in the frontmatter of this + /// `Zettel` to the database. + pub async fn sync_tags_with_db( + &mut self, + zid: &ZettelId, + db: &DatabaseConnection, + ) -> Result<()> { + let fm = &mut self.get_zod_mut(zid).fm; + + let mut tag_strings = fm.tag_strings.clone(); + + tag_strings.sort(); + + let db_zettel: ZettelModelEx = ZettelEntity::load() + .with(TagEntity) + .filter_by_nano_id(zid.clone()) + .one(db) + .await? + .expect("Invariant broken, zettel should not be deleted"); + + for db_tag in db_zettel.tags { + if let Ok(idx) = tag_strings.binary_search(&db_tag.name) { + // we remove tags we have already processed + tag_strings.remove(idx); + } else { + // the db says the file has tag `x`, but that tag is missing from the + // front matter, we can assume its gone, lets delete that link + let to_remove = ZettelTagEntity::find() + .filter(ZettelTagColumns::ZettelNanoId.eq(zid.to_string())) + .filter(ZettelTagColumns::TagNanoId.eq(db_tag.nano_id)) + .one(db) + .await? + .expect("this link must exist"); + + to_remove.into_active_model().delete(db).await?; + } + } + + // now any tags that are left inside zettel_tag_strings, + // we have to look up the tags in the db and then reset them? + for tag_str in tag_strings { + // this is the tag that either already exists with this name, or we just created this new one + let tag = + if let Some(existing) = TagEntity::load().filter_by_name(&tag_str).one(db).await? { + existing + } else { + let am = TagActiveModel { + name: ActiveValue::Set(tag_str.clone()), + ..Default::default() + }; + + am.insert(db).await?.into() + }; + + // this zettel has this tag now + let _ = ZettelTagActiveModel { + zettel_nano_id: ActiveValue::Set(zid.to_string()), + tag_nano_id: ActiveValue::Set(tag.nano_id.to_string()), + } + .insert(db) + .await?; + } + Ok(()) + } + + //TODO:need to process + + pub fn get_zod(&self, zid: &ZettelId) -> &ZettelOnDisk { + 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.") + } + + fn get_zod_mut(&mut self, zid: &ZettelId) -> &mut ZettelOnDisk { + 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.") + } + + pub const fn zods(&self) -> &HashMap { + &self.zods + } + + pub fn sync_with_db(&self, _db: &DatabaseConnection) { + todo!() + } + + //NOTE: we dont support links just yet + // fn parse_links(src: &ZettelId, body: Body) -> Result> { + // let parsed = Parser::new(&body); + + // let mut links = vec![]; + + // for event in parsed { + // if let Event::Start(MkTag::Link { dest_url, .. }) = event { + // info!("Found dest_url: {dest_url:#?}"); + + // let dest_path = { + // // remove leading "./" + // let without_prefix = dest_url.strip_prefix("./").unwrap_or(&dest_url); + + // // remove "#" and everything after it + // let without_anchor = without_prefix.split('#').next().unwrap(); + + // // add .md if not present + // let normalized = if std::path::Path::new(without_anchor) + // .extension() + // .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) + // { + // without_anchor.to_string() + // } else { + // format!("{without_anchor}.md") + // }; + + // let mut tmp_root = self + // .zods + // .get(src) + // .expect("Invariant Broken! src must exist inside index") + // .path + // .clone(); + // tmp_root.push(normalized); + // tmp_root + // }; + // // simplest way to validate that the path exists + // let Ok(canon_url) = dest_path.canonicalize() else { + // error!("Link not found!: {dest_path:?}"); + // continue; + // }; + + // // TODO: check that the thing actually exists inside the ws.db + // // instead of just seeing if we can turn it into a ZettelId + // let dst_id = ZettelId::try_from(canon_url)?; + + // let link = Link::new(src.clone(), dst_id); + + // links.push(link); + // } + // } + + // Ok(links) + // } +} diff --git a/src/types/kasten.rs b/src/types/kasten.rs index ae39f6a..70e3840 100644 --- a/src/types/kasten.rs +++ b/src/types/kasten.rs @@ -1,216 +1,107 @@ -use crate::types::{Link, Zettel, ZettelId}; -use color_eyre::eyre::Result; -use dto::{TagEntity, ZettelEntity}; -use eframe::emath; -use egui_graphs::{ - Graph, Node, - petgraph::{Directed, Direction, graph::NodeIndex, prelude::StableGraph, visit::EdgeRef}, +use std::{ + path::{Path, PathBuf}, + sync::Arc, }; -use rayon::iter::{ParallelBridge as _, ParallelIterator as _}; -use std::{cmp::max, collections::HashMap, path::Path, sync::Arc}; -use tokio::sync::RwLock; -use tracing::{debug, error}; -use crate::types::Workspace; +use color_eyre::eyre::{Context, Result}; +use dto::{Database, DatabaseConnection, Migrator, MigratorTrait}; +use tokio::{ + fs::{File, create_dir_all}, + sync::RwLock, +}; +use tracing::debug; + +use crate::types::{Index, ZettelId}; #[derive(Debug, Clone)] -#[expect(dead_code)] pub struct Kasten { /// Private field so it can only be instantiated from a `Path` _private: (), - /// The workspace this `Kasten` is in - pub ws: Workspace, - - /// the graph of `Zettel`s and the `Links` between them - pub graph: ZkGraph, + pub root: PathBuf, - /// simple conversions - zid_to_gid: HashMap, + pub index: Index, - pub most_recently_edited: Option, + pub db: DatabaseConnection, } -pub type ZkGraph = Graph; - -/// Minimum number of nodes in our graph. -const GRAPH_MIN_NODES: usize = 128; -/// Arbitrarily chosen minimum number of edges -const GRAPH_MIN_EDGES: usize = GRAPH_MIN_NODES * 3; - pub type KastenHandle = Arc>; impl Kasten { - /// Indexes the `Workspace` and constructs a `Kasten` - pub async fn index(ws: Workspace) -> Result { - let paths = std::fs::read_dir(&ws.root)? - .par_bridge() - .flatten() - .filter(|entry| { - entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) - && entry - .path() - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext == "md") - }) - .map(|entry| entry.path()) - .collect::>(); - - debug!( - "indexing the following paths {paths:#?} at root {:#?}", - ws.root + /// Given a path, try to construct a `Kasten` based on its contents. + /// + /// Note: this means that there should already exist a valid `Kasten` + /// at that path. + pub async fn instansiate(root: impl Into) -> Result { + let root = root.into(); + + let db_conn_string = format!( + "sqlite://{}", + root.clone() + .join(".filaments/filaments.db") + .canonicalize() + .context("Invalid Filaments workspace!!")? + .to_string_lossy() ); - let zettel_tasks = paths - .into_iter() - .map(|path| { - let ws = ws.clone(); - tokio::spawn(async move { Zettel::from_path(path, &ws).await }) - }) - .collect::>(); + debug!("connecting to {db_conn_string}"); - // await all of them - let zettels = futures::future::join_all(zettel_tasks) + let conn = Database::connect(db_conn_string) .await - .into_iter() - .filter_map(|result| { - result - .inspect_err(|e| error!("Failed to join on zettel task parsing: {e:#?}")) - .ok()? - .inspect_err(|e| error!("Failed to parse file into zettel: {e:#?}")) - .ok() - }) - .collect::>(); - - debug!("parsed zettels: {zettels:#?}"); - - // capacity! - let mut graph: ZkGraph = ZkGraph::from(&StableGraph::with_capacity( - max(zettels.len() * 2, GRAPH_MIN_EDGES), - max(zettels.len() * 3, GRAPH_MIN_EDGES), - )); - - let mut zid_to_gid = HashMap::new(); - for zettel in &zettels { - let fm = zettel.front_matter(&ws).await?; - let id = graph.add_node_custom(zettel.clone(), |node| { - fm.apply_node_transform(node); - let x = rand::random_range(0.0..=100.0); - let y = rand::random_range(0.0..=100.0); - node.set_location(emath::Pos2 { x, y }); - }); - zid_to_gid.insert(zettel.id.clone(), id); - } - - for zettel in &zettels { - let src = zid_to_gid.get(&zettel.id).expect("must exist"); - for link in &zettel.links(&ws).await? { - let dst = zid_to_gid.get(&link.dest).expect("must exist"); - graph.add_edge(*src, *dst, link.clone()); - } - } - - debug!("parsed graph: {graph:#?}"); + .context("Failed to connect to the database in the filaments workspace!")?; + + let index = Index::tabulate(&root)?; + + // run da migrations every time we connect, just in case + Migrator::up(&conn, None).await?; Ok(Self { _private: (), - ws, - graph, - zid_to_gid, - most_recently_edited: None, + db: conn, + root, + index, }) } + /// Create a new `Kasten` at the provided `path`. + pub async fn initialize(path: impl Into) -> Result { + let path = path.into(); + + let filaments_dir = path.join(".filaments"); + + // create the dir + create_dir_all(&filaments_dir) + .await + .context("Failed to create the filaments directory!")?; + + let filaments_dir = filaments_dir.canonicalize()?; + + File::create(filaments_dir.join("filaments.db")).await?; + + Ok(Self::instansiate(&path).await.expect( + "Invariant broken. This instantiation call must always work \ + since we just initialized the workspace.", + )) + } + /// processes the `Zettel` for the provided `ZettelId`, /// meaning it updates the internal state of the `Kasten` /// with the changes in `Zettel`. - pub async fn process_path(&mut self, path: &Path) -> Result<()> { + pub async fn process_path(&mut self, path: impl AsRef) -> Result<()> { //NOTE: need to clone to get around borrowing rules but // ideally we dont have to do this, kind of cringe imo. - let ws = self.ws.clone(); + let path = path.as_ref(); let zid = ZettelId::try_from(path)?; - let mut gid = self.zid_to_gid.get(&zid).copied(); - // sometimes this zid is new, so it wont be in the kasten - let zettel = if let Some(existing) = self.get_node_by_zettel_id_mut(&zid) { - existing.payload_mut() - } else { - // this should aleady be in the database though so lets get it from there first - let zettel: Zettel = ZettelEntity::load() - .filter_by_nano_id(zid) - .with(TagEntity) - .one(&ws.db) - .await? - .expect("This should be in the database already") - .into(); - - let zid = zettel.id.clone(); - let idx = self.graph.add_node(zettel); - - self.zid_to_gid.insert(zid.clone(), idx); - - gid = Some(idx); - - self.get_node_by_zettel_id_mut(&zid) - .expect("we just inserted it") - .payload_mut() - }; - - // and then we sync with the file - zettel.sync_with_file(&ws).await?; - - // to get past borrowchecker rules - let zettel = zettel.clone(); - - // gid must be set - let gid = gid.unwrap(); - - // and now we manage the links going out of the file - - // remove all the old shit - self.graph - .edges_directed(gid, Direction::Outgoing) - .map(|e| e.id()) - .collect::>() - .into_iter() - .for_each(|e| { - let _ = self.graph.remove_edge(e); - }); - - // add the links that actually exist - zettel.links(&ws).await?.into_iter().for_each(|link| { - // this is an option because a user c - let dest = self - .zid_to_gid - .get(&link.dest) - .expect("Links should be valid"); - - self.graph.add_edge(gid, *dest, link); - }); + // incase the path of the zettel changed + self.index.update_path_for_zid(&zid, path.to_path_buf()); + // let the index process the zettel, basically update the internal state of the zod + self.index.process_zid(&zid)?; + // and then we sync tags + self.index.sync_tags_with_db(&zid, &self.db).await?; + self.index.sync_title_with_db(&zid, &self.db).await?; Ok(()) } - - pub fn get_node_by_zettel_id(&self, id: &ZettelId) -> Option<&Node> { - let idx = self.zid_to_gid.get(id)?; - - let node = self.graph.node(*idx).expect( - "invariant broken if internal hashmap is not uptodate with - the state of the graph...", - ); - Some(node) - } - - pub fn get_node_by_zettel_id_mut(&mut self, id: &ZettelId) -> Option<&mut Node> { - let idx = self.zid_to_gid.get(id)?; - - let node = self.graph.node_mut(*idx).expect( - "invariant broken if internal hashmap is not uptodate with the - state of the graph...", - ); - - Some(node) - } } diff --git a/src/types/mod.rs b/src/types/mod.rs index 13b2415..3be8929 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -18,12 +18,16 @@ mod task; #[expect(unused_imports)] pub use task::Task; -mod workspace; -pub use workspace::Workspace; - mod link; pub use link::Link; +mod filaments; +#[expect(unused_imports)] +pub use filaments::Filaments; + +mod index; +pub use index::Index; + mod kasten; pub use kasten::Kasten; pub use kasten::KastenHandle; diff --git a/src/types/workspace.rs b/src/types/workspace.rs deleted file mode 100644 index e56bd4b..0000000 --- a/src/types/workspace.rs +++ /dev/null @@ -1,95 +0,0 @@ -use std::path::PathBuf; - -use color_eyre::eyre::{Context, Result}; -use dto::{Database, DatabaseConnection, Migrator, MigratorTrait}; -use tokio::fs::{File, create_dir_all}; -use tracing::debug; - -/// The `Workspace` in which the filaments exist. -#[derive(Debug, Clone)] -pub struct Workspace { - /// Private field so it can only be instantiated from a `Path` - _private: (), - /// Connection to the sqlite database inside the `Workspace` - pub db: DatabaseConnection, - /// The path to the root of this workspace - pub root: PathBuf, -} - -impl Workspace { - /// Given a path, try to construct a `Workspace` based on its contents. - /// - /// Note: this means that there should already exist a valid `Workspace` - /// at that path. - pub async fn instansiate(path: impl Into) -> Result { - let path = path.into(); - - let db_conn_string = format!( - "sqlite://{}", - path.clone() - .join(".filaments/filaments.db") - .canonicalize() - .context("Invalid Filaments workspace!!")? - .to_string_lossy() - ); - - debug!("connecting to {db_conn_string}"); - - let conn = Database::connect(db_conn_string) - .await - .context("Failed to connect to the database in the filaments workspace!")?; - - // run da migrations every time we connect, just in case - Migrator::up(&conn, None).await?; - - Ok(Self { - _private: (), - db: conn, - root: path, - }) - } - - pub async fn initialize(path: impl Into) -> Result { - let path = path.into(); - - let filaments_dir = path.join(".filaments"); - - // create the dir - create_dir_all(&filaments_dir) - .await - .context("Failed to create the filaments directory!")?; - - let filaments_dir = filaments_dir.canonicalize()?; - - File::create(filaments_dir.join("filaments.db")).await?; - - Ok(Self::instansiate(&path).await.expect( - "Invariant broken. This instantiation call must always work \ - since we just initialized the workspace.", - )) - } -} - -#[cfg(test)] -mod tests { - - use crate::types::Workspace; - - #[tokio::test] - async fn test_instantiation() { - let tmp = tempfile::tempdir().unwrap(); - let filaments_dir = tmp.path().join(".filaments"); - std::fs::create_dir_all(&filaments_dir).unwrap(); - std::fs::File::create(filaments_dir.join("filaments.db")).unwrap(); - let _ws = Workspace::instansiate(tmp.path()).await.unwrap(); - } - - #[tokio::test] - async fn test_initialization() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("workspace"); - Workspace::initialize(path) - .await - .expect("Should initialize just fine"); - } -} diff --git a/src/types/zettel.rs b/src/types/zettel.rs deleted file mode 100644 index d92b64d..0000000 --- a/src/types/zettel.rs +++ /dev/null @@ -1,414 +0,0 @@ -use dto::{ - ActiveModelTrait, ActiveValue, ColumnTrait, DateTime, EntityTrait as _, IntoActiveModel, - QueryFilter, TagActiveModel, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModel, - ZettelModelEx, ZettelTagActiveModel, ZettelTagColumns, ZettelTagEntity, -}; -use pulldown_cmark::{Event, Parser, Tag as MkTag}; -use serde::{Deserialize, Serialize}; -use std::{ - fmt::Display, - path::{Path, PathBuf}, -}; -use tracing::{error, info}; - -use color_eyre::eyre::{Error, Result, eyre}; -use dto::NanoId; -use tokio::{fs::File, io::AsyncWriteExt}; - -use crate::types::{FrontMatter, Link, Tag, Workspace, frontmatter}; - -/// A `Zettel` is a note about a single idea. -/// It can have many `Tag`s, just meaning it can fall under many -/// categories. -#[derive(Debug, Clone)] -pub struct Zettel { - /// Should only be constructed from models. - _private: (), - pub id: ZettelId, - pub title: String, - /// a workspace-local file path, needs to be canonicalized before usage - pub file_path: PathBuf, - pub created_at: DateTime, - pub modified_at: DateTime, - pub tags: Vec, -} - -/// A `ZettelId` is essentially a `NanoId`, -/// with some `Zettel` specific helpers written -/// onto it -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ZettelId(NanoId); - -impl Zettel { - pub async fn new(title: impl Into, ws: &Workspace) -> Result { - // fn new(title: impl Into) -> Result { - let title = title.into(); - - // make a file that has a random identifier, and then - // also has the name "title" - let nano_id = NanoId::default(); - - let local_file_path = format!("{nano_id}.md"); - - // now we have to create the file - let mut file = File::create_new(ws.root.clone().join(&local_file_path)).await?; - - let inserted = ZettelActiveModel::builder() - .set_title(title.clone()) - .set_file_path(local_file_path) - .set_nano_id(nano_id) - .insert(&ws.db) - .await?; - - // need to load tags... - let zettel = ZettelEntity::load() - .filter_by_nano_id(inserted.nano_id) - .with(TagEntity) - .one(&ws.db) - .await? - .expect("This must exist since we just inserted it"); - - let front_matter = FrontMatter::new( - title, - zettel.created_at, - zettel.tags.iter().map(|t| t.name.clone()).collect(), - ); - - file.write_all(front_matter.to_string().as_bytes()).await?; - - Ok(zettel.into()) - } - - pub async fn sync_with_file(&mut self, ws: &Workspace) -> Result<()> { - let (fm, _) = FrontMatter::extract_from_file(self.absolute_path(ws)).await?; - - let mut model = ZettelEntity::find_by_nano_id(self.id.clone()) - .one(&ws.db) - .await? - .expect("this must exist") - .into_active_model(); - - model.title = ActiveValue::Set(fm.title); - - let updated: ZettelModel = model.update(&ws.db).await?; - - self.title = updated.title; - self.modified_at = updated.modified_at; - self.created_at = updated.created_at; - - self.sync_tags(ws).await?; - - Ok(()) - } - - /// Sync's `Tag`'s that are present in the frontmatter of this - /// `Zettel` to the database, and then updates the `Tag`s on the - /// `Zettel` to reflect the changes. - pub async fn sync_tags(&mut self, ws: &Workspace) -> Result<()> { - let mut fm = self.front_matter(ws).await?; - fm.tag_strings.sort(); - - let mut tag_strings = fm.tag_strings; - - let Some(db_zettel): Option = ZettelEntity::load() - .with(TagEntity) - .filter_by_nano_id(self.id.clone()) - .one(&ws.db) - .await? - else { - panic!("how the fuck was this deleted"); - }; - - for db_tag in db_zettel.tags { - if let Ok(idx) = tag_strings.binary_search(&db_tag.name) { - // we remove tags we have already processed - tag_strings.remove(idx); - } else { - // the db says the file has tag `x`, but that tag is missing from the - // front matter, we can assume its gone, lets delete that link - let to_remove = ZettelTagEntity::find() - .filter(ZettelTagColumns::ZettelNanoId.eq(self.id.0.clone())) - .filter(ZettelTagColumns::TagNanoId.eq(db_tag.nano_id)) - .one(&ws.db) - .await? - .expect("this link must exist"); - - to_remove.into_active_model().delete(&ws.db).await?; - } - } - - // now any tags that are left inside zettel_tag_strings, - // we have to look up the tags in the db and then reset them? - for tag_str in tag_strings { - // this is the tag that either already exists with this name, or we just created this new one - let tag = if let Some(existing) = TagEntity::load() - .filter_by_name(&tag_str) - .one(&ws.db) - .await? - { - existing - } else { - let am = TagActiveModel { - name: ActiveValue::Set(tag_str), - ..Default::default() - }; - - am.insert(&ws.db).await?.into() - }; - - // this zettel has this tag now - let _ = ZettelTagActiveModel { - zettel_nano_id: ActiveValue::Set(self.id.to_string()), - tag_nano_id: ActiveValue::Set(tag.nano_id.to_string()), - } - .insert(&ws.db) - .await?; - } - - let entity = ZettelEntity::load() - .with(TagEntity) - .filter_by_nano_id(self.id.clone()) - .one(&ws.db) - .await? - .expect("this exists"); - - let temp_zettel: Self = entity.into(); - - self.tags = temp_zettel.tags; - - Ok(()) - } - - /// Returns the most up-to-date `FrontMatter` for this - /// `Zettel` - pub async fn front_matter(&self, ws: &Workspace) -> Result { - let path = self.absolute_path(ws); - let (fm, _) = FrontMatter::extract_from_file(path).await?; - Ok(fm) - } - - /// Returns the content of this `Zettel`, which is everything - /// but the `FrontMatter` - pub async fn content(&self, ws: &Workspace) -> Result { - let path = self.absolute_path(ws); - let (_, content) = FrontMatter::extract_from_file(path).await?; - Ok(content) - } - - #[expect(dead_code)] - async fn open_file(&self, ws: &Workspace) -> Result { - let path = self.absolute_path(ws); - Ok(File::open(path).await?) - } - - pub fn absolute_path(&self, ws: &Workspace) -> PathBuf { - ws.root.clone().join(&self.file_path) - } - - /// uses the id and root to parse out of the root directory - pub async fn from_id(id: &ZettelId, ws: &Workspace) -> Result { - let mut path = ws.root.clone(); - path.push(id.0.to_string()); - Self::from_path(path, ws).await - } - - pub fn created_at(&self) -> String { - self.created_at - .format(frontmatter::DATE_FMT_STR) - .to_string() - } - - pub fn modified_at(&self) -> String { - self.modified_at - .format(frontmatter::DATE_FMT_STR) - .to_string() - } - - pub async fn from_path(path: impl Into, ws: &Workspace) -> Result { - let path: PathBuf = path.into(); - - let id = ZettelId::try_from(path.as_path())?; - - let (front_matter, _) = FrontMatter::extract_from_file(&ws.root.clone().join(path)).await?; - - // get the zettel from the db - let db_zettel: ZettelModelEx = if let Some(existing_zettel) = ZettelEntity::load() - .with(TagEntity) - .filter_by_nano_id(id.clone()) - .one(&ws.db) - .await? - { - existing_zettel - } else { - // if zettel is missing from db, we just add it here - info!("adding zettel to db"); - let am = ZettelActiveModel { - nano_id: ActiveValue::Set(id.clone().into()), - title: ActiveValue::Set(front_matter.title.clone()), - ..Default::default() - }; - - am.insert(&ws.db).await?; - - ZettelEntity::load() - .with(TagEntity) - .filter_by_nano_id(id.clone()) - .one(&ws.db) - .await? - .expect("we just inserted the zettel") - }; - - let mut temp_zettel: Self = db_zettel.clone().into(); - temp_zettel.sync_tags(ws).await?; - - if front_matter.title != db_zettel.title { - let mut am = db_zettel.into_active_model(); - am.title = ActiveValue::Set(front_matter.title.clone()); - am.update(&ws.db).await?; - } - - Ok(ZettelEntity::load() - .with(TagEntity) - .filter_by_nano_id(id.clone()) - .one(&ws.db) - .await? - .expect("We just inserted it right above") - .into()) - } - - /// The `Link`s that are going out of this `Zettel` - pub async fn links(&self, ws: &Workspace) -> Result> { - let content = self.content(ws).await?; - let parsed = Parser::new(&content); - - let mut links = vec![]; - - for event in parsed { - if let Event::Start(MkTag::Link { dest_url, .. }) = event { - info!("Found dest_url: {dest_url:#?}"); - - let dest_path = { - // remove leading "./" - let without_prefix = dest_url.strip_prefix("./").unwrap_or(&dest_url); - - // remove "#" and everything after it - let without_anchor = without_prefix.split('#').next().unwrap(); - - // add .md if not present - let normalized = if std::path::Path::new(without_anchor) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) - { - without_anchor.to_string() - } else { - format!("{without_anchor}.md") - }; - - let mut tmp_root = ws.root.clone(); - tmp_root.push(normalized); - tmp_root - }; - // simplest way to validate that the path exists - let Ok(canon_url) = dest_path.canonicalize() else { - error!("Link not found!: {dest_path:?}"); - continue; - }; - - // TODO: check that the thing actually exists inside the ws.db - // instead of just seeing if we can turn it into a ZettelId - let dst_id = ZettelId::try_from(canon_url)?; - - let link = Link::new(self.id.clone(), dst_id); - - links.push(link); - } - } - - Ok(links) - } -} - -impl From for Zettel { - fn from(value: ZettelModelEx) -> Self { - assert!( - !value.tags.is_unloaded(), - "When fetching a Zettel from the database, we expect - to always have the tags loaded!!" - ); - - Self { - _private: (), - id: value.nano_id.into(), - title: value.title, - file_path: value.file_path.into(), - created_at: value.created_at, - modified_at: value.modified_at, - tags: value.tags.into_iter().map(Into::into).collect(), - } - } -} - -impl From<&str> for ZettelId { - fn from(value: &str) -> Self { - Self(NanoId::from(value)) - } -} - -impl From<&NanoId> for ZettelId { - fn from(value: &NanoId) -> Self { - value.clone().into() - } -} - -impl From for ZettelId { - fn from(value: NanoId) -> Self { - Self(value) - } -} - -impl TryFrom for ZettelId { - type Error = Error; - - fn try_from(value: PathBuf) -> Result { - let path = value.as_path(); - path.try_into() - } -} - -impl TryFrom<&Path> for ZettelId { - type Error = Error; - - fn try_from(value: &Path) -> Result { - let extension = value - .extension() - .and_then(|ext| ext.to_str()) - .ok_or_else(|| eyre!("Unable to turn file extension into string".to_owned(),))?; - - if extension != "md" { - return Err(eyre!(format!("Wrong extension: {extension}, expected .md"))); - } - - let id: Self = value - .file_name() - .ok_or_else(|| eyre!("Invalid File Name!".to_owned()))? - .to_str() - .ok_or_else(|| eyre!("File Name cannot be translated into str!".to_owned(),))? - .strip_suffix(".md") - .expect("we statically verify this right above") - .into(); - - Ok(id) - } -} - -impl Display for ZettelId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0.to_string()) - } -} - -impl From for NanoId { - fn from(value: ZettelId) -> Self { - value.0 - } -} diff --git a/src/types/zettel/id.rs b/src/types/zettel/id.rs new file mode 100644 index 0000000..9f02719 --- /dev/null +++ b/src/types/zettel/id.rs @@ -0,0 +1,100 @@ +use std::{ + fmt::Display, + path::{Path, PathBuf}, +}; + +use color_eyre::eyre::{Error, eyre}; +use dto::NanoId; +use serde::{Deserialize, Serialize}; + +/// A `ZettelId` is essentially a `NanoId`, +/// with some `Zettel` specific helpers written +/// onto it +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ZettelId(pub(super) NanoId); + +impl From<&str> for ZettelId { + fn from(value: &str) -> Self { + Self(NanoId::from(value)) + } +} + +impl From<&NanoId> for ZettelId { + fn from(value: &NanoId) -> Self { + value.clone().into() + } +} + +impl From for ZettelId { + fn from(value: NanoId) -> Self { + Self(value) + } +} + +impl TryFrom for ZettelId { + type Error = Error; + + fn try_from(value: PathBuf) -> Result { + let path = value.as_path(); + path.try_into() + } +} + +impl TryFrom<&Path> for ZettelId { + type Error = Error; + + fn try_from(value: &Path) -> Result { + let extension = value + .extension() + .and_then(|ext| ext.to_str()) + .ok_or_else(|| eyre!("Unable to turn file extension into string".to_owned(),))?; + + if extension != "md" { + return Err(eyre!(format!("Wrong extension: {extension}, expected .md"))); + } + + let id: Self = (value + .file_name() + .ok_or_else(|| eyre!("Invalid File Name!".to_owned()))? + .to_str() + .ok_or_else(|| eyre!("File Name cannot be translated into str!".to_owned(),))? + .strip_suffix(".md") + .expect("we statically verify this right above") + .split('-')) + .next() + .ok_or_else(|| eyre!("Unable to get the first part of the file name!"))? + .into(); + + Ok(id) + } +} + +impl Display for ZettelId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0.to_string()) + } +} + +impl From for NanoId { + fn from(value: ZettelId) -> Self { + value.0 + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[tokio::test] + async fn test_zettel_id_parsing_from_path() { + let path = PathBuf::from("/what/the/fuck/are/you/abcdef-doing-monkey.md"); + + let id: ZettelId = path + .try_into() + .expect("Should be able to parse the test path just file"); + + assert_eq!(id.0, "abcdef".into()); + } +} diff --git a/src/types/zettel/mod.rs b/src/types/zettel/mod.rs new file mode 100644 index 0000000..93de993 --- /dev/null +++ b/src/types/zettel/mod.rs @@ -0,0 +1,134 @@ +use std::path::{Path, PathBuf}; + +use dto::{ + DatabaseConnection, DateTime, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModelEx, +}; + +use color_eyre::eyre::Result; +use dto::NanoId; +use tokio::{fs::File, io::AsyncWriteExt}; + +use crate::types::{ + FrontMatter, Index, Kasten, Tag, + frontmatter::{self, Body}, +}; + +mod id; +pub use id::ZettelId; + +/// A `Zettel` is a note about a single idea. +/// It can have many `Tag`s, just meaning it can fall under many +/// categories. +#[derive(Debug, Clone)] +pub struct Zettel { + /// Should only be constructed from models. + _private: (), + pub id: ZettelId, + pub title: String, + /// a workspace-local file path, needs to be canonicalized before usage + pub file_path: PathBuf, + pub created_at: DateTime, + pub modified_at: DateTime, + pub tags: Vec, +} + +impl Zettel { + /// fetches the `Zettel` with the provided `ZettelId`, returning `None` if not found. + pub async fn fetch_from_db(zid: &ZettelId, db: &DatabaseConnection) -> Result> { + Ok(ZettelEntity::load() + .filter_by_nano_id(zid.0.clone()) + .with(TagEntity) + .one(db) + .await? + .map(Into::into)) + } + + pub async fn new(title: impl Into, kt: &mut Kasten) -> Result { + // fn new(title: impl Into) -> Result { + let title = title.into(); + + // make a file that has a random identifier, and then + // also has the name "title" + let nano_id = NanoId::default(); + + let local_file_path = format!("{nano_id}.md"); + + // now we have to create the file + let mut file = File::create_new(kt.root.clone().join(&local_file_path)).await?; + + let inserted = ZettelActiveModel::builder() + .set_title(title.clone()) + .set_file_path(local_file_path) + .set_nano_id(nano_id) + .insert(&kt.db) + .await?; + + // need to load tags... + let zettel = ZettelEntity::load() + .filter_by_nano_id(inserted.nano_id) + .with(TagEntity) + .one(&kt.db) + .await? + .expect("This must exist since we just inserted it"); + + let front_matter = FrontMatter::new( + title, + zettel.created_at, + zettel.tags.iter().map(|t| t.name.clone()).collect(), + ); + + file.write_all(front_matter.to_string().as_bytes()).await?; + + kt.process_path(zettel.file_path.clone()).await?; + + Ok(zettel.into()) + } + + /// Returns the most up-to-date `FrontMatter` for this + /// `Zettel` + pub fn front_matter<'index>(&self, idx: &'index Index) -> &'index FrontMatter { + &idx.get_zod(&self.id).fm + } + + /// Returns the content of this `Zettel`, which is everything + /// but the `FrontMatter` + pub fn content<'index>(&self, idx: &'index Index) -> &'index Body { + &idx.get_zod(&self.id).body + } + /// Get the absolute path to this `Zettel` + pub fn absolute_path<'index>(&self, idx: &'index Index) -> &'index Path { + &idx.get_zod(&self.id).path + } + + pub fn created_at(&self) -> String { + self.created_at + .format(frontmatter::DATE_FMT_STR) + .to_string() + } + + pub fn modified_at(&self) -> String { + self.modified_at + .format(frontmatter::DATE_FMT_STR) + .to_string() + } +} + +impl From for Zettel { + fn from(value: ZettelModelEx) -> Self { + assert!( + !value.tags.is_unloaded(), + "When fetching a Zettel from the database, we expect + to always have the tags loaded!!" + ); + + Self { + _private: (), + id: value.nano_id.into(), + title: value.title, + file_path: value.file_path.into(), + created_at: value.created_at, + modified_at: value.modified_at, + tags: value.tags.into_iter().map(Into::into).collect(), + } + } +} -- 2.51.2 From b6baaa6ca32dbaf5302ba96d2cbc8beb5282ca48 Mon Sep 17 00:00:00 2001 From: suri-codes Date: Mon, 6 Apr 2026 09:31:10 -0700 Subject: [PATCH 2/4] fix: creation of zettel's indexes properly --- src/tui/app.rs | 17 +++++++++++++++-- src/tui/components/zk/mod.rs | 21 ++++++++++++--------- src/types/index.rs | 11 ++++++++--- src/types/kasten.rs | 20 ++++++++++++++++---- src/types/zettel/mod.rs | 19 ++++++++++++++++--- 5 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index fce883a..a6ab3e7 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,6 +1,6 @@ use std::{process::Command, thread::spawn}; -use color_eyre::eyre::Result; +use color_eyre::eyre::{Context, Result}; use crossterm::event::KeyEvent; use ratatui::layout::Rect; use serde::{Deserialize, Serialize}; @@ -192,7 +192,20 @@ impl App { // once we get out of the edit, we need to update the zettel for this // path and then update the db and the kasten for this stuff - self.kh.write().await.process_path(&path).await?; + self.kh + .write() + .await + .process_path(&path) + .await + .with_context(|| { + format!( + "Failed to process the path + for this zettel: {}", + path.display() + ) + })?; + + debug!("successfully processed path: {}", path.display()); self.signal_tx.send(Signal::ClosedZettel)?; diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index a53e25b..6d055d7 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use color_eyre::eyre::{ContextCompat, Result}; +use color_eyre::eyre::{Context as _, ContextCompat, Result}; use crossterm::event::KeyEvent; use dto::{QueryOrder, TagEntity, ZettelColumns, ZettelEntity}; use ratatui::{prelude::*, widgets::ListState}; @@ -243,7 +243,10 @@ impl Component for Zk<'_> { let mut kt = self.kh.write().await; // we create the zettel with the query as the - let z = Zettel::new(self.search.query(), &mut kt).await?; + let z = Zettel::new(self.search.query(), &mut kt) + .await + .with_context(|| "Failed to create a new Zettel!")?; + let path = z.absolute_path(&kt.index).to_path_buf(); drop(kt); @@ -258,6 +261,13 @@ impl Component for Zk<'_> { .selected() .expect("This must be the zettel we just edited"); + // regenerate a fresh zettel list + self.zettel_list = ZettelList::new( + self.get_zettels_by_current_query().await?, + self.zettel_list.state, + self.zettel_list.width, + ); + let Some(zid) = self.zettel_list.id_list.get(selected) else { return Ok(None); }; @@ -272,13 +282,6 @@ impl Component for Zk<'_> { self.search.clear_query(); self.zettel_list.state.select_first(); - // regenerate a fresh zettel list - self.zettel_list = ZettelList::new( - self.get_zettels_by_current_query().await?, - self.zettel_list.state, - self.zettel_list.width, - ); - self.zettel_view = ZettelView::from(&zettel); self.preview = Preview::from(zettel.content(&kt.index).clone()); drop(kt); diff --git a/src/types/index.rs b/src/types/index.rs index e4b8d5d..8e7b896 100644 --- a/src/types/index.rs +++ b/src/types/index.rs @@ -49,7 +49,14 @@ impl Index { let id: ZettelId = path.as_path().try_into()?; let (fm, body) = FrontMatter::extract_from_file(&path)?; - Ok((id, ZettelOnDisk { fm, body, path })) + Ok(( + id, + ZettelOnDisk { + fm, + body, + path: path.canonicalize()?, + }, + )) }) .collect::>>()? .into_iter() @@ -166,8 +173,6 @@ impl Index { Ok(()) } - //TODO:need to process - pub fn get_zod(&self, zid: &ZettelId) -> &ZettelOnDisk { 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.") } diff --git a/src/types/kasten.rs b/src/types/kasten.rs index 70e3840..5190f10 100644 --- a/src/types/kasten.rs +++ b/src/types/kasten.rs @@ -11,7 +11,7 @@ use tokio::{ }; use tracing::debug; -use crate::types::{Index, ZettelId}; +use crate::types::{FrontMatter, Index, ZettelId, index::ZettelOnDisk}; #[derive(Debug, Clone)] pub struct Kasten { @@ -91,11 +91,23 @@ impl Kasten { //NOTE: need to clone to get around borrowing rules but // ideally we dont have to do this, kind of cringe imo. - let path = path.as_ref(); - let zid = ZettelId::try_from(path)?; + let path = path.as_ref().canonicalize()?; + let zid = ZettelId::try_from(path.as_path())?; + + if !self.index.zods.contains_key(&zid) { + let (fm, body) = FrontMatter::extract_from_file(&path)?; + self.index.zods.insert( + zid.clone(), + ZettelOnDisk { + fm, + body, + path: path.clone(), + }, + ); + } // incase the path of the zettel changed - self.index.update_path_for_zid(&zid, path.to_path_buf()); + self.index.update_path_for_zid(&zid, path.clone()); // let the index process the zettel, basically update the internal state of the zod self.index.process_zid(&zid)?; // and then we sync tags diff --git a/src/types/zettel/mod.rs b/src/types/zettel/mod.rs index 93de993..c86d250 100644 --- a/src/types/zettel/mod.rs +++ b/src/types/zettel/mod.rs @@ -4,7 +4,7 @@ use dto::{ DatabaseConnection, DateTime, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModelEx, }; -use color_eyre::eyre::Result; +use color_eyre::eyre::{Context, Result}; use dto::NanoId; use tokio::{fs::File, io::AsyncWriteExt}; @@ -53,8 +53,14 @@ impl Zettel { let local_file_path = format!("{nano_id}.md"); + let absolute_file_path = kt.root.clone().join(&local_file_path); + // now we have to create the file - let mut file = File::create_new(kt.root.clone().join(&local_file_path)).await?; + let mut file = File::create_new(&absolute_file_path) + .await + .with_context(|| { + format!("Failed to create file at local file path: {local_file_path}") + })?; let inserted = ZettelActiveModel::builder() .set_title(title.clone()) @@ -79,7 +85,14 @@ impl Zettel { file.write_all(front_matter.to_string().as_bytes()).await?; - kt.process_path(zettel.file_path.clone()).await?; + kt.process_path(&absolute_file_path) + .await + .with_context(|| { + format!( + "Kasten fails to process new Zettel at path: {}", + absolute_file_path.display(), + ) + })?; Ok(zettel.into()) } -- 2.51.2 From e3598b14fdbaa0dbc203ebdad96869ff4ea9aa2c Mon Sep 17 00:00:00 2001 From: suri-codes Date: Mon, 6 Apr 2026 12:45:43 -0700 Subject: [PATCH 3/4] feat: initialized config is now pretty printed --- .config/config.ron | 24 +++++++++++------------- .config/default_config.ron | 27 +++++++++++++++++++++++++++ src/cli/process.rs | 1 - src/config/mod.rs | 8 ++++++-- src/tui/components/zk/mod.rs | 10 +++------- src/types/kasten.rs | 1 - src/types/zettel/id.rs | 8 ++++++++ 7 files changed, 55 insertions(+), 24 deletions(-) create mode 100644 .config/default_config.ron diff --git a/.config/config.ron b/.config/config.ron index e0de0cb..59f20c8 100644 --- a/.config/config.ron +++ b/.config/config.ron @@ -1,29 +1,27 @@ ( - directory: "./ZettelKasten", + directory: "/Users/suri/dev/projects/filaments/ZettelKasten", global_key_binds: { + "up": MoveUp, "ctrl-c": Quit, "ctrl-z": Suspend, - "up": MoveUp, "down": MoveDown, }, zk: ( keybinds: { - "": NewZettel, "enter": OpenZettel, - "tab": SwitchTo ( - region: Todo - ), - + "tab": SwitchTo( + region: Todo, + ), + "": NewZettel, }, ), todo: ( keybinds: { - "j": MoveDown, "k": MoveUp, - "tab": SwitchTo ( - region: Zk - ), - + "j": MoveDown, + "tab": SwitchTo( + region: Zk, + ), }, ), -) +) \ No newline at end of file diff --git a/.config/default_config.ron b/.config/default_config.ron new file mode 100644 index 0000000..a9265d6 --- /dev/null +++ b/.config/default_config.ron @@ -0,0 +1,27 @@ +( + directory: "{INSERT_ROOT_HERE}", + global_key_binds: { + "up": MoveUp, + "ctrl-c": Quit, + "ctrl-z": Suspend, + "down": MoveDown, + }, + zk: ( + keybinds: { + "enter": OpenZettel, + "tab": SwitchTo( + region: Todo, + ), + "": NewZettel, + }, + ), + todo: ( + keybinds: { + "k": MoveUp, + "j": MoveDown, + "tab": SwitchTo( + region: Zk, + ), + }, + ), +) diff --git a/src/cli/process.rs b/src/cli/process.rs index f9a1671..79d9989 100644 --- a/src/cli/process.rs +++ b/src/cli/process.rs @@ -44,7 +44,6 @@ impl Commands { Self::Zettel(zettel_sub_command) => { let conf = Config::parse()?; - // let ws = Workspace::instansiate(conf.fil_dir).await?; let mut kt = Kasten::instansiate(conf.fil_dir).await?; match zettel_sub_command { diff --git a/src/config/mod.rs b/src/config/mod.rs index 29126ed..a9ec0b7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -7,6 +7,7 @@ use std::{ use color_eyre::eyre::Result; use directories::ProjectDirs; +use ron::ser::PrettyConfig; use crate::config::{file::RonConfig, keymap::KeyMap}; @@ -31,7 +32,7 @@ pub static CONFIG_DIRECTORY: LazyLock> = LazyLock::new(|| { .map(PathBuf::from) }); -const DEFAULT_CONFIG: &str = include_str!("../../.config/config.ron"); +const DEFAULT_CONFIG: &str = include_str!("../../.config/default_config.ron"); #[derive(Debug, Clone)] pub struct Config { @@ -47,7 +48,10 @@ impl Config { default_conf.directory = fil_dir.canonicalize()?; - Ok(ron::to_string(&default_conf)?) + Ok(ron::ser::to_string_pretty( + &default_conf, + PrettyConfig::default(), + )?) } /// Parse the config from `~/.config/filaments`, but will prioritize /// `FIL_CONFIG_DIR`. diff --git a/src/tui/components/zk/mod.rs b/src/tui/components/zk/mod.rs index 6d055d7..ef6921e 100644 --- a/src/tui/components/zk/mod.rs +++ b/src/tui/components/zk/mod.rs @@ -105,9 +105,6 @@ impl Zk<'_> { let preview = Preview::from(zettel.content(&kt.index).clone()); - // okay now that we have the zettel we need to construct the zettel out of this id - let zettel_view: ZettelView = zettel.into(); - drop(kt); Ok(Self { @@ -116,7 +113,7 @@ impl Zk<'_> { kh, layouts: Layouts::default(), zettel_list, - zettel_view, + zettel_view: zettel.into(), preview, }) } @@ -140,12 +137,11 @@ impl Zk<'_> { .await? .context("Unknown Behaviour, A selected zettel got deleted somehow.")?; - self.zettel_view = zettel.into(); - self.preview = zettel.content(&kh.index).clone().into(); - drop(kh); + self.zettel_view = zettel.into(); + Ok(()) } diff --git a/src/types/kasten.rs b/src/types/kasten.rs index 5190f10..3fe73bc 100644 --- a/src/types/kasten.rs +++ b/src/types/kasten.rs @@ -34,7 +34,6 @@ impl Kasten { /// at that path. pub async fn instansiate(root: impl Into) -> Result { let root = root.into(); - let db_conn_string = format!( "sqlite://{}", root.clone() diff --git a/src/types/zettel/id.rs b/src/types/zettel/id.rs index 9f02719..11ac921 100644 --- a/src/types/zettel/id.rs +++ b/src/types/zettel/id.rs @@ -96,5 +96,13 @@ mod tests { .expect("Should be able to parse the test path just file"); assert_eq!(id.0, "abcdef".into()); + + let path = PathBuf::from("/what/the/fuck/are/you/abcdef.md"); + + let id: ZettelId = path + .try_into() + .expect("Should be able to parse the test path just file"); + + assert_eq!(id.0, "abcdef".into()); } } -- 2.51.2 From a4e1de5f1f4d7c381751958e9b1d9d8723c702b1 Mon Sep 17 00:00:00 2001 From: suri-codes Date: Mon, 6 Apr 2026 13:12:13 -0700 Subject: [PATCH 4/4] feat: remove path column from zettel table --- .config/config.ron | 8 +-- .harper-dictionary.txt | 1 + crates/dto/migration/src/lib.rs | 2 + ...20260406_200424_remove_path_from_zettel.rs | 31 +++++++++++ crates/dto/src/entity/zettel.rs | 1 - crates/dto/tests/task.rs | 2 - crates/dto/tests/zettel.rs | 3 - src/types/index.rs | 55 ------------------- src/types/zettel/mod.rs | 8 +-- 9 files changed, 41 insertions(+), 70 deletions(-) create mode 100644 crates/dto/migration/src/m20260406_200424_remove_path_from_zettel.rs diff --git a/.config/config.ron b/.config/config.ron index 59f20c8..3831180 100644 --- a/.config/config.ron +++ b/.config/config.ron @@ -1,24 +1,24 @@ ( directory: "/Users/suri/dev/projects/filaments/ZettelKasten", global_key_binds: { + "down": MoveDown, "up": MoveUp, - "ctrl-c": Quit, "ctrl-z": Suspend, - "down": MoveDown, + "ctrl-c": Quit, }, zk: ( keybinds: { - "enter": OpenZettel, "tab": SwitchTo( region: Todo, ), "": NewZettel, + "enter": OpenZettel, }, ), todo: ( keybinds: { - "k": MoveUp, "j": MoveDown, + "k": MoveUp, "tab": SwitchTo( region: Zk, ), diff --git a/.harper-dictionary.txt b/.harper-dictionary.txt index 077da36..bc4c2be 100644 --- a/.harper-dictionary.txt +++ b/.harper-dictionary.txt @@ -6,3 +6,4 @@ clippy dir env eyre +zettel diff --git a/crates/dto/migration/src/lib.rs b/crates/dto/migration/src/lib.rs index 24fd078..13dfb1e 100644 --- a/crates/dto/migration/src/lib.rs +++ b/crates/dto/migration/src/lib.rs @@ -7,6 +7,7 @@ mod m20260319_002245_task_table; mod m20260323_002518_zettel_table; mod m20260327_175853_tag_table; mod m20260327_180618_zettel_tag_table; +mod m20260406_200424_remove_path_from_zettel; pub struct Migrator; @@ -19,6 +20,7 @@ impl MigratorTrait for Migrator { Box::new(m20260323_002518_zettel_table::Migration), Box::new(m20260327_175853_tag_table::Migration), Box::new(m20260327_180618_zettel_tag_table::Migration), + Box::new(m20260406_200424_remove_path_from_zettel::Migration), ] } } diff --git a/crates/dto/migration/src/m20260406_200424_remove_path_from_zettel.rs b/crates/dto/migration/src/m20260406_200424_remove_path_from_zettel.rs new file mode 100644 index 0000000..d49e6f0 --- /dev/null +++ b/crates/dto/migration/src/m20260406_200424_remove_path_from_zettel.rs @@ -0,0 +1,31 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +use crate::m20260323_002518_zettel_table::Zettel; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Zettel::Table) + .drop_column(Zettel::FilePath) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Zettel::Table) + .add_column(string(Zettel::FilePath).not_null()) + .to_owned(), + ) + .await + } +} diff --git a/crates/dto/src/entity/zettel.rs b/crates/dto/src/entity/zettel.rs index fa52894..f02d968 100644 --- a/crates/dto/src/entity/zettel.rs +++ b/crates/dto/src/entity/zettel.rs @@ -15,7 +15,6 @@ pub struct Model { #[sea_orm(unique)] pub nano_id: NanoId, pub title: String, - pub file_path: String, pub created_at: DateTime, pub modified_at: DateTime, #[sea_orm(has_one)] diff --git a/crates/dto/tests/task.rs b/crates/dto/tests/task.rs index 3528803..48e4a3f 100644 --- a/crates/dto/tests/task.rs +++ b/crates/dto/tests/task.rs @@ -13,7 +13,6 @@ async fn test_group_task_insert() { let group_zettel: ZettelModel = ZettelActiveModel { title: Set("Something".to_owned()), - file_path: Set("/voo/doo".to_owned()), ..Default::default() } .insert(&db) @@ -33,7 +32,6 @@ async fn test_group_task_insert() { let task_zettel: ZettelModel = ZettelActiveModel { // nano_id: Set(NanoId::default()), title: Set("nomething".to_owned()), - file_path: Set("/voo/doo".to_owned()), ..Default::default() } .insert(&db) diff --git a/crates/dto/tests/zettel.rs b/crates/dto/tests/zettel.rs index fa7f7f4..ef5320b 100644 --- a/crates/dto/tests/zettel.rs +++ b/crates/dto/tests/zettel.rs @@ -22,7 +22,6 @@ async fn test_zettel_tag_insert() { let _: ZettelModel = ZettelActiveModel { // nano_id: Set(NanoId::default()), title: Set("something1".to_owned()), - file_path: Set("/voo/doo".to_owned()), ..Default::default() } .insert(&db) @@ -31,7 +30,6 @@ async fn test_zettel_tag_insert() { let x = ZettelActiveModel::builder() .set_title("Hello") - .set_file_path("/voo/doo") // .add_tag( // TagActiveModel::builder() // .set_name("Hi") @@ -46,7 +44,6 @@ async fn test_zettel_tag_insert() { let _: ZettelModel = ZettelActiveModel { // nano_id: Set(NanoId::default()), title: Set("nomething2".to_owned()), - file_path: Set("/voo/doo".to_owned()), ..Default::default() } .insert(&db) diff --git a/src/types/index.rs b/src/types/index.rs index 8e7b896..b71b94e 100644 --- a/src/types/index.rs +++ b/src/types/index.rs @@ -188,59 +188,4 @@ impl Index { pub fn sync_with_db(&self, _db: &DatabaseConnection) { todo!() } - - //NOTE: we dont support links just yet - // fn parse_links(src: &ZettelId, body: Body) -> Result> { - // let parsed = Parser::new(&body); - - // let mut links = vec![]; - - // for event in parsed { - // if let Event::Start(MkTag::Link { dest_url, .. }) = event { - // info!("Found dest_url: {dest_url:#?}"); - - // let dest_path = { - // // remove leading "./" - // let without_prefix = dest_url.strip_prefix("./").unwrap_or(&dest_url); - - // // remove "#" and everything after it - // let without_anchor = without_prefix.split('#').next().unwrap(); - - // // add .md if not present - // let normalized = if std::path::Path::new(without_anchor) - // .extension() - // .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) - // { - // without_anchor.to_string() - // } else { - // format!("{without_anchor}.md") - // }; - - // let mut tmp_root = self - // .zods - // .get(src) - // .expect("Invariant Broken! src must exist inside index") - // .path - // .clone(); - // tmp_root.push(normalized); - // tmp_root - // }; - // // simplest way to validate that the path exists - // let Ok(canon_url) = dest_path.canonicalize() else { - // error!("Link not found!: {dest_path:?}"); - // continue; - // }; - - // // TODO: check that the thing actually exists inside the ws.db - // // instead of just seeing if we can turn it into a ZettelId - // let dst_id = ZettelId::try_from(canon_url)?; - - // let link = Link::new(src.clone(), dst_id); - - // links.push(link); - // } - // } - - // Ok(links) - // } } diff --git a/src/types/zettel/mod.rs b/src/types/zettel/mod.rs index c86d250..13074bc 100644 --- a/src/types/zettel/mod.rs +++ b/src/types/zettel/mod.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use dto::{ DatabaseConnection, DateTime, TagEntity, ZettelActiveModel, ZettelEntity, ZettelModelEx, @@ -25,8 +25,6 @@ pub struct Zettel { _private: (), pub id: ZettelId, pub title: String, - /// a workspace-local file path, needs to be canonicalized before usage - pub file_path: PathBuf, pub created_at: DateTime, pub modified_at: DateTime, pub tags: Vec, @@ -64,7 +62,6 @@ impl Zettel { let inserted = ZettelActiveModel::builder() .set_title(title.clone()) - .set_file_path(local_file_path) .set_nano_id(nano_id) .insert(&kt.db) .await?; @@ -113,12 +110,14 @@ impl Zettel { &idx.get_zod(&self.id).path } + /// Get the formatted creation datetime for this `Zettel` pub fn created_at(&self) -> String { self.created_at .format(frontmatter::DATE_FMT_STR) .to_string() } + /// Get the formatted modified datetime for this `Zettel` pub fn modified_at(&self) -> String { self.modified_at .format(frontmatter::DATE_FMT_STR) @@ -138,7 +137,6 @@ impl From for Zettel { _private: (), id: value.nano_id.into(), title: value.title, - file_path: value.file_path.into(), created_at: value.created_at, modified_at: value.modified_at, tags: value.tags.into_iter().map(Into::into).collect(),