//! 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>>, /// Parser instance. parser: RwLock, } impl OrkState { /// Create new state watching the given directory. pub fn new(org_dir: impl AsRef) -> Result { 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 { 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 { self.docs.read().keys().cloned().collect() } /// Get a document by name. pub fn get_doc(&self, name: &str) -> Option> { 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 = 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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() } }) }) } } // ═══════════════════════════════════════════════════════════════════ // 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 { 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 { 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 { 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 { 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, §ion_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(§ion_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::() .split('-') .filter(|s| !s.is_empty()) .collect::>() .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"); } }