Add structural ops, query, lint, and real agenda views to ork-server
Rework /ctl into a self-describing dispatch table (name, desc, fn), inspired by the ollie 9P server: an empty write or "help" lists the verbs, and unknown verbs report the valid set. New control verbs, each operating on a section subtree: - rm: delete a section and its subtree - move up|down: reorder among siblings - promote/demote: shift the subtree's outline level - refile <to-doc> [parent]: move a subtree across or within documents, re-leveling to the target depth - archive: move a subtree to the companion <doc>.org_archive file - lint [doc]: reparse and report per document Add a root /query file (rdwr): write a tag:X+todo:Y+priority:Z match expression, read back matching sections. Implement the previously stubbed agenda today/week views with real date filtering, using a dependency-free civil-date day-number calculation. Subtree edits share span-based helpers (subtree_range, sibling_ranges, shift_headline_levels, insert_at). Covered by unit tests.
This commit is contained in:
parent
73c4eb6cff
commit
12869fb9f0
|
|
@ -34,6 +34,15 @@ pub fn build_namespace(state: Arc<OrkState>) -> FsNode {
|
|||
move || Ok(state.keywords().into_bytes())
|
||||
})),
|
||||
|
||||
// query - write a match expression, read matching sections
|
||||
virtfs::file("query", 0o666, virtfs::rdwr({
|
||||
let state = state.clone();
|
||||
move |data| {
|
||||
let expr = String::from_utf8_lossy(data);
|
||||
state.query(&expr).map(|s| s.into_bytes())
|
||||
}
|
||||
})),
|
||||
|
||||
// new - create new org file
|
||||
virtfs::file("new", 0o666, virtfs::rdwr({
|
||||
let state = state.clone();
|
||||
|
|
@ -427,14 +436,79 @@ fn build_todos_view(state: &OrkState) -> String {
|
|||
lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
/// Sections whose SCHEDULED or DEADLINE falls today.
|
||||
fn build_today_view(state: &OrkState) -> String {
|
||||
// TODO: implement with actual date filtering
|
||||
build_todos_view(state)
|
||||
build_dated_view(state, 1)
|
||||
}
|
||||
|
||||
/// Sections whose SCHEDULED or DEADLINE falls within the next `span` days
|
||||
/// (starting today).
|
||||
fn build_week_view(state: &OrkState) -> String {
|
||||
// TODO: implement with actual date filtering
|
||||
build_todos_view(state)
|
||||
build_dated_view(state, 7)
|
||||
}
|
||||
|
||||
/// List sections with a planning date in `[today, today + days)`, formatted as
|
||||
/// `date\tkind\tid\tdoc\ttitle`.
|
||||
fn build_dated_view(state: &OrkState, days: i64) -> String {
|
||||
let today = today_daynum();
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for doc_name in state.doc_names() {
|
||||
let Some(doc_state) = state.get_doc(&doc_name) else { continue };
|
||||
for section in doc_state.doc.all_sections() {
|
||||
let Some(planning) = section.planning.as_ref() else { continue };
|
||||
for (kind, ts) in [
|
||||
("SCHEDULED", planning.scheduled.as_ref()),
|
||||
("DEADLINE", planning.deadline.as_ref()),
|
||||
] {
|
||||
let Some(ts) = ts else { continue };
|
||||
let Some(date) = timestamp_date(ts) else { continue };
|
||||
let dn = daynum(date.year as i64, date.month as i64, date.day as i64);
|
||||
if dn >= today && dn < today + days {
|
||||
lines.push(format!(
|
||||
"{:04}-{:02}-{:02}\t{}\t{}\t{}\t{}",
|
||||
date.year, date.month, date.day, kind,
|
||||
state.section_id(section), doc_name,
|
||||
section.headline.title_text(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.sort();
|
||||
lines.join("\n") + if lines.is_empty() { "" } else { "\n" }
|
||||
}
|
||||
|
||||
/// Extract the (start) date of a timestamp, if it has one.
|
||||
fn timestamp_date(ts: &org_ast::Timestamp) -> Option<&org_ast::Date> {
|
||||
match &ts.value {
|
||||
org_ast::TimestampValue::Point(p) => Some(&p.date),
|
||||
org_ast::TimestampValue::Range { start, .. } => Some(&start.date),
|
||||
org_ast::TimestampValue::Diary(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Days since an arbitrary epoch for a proleptic-Gregorian civil date.
|
||||
/// From Howard Hinnant's `days_from_civil` algorithm.
|
||||
fn daynum(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = (y - era * 400) as i64; // [0, 399]
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365]
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
|
||||
era * 146097 + doe - 719468
|
||||
}
|
||||
|
||||
/// Today's civil date as a day number, from the system clock (UTC).
|
||||
fn today_daynum() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
// Unix epoch (1970-01-01) has day number 0 in this scheme.
|
||||
secs.div_euclid(86_400)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -443,6 +517,60 @@ mod tests {
|
|||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_daynum_civil() {
|
||||
// 1970-01-01 is day 0; the next day is 1; leap years handled.
|
||||
assert_eq!(daynum(1970, 1, 1), 0);
|
||||
assert_eq!(daynum(1970, 1, 2), 1);
|
||||
assert_eq!(daynum(2000, 3, 1) - daynum(2000, 2, 28), 2); // 2000 is a leap year
|
||||
assert_eq!(daynum(2001, 3, 1) - daynum(2001, 2, 28), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dated_view_windows() {
|
||||
let dir = tempdir().unwrap();
|
||||
// Build dates relative to the server's notion of "today".
|
||||
let today = today_daynum();
|
||||
let ymd = |dn: i64| civil_from_daynum(dn);
|
||||
let (ty, tm, td) = ymd(today);
|
||||
let (fy, fm, fd) = ymd(today + 3); // inside week, outside today
|
||||
let (oy, om, od) = ymd(today + 30); // outside both
|
||||
let content = format!(
|
||||
"#+TITLE: W\n\
|
||||
* TODO Today\nSCHEDULED: <{:04}-{:02}-{:02}>\n\
|
||||
* TODO Soon\nDEADLINE: <{:04}-{:02}-{:02}>\n\
|
||||
* TODO Later\nSCHEDULED: <{:04}-{:02}-{:02}>\n",
|
||||
ty, tm, td, fy, fm, fd, oy, om, od,
|
||||
);
|
||||
fs::write(dir.path().join("w.org"), content).unwrap();
|
||||
let state = OrkState::new(dir.path()).unwrap();
|
||||
|
||||
let today_view = build_today_view(&state);
|
||||
assert!(today_view.contains("Today"));
|
||||
assert!(!today_view.contains("Soon"));
|
||||
assert!(!today_view.contains("Later"));
|
||||
|
||||
let week_view = build_week_view(&state);
|
||||
assert!(week_view.contains("Today"));
|
||||
assert!(week_view.contains("Soon"));
|
||||
assert!(!week_view.contains("Later"));
|
||||
}
|
||||
|
||||
/// Inverse of `daynum` for building test dates (Hinnant's civil_from_days).
|
||||
fn civil_from_daynum(dn: i64) -> (u16, u8, u8) {
|
||||
let z = dn + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y as u16, m as u8, d as u8)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_namespace_structure() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -194,16 +194,107 @@ impl OrkState {
|
|||
"TODO NEXT WAITING | DONE CANCELLED\n".to_string()
|
||||
}
|
||||
|
||||
/// Handle control commands.
|
||||
/// Handle control commands via a self-describing dispatch table.
|
||||
///
|
||||
/// An empty write or `help` returns the command listing; each verb is a
|
||||
/// `{name, desc, fn}` entry dispatched by its first word. Inspired by the
|
||||
/// ctl surface in the ollie 9P server.
|
||||
pub fn handle_ctl(&self, cmd: &str) -> Result<String> {
|
||||
type CtlFn = fn(&OrkState, &[&str]) -> Result<String>;
|
||||
const CMDS: &[(&str, &str, CtlFn)] = &[
|
||||
("reload", "rescan org files from disk", OrkState::ctl_reload),
|
||||
("rm", "rm <doc> <id> — delete a section and its subtree", OrkState::ctl_rm),
|
||||
("move", "move <doc> <id> up|down — reorder among siblings", OrkState::ctl_move),
|
||||
("promote", "promote <doc> <id> — decrease subtree level", OrkState::ctl_promote),
|
||||
("demote", "demote <doc> <id> — increase subtree level", OrkState::ctl_demote),
|
||||
("refile", "refile <doc> <id> <to-doc> [parent-id] — move subtree", OrkState::ctl_refile),
|
||||
("archive", "archive <doc> <id> — move subtree to <doc>.org_archive", OrkState::ctl_archive),
|
||||
("lint", "lint [doc] — validate documents", OrkState::ctl_lint),
|
||||
];
|
||||
|
||||
let cmd = cmd.trim();
|
||||
match cmd {
|
||||
"reload" | "rescan" => {
|
||||
self.scan()?;
|
||||
Ok("ok\n".to_string())
|
||||
if cmd.is_empty() || cmd == "help" {
|
||||
let mut out = String::new();
|
||||
for (name, desc, _) in CMDS {
|
||||
out.push_str(&format!("{:<10} {}\n", name, desc));
|
||||
}
|
||||
_ => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unknown command: {}", cmd))),
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||||
let verb = parts[0];
|
||||
let args = &parts[1..];
|
||||
|
||||
// Accept "rescan" as an alias for "reload".
|
||||
let verb = if verb == "rescan" { "reload" } else { verb };
|
||||
|
||||
for (name, _, f) in CMDS {
|
||||
if *name == verb {
|
||||
return f(self, args);
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = CMDS.iter().map(|(n, _, _)| *n).collect();
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("unknown ctl command {:?}; valid: {}", verb, names.join(", ")),
|
||||
))
|
||||
}
|
||||
|
||||
fn ctl_reload(&self, _args: &[&str]) -> Result<String> {
|
||||
self.scan()?;
|
||||
Ok("ok\n".to_string())
|
||||
}
|
||||
|
||||
fn ctl_rm(&self, args: &[&str]) -> Result<String> {
|
||||
let (doc, id) = two_args(args, "rm <doc> <id>")?;
|
||||
self.delete_section(doc, id)?;
|
||||
Ok("ok\n".to_string())
|
||||
}
|
||||
|
||||
fn ctl_move(&self, args: &[&str]) -> Result<String> {
|
||||
if args.len() != 3 {
|
||||
return Err(bad_args("move <doc> <id> up|down"));
|
||||
}
|
||||
let dir = match args[2] {
|
||||
"up" => MoveDir::Up,
|
||||
"down" => MoveDir::Down,
|
||||
other => return Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("direction must be up or down, got {:?}", other))),
|
||||
};
|
||||
self.move_section(args[0], args[1], dir)?;
|
||||
Ok("ok\n".to_string())
|
||||
}
|
||||
|
||||
fn ctl_promote(&self, args: &[&str]) -> Result<String> {
|
||||
let (doc, id) = two_args(args, "promote <doc> <id>")?;
|
||||
self.relevel_section(doc, id, -1)?;
|
||||
Ok("ok\n".to_string())
|
||||
}
|
||||
|
||||
fn ctl_demote(&self, args: &[&str]) -> Result<String> {
|
||||
let (doc, id) = two_args(args, "demote <doc> <id>")?;
|
||||
self.relevel_section(doc, id, 1)?;
|
||||
Ok("ok\n".to_string())
|
||||
}
|
||||
|
||||
fn ctl_refile(&self, args: &[&str]) -> Result<String> {
|
||||
if args.len() < 3 || args.len() > 4 {
|
||||
return Err(bad_args("refile <doc> <id> <to-doc> [parent-id]"));
|
||||
}
|
||||
let parent = args.get(3).copied();
|
||||
self.refile_section(args[0], args[1], args[2], parent)?;
|
||||
Ok("ok\n".to_string())
|
||||
}
|
||||
|
||||
fn ctl_archive(&self, args: &[&str]) -> Result<String> {
|
||||
let (doc, id) = two_args(args, "archive <doc> <id>")?;
|
||||
let dest = self.archive_section(doc, id)?;
|
||||
Ok(format!("{}\n", dest))
|
||||
}
|
||||
|
||||
fn ctl_lint(&self, args: &[&str]) -> Result<String> {
|
||||
let target = args.first().copied();
|
||||
Ok(self.lint(target))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +330,7 @@ impl OrkState {
|
|||
}
|
||||
|
||||
/// Generate section ID: CUSTOM_ID > ID > slugified title.
|
||||
fn section_id(&self, section: &org_ast::Section) -> String {
|
||||
pub fn section_id(&self, section: &org_ast::Section) -> String {
|
||||
if let Some(id) = section.custom_id() {
|
||||
return id.to_string();
|
||||
}
|
||||
|
|
@ -859,6 +950,374 @@ impl OrkState {
|
|||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Structural Operations (delete, move, relevel, refile, archive, lint)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Direction for `move_section`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MoveDir {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
impl OrkState {
|
||||
/// Byte range [start, end) of a section's full subtree (headline through
|
||||
/// the end of its content and all descendants).
|
||||
fn subtree_range(&self, doc: &Document, id: &str) -> Option<(usize, usize)> {
|
||||
let s = self.find_section(doc, id)?;
|
||||
Some((s.headline.span.start.offset, s.span.end.offset))
|
||||
}
|
||||
|
||||
/// Sibling subtree ranges (in document order) for the section with `id`,
|
||||
/// plus the index of `id` within them. Siblings are the children of the
|
||||
/// same parent, or the top-level sections when `id` is top-level.
|
||||
fn sibling_ranges(&self, doc: &Document, id: &str) -> Option<(Vec<(usize, usize)>, usize)> {
|
||||
// Top-level?
|
||||
let top: Vec<&org_ast::Section> = doc.sections.iter().collect();
|
||||
if let Some(i) = top.iter().position(|s| self.section_id(s) == id) {
|
||||
let ranges = top.iter()
|
||||
.map(|s| (s.headline.span.start.offset, s.span.end.offset))
|
||||
.collect();
|
||||
return Some((ranges, i));
|
||||
}
|
||||
// Otherwise find the parent whose children include id.
|
||||
for parent in doc.all_sections() {
|
||||
if let Some(i) = parent.children.iter().position(|c| self.section_id(c) == id) {
|
||||
let ranges = parent.children.iter()
|
||||
.map(|c| (c.headline.span.start.offset, c.span.end.offset))
|
||||
.collect();
|
||||
return Some((ranges, i));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Delete a section and its entire subtree.
|
||||
pub fn delete_section(&self, doc_name: &str, id: &str) -> Result<()> {
|
||||
let doc_state = self.get_doc(doc_name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?;
|
||||
let (start, end) = self.subtree_range(&doc_state.doc, id)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?;
|
||||
let mut content = String::with_capacity(doc_state.content.len());
|
||||
content.push_str(&doc_state.content[..start]);
|
||||
content.push_str(&doc_state.content[end..]);
|
||||
self.update_doc(doc_name, &doc_state.path, content)
|
||||
}
|
||||
|
||||
/// Reorder a section among its siblings.
|
||||
pub fn move_section(&self, doc_name: &str, id: &str, dir: MoveDir) -> Result<()> {
|
||||
let doc_state = self.get_doc(doc_name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?;
|
||||
let (ranges, idx) = self.sibling_ranges(&doc_state.doc, id)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?;
|
||||
|
||||
let other = match dir {
|
||||
MoveDir::Up if idx > 0 => idx - 1,
|
||||
MoveDir::Down if idx + 1 < ranges.len() => idx + 1,
|
||||
// Already at the boundary: nothing to do.
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let (a, b) = if idx < other { (idx, other) } else { (other, idx) };
|
||||
let (a_start, a_end) = ranges[a];
|
||||
let (b_start, b_end) = ranges[b];
|
||||
|
||||
let content = &doc_state.content;
|
||||
let mut new = String::with_capacity(content.len());
|
||||
new.push_str(&content[..a_start]);
|
||||
new.push_str(&content[b_start..b_end]); // second block first
|
||||
new.push_str(&content[a_end..b_start]); // any gap between them
|
||||
new.push_str(&content[a_start..a_end]); // first block second
|
||||
new.push_str(&content[b_end..]);
|
||||
self.update_doc(doc_name, &doc_state.path, new)
|
||||
}
|
||||
|
||||
/// Shift a section's subtree level by `delta` (negative promotes).
|
||||
pub fn relevel_section(&self, doc_name: &str, id: &str, delta: i32) -> Result<()> {
|
||||
let doc_state = self.get_doc(doc_name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?;
|
||||
let section = self.find_section(&doc_state.doc, id)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?;
|
||||
if delta < 0 && section.headline.level <= 1 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
"cannot promote a top-level section"));
|
||||
}
|
||||
let (start, end) = (section.headline.span.start.offset, section.span.end.offset);
|
||||
let subtree = shift_headline_levels(&doc_state.content[start..end], delta);
|
||||
let mut new = String::with_capacity(doc_state.content.len());
|
||||
new.push_str(&doc_state.content[..start]);
|
||||
new.push_str(&subtree);
|
||||
new.push_str(&doc_state.content[end..]);
|
||||
self.update_doc(doc_name, &doc_state.path, new)
|
||||
}
|
||||
|
||||
/// Move a section's subtree to another document (or elsewhere in the same
|
||||
/// document), re-leveling it to the target depth. `to_parent` places it as
|
||||
/// a child of that section; otherwise it becomes a top-level section.
|
||||
pub fn refile_section(
|
||||
&self,
|
||||
doc_name: &str,
|
||||
id: &str,
|
||||
to_doc: &str,
|
||||
to_parent: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let src = self.get_doc(doc_name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "source document not found"))?;
|
||||
let dst = self.get_doc(to_doc)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "target document not found"))?;
|
||||
|
||||
let section = self.find_section(&src.doc, id)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?;
|
||||
let (start, end) = (section.headline.span.start.offset, section.span.end.offset);
|
||||
let src_level = section.headline.level as i32;
|
||||
|
||||
// Target level: one below the parent, or top-level.
|
||||
let target_level = match to_parent {
|
||||
Some(pid) => {
|
||||
let parent = self.find_section(&dst.doc, pid)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "parent section not found"))?;
|
||||
parent.headline.level as i32 + 1
|
||||
}
|
||||
None => 1,
|
||||
};
|
||||
|
||||
// Extract and re-level the subtree text.
|
||||
let mut subtree = shift_headline_levels(&src.content[start..end], target_level - src_level);
|
||||
if !subtree.ends_with('\n') {
|
||||
subtree.push('\n');
|
||||
}
|
||||
|
||||
// Same-document refile: compute both edits against one content string.
|
||||
if doc_name == to_doc {
|
||||
// Remove the subtree, then recompute the insertion point on the
|
||||
// post-removal content by reparsing.
|
||||
let mut content = String::with_capacity(src.content.len());
|
||||
content.push_str(&src.content[..start]);
|
||||
content.push_str(&src.content[end..]);
|
||||
self.update_doc(doc_name, &src.path, content)?;
|
||||
|
||||
let doc_state = self.get_doc(to_doc).unwrap();
|
||||
let insert_pos = match to_parent {
|
||||
Some(pid) => {
|
||||
let parent = self.find_section(&doc_state.doc, pid)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "parent section not found"))?;
|
||||
self.find_section_end(&doc_state.content, parent)
|
||||
}
|
||||
None => doc_state.content.len(),
|
||||
};
|
||||
let new = insert_at(&doc_state.content, insert_pos, &subtree);
|
||||
return self.update_doc(to_doc, &doc_state.path, new);
|
||||
}
|
||||
|
||||
// Cross-document refile: insert into target, then remove from source.
|
||||
let insert_pos = match to_parent {
|
||||
Some(pid) => {
|
||||
let parent = self.find_section(&dst.doc, pid).unwrap();
|
||||
self.find_section_end(&dst.content, parent)
|
||||
}
|
||||
None => dst.content.len(),
|
||||
};
|
||||
let dst_new = insert_at(&dst.content, insert_pos, &subtree);
|
||||
self.update_doc(to_doc, &dst.path, dst_new)?;
|
||||
|
||||
let mut src_new = String::with_capacity(src.content.len());
|
||||
src_new.push_str(&src.content[..start]);
|
||||
src_new.push_str(&src.content[end..]);
|
||||
self.update_doc(doc_name, &src.path, src_new)
|
||||
}
|
||||
|
||||
/// Move a section's subtree to `<doc>.org_archive` and remove it from the
|
||||
/// source. Returns the archive filename.
|
||||
pub fn archive_section(&self, doc_name: &str, id: &str) -> Result<String> {
|
||||
let src = self.get_doc(doc_name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?;
|
||||
let (start, end) = self.subtree_range(&src.doc, id)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?;
|
||||
|
||||
let mut subtree = src.content[start..end].to_string();
|
||||
if !subtree.ends_with('\n') {
|
||||
subtree.push('\n');
|
||||
}
|
||||
|
||||
// Append to the companion archive file (created if missing).
|
||||
let archive_path = archive_path_for(&src.path);
|
||||
let mut archive = if archive_path.exists() {
|
||||
fs::read_to_string(&archive_path)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if !archive.is_empty() && !archive.ends_with('\n') {
|
||||
archive.push('\n');
|
||||
}
|
||||
archive.push_str(&subtree);
|
||||
fs::write(&archive_path, &archive)?;
|
||||
|
||||
// Remove the subtree from the source document.
|
||||
let mut content = String::with_capacity(src.content.len());
|
||||
content.push_str(&src.content[..start]);
|
||||
content.push_str(&src.content[end..]);
|
||||
self.update_doc(doc_name, &src.path, content)?;
|
||||
|
||||
Ok(archive_path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Validate documents by reparsing. Reports one line per document.
|
||||
/// With no target, lints every document.
|
||||
pub fn lint(&self, target: Option<&str>) -> String {
|
||||
let names: Vec<String> = match target {
|
||||
Some(t) => vec![t.to_string()],
|
||||
None => {
|
||||
let mut n = self.doc_names();
|
||||
n.sort();
|
||||
n
|
||||
}
|
||||
};
|
||||
let mut out = String::new();
|
||||
for name in names {
|
||||
match self.get_doc(&name) {
|
||||
None => out.push_str(&format!("{}\tnot found\n", name)),
|
||||
Some(d) => match self.parser.write().parse(&d.content) {
|
||||
Ok(_) => out.push_str(&format!("{}\tok\n", name)),
|
||||
Err(e) => out.push_str(&format!("{}\terror: {}\n", name, e)),
|
||||
},
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run a simple query and return matching sections as
|
||||
/// `id\tdoc\ttitle\tkeyword` lines. The expression is a `+`-separated
|
||||
/// conjunction of `tag:X`, `todo:X`, `todo` (any), or `priority:X` terms.
|
||||
pub fn query(&self, expr: &str) -> Result<String> {
|
||||
let terms: Vec<QueryTerm> = expr
|
||||
.split('+')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(QueryTerm::parse)
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut names = self.doc_names();
|
||||
names.sort();
|
||||
for doc_name in names {
|
||||
let Some(d) = self.get_doc(&doc_name) else { continue };
|
||||
for section in d.doc.all_sections() {
|
||||
if terms.iter().all(|t| t.matches(section)) {
|
||||
let id = self.section_id(section);
|
||||
let kw = section.headline.keyword.as_ref()
|
||||
.map(|k| k.keyword.as_str()).unwrap_or("");
|
||||
lines.push(format!("{}\t{}\t{}\t{}",
|
||||
id, doc_name, section.headline.title_text(), kw));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.sort();
|
||||
Ok(lines.join("\n") + if lines.is_empty() { "" } else { "\n" })
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed query term.
|
||||
enum QueryTerm {
|
||||
Tag(String),
|
||||
Todo(Option<String>),
|
||||
Priority(char),
|
||||
}
|
||||
|
||||
impl QueryTerm {
|
||||
fn parse(s: &str) -> Result<Self> {
|
||||
if let Some(v) = s.strip_prefix("tag:") {
|
||||
Ok(QueryTerm::Tag(v.to_string()))
|
||||
} else if s == "todo" {
|
||||
Ok(QueryTerm::Todo(None))
|
||||
} else if let Some(v) = s.strip_prefix("todo:") {
|
||||
Ok(QueryTerm::Todo(Some(v.to_string())))
|
||||
} else if let Some(v) = s.strip_prefix("priority:") {
|
||||
let c = v.chars().next()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "empty priority"))?;
|
||||
Ok(QueryTerm::Priority(c.to_ascii_uppercase()))
|
||||
} else {
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput,
|
||||
format!("bad query term {:?}; use tag:X, todo[:X], or priority:X", s)))
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, section: &org_ast::Section) -> bool {
|
||||
match self {
|
||||
QueryTerm::Tag(t) => section.has_tag(t),
|
||||
QueryTerm::Todo(None) => section.is_todo() || section.is_done(),
|
||||
QueryTerm::Todo(Some(kw)) => section.headline.keyword.as_ref()
|
||||
.map_or(false, |k| k.keyword == *kw),
|
||||
QueryTerm::Priority(p) => section.headline.priority.map_or(false, |pr| match pr {
|
||||
org_ast::Priority::A => *p == 'A',
|
||||
org_ast::Priority::B => *p == 'B',
|
||||
org_ast::Priority::C => *p == 'C',
|
||||
org_ast::Priority::Numeric(_) => false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Require exactly two positional args (doc, id).
|
||||
fn two_args<'a>(args: &[&'a str], usage: &str) -> Result<(&'a str, &'a str)> {
|
||||
if args.len() != 2 {
|
||||
return Err(bad_args(usage));
|
||||
}
|
||||
Ok((args[0], args[1]))
|
||||
}
|
||||
|
||||
fn bad_args(usage: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, format!("usage: {}", usage))
|
||||
}
|
||||
|
||||
/// Insert `text` into `content` at byte offset `pos`, ensuring the insertion
|
||||
/// begins on its own line.
|
||||
fn insert_at(content: &str, pos: usize, text: &str) -> String {
|
||||
let mut out = String::with_capacity(content.len() + text.len() + 1);
|
||||
out.push_str(&content[..pos]);
|
||||
if pos > 0 && !content[..pos].ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(text);
|
||||
out.push_str(&content[pos..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The companion archive path for a document: `<path>_archive`.
|
||||
fn archive_path_for(path: &Path) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_os_string();
|
||||
s.push("_archive");
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Shift the level of every headline line in `text` by `delta`, clamping to a
|
||||
/// minimum of level 1. A headline line begins at column 0 with one or more
|
||||
/// `*` followed by a space (or end of line).
|
||||
fn shift_headline_levels(text: &str, delta: i32) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for line in text.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches('\n');
|
||||
let stars = trimmed.chars().take_while(|c| *c == '*').count();
|
||||
// A headline is `*`+ followed by a space, or a bare run of `*`.
|
||||
let is_headline = stars > 0
|
||||
&& (trimmed[stars..].starts_with(' ') || trimmed.len() == stars);
|
||||
if is_headline {
|
||||
let new_level = ((stars as i32 + delta).max(1)) as usize;
|
||||
let rest = &trimmed[stars..];
|
||||
out.push_str(&"*".repeat(new_level));
|
||||
out.push_str(rest);
|
||||
if line.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace the leading run of `*` in a headline with exactly `level` stars,
|
||||
/// preserving the rest of the line.
|
||||
fn restar(headline: &str, level: usize) -> String {
|
||||
|
|
@ -934,4 +1393,107 @@ mod tests {
|
|||
// The child is walkable by its generated UUID.
|
||||
assert!(state.section_exists("w", &child_uuid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shift_headline_levels() {
|
||||
let src = "* A\nbody\n** B\n*not a headline\n";
|
||||
assert_eq!(shift_headline_levels(src, 1), "** A\nbody\n*** B\n*not a headline\n");
|
||||
assert_eq!(shift_headline_levels("*** A\n", -1), "** A\n");
|
||||
// Clamp at level 1.
|
||||
assert_eq!(shift_headline_levels("* A\n", -1), "* A\n");
|
||||
}
|
||||
|
||||
fn state_with(content: &str) -> (tempfile::TempDir, OrkState) {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("w.org"), content).unwrap();
|
||||
let state = OrkState::new(dir.path()).unwrap();
|
||||
(dir, state)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_section() {
|
||||
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n");
|
||||
state.delete_section("w", "two").unwrap();
|
||||
let raw = state.doc_raw("w").unwrap();
|
||||
assert!(!raw.contains("* Two"));
|
||||
assert!(raw.contains("* One") && raw.contains("* Three"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_section() {
|
||||
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n");
|
||||
state.move_section("w", "one", MoveDir::Down).unwrap();
|
||||
let raw = state.doc_raw("w").unwrap();
|
||||
let one = raw.find("* One").unwrap();
|
||||
let two = raw.find("* Two").unwrap();
|
||||
assert!(two < one, "Two should now precede One");
|
||||
// Boundary is a no-op.
|
||||
state.move_section("w", "three", MoveDir::Down).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relevel_section() {
|
||||
let (_d, state) = state_with("#+TITLE: W\n* Parent\n** Child\n* Other\n");
|
||||
state.relevel_section("w", "parent", 1).unwrap();
|
||||
let raw = state.doc_raw("w").unwrap();
|
||||
assert!(raw.contains("** Parent"));
|
||||
assert!(raw.contains("*** Child"), "child shifts with parent");
|
||||
assert!(raw.contains("* Other"), "sibling untouched");
|
||||
// Cannot promote a top-level section.
|
||||
assert!(state.relevel_section("w", "other", -1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refile_cross_document() {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("a.org"), "#+TITLE: A\n* Move Me\nbody\n").unwrap();
|
||||
fs::write(dir.path().join("b.org"), "#+TITLE: B\n* Target\n").unwrap();
|
||||
let state = OrkState::new(dir.path()).unwrap();
|
||||
|
||||
state.refile_section("a", "move-me", "b", Some("target")).unwrap();
|
||||
|
||||
let a = state.doc_raw("a").unwrap();
|
||||
let b = state.doc_raw("b").unwrap();
|
||||
assert!(!a.contains("Move Me"), "removed from source");
|
||||
assert!(b.contains("** Move Me"), "demoted under target in b");
|
||||
assert!(b.contains("body"), "body carried over");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archive_section() {
|
||||
use std::fs;
|
||||
let (dir, state) = state_with("#+TITLE: W\n* Keep\n* Archive Me\nnote\n");
|
||||
let dest = state.archive_section("w", "archive-me").unwrap();
|
||||
assert_eq!(dest, "w.org_archive");
|
||||
let src = state.doc_raw("w").unwrap();
|
||||
assert!(!src.contains("Archive Me"));
|
||||
let archived = fs::read_to_string(dir.path().join("w.org_archive")).unwrap();
|
||||
assert!(archived.contains("* Archive Me"));
|
||||
assert!(archived.contains("note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query() {
|
||||
let (_d, state) = state_with(
|
||||
"#+TITLE: W\n* TODO [#A] Alpha :work:\n* DONE Beta :work:\n* TODO Gamma\n",
|
||||
);
|
||||
let r = state.query("tag:work+todo:TODO").unwrap();
|
||||
assert!(r.contains("Alpha"));
|
||||
assert!(!r.contains("Beta"), "Beta is DONE");
|
||||
assert!(!r.contains("Gamma"), "Gamma lacks the work tag");
|
||||
|
||||
let r2 = state.query("priority:A").unwrap();
|
||||
assert!(r2.contains("Alpha") && !r2.contains("Gamma"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lint() {
|
||||
let (_d, state) = state_with("#+TITLE: W\n* One\n");
|
||||
let report = state.lint(Some("w"));
|
||||
assert_eq!(report, "w\tok\n");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue