From 6bb2994dffe26197fae68aff1d5fe0e15dcffc74 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 30 Sep 2026 10:41:19 +0200 Subject: [PATCH] Add section creation with UUID IDs - new file at root: create org file - new file at doc level: create top-level section - new file at section level: create child section - All created sections get UUID in :ID: property - body file added to doc (alias for raw) - Test for section creation via 9P namespace --- Cargo.lock | 110 ++++++++++++++++++++++++++ crates/ork-server/Cargo.toml | 1 + crates/ork-server/src/namespace.rs | 88 ++++++++++++++++++++- crates/ork-server/src/state.rs | 123 +++++++++++++++++++++++++++++ 4 files changed, 321 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index fd03b04..5e0a6e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,12 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -267,6 +273,30 @@ dependencies = [ "libc", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -322,6 +352,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.2.1" @@ -532,6 +573,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -666,6 +708,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "same-file" version = "1.0.6" @@ -749,6 +797,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.16.2" @@ -975,6 +1029,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -997,6 +1062,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/crates/ork-server/Cargo.toml b/crates/ork-server/Cargo.toml index b1d887b..030fd33 100644 --- a/crates/ork-server/Cargo.toml +++ b/crates/ork-server/Cargo.toml @@ -28,6 +28,7 @@ notify = "6" # Utilities parking_lot = "0.12" +uuid = { version = "1", features = ["v4"] } [dev-dependencies] tempfile = "3" diff --git a/crates/ork-server/src/namespace.rs b/crates/ork-server/src/namespace.rs index 62c62bc..7c8944d 100644 --- a/crates/ork-server/src/namespace.rs +++ b/crates/ork-server/src/namespace.rs @@ -34,6 +34,16 @@ pub fn build_namespace(state: Arc) -> FsNode { move || Ok(state.keywords().into_bytes()) })), + // new - create new org file + virtfs::file("new", 0o222, virtfs::rdwr({ + let state = state.clone(); + move |data| { + let name = String::from_utf8_lossy(data); + state.create_doc(&name) + .map(|s| format!("{}\n", s).into_bytes()) + } + })), + // agenda - computed views virtfs::dir("agenda", vec![ virtfs::file("todos", 0o444, virtfs::read({ @@ -69,7 +79,7 @@ fn build_doc_node(state: Arc, doc_name: &str) -> FsNode { virtfs::dir_dynamic(&name, vec![ - // raw - full org content + // raw - full org content (alias for body) virtfs::file("raw", 0o644, virtfs::rw( { let state = state.clone(); @@ -90,6 +100,27 @@ fn build_doc_node(state: Arc, doc_name: &str) -> FsNode { }, )), + // body - full org content (same as raw) + virtfs::file("body", 0o644, virtfs::rw( + { + let state = state.clone(); + let name = name.clone(); + move || { + state.doc_raw(&name) + .map(|s| s.into_bytes()) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) + } + }, + { + let state = state.clone(); + let name = name.clone(); + move |data| { + let content = String::from_utf8_lossy(data); + state.write_doc_raw(&name, &content) + } + }, + )), + // meta - document metadata virtfs::file("meta", 0o444, virtfs::read({ let state = state.clone(); @@ -100,6 +131,17 @@ fn build_doc_node(state: Arc, doc_name: &str) -> FsNode { .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) } })), + + // new - create top-level section + virtfs::file("new", 0o222, virtfs::rdwr({ + let state = state.clone(); + let name = name.clone(); + move |data| { + let headline = String::from_utf8_lossy(data); + state.create_section(&name, &headline) + .map(|uuid| format!("{}\n", uuid).into_bytes()) + } + })), ], // Dynamic section directories move || Ok(s.section_ids(&n)), @@ -121,6 +163,7 @@ fn build_section_node(state: Arc, doc_name: &str, section_id: &str) -> let s5 = state.clone(); let s6 = state.clone(); let s7 = state.clone(); + let s8 = state.clone(); let d1 = doc.clone(); let d2 = doc.clone(); @@ -129,6 +172,7 @@ fn build_section_node(state: Arc, doc_name: &str, section_id: &str) -> let d5 = doc.clone(); let d6 = doc.clone(); let d7 = doc.clone(); + let d8 = doc.clone(); let i1 = id.clone(); let i2 = id.clone(); @@ -136,6 +180,7 @@ fn build_section_node(state: Arc, doc_name: &str, section_id: &str) -> let i4 = id.clone(); let i5 = id.clone(); let i6 = id.clone(); + let i8 = id.clone(); virtfs::dir_dynamic(&id, vec![ @@ -173,6 +218,13 @@ fn build_section_node(state: Arc, doc_name: &str, section_id: &str) -> .map(|s| format!("{}\n", s).into_bytes()) .unwrap_or_else(|| b"\n".to_vec())) })), + + // new - create child section + virtfs::file("new", 0o222, virtfs::rdwr(move |data| { + let headline = String::from_utf8_lossy(data); + s8.create_child_section(&d8, &i8, &headline) + .map(|uuid| format!("{}\n", uuid).into_bytes()) + })), ], // children - nested sections move || Ok(s6.child_ids(&d6, &i6)), @@ -289,4 +341,38 @@ mod tests { let content = String::from_utf8(tags.read().unwrap()).unwrap(); assert_eq!(content.trim(), "urgent"); } + + #[test] + fn test_create_section() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("work.org"), "#+TITLE: Work\n").unwrap(); + + let state = Arc::new(OrkState::new(dir.path()).unwrap()); + let root = build_namespace(state.clone()); + + // Create section via new file + let new_file = root.walk("work/new").unwrap(); + let result = new_file.rdwr(b"* TODO New Task :work:").unwrap(); + let uuid = String::from_utf8(result).unwrap().trim().to_string(); + + // UUID should be valid format + assert!(uuid.len() == 36); // UUID format + assert!(uuid.contains('-')); + + // Section should now exist + let title = root.walk(&format!("work/{}/title", uuid)).unwrap(); + let content = String::from_utf8(title.read().unwrap()).unwrap(); + assert_eq!(content.trim(), "New Task"); + + // Check keyword + let kw = root.walk(&format!("work/{}/keyword", uuid)).unwrap(); + let content = String::from_utf8(kw.read().unwrap()).unwrap(); + assert_eq!(content.trim(), "TODO"); + + // Check the org file was modified + let org_content = fs::read_to_string(dir.path().join("work.org")).unwrap(); + assert!(org_content.contains("* TODO New Task")); + assert!(org_content.contains(":ID:")); + assert!(org_content.contains(&uuid)); + } } diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index 3956e8c..0526e2f 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -271,6 +271,129 @@ impl OrkState { } } +// ═══════════════════════════════════════════════════════════════════ +// Section Creation +// ═══════════════════════════════════════════════════════════════════ + +impl OrkState { + /// Create a new org file. + /// Input: filename (without .org extension) + /// Returns: created filename + pub fn create_doc(&self, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty filename")); + } + + // Sanitize filename + let safe_name: String = name.chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }) + .collect(); + + let filename = format!("{}.org", safe_name); + let path = self.org_dir.join(&filename); + + if path.exists() { + return Err(io::Error::new(io::ErrorKind::AlreadyExists, "file already exists")); + } + + // Create empty org file with title + let content = format!("#+TITLE: {}\n", name); + fs::write(&path, &content)?; + + // Parse and add to state + let doc = self.parser.write().parse(&content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + + self.docs.write().insert(safe_name.clone(), Arc::new(DocState { + path, + content, + doc, + })); + + Ok(filename) + } + + /// Create a new top-level section in a document. + /// Input: headline text (e.g., "* TODO Task :tag:") + /// Returns: UUID of created section + pub fn create_section(&self, doc_name: &str, headline: &str) -> Result { + self.create_section_at(doc_name, None, headline) + } + + /// Create a child section under a parent. + /// Input: headline text + /// Returns: UUID of created section + pub fn create_child_section(&self, doc_name: &str, parent_id: &str, headline: &str) -> Result { + self.create_section_at(doc_name, Some(parent_id), headline) + } + + /// Create a section, optionally under a parent. + fn create_section_at(&self, doc_name: &str, parent_id: Option<&str>, headline: &str) -> Result { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + + let headline = headline.trim(); + if !headline.starts_with('*') { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "headline must start with *")); + } + + // Generate UUID + let uuid = uuid::Uuid::new_v4().to_string(); + + // Build section text with ID property + let section_text = format!( + "{}\n:PROPERTIES:\n:ID: {}\n:END:\n", + headline, uuid + ); + + // Find insertion point + let mut content = doc_state.content.clone(); + + if let Some(pid) = parent_id { + // Find parent section and insert after it + if let Some(parent) = self.find_section(&doc_state.doc, pid) { + // Insert at end of parent's span (before any siblings) + let insert_pos = self.find_section_end(&content, parent); + content.insert_str(insert_pos, §ion_text); + } else { + return Err(io::Error::new(io::ErrorKind::NotFound, "parent section not found")); + } + } else { + // Append to end of file + if !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(§ion_text); + } + + // Write and reparse + fs::write(&doc_state.path, &content)?; + + let new_doc = self.parser.write().parse(&content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + + self.docs.write().insert(doc_name.to_string(), Arc::new(DocState { + path: doc_state.path.clone(), + content, + doc: new_doc, + })); + + Ok(uuid) + } + + /// Find the end position of a section (after its content, before siblings). + fn find_section_end(&self, content: &str, section: &org_ast::Section) -> usize { + // If section has children, insert after last child + if let Some(last_child) = section.children.last() { + return self.find_section_end(content, last_child); + } + + // Otherwise, use section's span end + section.headline.span.end.offset.min(content.len()) + } +} + /// Slugify a string for use as an ID. fn slugify(s: &str) -> String { s.to_lowercase()