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:
parent
246bd9bb5a
commit
6bb2994dff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ notify = "6"
|
|||
|
||||
# Utilities
|
||||
parking_lot = "0.12"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, §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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue