Skip to content

Commit

Permalink
#101 - allow editing tasks in the external editor
Browse files Browse the repository at this point in the history
  • Loading branch information
VladimirMarkelov committed Nov 25, 2024
1 parent e5a1416 commit a384921
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 5 deletions.
58 changes: 58 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ serde_derive = "1"
json = "^0.12"
unicode-width="^0.2"
anyhow = "1"
tempfile = "3"

[package.metadata.deb]
section = "utility"
Expand Down
37 changes: 35 additions & 2 deletions src/conf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,6 +65,8 @@ pub struct Conf {
pub done_file: PathBuf,
pub keep_empty: bool,
pub keep_tags: bool,
editor_path: Option<String>,
pub use_editor: bool,

pub auto_hide_columns: bool,
pub auto_show_columns: bool,
Expand Down Expand Up @@ -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,
Expand All @@ -114,6 +119,22 @@ impl Conf {
fn new() -> Self {
Default::default()
}
pub fn editor(&self) -> Option<PathBuf> {
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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1055,6 +1079,8 @@ pub fn parse_args(args: &[String]) -> Result<Conf> {
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");
Expand Down Expand Up @@ -1197,7 +1223,7 @@ pub fn parse_args(args: &[String]) -> Result<Conf> {
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");
Expand All @@ -1220,7 +1246,7 @@ pub fn parse_args(args: &[String]) -> Result<Conf> {
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(
Expand All @@ -1229,6 +1255,7 @@ pub fn parse_args(args: &[String]) -> Result<Conf> {
"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,
Expand Down Expand Up @@ -1311,6 +1338,7 @@ pub fn parse_args(args: &[String]) -> Result<Conf> {
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");
Expand All @@ -1335,6 +1363,11 @@ pub fn parse_args(args: &[String]) -> Result<Conf> {
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
Expand Down
108 changes: 105 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -470,15 +473,114 @@ 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<TempPath> {
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<Option<u64>> {
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);
}
let todos = filter_tasks(tasks, 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 {
Expand Down
1 change: 1 addition & 0 deletions src/tml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub struct Global {
pub always_hide_columns: Option<String>,
pub priority_on_done: Option<String>,
pub add_completion_date_always: Option<bool>,
pub editor: Option<String>,
}

#[derive(Deserialize)]
Expand Down
4 changes: 4 additions & 0 deletions ttdl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit a384921

Please sign in to comment.