534 lines
19 KiB
Rust
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, §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::<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");
|
|
}
|
|
}
|