Add complete section properties to 9P namespace

Section files now include:
- level (read-only): headline level 1-6
- scheduled: SCHEDULED timestamp
- deadline: DEADLINE timestamp
- closed: CLOSED timestamp
- properties: key=value lines from property drawer
- body: section content (text between headline and first child)

All timestamp fields use org-mode format: <YYYY-MM-DD Day HH:MM +repeat>
This commit is contained in:
Levi Neely 2026-09-30 14:16:18 +02:00
parent 6bb2994dff
commit 96f89acd65
3 changed files with 219 additions and 40 deletions

View File

@ -156,80 +156,142 @@ 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();
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))
},
)
}

View File

@ -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<u8> {
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<String> {
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<String> {
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<String> {
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<String> {
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::<Vec<_>>()
.join("\n")
})
})
}
/// Get section body content (text between headline and first child).
pub fn section_body(&self, doc_name: &str, id: &str) -> Option<String> {
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()
}
})
})
}
}
// ═══════════════════════════════════════════════════════════════════

View File

@ -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)
└── ...
```