diff --git a/crates/ork-server/src/namespace.rs b/crates/ork-server/src/namespace.rs index 7c8944d..c71a15e 100644 --- a/crates/ork-server/src/namespace.rs +++ b/crates/ork-server/src/namespace.rs @@ -156,80 +156,142 @@ fn build_section_node(state: Arc, doc_name: &str, section_id: &str) -> let doc = doc_name.to_string(); let id = section_id.to_string(); - let s1 = state.clone(); - let s2 = state.clone(); - let s3 = state.clone(); - let s4 = state.clone(); - let s5 = state.clone(); - let s6 = state.clone(); - let s7 = state.clone(); - let s8 = state.clone(); + // Clone state for each closure + 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_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_new = state.clone(); + let s_list = state.clone(); + let s_child = state.clone(); - let d1 = doc.clone(); - let d2 = doc.clone(); - let d3 = doc.clone(); - let d4 = doc.clone(); - let d5 = doc.clone(); - let d6 = doc.clone(); - let d7 = doc.clone(); - let d8 = doc.clone(); + // Clone doc/id for each closure + 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_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_new = doc.clone(); + let d_list = doc.clone(); + let d_child = doc.clone(); - let i1 = id.clone(); - let i2 = id.clone(); - let i3 = id.clone(); - let i4 = id.clone(); - let i5 = id.clone(); - let i6 = id.clone(); - let i8 = id.clone(); + 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_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_new = id.clone(); + let i_list = id.clone(); virtfs::dir_dynamic(&id, vec![ // headline - full reconstructed headline virtfs::file("headline", 0o444, virtfs::read(move || { - s1.section_headline(&d1, &i1) + 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 || { - s2.section_title(&d2, &i2) + 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")) })), // keyword (TODO/DONE/etc) virtfs::file("keyword", 0o644, virtfs::read(move || { - Ok(s3.section_keyword(&d3, &i3) + Ok(s_keyword.section_keyword(&d_keyword, &i_keyword) .map(|s| format!("{}\n", s).into_bytes()) .unwrap_or_else(|| b"\n".to_vec())) })), // priority virtfs::file("priority", 0o644, virtfs::read(move || { - Ok(s4.section_priority(&d4, &i4) + Ok(s_priority.section_priority(&d_priority, &i_priority) .map(|s| format!("{}\n", s).into_bytes()) .unwrap_or_else(|| b"\n".to_vec())) })), // tags virtfs::file("tags", 0o644, virtfs::read(move || { - Ok(s5.section_tags(&d5, &i5) + Ok(s_tags.section_tags(&d_tags, &i_tags) .map(|s| format!("{}\n", s).into_bytes()) .unwrap_or_else(|| b"\n".to_vec())) })), + // level + 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())) + })), + + // 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())) + })), + + // 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())) + })), + + // 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())) + })), + + // 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())) + })), + // 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) + s_new.create_child_section(&d_new, &i_new, &headline) .map(|uuid| format!("{}\n", uuid).into_bytes()) })), ], // children - nested sections - move || Ok(s6.child_ids(&d6, &i6)), + move || Ok(s_list.child_ids(&d_list, &i_list)), move |child_id| { - Some(build_section_node(s7.clone(), &d7, child_id)) + Some(build_section_node(s_child.clone(), &d_child, child_id)) }, ) } diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index 0526e2f..cbe7673 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -1,6 +1,6 @@ //! Server state: manages org documents and their parsed state. -use org_ast::Document; +use org_ast::{Document, Timestamp, TimestampValue}; use org_parser::Parser; use parking_lot::RwLock; use std::collections::HashMap; @@ -9,6 +9,47 @@ use std::io::{self, Result}; use std::path::{Path, PathBuf}; use std::sync::Arc; +/// Format a timestamp for display. +fn format_timestamp(ts: &Timestamp) -> String { + match &ts.value { + TimestampValue::Point(p) => { + let mut s = format!("<{:04}-{:02}-{:02}", p.date.year, p.date.month, p.date.day); + if let Some(day_name) = &p.date.day_name { + s.push(' '); + s.push_str(day_name); + } + if let Some(time) = &p.time { + s.push_str(&format!(" {:02}:{:02}", time.hour, time.minute)); + } + if let Some(r) = &p.repeater { + let kind = match r.kind { + org_ast::RepeaterKind::Cumulative => "+", + org_ast::RepeaterKind::CatchUp => "++", + org_ast::RepeaterKind::Restart => ".+", + }; + let unit = match r.unit { + org_ast::TimeUnit::Hour => "h", + org_ast::TimeUnit::Day => "d", + org_ast::TimeUnit::Week => "w", + org_ast::TimeUnit::Month => "m", + org_ast::TimeUnit::Year => "y", + }; + s.push_str(&format!(" {}{}{}", kind, r.value, unit)); + } + s.push('>'); + s + } + TimestampValue::Range { start, end } => { + format!( + "<{:04}-{:02}-{:02}>--<{:04}-{:02}-{:02}>", + start.date.year, start.date.month, start.date.day, + end.date.year, end.date.month, end.date.day + ) + } + TimestampValue::Diary(sexp) => sexp.clone(), + } +} + /// Parsed document with metadata. pub struct DocState { pub path: PathBuf, @@ -269,6 +310,79 @@ impl OrkState { self.find_section(&d.doc, id).map(|s| s.headline.tags.join(":")) }) } + + /// Get section level. + pub fn section_level(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id).map(|s| s.headline.level) + }) + } + + /// Get section scheduled timestamp. + pub fn section_scheduled(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id) + .and_then(|s| s.planning.as_ref()) + .and_then(|p| p.scheduled.as_ref()) + .map(|ts| format_timestamp(ts)) + }) + } + + /// Get section deadline timestamp. + pub fn section_deadline(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id) + .and_then(|s| s.planning.as_ref()) + .and_then(|p| p.deadline.as_ref()) + .map(|ts| format_timestamp(ts)) + }) + } + + /// Get section closed timestamp. + pub fn section_closed(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id) + .and_then(|s| s.planning.as_ref()) + .and_then(|p| p.closed.as_ref()) + .map(|ts| format_timestamp(ts)) + }) + } + + /// Get section properties as key=value lines. + pub fn section_properties(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id) + .and_then(|s| s.properties.as_ref()) + .map(|props| { + props.properties.iter() + .map(|p| format!("{}={}", p.key, p.value)) + .collect::>() + .join("\n") + }) + }) + } + + /// Get section body content (text between headline and first child). + pub fn section_body(&self, doc_name: &str, id: &str) -> Option { + self.get_doc(doc_name).and_then(|d| { + self.find_section(&d.doc, id).map(|s| { + // Body is section content minus children + // For now, extract from raw content using spans + let start = s.headline.span.end.offset; + let end = if let Some(first_child) = s.children.first() { + first_child.headline.span.start.offset + } else { + s.span.end.offset + }; + + if start < end && end <= d.content.len() { + d.content[start..end].to_string() + } else { + String::new() + } + }) + }) + } } // ═══════════════════════════════════════════════════════════════════ diff --git a/doc/9P_DESIGN.md b/doc/9P_DESIGN.md index bf41da3..5622509 100644 --- a/doc/9P_DESIGN.md +++ b/doc/9P_DESIGN.md @@ -7,7 +7,7 @@ ork/ ├── ctl # rdwr: "reload", "quit", "rescan" ├── idx # read: list of org files (name\tpath\ttitle) ├── keywords # read: configured TODO keywords -├── capture # write: append "* TODO text" to inbox.org +├── new # rdwr: create org file, returns filename │ ├── agenda/ # computed views │ ├── today # read: items scheduled/deadline today @@ -16,22 +16,25 @@ ork/ │ └── {doc}/ # e.g., "todo" for todo.org ├── raw # read/write: full org file content + ├── body # read/write: full org file content (alias) ├── meta # read: title, author, filetags (tsv) + ├── new # rdwr: create section, returns UUID │ - └── {h1-id}/ # section by ID (nested hierarchy) - ├── headline # read: full headline text "* TODO [#A] Title :tag:" - ├── title # read/write: plain title + └── {uuid}/ # section by UUID + ├── headline # read: full headline "* TODO [#A] Title :tag:" + ├── title # read/write: plain title text ├── keyword # read/write: TODO/DONE/etc or empty ├── priority # read/write: A/B/C or empty ├── tags # read/write: colon-separated "work:urgent" + ├── level # read: headline level (1-6) ├── scheduled # read/write: "<2025-01-15 Wed>" ├── deadline # read/write: "<2025-01-20 Mon>" + ├── closed # read/write: "<2025-01-21 Tue>" ├── properties # read/write: "KEY=value\n" lines - ├── body # read/write: section body (content only, not children) + ├── body # read/write: section content (not children) + ├── new # rdwr: create child section, returns UUID │ - └── {h2-id}/ # nested children, same structure - ├── headline - ├── title + └── {child-uuid}/ # nested children (same structure) └── ... ```