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
This commit is contained in:
Levi Neely 2026-09-30 10:41:19 +02:00
parent 246bd9bb5a
commit 6bb2994dff
4 changed files with 321 additions and 1 deletions

110
Cargo.lock generated
View File

@ -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"

View File

@ -28,6 +28,7 @@ notify = "6"
# Utilities
parking_lot = "0.12"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tempfile = "3"

View File

@ -34,6 +34,16 @@ pub fn build_namespace(state: Arc<OrkState>) -> 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<OrkState>, 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<OrkState>, 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<OrkState>, 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<OrkState>, 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<OrkState>, 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<OrkState>, 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<OrkState>, 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));
}
}

View File

@ -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<String> {
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<String> {
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<String> {
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<String> {
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, &section_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(&section_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()