diff --git a/AGENTS.md b/AGENTS.md index 3c9cc18..24c5c2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,18 +39,20 @@ cargo test ### CLI -The `ork` CLI operates on directories by default: +The `ork` CLI is a 9P client that talks to `ork-server`. Start a server first, then: ```bash -ork info . # Aggregate info for all .org files -ork sections --todos-only # List TODOs across files -ork query "tag:work" -R # Recursive search -ork get "section-id" -j # JSON output -ork toggle "task-id" -n # Dry run -ork add "New Task" file.org --todo TODO +export ORK_ADDR=unix:///tmp/ork.sock + +ork docs # List documents +ork tree work # Outline of work.org +ork todos # All TODOs +ork get work my-task # Section fields +ork set work my-task keyword DONE +ork add work "* TODO New task :work:" ``` -Use `-j/--json` for machine-readable output. Use `-n/--dry-run` for mutations. +Base 9P operations are available as `ork ls`, `ork read`, `ork write`, `ork rdwr`. ## Architecture Decisions @@ -99,9 +101,8 @@ Use `-j/--json` for machine-readable output. Use `-n/--dry-run` for mutations. ## Current Limitations 1. **Inline objects incomplete** — tree-sitter-org doesn't fully parse bold/italic/links -2. **Property/tag write commands** — stubs only, not implemented -3. **Timestamp text** — displays "set" placeholder, not actual date -4. **No incremental parsing** — full reparse on every call +2. **No incremental parsing** — full reparse on every write +3. **Section ID instability** — slug-derived IDs change when titles change (UUID IDs via `:ID:` are stable) ## Next Steps (v0.4) @@ -126,8 +127,14 @@ Before committing: ```bash cargo build --release cargo test -./target/release/ork check . -./target/release/ork info test.org -j + +# Integration test (start server, run CLI, stop server) +ork-server -a unix:///tmp/ork-test.sock -d /tmp/test-org & +SERVER_PID=$! +export ORK_ADDR=unix:///tmp/ork-test.sock +ork docs +ork lint +kill $SERVER_PID ``` ## Questions? diff --git a/README.md b/README.md index 3bcbae4..ae4accd 100644 --- a/README.md +++ b/README.md @@ -116,126 +116,91 @@ cmake --build ui/build -j ## Usage -### CLI (ork) +### Server -The `ork` command operates on org directories or individual files. All commands default to -the current directory (`.`) when no path is specified. Use `-R` for recursive subdirectory scanning. +Start the 9P server to expose an org directory: ```bash -# List org files -ork files # Current directory -ork files ~/org # Specific directory -ork files -R . # Recursive +# Unix socket (preferred for local use) +ork-server -a unix:///tmp/ork.sock -d ~/org -# Document info (aggregated across all files) -ork info # Current directory -ork info ~/org -R # Recursive -ork info file.org # Single file -ork info --json # JSON output - -# List sections -ork sections # All sections in cwd -ork sections --todos-only # Only TODOs -ork sections --tag work # Filter by tag -ork sections --depth 2 # Max depth -ork sections -R -j # Recursive, JSON - -# Query sections -ork query "tag:work" # By tag -ork query "todo:TODO" # By TODO state -ork query "priority:A" # By priority -ork query "title:pattern" # By title -ork query "file:notes" # By filename -ork query "done:false" -j # JSON output - -# Get specific section by ID or title -ork get "my-task-id" # Searches all files -ork get "My Task" -j # JSON output -ork get "intro" ~/org -R # In specific path - -# List tags with counts -ork tags # All tags in cwd -ork tags -R --json # Recursive, JSON - -# Export as JSON -ork export --pretty # All documents -ork export --section my-id # Specific section - -# Add section -ork add "New Task" file.org # To specific file -ork add "New Task" --file notes.org # When path is directory -ork add "New Task" file.org --todo TODO --priority A --tags "work,urgent" -ork add "New Task" file.org -n # Dry run - -# Toggle TODO state -ork toggle "task-id" # Searches all files -ork toggle "task-id" -n # Dry run - -# Agenda view -ork agenda # Current directory -ork agenda ~/org -R # Recursive -ork agenda --days 14 --json # 14 days, JSON - -# Validate syntax -ork check # Current directory -ork check -R --json # Recursive, JSON +# TCP socket +ork-server -a tcp://localhost:5640 -d ~/org ``` -### Rust API +### CLI (ork) -```rust -use org_parser::Parser; +`ork` is a 9P client that talks to `ork-server`. All org-mode intelligence lives in the server. -let mut parser = Parser::new(); -let doc = parser.parse(r#" -#+TITLE: My Document -#+TODO: TODO WAITING | DONE - -* TODO [#A] First Task :work: -DEADLINE: <2025-01-15 Wed 14:00> - -Some description. - -** Subtask -- [ ] Item 1 -- [X] Item 2 -"#).unwrap(); - -// Access document properties -println!("Title: {:?}", doc.title()); -println!("Sections: {}", doc.sections.len()); -println!("TODOs: {}", doc.todos().count()); - -// Iterate sections -for section in doc.all_sections() { - println!("{} {}", - "*".repeat(section.level() as usize), - section.headline.title_text() - ); -} -``` - -### 9P server & client - -Serve a directory of org files over 9P, then act on it with `ork`: - -```sh -# Start the server (unix socket or tcp) -ork-server -a unix:///tmp/ork.sock -d ~/org & - -# Point the client at it (or pass -a on each call) +```bash +# Set the server address (or use -a on each call) export ORK_ADDR=unix:///tmp/ork.sock -ork docs # list documents -ork tree work # outline of work.org -ork todos # TODO items across all docs -ork set work my-task keyword DONE -ork refile work my-task archive --parent done-items -ork query 'tag:work+todo:TODO' +# Base 9P operations +ork ls / # List root namespace +ork read /idx # Read a file +ork write /work/task/keyword DONE # Write a value + +# Friendly verbs (compositions of 9P ops) +ork docs # List documents (name + title) +ork sections work # Top-level sections in work.org +ork tree work # Full section outline +ork todos # All TODO items across docs +ork todos work # TODOs in work.org only + +# Section details +ork get work my-task # Show section fields +ork set work my-task keyword DONE # Set a field +ork set work my-task tags work:urgent # Set tags (colon-separated) + +# Create sections +ork mkdoc notes # Create notes.org +ork add work "* TODO New task :work:" # Add top-level section +ork add work "* Subtask" -p parent-id # Add child section + +# TODO management +ork todo work my-task # Cycle to next state +ork todo work my-task DONE # Set explicit state +ork tag work my-task urgent # Add tag +ork tag work my-task urgent -r # Remove tag + +# Structural operations +ork mv work my-task up # Reorder among siblings +ork promote work my-task # Decrease level +ork demote work my-task # Increase level +ork rm work my-task # Delete section and subtree +ork refile work my-task archive # Move to another doc +ork refile work my-task archive -p done # As child of 'done' section +ork archive work my-task # Move to work.org_archive + +# Properties +ork prop work my-task CUSTOM_ID # Get property +ork prop work my-task EFFORT 2h # Set property +ork prop work my-task EFFORT -d # Delete property + +# Agenda views +ork agenda todos # All TODOs +ork agenda today # Items scheduled/due today +ork agenda week # Next 7 days + +# Query +ork query 'tag:work' # Match sections by tag +ork query 'tag:work+todo:TODO' # Compound query +ork query 'priority:A' # By priority + +# Maintenance +ork reload # Rescan files from disk +ork lint # Validate all documents +ork lint work # Validate one document ``` -Any 9P client works too (e.g. plan9port `9p`); the server owns all org -logic and reads/writes the real `.org` files. +Any 9P client works (e.g., plan9port `9p`): + +```bash +9p -a 'unix!/tmp/ork.sock' ls / +9p -a 'unix!/tmp/ork.sock' read /idx +9p -a 'unix!/tmp/ork.sock' write /work/task/keyword DONE +``` ## Architecture @@ -263,30 +228,36 @@ All nodes include source span information for editor integration. ## Roadmap -### Phase 1: Parser (Current) +### Phase 1: Parser ✓ - [x] Tree-sitter integration - [x] Core AST types - [x] Headline/section parsing - [x] Block element parsing - [ ] Complete inline object parsing -### Phase 2: Qt UI +### Phase 2: 9P Server ✓ +- [x] Document state with locking +- [x] Read/write section fields (keyword, priority, title, tags, timestamps, body) +- [x] Create documents and sections +- [x] Structural ops (delete, move, promote, demote, refile, archive) +- [x] Agenda views (todos, today, week) +- [x] Query interface (tag, todo, priority) +- [x] ctl verbs (reload, lint) + +### Phase 3: Qt UI (Current) - [x] Document rendering (read-only) - [x] Structural styling (keywords, priorities, tags, timestamps) -- [x] Folding/cycling +- [x] Folding/cycling (TreeView) - [x] Navigation -- [ ] Syntax highlighting for section bodies - -### Phase 3: Editing - [ ] Inline editing -- [ ] TODO state cycling -- [ ] Tag editing -- [ ] Property editing +- [ ] TODO state cycling (click to toggle) +- [ ] Tag/property editing +- [ ] Syntax highlighting for bodies ### Phase 4: Agenda -- [ ] Basic agenda view +- [ ] Dedicated agenda view panel - [ ] Day/week/month views -- [ ] Filtering +- [ ] Filtering and sorting - [ ] Custom views ### Phase 5: Advanced Features diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index f9e6953..2e564ee 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -594,42 +594,57 @@ impl OrkState { let planning_end = planning.span.end.offset; let planning_text = &content[planning_start..planning_end]; - // Parse existing planning fields - let mut fields: HashMap<&str, &str> = HashMap::new(); - for part in planning_text.split_whitespace() { - if part.ends_with(':') { - let key = &part[..part.len()-1]; - continue; - } - } - // Rebuild planning line let new_planning = if timestamp.is_empty() { - // Remove this field - let filtered: String = planning_text - .replace(&format!("{}: ", field), "") - .split_whitespace() - .filter(|s| !s.starts_with('<') || !planning_text.contains(&format!("{}: {}", field, s))) - .collect::>() - .join(" "); - if filtered.trim().is_empty() { + // Remove this field: strip "FIELD: " pattern + let mut result = String::new(); + let mut skip_next_ts = false; + for part in planning_text.split_whitespace() { + if part == &format!("{}:", field) { + skip_next_ts = true; + continue; + } + if skip_next_ts && part.starts_with('<') { + skip_next_ts = false; + continue; + } + skip_next_ts = false; + if !result.is_empty() { result.push(' '); } + result.push_str(part); + } + if result.trim().is_empty() { String::new() } else { - format!("{}\n", filtered) + format!("{}\n", result.trim()) } } else { // Add/update this field let ts = if timestamp.starts_with('<') { timestamp.to_string() } else { format!("<{}>", timestamp) }; if planning_text.contains(&format!("{}:", field)) { - // Replace existing - let re_pattern = format!(r"{}: <[^>]+>", field); - planning_text.replace(&planning_text - .split_whitespace() - .collect::>() - .join(" "), &format!("{}: {}", field, ts)) + // Replace existing: rebuild with new timestamp for this field + let mut result = String::new(); + let mut replace_next_ts = false; + for part in planning_text.split_whitespace() { + if part == &format!("{}:", field) { + replace_next_ts = true; + if !result.is_empty() { result.push(' '); } + result.push_str(part); + continue; + } + if replace_next_ts && part.starts_with('<') { + result.push(' '); + result.push_str(&ts); + replace_next_ts = false; + continue; + } + replace_next_ts = false; + if !result.is_empty() { result.push(' '); } + result.push_str(part); + } + format!("{}\n", result.trim()) } else { - // Add new - format!("{}{}: {}\n", planning_text.trim_end(), field, ts) + // Add new field + format!("{} {}: {}\n", planning_text.trim(), field, ts) } };