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:
parent
94862b7f26
commit
17f4caa37f
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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::<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) = §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<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
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Reference in New Issue