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.
This commit is contained in:
Levi Neely 2026-09-30 16:09:44 +02:00
parent 94862b7f26
commit 17f4caa37f
3 changed files with 517 additions and 85 deletions

View File

@ -156,130 +156,221 @@ fn build_section_node(state: Arc<OrkState>, 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| {

View File

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

View File

@ -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) = &section.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::<Vec<_>>()
.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::<Vec<_>>()
.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) = &section.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) = &section.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) = &section.properties {
props.span.end.offset
} else if let Some(planning) = &section.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<String> {
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
// ═══════════════════════════════════════════════════════════════════