orkmode/crates/ork-server/src/state.rs

534 lines
19 KiB
Rust

//! Server state: manages org documents and their parsed state.
use org_ast::{Document, Timestamp, TimestampValue};
use org_parser::Parser;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::fs;
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,
pub content: String,
pub doc: Document,
}
/// Server state: all loaded documents.
pub struct OrkState {
/// Directory containing org files.
pub org_dir: PathBuf,
/// Parsed documents by name (without .org extension).
docs: RwLock<HashMap<String, Arc<DocState>>>,
/// Parser instance.
parser: RwLock<Parser>,
}
impl OrkState {
/// Create new state watching the given directory.
pub fn new(org_dir: impl AsRef<Path>) -> Result<Self> {
let org_dir = org_dir.as_ref().to_path_buf();
if !org_dir.is_dir() {
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory"));
}
let state = Self {
org_dir,
docs: RwLock::new(HashMap::new()),
parser: RwLock::new(Parser::new()),
};
state.scan()?;
Ok(state)
}
/// Scan org directory for files.
pub fn scan(&self) -> Result<()> {
let mut docs = self.docs.write();
docs.clear();
for entry in fs::read_dir(&self.org_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |e| e == "org") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
match self.load_doc(&path) {
Ok(doc_state) => {
docs.insert(name.to_string(), Arc::new(doc_state));
}
Err(e) => {
eprintln!("warning: failed to load {}: {}", path.display(), e);
}
}
}
}
}
Ok(())
}
/// Load and parse a single document.
fn load_doc(&self, path: &Path) -> Result<DocState> {
let content = fs::read_to_string(path)?;
let doc = self.parser.write().parse(&content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
Ok(DocState {
path: path.to_path_buf(),
content,
doc,
})
}
/// List document names.
pub fn doc_names(&self) -> Vec<String> {
self.docs.read().keys().cloned().collect()
}
/// Get a document by name.
pub fn get_doc(&self, name: &str) -> Option<Arc<DocState>> {
self.docs.read().get(name).cloned()
}
/// Get document index: name\tpath\ttitle.
pub fn doc_index(&self) -> String {
let docs = self.docs.read();
let mut lines: Vec<String> = docs.iter()
.map(|(name, state)| {
let title = state.doc.title().unwrap_or("-");
format!("{}\t{}\t{}", name, state.path.display(), title)
})
.collect();
lines.sort();
lines.join("\n") + "\n"
}
/// Get raw content of a document.
pub fn doc_raw(&self, name: &str) -> Option<String> {
self.get_doc(name).map(|d| d.content.clone())
}
/// Write raw content to a document.
pub fn write_doc_raw(&self, name: &str, content: &str) -> Result<()> {
let doc_state = self.get_doc(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?;
// 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(&doc_state.path, content)?;
// Update state
let new_state = Arc::new(DocState {
path: doc_state.path.clone(),
content: content.to_string(),
doc: new_doc,
});
self.docs.write().insert(name.to_string(), new_state);
Ok(())
}
/// Get document metadata.
pub fn doc_meta(&self, name: &str) -> Option<String> {
self.get_doc(name).map(|d| {
let title = d.doc.title().unwrap_or("");
let author = d.doc.author().unwrap_or("");
let filetags = d.doc.settings.filetags.join(":");
format!("title\t{}\nauthor\t{}\nfiletags\t{}\n", title, author, filetags)
})
}
/// Get all TODO keywords configuration.
pub fn keywords(&self) -> String {
// Return default TODO keywords
// TODO: aggregate from all docs or use config
"TODO NEXT WAITING | DONE CANCELLED\n".to_string()
}
/// Handle control commands.
pub fn handle_ctl(&self, cmd: &str) -> Result<String> {
let cmd = cmd.trim();
match cmd {
"reload" | "rescan" => {
self.scan()?;
Ok("ok\n".to_string())
}
_ => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unknown command: {}", cmd))),
}
}
}
// ═══════════════════════════════════════════════════════════════════
// Section Access
// ═══════════════════════════════════════════════════════════════════
impl OrkState {
/// Get section IDs for a document (top-level only).
pub fn section_ids(&self, doc_name: &str) -> Vec<String> {
self.get_doc(doc_name)
.map(|d| {
d.doc.sections.iter()
.map(|s| self.section_id(s))
.collect()
})
.unwrap_or_default()
}
/// Get child section IDs.
pub fn child_ids(&self, doc_name: &str, parent_id: &str) -> Vec<String> {
self.get_doc(doc_name)
.and_then(|d| {
d.doc.all_sections()
.find(|s| self.section_id(s) == parent_id)
.map(|s| {
s.children.iter()
.map(|c| self.section_id(c))
.collect()
})
})
.unwrap_or_default()
}
/// Generate section ID: CUSTOM_ID > ID > slugified title.
fn section_id(&self, section: &org_ast::Section) -> String {
if let Some(id) = section.custom_id() {
return id.to_string();
}
if let Some(id) = section.id() {
return id.to_string();
}
// Slugify title
let title = section.headline.title_text();
slugify(&title)
}
/// Find section by ID in document.
pub fn find_section<'a>(&self, doc: &'a Document, id: &str) -> Option<&'a org_ast::Section> {
doc.all_sections().find(|s| self.section_id(s) == id)
}
/// Get section headline.
pub fn section_headline(&self, doc_name: &str, id: &str) -> Option<String> {
self.get_doc(doc_name).and_then(|d| {
self.find_section(&d.doc, id).map(|s| {
// Reconstruct headline
let mut line = "*".repeat(s.headline.level as usize);
line.push(' ');
if let Some(kw) = &s.headline.keyword {
line.push_str(&kw.keyword);
line.push(' ');
}
if let Some(p) = s.headline.priority {
line.push_str(&format!("[#{}] ", p.as_char()));
}
line.push_str(&s.headline.title_text());
if !s.headline.tags.is_empty() {
line.push_str(&format!(" :{}:", s.headline.tags.join(":")));
}
line
})
})
}
/// Get section title.
pub fn section_title(&self, doc_name: &str, id: &str) -> Option<String> {
self.get_doc(doc_name).and_then(|d| {
self.find_section(&d.doc, id).map(|s| s.headline.title_text())
})
}
/// Get section keyword.
pub fn section_keyword(&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.headline.keyword.as_ref().map(|k| k.keyword.clone())
})
})
}
/// Get section priority.
pub fn section_priority(&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.headline.priority.map(|p| p.as_char().to_string())
})
})
}
/// Get section tags.
pub fn section_tags(&self, doc_name: &str, id: &str) -> Option<String> {
self.get_doc(doc_name).and_then(|d| {
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()
}
})
})
}
}
// ═══════════════════════════════════════════════════════════════════
// Section Creation
// ═══════════════════════════════════════════════════════════════════
impl OrkState {
/// Create a new org file.
/// Input: filename (without .org extension)
/// Returns: created filename
pub fn create_doc(&self, name: &str) -> Result<String> {
let name = name.trim();
if name.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty filename"));
}
// Sanitize filename
let safe_name: String = name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
.collect();
let filename = format!("{}.org", safe_name);
let path = self.org_dir.join(&filename);
if path.exists() {
return Err(io::Error::new(io::ErrorKind::AlreadyExists, "file already exists"));
}
// Create empty org file with title
let content = format!("#+TITLE: {}\n", name);
fs::write(&path, &content)?;
// Parse and add to state
let doc = self.parser.write().parse(&content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
self.docs.write().insert(safe_name.clone(), Arc::new(DocState {
path,
content,
doc,
}));
Ok(filename)
}
/// Create a new top-level section in a document.
/// Input: headline text (e.g., "* TODO Task :tag:")
/// Returns: UUID of created section
pub fn create_section(&self, doc_name: &str, headline: &str) -> Result<String> {
self.create_section_at(doc_name, None, headline)
}
/// Create a child section under a parent.
/// Input: headline text
/// Returns: UUID of created section
pub fn create_child_section(&self, doc_name: &str, parent_id: &str, headline: &str) -> Result<String> {
self.create_section_at(doc_name, Some(parent_id), headline)
}
/// Create a section, optionally under a parent.
fn create_section_at(&self, doc_name: &str, parent_id: Option<&str>, headline: &str) -> Result<String> {
let doc_state = self.get_doc(doc_name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?;
let headline = headline.trim();
if !headline.starts_with('*') {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "headline must start with *"));
}
// Generate UUID
let uuid = uuid::Uuid::new_v4().to_string();
// Build section text with ID property
let section_text = format!(
"{}\n:PROPERTIES:\n:ID: {}\n:END:\n",
headline, uuid
);
// Find insertion point
let mut content = doc_state.content.clone();
if let Some(pid) = parent_id {
// Find parent section and insert after it
if let Some(parent) = self.find_section(&doc_state.doc, pid) {
// Insert at end of parent's span (before any siblings)
let insert_pos = self.find_section_end(&content, parent);
content.insert_str(insert_pos, &section_text);
} else {
return Err(io::Error::new(io::ErrorKind::NotFound, "parent section not found"));
}
} else {
// Append to end of file
if !content.ends_with('\n') {
content.push('\n');
}
content.push_str(&section_text);
}
// Write and reparse
fs::write(&doc_state.path, &content)?;
let new_doc = self.parser.write().parse(&content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
self.docs.write().insert(doc_name.to_string(), Arc::new(DocState {
path: doc_state.path.clone(),
content,
doc: new_doc,
}));
Ok(uuid)
}
/// Find the end position of a section (after its content, before siblings).
fn find_section_end(&self, content: &str, section: &org_ast::Section) -> usize {
// If section has children, insert after last child
if let Some(last_child) = section.children.last() {
return self.find_section_end(content, last_child);
}
// Otherwise, use section's span end
section.headline.span.end.offset.min(content.len())
}
}
/// Slugify a string for use as an ID.
fn slugify(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slugify() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("TODO: Fix bug #123"), "todo-fix-bug-123");
assert_eq!(slugify(" multiple spaces "), "multiple-spaces");
}
}