diff --git a/Cargo.lock b/Cargo.lock index 87eb966..b04432a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" + [[package]] name = "getopts" version = "0.2.21" @@ -218,6 +234,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + [[package]] name = "log" version = "0.4.22" @@ -309,6 +331,19 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rustix" +version = "0.38.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "serde" version = "1.0.215" @@ -361,6 +396,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "term_size" version = "0.3.2" @@ -504,6 +552,7 @@ dependencies = [ "lazy_static", "serde", "serde_derive", + "tempfile", "term_size", "termcolor", "textwrap", @@ -655,6 +704,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index fc89f1d..c7f897e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ serde_derive = "1" json = "^0.12" unicode-width="^0.2" anyhow = "1" +tempfile = "3" [package.metadata.deb] section = "utility" diff --git a/src/conf.rs b/src/conf.rs index 90b9108..495a531 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -23,6 +23,7 @@ const APP_DIR: &str = "ttdl"; const CONF_FILE: &str = "ttdl.toml"; const TODO_FILE: &str = "todo.txt"; const DONE_FILE: &str = "done.txt"; +const EDITOR: &str = "EDITOR"; struct RangeEnds { l: usize, @@ -64,6 +65,8 @@ pub struct Conf { pub done_file: PathBuf, pub keep_empty: bool, pub keep_tags: bool, + editor_path: Option, + pub use_editor: bool, pub auto_hide_columns: bool, pub auto_show_columns: bool, @@ -94,6 +97,8 @@ impl Default for Conf { done_file: PathBuf::from(""), keep_empty: false, keep_tags: false, + editor_path: None, + use_editor: false, auto_hide_columns: false, auto_show_columns: false, @@ -114,6 +119,22 @@ impl Conf { fn new() -> Self { Default::default() } + pub fn editor(&self) -> Option { + let mut spth: String = match env::var(EDITOR) { + Ok(p) => p, + Err(_) => String::new(), + }; + if spth.is_empty() { + if let Some(p) = &self.editor_path { + spth = p.clone(); + } + } + if spth.is_empty() { + None + } else { + Some(PathBuf::from(spth)) + } + } } fn print_usage(program: &str, opts: &Options) { @@ -989,6 +1010,9 @@ fn update_global_from_conf(tc: &tml::Conf, conf: &mut Conf) { if let Some(acda) = &tc.global.add_completion_date_always { conf.add_completion_date_always = *acda; } + if let Some(p) = &tc.global.editor { + conf.editor_path = Some(p.clone()); + } } fn detect_conf_file_path() -> PathBuf { @@ -1055,6 +1079,8 @@ pub fn parse_args(args: &[String]) -> Result { let program = args[0].clone(); let mut conf = Conf::new(); + // Free short options: BCDEFGHIJKLMNOPQRSTUVWXYZbdfgjlmnopqruxyz" + let mut opts = Options::new(); opts.optflag("h", "help", "Show this help"); opts.optflag("a", "all", "Select all todos including completed ones"); @@ -1197,7 +1223,7 @@ pub fn parse_args(args: &[String]) -> Result { opts.optopt( "", "calendar", - "Display a calendar with dates highlighted if any todo is due on that date(foreground color). Today is highlighted with background color, Default values for `NUMBER` is `1` and for `TYPE` is `d`(days). Valid values for type are `d`(days), `w`(weeks), and `m`(months). Pepending plus sign shows the selected interval starting from today, not from Monday or first day of the month", + "Display a calendar with dates highlighted if any todo is due on that date(foreground color). Today is highlighted with background color, Default values for `NUMBER` is `1` and for `TYPE` is `d`(days). Valid values for type are `d`(days), `w`(weeks), and `m`(months). Prepending plus sign shows the selected interval starting from today, not from Monday or first day of the month", "[+][NUMBER][TYPE]", ); opts.optflag("", "syntax", "Enable keyword highlights when printing subject"); @@ -1220,7 +1246,7 @@ pub fn parse_args(args: &[String]) -> Result { opts.optopt( "", "priority-on-done", - "what to do with priority on task completion: keep - no special action(default behavior), move - place priority after completion date, tag - convert priority to a tag 'pri:', erase - remove priority. Notethat in all modes, except `erase`, the operation is reversible and on task uncompleting, the task gets its priority back", + "what to do with priority on task completion: keep - no special action(default behavior), move - place priority after completion date, tag - convert priority to a tag 'pri:', erase - remove priority. Note that in all modes, except `erase`, the operation is reversible and on task uncompleting, the task gets its priority back", "VALUE", ); opts.optflag( @@ -1229,6 +1255,7 @@ pub fn parse_args(args: &[String]) -> Result { "When task is finished, always add completion date, regardless of whether or not creation date is present", ); opts.optflag("k", "keep-tags", "in edit mode a new subject replaces regular text of the todo, everything else(tags, priority etc) is taken from the old and appended to the new subject. A convenient way to replace just text and keep all the tags without typing the tags again"); + opts.optflag("i", "interactive", "Open an external edit to modify all filtered tasks. If the task list is modified inside an editor, the old tasks will be removed and new ones will be added to the end of the task list. If you do not change anything or save an empty file, the edit operation will be canceled. To set editor, change config.global.editor option or set EDITOR environment variable."); let matches: Matches = match opts.parse(&args[1..]) { Ok(m) => m, @@ -1311,6 +1338,7 @@ pub fn parse_args(args: &[String]) -> Result { if matches.opt_present("add-completion-date-always") { conf.add_completion_date_always = true; } + conf.use_editor = matches.opt_present("interactive"); let soon_days = conf.fmt.colors.soon_days; conf.keep_empty = matches.opt_present("keep-empty"); @@ -1335,6 +1363,11 @@ pub fn parse_args(args: &[String]) -> Result { return Ok(conf); } + if conf.use_editor && conf.mode != RunMode::Edit { + eprintln!("Option '--interactive' can be used only with `edit` command"); + exit(1); + } + // second should be a range if matches.free[idx].find(|c: char| !c.is_ascii_digit()).is_none() { // a single ID diff --git a/src/main.rs b/src/main.rs index d807a8c..bd28260 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,12 +14,15 @@ mod tml; use std::collections::HashMap; use std::env; -use std::io::{self, Write}; +use std::fs::{read_to_string, File}; +use std::hash::Hasher; +use std::io::{self, Read, Write}; use std::path::Path; -use std::process::exit; +use std::process::{exit, Command}; use std::str::FromStr; use chrono::NaiveDate; +use tempfile::{self, NamedTempFile, TempPath}; use termcolor::{ColorChoice, ColorSpec, StandardStream, WriteColor}; use todotxt::CompletionConfig; @@ -470,8 +473,42 @@ fn copy_tags_from_task(subj: &str, task: &mut todotxt::Task) -> String { sbj } +fn create_temp_file(tasks: &mut todo::TaskVec, ids: &todo::IDVec) -> io::Result { + let named = NamedTempFile::new()?; + let filetmp = named.into_temp_path(); + println!("Temp: {0:?}", filetmp); + let mut file = File::create(filetmp.as_os_str())?; + for idx in ids { + writeln!(file, "{0}", tasks[*idx])?; + } + Ok(filetmp) +} + +fn tmp_file_hash(f: &TempPath) -> io::Result> { + let mut hasher = std::hash::DefaultHasher::new(); + let mut file = File::open(f.as_os_str())?; + let mut data = vec![]; + file.read_to_end(&mut data)?; + let has_some = data.iter().any(|&c| c != b' ' && c != b'\n' && c != b'\r'); + if !has_some { + Ok(None) + } else { + hasher.write(&data); + Ok(Some(hasher.finish())) + } +} + fn task_edit(stdout: &mut StandardStream, tasks: &mut todo::TaskVec, conf: &conf::Conf) -> io::Result<()> { - if is_filter_empty(&conf.flt) { + if conf.use_editor && conf.dry { + writeln!(stdout, "Interactive editing does not support dry run")?; + std::process::exit(1); + } + let editor = conf.editor(); + if conf.use_editor && conf.editor().is_none() { + writeln!(stdout, "Interactive editing requires setting up a path to an editor. Either set environment variable 'EDITOR' or define a path to an editor in TTDL config in the section 'global'")?; + std::process::exit(1); + } + if is_filter_empty(&conf.flt) && !conf.use_editor { writeln!(stdout, "Warning: modifying of all tasks requested. Please specify tasks to edit.")?; std::process::exit(1); } @@ -479,6 +516,71 @@ fn task_edit(stdout: &mut StandardStream, tasks: &mut todo::TaskVec, conf: &conf let action = "changed"; if todos.is_empty() { writeln!(stdout, "No todo changed")? + } else if conf.use_editor { + // unwrap cannot fail here as we already check it for 'Some' before. + let editor = editor.unwrap(); + let filepath = create_temp_file(tasks, &todos)?; + let orig_hash = tmp_file_hash(&filepath)?; + let mut child = Command::new(editor).arg(filepath.as_os_str()).spawn()?; + if let Err(e) = child.wait() { + writeln!(stdout, "Failed to execute editor: {e:?}")?; + exit(1); + } + let new_hash = tmp_file_hash(&filepath)?; + match (orig_hash, new_hash) { + (_, None) => { + writeln!(stdout, "Empty file detected. Edit operation canceled")?; + exit(0); + } + (_, Some(b)) => { + let now = chrono::Local::now().date_naive(); + let content = read_to_string(filepath.as_os_str())?; + let mut removed_cnt = 0; + if let Some(a) = orig_hash { + if a == b { + // The temporary file was not changed. Nothing to do + writeln!(stdout, "No changes detected. Edit operation canceled")?; + exit(0); + } + let removed = todo::remove(tasks, Some(&todos)); + removed_cnt = calculate_updated(&removed); + } + let mut added_cnt = 0; + for line in content.lines() { + let subj = line.trim(); + if subj.is_empty() { + continue; + } + // TODO: move duplicated code (here and in task_add) to a separate fn + let mut tag_list = date_expr::TaskTagList::from_str(&subj, now); + let soon = conf.fmt.colors.soon_days; + let subj = match date_expr::calculate_main_tags(now, &mut tag_list, soon) { + Err(e) => { + writeln!(stdout, "{e:?}")?; + exit(1); + } + Ok(changed) => match changed { + false => subj.to_string(), + true => date_expr::update_tags_in_str(&tag_list, &subj), + }, + }; + let mut cnf = conf.clone(); + cnf.todo.subject = Some(subj.clone()); + let id = todo::add(tasks, &cnf.todo); + if id == todo::INVALID_ID { + writeln!(stdout, "Failed to add: parse error '{subj}'")?; + } else { + added_cnt += 1; + } + } + if let Err(e) = todo::save(tasks, Path::new(&conf.todo_file)) { + writeln!(stdout, "Failed to save to '{0:?}': {e}", &conf.todo_file)?; + std::process::exit(1); + } + writeln!(stdout, "Removed {removed_cnt} tasks, added {added_cnt} tasks.")?; + } + } + let _ = filepath.close()?; } else if conf.dry { let mut clones = todo::clone_tasks(tasks, &todos); let updated = if conf.keep_tags { diff --git a/src/tml.rs b/src/tml.rs index b5c0823..7d858fd 100644 --- a/src/tml.rs +++ b/src/tml.rs @@ -36,6 +36,7 @@ pub struct Global { pub always_hide_columns: Option, pub priority_on_done: Option, pub add_completion_date_always: Option, + pub editor: Option, } #[derive(Deserialize)] diff --git a/ttdl.toml b/ttdl.toml index f12bb23..f115f6c 100644 --- a/ttdl.toml +++ b/ttdl.toml @@ -160,6 +160,10 @@ old = "1y" # true = always add completion date, regardless of creation date is presence # add_completion_date_always = false +# Path an external editor binary. +# It is used when 'edit' command includes the option `--interactive`. +# editor = "" + [syntax] # Set enabled to 'true' to highlight projects, contexts, tags, and hashtags # inside the subject