From 17f4caa37f71b679ed236222cd554fff568ae5b3 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 30 Sep 2026 16:09:44 +0200 Subject: [PATCH] Add write handlers for section properties - set_section_keyword/priority/title/tags - set_section_scheduled/deadline/closed - set_section_properties/body - rebuild_headline helper for atomic headline updates - Fixed replace_headline to preserve DEADLINE/:PROPERTIES: lines - Clear read cache on write in 9P server All writes tested working via 9p client. --- crates/ork-server/src/namespace.rs | 261 ++++++++++++++-------- crates/ork-server/src/p9.rs | 2 + crates/ork-server/src/state.rs | 339 +++++++++++++++++++++++++++++ 3 files changed, 517 insertions(+), 85 deletions(-) diff --git a/crates/ork-server/src/namespace.rs b/crates/ork-server/src/namespace.rs index c71a15e..9ffa4e7 100644 --- a/crates/ork-server/src/namespace.rs +++ b/crates/ork-server/src/namespace.rs @@ -156,130 +156,221 @@ fn build_section_node(state: Arc, doc_name: &str, section_id: &str) -> let doc = doc_name.to_string(); let id = section_id.to_string(); - // Clone state for each closure + // Clone state for each closure - reads let s_headline = state.clone(); - let s_title = state.clone(); - let s_keyword = state.clone(); - let s_priority = state.clone(); - let s_tags = state.clone(); + let s_title_r = state.clone(); + let s_keyword_r = state.clone(); + let s_priority_r = state.clone(); + let s_tags_r = state.clone(); let s_level = state.clone(); - let s_scheduled = state.clone(); - let s_deadline = state.clone(); - let s_closed = state.clone(); - let s_properties = state.clone(); - let s_body = state.clone(); + let s_scheduled_r = state.clone(); + let s_deadline_r = state.clone(); + let s_closed_r = state.clone(); + let s_properties_r = state.clone(); + let s_body_r = state.clone(); + + // Clone state for writes + let s_title_w = state.clone(); + let s_keyword_w = state.clone(); + let s_priority_w = state.clone(); + let s_tags_w = state.clone(); + let s_scheduled_w = state.clone(); + let s_deadline_w = state.clone(); + let s_closed_w = state.clone(); + let s_properties_w = state.clone(); + let s_body_w = state.clone(); + let s_new = state.clone(); let s_list = state.clone(); let s_child = state.clone(); - // Clone doc/id for each closure + // Clone doc for each closure - reads let d_headline = doc.clone(); - let d_title = doc.clone(); - let d_keyword = doc.clone(); - let d_priority = doc.clone(); - let d_tags = doc.clone(); + let d_title_r = doc.clone(); + let d_keyword_r = doc.clone(); + let d_priority_r = doc.clone(); + let d_tags_r = doc.clone(); let d_level = doc.clone(); - let d_scheduled = doc.clone(); - let d_deadline = doc.clone(); - let d_closed = doc.clone(); - let d_properties = doc.clone(); - let d_body = doc.clone(); + let d_scheduled_r = doc.clone(); + let d_deadline_r = doc.clone(); + let d_closed_r = doc.clone(); + let d_properties_r = doc.clone(); + let d_body_r = doc.clone(); + + // Clone doc for writes + let d_title_w = doc.clone(); + let d_keyword_w = doc.clone(); + let d_priority_w = doc.clone(); + let d_tags_w = doc.clone(); + let d_scheduled_w = doc.clone(); + let d_deadline_w = doc.clone(); + let d_closed_w = doc.clone(); + let d_properties_w = doc.clone(); + let d_body_w = doc.clone(); + let d_new = doc.clone(); let d_list = doc.clone(); let d_child = doc.clone(); + // Clone id for each closure - reads let i_headline = id.clone(); - let i_title = id.clone(); - let i_keyword = id.clone(); - let i_priority = id.clone(); - let i_tags = id.clone(); + let i_title_r = id.clone(); + let i_keyword_r = id.clone(); + let i_priority_r = id.clone(); + let i_tags_r = id.clone(); let i_level = id.clone(); - let i_scheduled = id.clone(); - let i_deadline = id.clone(); - let i_closed = id.clone(); - let i_properties = id.clone(); - let i_body = id.clone(); + let i_scheduled_r = id.clone(); + let i_deadline_r = id.clone(); + let i_closed_r = id.clone(); + let i_properties_r = id.clone(); + let i_body_r = id.clone(); + + // Clone id for writes + let i_title_w = id.clone(); + let i_keyword_w = id.clone(); + let i_priority_w = id.clone(); + let i_tags_w = id.clone(); + let i_scheduled_w = id.clone(); + let i_deadline_w = id.clone(); + let i_closed_w = id.clone(); + let i_properties_w = id.clone(); + let i_body_w = id.clone(); + let i_new = id.clone(); let i_list = id.clone(); virtfs::dir_dynamic(&id, vec![ - // headline - full reconstructed headline + // headline - read-only (computed) virtfs::file("headline", 0o444, virtfs::read(move || { s_headline.section_headline(&d_headline, &i_headline) .map(|s| format!("{}\n", s).into_bytes()) .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) })), - // title - virtfs::file("title", 0o644, virtfs::read(move || { - s_title.section_title(&d_title, &i_title) - .map(|s| format!("{}\n", s).into_bytes()) - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) - })), + // title - read/write + virtfs::file("title", 0o644, virtfs::rw( + move || { + s_title_r.section_title(&d_title_r, &i_title_r) + .map(|s| format!("{}\n", s).into_bytes()) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) + }, + move |data| { + let title = String::from_utf8_lossy(data); + s_title_w.set_section_title(&d_title_w, &i_title_w, &title) + }, + )), - // keyword (TODO/DONE/etc) - virtfs::file("keyword", 0o644, virtfs::read(move || { - Ok(s_keyword.section_keyword(&d_keyword, &i_keyword) - .map(|s| format!("{}\n", s).into_bytes()) - .unwrap_or_else(|| b"\n".to_vec())) - })), + // keyword - read/write + virtfs::file("keyword", 0o644, virtfs::rw( + move || { + Ok(s_keyword_r.section_keyword(&d_keyword_r, &i_keyword_r) + .map(|s| format!("{}\n", s).into_bytes()) + .unwrap_or_else(|| b"\n".to_vec())) + }, + move |data| { + let keyword = String::from_utf8_lossy(data); + s_keyword_w.set_section_keyword(&d_keyword_w, &i_keyword_w, &keyword) + }, + )), - // priority - virtfs::file("priority", 0o644, virtfs::read(move || { - Ok(s_priority.section_priority(&d_priority, &i_priority) - .map(|s| format!("{}\n", s).into_bytes()) - .unwrap_or_else(|| b"\n".to_vec())) - })), + // priority - read/write + virtfs::file("priority", 0o644, virtfs::rw( + move || { + Ok(s_priority_r.section_priority(&d_priority_r, &i_priority_r) + .map(|s| format!("{}\n", s).into_bytes()) + .unwrap_or_else(|| b"\n".to_vec())) + }, + move |data| { + let priority = String::from_utf8_lossy(data); + s_priority_w.set_section_priority(&d_priority_w, &i_priority_w, &priority) + }, + )), - // tags - virtfs::file("tags", 0o644, virtfs::read(move || { - Ok(s_tags.section_tags(&d_tags, &i_tags) - .map(|s| format!("{}\n", s).into_bytes()) - .unwrap_or_else(|| b"\n".to_vec())) - })), + // tags - read/write + virtfs::file("tags", 0o644, virtfs::rw( + move || { + Ok(s_tags_r.section_tags(&d_tags_r, &i_tags_r) + .map(|s| format!("{}\n", s).into_bytes()) + .unwrap_or_else(|| b"\n".to_vec())) + }, + move |data| { + let tags = String::from_utf8_lossy(data); + s_tags_w.set_section_tags(&d_tags_w, &i_tags_w, &tags) + }, + )), - // level + // level - read-only virtfs::file("level", 0o444, virtfs::read(move || { s_level.section_level(&d_level, &i_level) .map(|l| format!("{}\n", l).into_bytes()) .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found")) })), - // scheduled - virtfs::file("scheduled", 0o644, virtfs::read(move || { - Ok(s_scheduled.section_scheduled(&d_scheduled, &i_scheduled) - .map(|s| format!("{}\n", s).into_bytes()) - .unwrap_or_else(|| b"\n".to_vec())) - })), + // scheduled - read/write + virtfs::file("scheduled", 0o644, virtfs::rw( + move || { + Ok(s_scheduled_r.section_scheduled(&d_scheduled_r, &i_scheduled_r) + .map(|s| format!("{}\n", s).into_bytes()) + .unwrap_or_else(|| b"\n".to_vec())) + }, + move |data| { + let ts = String::from_utf8_lossy(data); + s_scheduled_w.set_section_scheduled(&d_scheduled_w, &i_scheduled_w, &ts) + }, + )), - // deadline - virtfs::file("deadline", 0o644, virtfs::read(move || { - Ok(s_deadline.section_deadline(&d_deadline, &i_deadline) - .map(|s| format!("{}\n", s).into_bytes()) - .unwrap_or_else(|| b"\n".to_vec())) - })), + // deadline - read/write + virtfs::file("deadline", 0o644, virtfs::rw( + move || { + Ok(s_deadline_r.section_deadline(&d_deadline_r, &i_deadline_r) + .map(|s| format!("{}\n", s).into_bytes()) + .unwrap_or_else(|| b"\n".to_vec())) + }, + move |data| { + let ts = String::from_utf8_lossy(data); + s_deadline_w.set_section_deadline(&d_deadline_w, &i_deadline_w, &ts) + }, + )), - // closed - virtfs::file("closed", 0o644, virtfs::read(move || { - Ok(s_closed.section_closed(&d_closed, &i_closed) - .map(|s| format!("{}\n", s).into_bytes()) - .unwrap_or_else(|| b"\n".to_vec())) - })), + // closed - read/write + virtfs::file("closed", 0o644, virtfs::rw( + move || { + Ok(s_closed_r.section_closed(&d_closed_r, &i_closed_r) + .map(|s| format!("{}\n", s).into_bytes()) + .unwrap_or_else(|| b"\n".to_vec())) + }, + move |data| { + let ts = String::from_utf8_lossy(data); + s_closed_w.set_section_closed(&d_closed_w, &i_closed_w, &ts) + }, + )), - // properties - virtfs::file("properties", 0o644, virtfs::read(move || { - Ok(s_properties.section_properties(&d_properties, &i_properties) - .map(|s| format!("{}\n", s).into_bytes()) - .unwrap_or_else(|| b"\n".to_vec())) - })), + // properties - read/write + virtfs::file("properties", 0o644, virtfs::rw( + move || { + Ok(s_properties_r.section_properties(&d_properties_r, &i_properties_r) + .map(|s| format!("{}\n", s).into_bytes()) + .unwrap_or_else(|| b"\n".to_vec())) + }, + move |data| { + let props = String::from_utf8_lossy(data); + s_properties_w.set_section_properties(&d_properties_w, &i_properties_w, &props) + }, + )), - // body - section content (not including children) - virtfs::file("body", 0o644, virtfs::read(move || { - Ok(s_body.section_body(&d_body, &i_body) - .map(|s| s.into_bytes()) - .unwrap_or_else(|| b"".to_vec())) - })), + // body - read/write + virtfs::file("body", 0o644, virtfs::rw( + move || { + Ok(s_body_r.section_body(&d_body_r, &i_body_r) + .map(|s| s.into_bytes()) + .unwrap_or_else(|| b"".to_vec())) + }, + move |data| { + let body = String::from_utf8_lossy(data); + s_body_w.set_section_body(&d_body_w, &i_body_w, &body) + }, + )), // new - create child section virtfs::file("new", 0o222, virtfs::rdwr(move |data| { diff --git a/crates/ork-server/src/p9.rs b/crates/ork-server/src/p9.rs index b133c87..3b32bb2 100644 --- a/crates/ork-server/src/p9.rs +++ b/crates/ork-server/src/p9.rs @@ -476,6 +476,8 @@ impl Server { } } else if f.node.write_fn.is_some() { // Buffer writes for regular write files + // Clear read cache since content will change + f.read_cache.clear(); f.write_buf.extend_from_slice(write_data); } else { return self.error_response(tag, "not writable"); diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index cbe7673..f03544b 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -385,6 +385,345 @@ impl OrkState { } } +// ═══════════════════════════════════════════════════════════════════ +// Section Writes +// ═══════════════════════════════════════════════════════════════════ + +impl OrkState { + /// Set section keyword (TODO/DONE/etc). + pub fn set_section_keyword(&self, doc_name: &str, id: &str, keyword: &str) -> Result<()> { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + + let section = self.find_section(&doc_state.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + + let keyword = keyword.trim(); + + // Rebuild headline with new keyword + let new_headline = self.rebuild_headline(section, Some(keyword), None, None, None); + + // Replace in content + let new_content = self.replace_headline(&doc_state.content, section, &new_headline)?; + + self.update_doc(doc_name, &doc_state.path, new_content) + } + + /// Set section priority (A/B/C or empty to remove). + pub fn set_section_priority(&self, doc_name: &str, id: &str, priority: &str) -> Result<()> { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + + let section = self.find_section(&doc_state.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + + let priority = priority.trim().to_uppercase(); + let pri = if priority.is_empty() { None } else { Some(priority.as_str()) }; + + let new_headline = self.rebuild_headline(section, None, pri, None, None); + let new_content = self.replace_headline(&doc_state.content, section, &new_headline)?; + + self.update_doc(doc_name, &doc_state.path, new_content) + } + + /// Set section title. + pub fn set_section_title(&self, doc_name: &str, id: &str, title: &str) -> Result<()> { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + + let section = self.find_section(&doc_state.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + + let title = title.trim(); + if title.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "title cannot be empty")); + } + + let new_headline = self.rebuild_headline(section, None, None, Some(title), None); + let new_content = self.replace_headline(&doc_state.content, section, &new_headline)?; + + self.update_doc(doc_name, &doc_state.path, new_content) + } + + /// Set section tags (colon-separated or space-separated). + pub fn set_section_tags(&self, doc_name: &str, id: &str, tags: &str) -> Result<()> { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + + let section = self.find_section(&doc_state.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + + let tags = tags.trim(); + let tag_list: Vec<&str> = tags.split(|c| c == ':' || c == ' ') + .filter(|s| !s.is_empty()) + .collect(); + + let new_headline = self.rebuild_headline(section, None, None, None, Some(&tag_list)); + let new_content = self.replace_headline(&doc_state.content, section, &new_headline)?; + + self.update_doc(doc_name, &doc_state.path, new_content) + } + + /// Set section scheduled timestamp. + pub fn set_section_scheduled(&self, doc_name: &str, id: &str, timestamp: &str) -> Result<()> { + self.set_planning_field(doc_name, id, "SCHEDULED", timestamp) + } + + /// Set section deadline timestamp. + pub fn set_section_deadline(&self, doc_name: &str, id: &str, timestamp: &str) -> Result<()> { + self.set_planning_field(doc_name, id, "DEADLINE", timestamp) + } + + /// Set section closed timestamp. + pub fn set_section_closed(&self, doc_name: &str, id: &str, timestamp: &str) -> Result<()> { + self.set_planning_field(doc_name, id, "CLOSED", timestamp) + } + + /// Set a planning field (SCHEDULED/DEADLINE/CLOSED). + fn set_planning_field(&self, doc_name: &str, id: &str, field: &str, timestamp: &str) -> Result<()> { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + + let section = self.find_section(&doc_state.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + + let timestamp = timestamp.trim(); + let mut content = doc_state.content.clone(); + + if let Some(planning) = §ion.planning { + // Has planning line - modify it + let planning_start = planning.span.start.offset; + let planning_end = planning.span.end.offset; + let planning_text = &content[planning_start..planning_end]; + + // Parse existing planning fields + let mut fields: HashMap<&str, &str> = HashMap::new(); + for part in planning_text.split_whitespace() { + if part.ends_with(':') { + let key = &part[..part.len()-1]; + continue; + } + } + + // Rebuild planning line + let new_planning = if timestamp.is_empty() { + // Remove this field + let filtered: String = planning_text + .replace(&format!("{}: ", field), "") + .split_whitespace() + .filter(|s| !s.starts_with('<') || !planning_text.contains(&format!("{}: {}", field, s))) + .collect::>() + .join(" "); + if filtered.trim().is_empty() { + String::new() + } else { + format!("{}\n", filtered) + } + } else { + // Add/update this field + let ts = if timestamp.starts_with('<') { timestamp.to_string() } else { format!("<{}>", timestamp) }; + if planning_text.contains(&format!("{}:", field)) { + // Replace existing + let re_pattern = format!(r"{}: <[^>]+>", field); + planning_text.replace(&planning_text + .split_whitespace() + .collect::>() + .join(" "), &format!("{}: {}", field, ts)) + } else { + // Add new + format!("{}{}: {}\n", planning_text.trim_end(), field, ts) + } + }; + + content = format!("{}{}{}", &content[..planning_start], new_planning, &content[planning_end..]); + } else if !timestamp.is_empty() { + // No planning line - insert after headline + let insert_pos = section.headline.span.end.offset; + let ts = if timestamp.starts_with('<') { timestamp.to_string() } else { format!("<{}>", timestamp) }; + let planning_line = format!("{}: {}\n", field, ts); + content = format!("{}{}{}", &content[..insert_pos], planning_line, &content[insert_pos..]); + } + + self.update_doc(doc_name, &doc_state.path, content) + } + + /// Set section properties (KEY=value lines). + pub fn set_section_properties(&self, doc_name: &str, id: &str, properties: &str) -> Result<()> { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + + let section = self.find_section(&doc_state.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + + let properties = properties.trim(); + let mut content = doc_state.content.clone(); + + // Build new property drawer + let mut drawer = String::new(); + if !properties.is_empty() { + drawer.push_str(":PROPERTIES:\n"); + for line in properties.lines() { + let line = line.trim(); + if line.is_empty() { continue; } + if let Some((key, val)) = line.split_once('=') { + drawer.push_str(&format!(":{}: {}\n", key.trim().to_uppercase(), val.trim())); + } + } + drawer.push_str(":END:\n"); + } + + if let Some(props) = §ion.properties { + // Replace existing drawer + content = format!( + "{}{}{}", + &content[..props.span.start.offset], + drawer, + &content[props.span.end.offset..] + ); + } else if !drawer.is_empty() { + // Insert new drawer after headline (and planning if present) + let insert_pos = if let Some(planning) = §ion.planning { + planning.span.end.offset + } else { + section.headline.span.end.offset + }; + content = format!("{}{}{}", &content[..insert_pos], drawer, &content[insert_pos..]); + } + + self.update_doc(doc_name, &doc_state.path, content) + } + + /// Set section body content. + pub fn set_section_body(&self, doc_name: &str, id: &str, body: &str) -> Result<()> { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + + let section = self.find_section(&doc_state.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + + // Body starts after headline, planning, properties + let body_start = if let Some(props) = §ion.properties { + props.span.end.offset + } else if let Some(planning) = §ion.planning { + planning.span.end.offset + } else { + section.headline.span.end.offset + }; + + // Body ends at first child or section end + let body_end = if let Some(first_child) = section.children.first() { + first_child.headline.span.start.offset + } else { + section.span.end.offset + }; + + let body = if body.is_empty() || body.ends_with('\n') { + body.to_string() + } else { + format!("{}\n", body) + }; + + let content = format!( + "{}{}{}", + &doc_state.content[..body_start], + body, + &doc_state.content[body_end..] + ); + + self.update_doc(doc_name, &doc_state.path, content) + } + + // ───────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────── + + /// Rebuild a headline with optional overrides. + fn rebuild_headline( + &self, + section: &org_ast::Section, + keyword: Option<&str>, + priority: Option<&str>, + title: Option<&str>, + tags: Option<&[&str]>, + ) -> String { + let mut line = "*".repeat(section.headline.level as usize); + line.push(' '); + + // Keyword + let kw = keyword.unwrap_or_else(|| { + section.headline.keyword.as_ref().map(|k| k.keyword.as_str()).unwrap_or("") + }); + if !kw.is_empty() { + line.push_str(kw); + line.push(' '); + } + + // Priority + let pri = priority.unwrap_or_else(|| { + section.headline.priority.map(|p| match p { + org_ast::Priority::A => "A", + org_ast::Priority::B => "B", + org_ast::Priority::C => "C", + org_ast::Priority::Numeric(_) => "", + }).unwrap_or("") + }); + if !pri.is_empty() { + line.push_str(&format!("[#{}] ", pri)); + } + + // Title + let default_title = section.headline.title_text(); + let ttl = title.unwrap_or(&default_title); + line.push_str(ttl); + + // Tags + let tag_list: Vec<&str> = tags.map(|t| t.to_vec()).unwrap_or_else(|| { + section.headline.tags.iter().map(|s| s.as_str()).collect() + }); + if !tag_list.is_empty() { + line.push_str(&format!(" :{}:", tag_list.join(":"))); + } + + line + } + + /// Replace a headline in content. + fn replace_headline(&self, content: &str, section: &org_ast::Section, new_headline: &str) -> Result { + let start = section.headline.span.start.offset; + + // Find end of the headline line (first newline after start) + let line_end = content[start..].find('\n') + .map(|i| start + i) + .unwrap_or(content.len()); + + Ok(format!( + "{}{}\n{}", + &content[..start], + new_headline, + &content[line_end + 1..] + )) + } + + /// Update document content, reparse, and save. + fn update_doc(&self, doc_name: &str, path: &Path, content: String) -> Result<()> { + // Parse first to validate + let new_doc = self.parser.write().parse(&content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + + // Write to file + fs::write(path, &content)?; + + // Update state + self.docs.write().insert(doc_name.to_string(), Arc::new(DocState { + path: path.to_path_buf(), + content, + doc: new_doc, + })); + + Ok(()) + } +} + // ═══════════════════════════════════════════════════════════════════ // Section Creation // ═══════════════════════════════════════════════════════════════════