Something went wrong. Try again.
A Rust clone of doing by Brett Terpstra — a command line tool for remembering what you were doing and tracking what you've done. doing.aaronmallen.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648use std::{mem, path::PathBuf};
use clap::Args;use doing_ops::{autotag::autotag, backup::write_with_backup};use doing_plugins::import;use doing_taskpaper::{Entry, Section, Tag};
use crate::{Result, cli::AppContext};
/// Import entries from other doing files or Timing.app JSON exports.////// Reads entries from a source file, optionally filters them, applies tags/// or prefixes, and merges them into the current doing file.#[derive(Args, Clone, Debug)]pub struct Command { /// Filter imported entries to those after this date #[arg(long)] after: Option<String>,
/// Apply autotagging rules to imported entries #[arg(long)] autotag: bool,
/// Filter imported entries to those before this date #[arg(long)] before: Option<String>,
/// Case sensitivity for search filter (sensitive/ignore/smart) #[arg(long)] case: Option<String>,
/// Use exact (literal substring) matching for search filter #[arg(short = 'x', long)] exact: bool,
/// Date range filter for imports (e.g. "last week", "2024-01-01 to 2024-03-01") #[arg(long)] from: Option<String>,
/// Import format type (doing, timing) #[arg(short = 't', long = "type")] import_type: Option<String>,
/// Skip entries whose time range overlaps with existing entries #[arg(long)] no_overlap: bool,
/// Negate filter results #[arg(long)] not: bool,
/// Only import entries with a recorded time interval #[arg(long)] only_timed: bool,
/// Path to the file to import path: PathBuf,
/// Prepend text to all imported entry titles #[arg(long)] prefix: Option<String>,
/// Filter which entries to import by search query #[arg(long)] search: Option<String>,
/// Target section for imported entries (default: current section) #[arg(short, long)] section: Option<String>,
/// Apply additional tags to all imported entries #[arg(long, value_delimiter = ',')] tag: Vec<String>,}
impl Command { pub fn call(&self, ctx: &mut AppContext) -> Result<()> { let registry = import::default_registry()?; let format = self.resolve_format()?; let plugin = registry.resolve(&format).ok_or_else(|| { let available = registry.available_formats().join(", "); crate::Error::Plugin(format!("unknown import format \"{format}\". Available: {available}")) })?;
let mut entries = plugin.import(&self.path)?;
if entries.is_empty() { ctx.status(format!("No entries found in {}", self.path.display())); return Ok(()); }
// When advanced filter flags are set, let the filter pipeline handle search let has_advanced_filters = self.after.is_some() || self.before.is_some() || self.case.is_some() || self.exact || self.not || self.only_timed;
if has_advanced_filters { self.apply_filter_flags(&mut entries, ctx)?; } else { self.apply_search_filter(&mut entries)?; self.apply_date_filter(&mut entries)?; }
let section_name = self.section.as_deref().unwrap_or(&ctx.config.current_section);
let mut imported = 0; for mut entry in entries { if self.no_overlap && has_overlap(&entry, ctx) { continue; }
self.apply_prefix(&mut entry); self.apply_tags(&mut entry);
if self.autotag { autotag(&mut entry, &ctx.config.autotag, &ctx.config.default_tags); }
if !ctx.document.has_section(section_name) { ctx.document.add_section(Section::new(section_name)); } ctx.document.section_by_name_mut(section_name).unwrap().add_entry(entry); imported += 1; }
ctx.document.dedup(); write_with_backup(&ctx.document, &ctx.doing_file, &ctx.config)?;
ctx.status(format!("Imported {imported} entries into {section_name}")); Ok(()) }
fn apply_date_filter(&self, entries: &mut Vec<Entry>) -> Result<()> { if let Some(ref from) = self.from { let (after, before) = doing_time::parse_range(from)?; entries.retain(|e| { let date = e.date(); date >= after && date <= before }); } Ok(()) }
fn apply_filter_flags(&self, entries: &mut Vec<Entry>, ctx: &AppContext) -> Result<()> { let has_filters = self.after.is_some() || self.before.is_some() || self.case.is_some() || self.exact || self.not || self.only_timed;
if !has_filters { return Ok(()); }
let filter_args = crate::cli::args::FilterArgs { after: self.after.clone(), before: self.before.clone(), case: self.case.clone(), exact: self.exact, from: self.from.clone(), not: self.not, only_timed: self.only_timed, search: self.search.clone(), ..Default::default() }; let options = filter_args.to_filter_options(&ctx.config, ctx.include_notes)?; let filtered = doing_ops::filter::filter_entries(mem::take(entries), &options); *entries = filtered; Ok(()) }
fn apply_prefix(&self, entry: &mut Entry) { if let Some(ref prefix) = self.prefix { let new_title = format!("{prefix} {}", entry.title()); entry.set_title(new_title); } }
fn apply_search_filter(&self, entries: &mut Vec<Entry>) -> Result<()> { if let Some(ref query) = self.search { let (mode, case) = doing_ops::search::parse_query(query, &Default::default()) .ok_or_else(|| crate::Error::Parse(format!("invalid search query: {query}")))?; entries.retain(|e| doing_ops::search::matches_entry(e, &mode, case, true)); } Ok(()) }
fn apply_tags(&self, entry: &mut Entry) { for tag_name in &self.tag { let name = tag_name.strip_prefix('@').unwrap_or(tag_name); if !entry.tags().has(name) { entry.tags_mut().add(Tag::new(name, None::<String>)); } } }
/// Determine the import format from `--type` flag or file extension. fn resolve_format(&self) -> Result<String> { if let Some(ref t) = self.import_type { return Ok(t.clone()); }
let ext = self.path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext { "json" => Ok("timing".into()), _ => Ok("doing".into()), } }}
/// Check whether an entry's time range overlaps with any existing entry.fn has_overlap(entry: &Entry, ctx: &AppContext) -> bool { ctx .document .all_entries() .any(|existing| entry.overlapping_time(existing))}
#[cfg(test)]mod test { use std::fs;
use chrono::{Local, TimeZone}; use doing_taskpaper::{Document, Entry, Note, Section, Tag, Tags};
use super::*;
fn sample_ctx(dir: &std::path::Path) -> AppContext { let path = dir.join("doing.md"); fs::write(&path, "Currently:\n").unwrap(); let mut ctx = AppContext::for_test(path); ctx.document = Document::parse("Currently:\n"); ctx }
mod call { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_applies_prefix_to_imported_entries() { let dir = tempfile::tempdir().unwrap(); let mut ctx = sample_ctx(dir.path()); let source = dir.path().join("source.md"); fs::write( &source, "Currently:\n\t- 2024-03-17 14:30 | Task <aaaabbbbccccddddeeeeffffaaaabbbb>\n", ) .unwrap(); let cmd = Command { after: None, autotag: false, before: None, case: None, exact: false, from: None, import_type: Some("doing".into()), no_overlap: false, not: false, only_timed: false, path: source, prefix: Some("[imported]".into()), search: None, section: None, tag: vec![], };
cmd.call(&mut ctx).unwrap();
let entries: Vec<_> = ctx.document.entries_in_section("Currently").collect(); assert_eq!(entries[0].title(), "[imported] Task"); }
#[test] fn it_applies_tags_to_imported_entries() { let dir = tempfile::tempdir().unwrap(); let mut ctx = sample_ctx(dir.path()); let source = dir.path().join("source.md"); fs::write( &source, "Currently:\n\t- 2024-03-17 14:30 | Task <aaaabbbbccccddddeeeeffffaaaabbbb>\n", ) .unwrap(); let cmd = Command { after: None, autotag: false, before: None, case: None, exact: false, from: None, import_type: Some("doing".into()), no_overlap: false, not: false, only_timed: false, path: source, prefix: None, search: None, section: None, tag: vec!["imported".into(), "@work".into()], };
cmd.call(&mut ctx).unwrap();
let entries: Vec<_> = ctx.document.entries_in_section("Currently").collect(); assert!(entries[0].tags().has("imported")); assert!(entries[0].tags().has("work")); }
#[test] fn it_imports_from_doing_file() { let dir = tempfile::tempdir().unwrap(); let mut ctx = sample_ctx(dir.path()); let source = dir.path().join("source.md"); fs::write( &source, "Currently:\n\t- 2024-03-17 14:30 | Imported task <aaaabbbbccccddddeeeeffffaaaabbbb>\n", ) .unwrap(); let cmd = Command { after: None, autotag: false, before: None, case: None, exact: false, from: None, import_type: Some("doing".into()), no_overlap: false, not: false, only_timed: false, path: source, prefix: None, search: None, section: None, tag: vec![], };
cmd.call(&mut ctx).unwrap();
let entries: Vec<_> = ctx.document.entries_in_section("Currently").collect(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].title(), "Imported task"); }
#[test] fn it_imports_from_timing_json() { let dir = tempfile::tempdir().unwrap(); let mut ctx = sample_ctx(dir.path()); let source = dir.path().join("timing.json"); fs::write( &source, r#"[ { "activityTitle": "Writing code", "activityType": "Task", "startDate": "2024-03-17 14:00", "endDate": "2024-03-17 15:00", "project": "Work", "notes": null } ]"#, ) .unwrap(); let cmd = Command { after: None, autotag: false, before: None, case: None, exact: false, from: None, import_type: Some("timing".into()), no_overlap: false, not: false, only_timed: false, path: source, prefix: None, search: None, section: None, tag: vec![], };
cmd.call(&mut ctx).unwrap();
let entries: Vec<_> = ctx.document.entries_in_section("Currently").collect(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].title(), "[Timing.app] Writing code"); }
#[test] fn it_imports_into_custom_section() { let dir = tempfile::tempdir().unwrap(); let mut ctx = sample_ctx(dir.path()); let source = dir.path().join("source.md"); fs::write( &source, "Currently:\n\t- 2024-03-17 14:30 | Task <aaaabbbbccccddddeeeeffffaaaabbbb>\n", ) .unwrap(); let cmd = Command { after: None, autotag: false, before: None, case: None, exact: false, from: None, import_type: Some("doing".into()), no_overlap: false, not: false, only_timed: false, path: source, prefix: None, search: None, section: Some("Archive".into()), tag: vec![], };
cmd.call(&mut ctx).unwrap();
assert!(ctx.document.has_section("Archive")); assert_eq!(ctx.document.entries_in_section("Archive").count(), 1); }
#[test] fn it_returns_ok_for_empty_source() { let dir = tempfile::tempdir().unwrap(); let mut ctx = sample_ctx(dir.path()); let source = dir.path().join("empty.md"); fs::write(&source, "").unwrap(); let cmd = Command { after: None, autotag: false, before: None, case: None, exact: false, from: None, import_type: Some("doing".into()), no_overlap: false, not: false, only_timed: false, path: source, prefix: None, search: None, section: None, tag: vec![], };
assert!(cmd.call(&mut ctx).is_ok()); }
#[test] fn it_skips_overlapping_entries_with_no_overlap() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("doing.md"); fs::write(&path, "Currently:\n").unwrap(); let mut doc = Document::new(); let mut section = Section::new("Currently"); section.add_entry(Entry::new( Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(), "Existing task", Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]), Note::new(), "Currently", None::<String>, )); doc.add_section(section); let mut ctx = { let mut ctx = AppContext::for_test(path); ctx.document = doc; ctx };
let source = dir.path().join("source.md"); fs::write( &source, "Currently:\n\t- 2024-03-17 14:30 | Overlapping task @done(2024-03-17 15:30) <aaaabbbbccccddddeeeeffffaaaabbbb>\n", ) .unwrap(); let cmd = Command { after: None, autotag: false, before: None, case: None, exact: false, from: None, import_type: Some("doing".into()), no_overlap: true, not: false, only_timed: false, path: source, prefix: None, search: None, section: None, tag: vec![], };
cmd.call(&mut ctx).unwrap();
let entries: Vec<_> = ctx.document.entries_in_section("Currently").collect(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].title(), "Existing task"); } }
mod has_overlap { use super::*;
#[test] fn it_detects_overlapping_entries() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("doing.md"); fs::write(&path, "Currently:\n").unwrap(); let mut doc = Document::new(); let mut section = Section::new("Currently"); section.add_entry(Entry::new( Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(), "Existing", Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]), Note::new(), "Currently", None::<String>, )); doc.add_section(section); let ctx = { let mut ctx = AppContext::for_test(path); ctx.document = doc; ctx };
let entry = Entry::new( Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(), "New", Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:30"))]), Note::new(), "Currently", None::<String>, );
assert!(super::has_overlap(&entry, &ctx)); }
#[test] fn it_returns_false_for_non_overlapping() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("doing.md"); fs::write(&path, "Currently:\n").unwrap(); let mut doc = Document::new(); let mut section = Section::new("Currently"); section.add_entry(Entry::new( Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(), "Existing", Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]), Note::new(), "Currently", None::<String>, )); doc.add_section(section); let ctx = { let mut ctx = AppContext::for_test(path); ctx.document = doc; ctx };
let entry = Entry::new( Local.with_ymd_and_hms(2024, 3, 17, 16, 0, 0).unwrap(), "New", Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 17:00"))]), Note::new(), "Currently", None::<String>, );
assert!(!super::has_overlap(&entry, &ctx)); } }
mod resolve_format { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_defaults_to_doing_format() { let cmd = Command { after: None, autotag: false, before: None, case: None, exact: false, from: None, import_type: None, no_overlap: false, not: false, only_timed: false, path: PathBuf::from("other.md"), prefix: None, search: None, section: None, tag: vec![], };
assert_eq!(cmd.resolve_format().unwrap(), "doing"); }
#[test] fn it_infers_timing_from_json_extension() { let cmd = Command { after: None, autotag: false, before: None, case: None, exact: false, from: None, import_type: None, no_overlap: false, not: false, only_timed: false, path: PathBuf::from("export.json"), prefix: None, search: None, section: None, tag: vec![], };
assert_eq!(cmd.resolve_format().unwrap(), "timing"); }
#[test] fn it_uses_explicit_type() { let cmd = Command { after: None, autotag: false, before: None, case: None, exact: false, from: None, import_type: Some("timing".into()), no_overlap: false, not: false, only_timed: false, path: PathBuf::from("file.md"), prefix: None, search: None, section: None, tag: vec![], };
assert_eq!(cmd.resolve_format().unwrap(), "timing"); } }}