list filtering options
This commit is contained in:
parent
170dbdcc2c
commit
0f542eb054
14
README.md
14
README.md
@ -80,17 +80,3 @@ To start up you will need a vault to store tasks in, which you can create by run
|
|||||||
If you ever want to view all vaults, along with which is the current one, run `toru vault list`.
|
If you ever want to view all vaults, along with which is the current one, run `toru vault list`.
|
||||||
|
|
||||||
Then you can run `toru new` to create your first task.
|
Then you can run `toru new` to create your first task.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
- Options to configure and customise output of `list`
|
|
||||||
- Simple query language to select:
|
|
||||||
- which columns to include
|
|
||||||
- which column to order by (and if ascending or descending)
|
|
||||||
- and to filter by
|
|
||||||
- tags within a specified collection
|
|
||||||
- only include tasks with incomplete dependencies, and similarly only tasks which are not dependents
|
|
||||||
- due date, completion date, etc greater than or less than specific value
|
|
||||||
- If no values given, use a default query stored in `state.toml`
|
|
||||||
|
@ -58,7 +58,7 @@ pub fn edit_raw(id : Id, vault_folder : path::PathBuf, editor : &str, state : &m
|
|||||||
|
|
||||||
let temp_path = vault_folder.join("temp.toml");
|
let temp_path = vault_folder.join("temp.toml");
|
||||||
|
|
||||||
fs::copy(task.path(), &temp_path)?;
|
fs::copy(&task.path, &temp_path)?;
|
||||||
|
|
||||||
let status = open_editor(&temp_path, editor)?;
|
let status = open_editor(&temp_path, editor)?;
|
||||||
|
|
||||||
|
81
src/main.rs
81
src/main.rs
@ -14,6 +14,8 @@ use tasks::Id;
|
|||||||
|
|
||||||
use std::path;
|
use std::path;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(clap::Parser, Debug)]
|
#[derive(clap::Parser, Debug)]
|
||||||
struct Args {
|
struct Args {
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
@ -71,13 +73,10 @@ enum Command {
|
|||||||
/// Adds the recommended .gitignore file to the vault.
|
/// Adds the recommended .gitignore file to the vault.
|
||||||
#[clap(name="gitignore")]
|
#[clap(name="gitignore")]
|
||||||
GitIgnore,
|
GitIgnore,
|
||||||
/// Lists tasks according to the specified ordering and filters.
|
/// Lists tasks according to the specified fields, ordering and filters.
|
||||||
List {
|
List {
|
||||||
// Need to have options for:
|
#[clap(flatten)]
|
||||||
// - column to order by
|
options : ListOptions,
|
||||||
// - ascending or descending
|
|
||||||
// - which columns to include
|
|
||||||
// - filters which exclude values
|
|
||||||
},
|
},
|
||||||
/// For tracking time against a task.
|
/// For tracking time against a task.
|
||||||
Track {
|
Track {
|
||||||
@ -102,6 +101,72 @@ enum Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(clap::StructOpt, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ListOptions {
|
||||||
|
#[clap(long)]
|
||||||
|
name : bool,
|
||||||
|
#[clap(long)]
|
||||||
|
tracked : bool,
|
||||||
|
#[clap(long)]
|
||||||
|
due : bool,
|
||||||
|
#[clap(long)]
|
||||||
|
tags : bool,
|
||||||
|
#[clap(long)]
|
||||||
|
priority : bool,
|
||||||
|
#[clap(long)]
|
||||||
|
status : bool,
|
||||||
|
#[clap(long)]
|
||||||
|
created : bool,
|
||||||
|
#[clap(long, value_enum, default_value_t=SortBy::Id)]
|
||||||
|
sort_by : SortBy,
|
||||||
|
#[clap(long, value_enum, default_value_t=SortType::Asc)]
|
||||||
|
sort_type : SortType,
|
||||||
|
#[clap(long)]
|
||||||
|
before : Option<chrono::NaiveDateTime>,
|
||||||
|
#[clap(long)]
|
||||||
|
after : Option<chrono::NaiveDateTime>,
|
||||||
|
#[clap(long)]
|
||||||
|
due_in : Option<u16>,
|
||||||
|
#[clap(long)]
|
||||||
|
include_complete : bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ListOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name : true,
|
||||||
|
tracked : true,
|
||||||
|
due : true,
|
||||||
|
tags : true,
|
||||||
|
priority : true,
|
||||||
|
status : false,
|
||||||
|
created : false,
|
||||||
|
sort_by : SortBy::Created,
|
||||||
|
sort_type : SortType::Desc,
|
||||||
|
before : None,
|
||||||
|
after : None,
|
||||||
|
due_in : None,
|
||||||
|
include_complete : false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum SortType {
|
||||||
|
#[default]
|
||||||
|
Asc,
|
||||||
|
Desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum SortBy {
|
||||||
|
#[default]
|
||||||
|
Id,
|
||||||
|
Name,
|
||||||
|
Due,
|
||||||
|
Priority,
|
||||||
|
Created,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(clap::Subcommand, Debug, PartialEq, Eq)]
|
#[derive(clap::Subcommand, Debug, PartialEq, Eq)]
|
||||||
enum StatsCommand {
|
enum StatsCommand {
|
||||||
@ -296,8 +361,8 @@ fn program() -> Result<(), error::Error> {
|
|||||||
task.save()?;
|
task.save()?;
|
||||||
println!("Marked task {} as complete", colour::id(id));
|
println!("Marked task {} as complete", colour::id(id));
|
||||||
},
|
},
|
||||||
List {} => {
|
List { options } => {
|
||||||
tasks::list(vault_folder)?;
|
tasks::list(options, vault_folder)?;
|
||||||
},
|
},
|
||||||
// All commands which are dealt with in if let chain at start.
|
// All commands which are dealt with in if let chain at start.
|
||||||
Vault(_) | Config(_) | Git { args : _ } | Svn { args : _ } | Switch { name : _ } | GitIgnore => unreachable!(),
|
Vault(_) | Config(_) | Git { args : _ } | Svn { args : _ } | Switch { name : _ } | GitIgnore => unreachable!(),
|
||||||
|
187
src/tasks.rs
187
src/tasks.rs
@ -17,7 +17,7 @@ use chrono::SubsecRound;
|
|||||||
pub type Id = u64;
|
pub type Id = u64;
|
||||||
|
|
||||||
pub struct Task {
|
pub struct Task {
|
||||||
path : path::PathBuf,
|
pub path : path::PathBuf,
|
||||||
file : fs::File,
|
file : fs::File,
|
||||||
pub data : InternalTask,
|
pub data : InternalTask,
|
||||||
}
|
}
|
||||||
@ -83,6 +83,7 @@ pub struct InternalTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Task {
|
impl Task {
|
||||||
|
/// Creates a new task from the input data.
|
||||||
pub fn new(name : String, info : Option<String>, tags : Vec<String>, dependencies : Vec<Id>, priority : Option<Priority>, due : Option<chrono::NaiveDateTime>, vault_folder : &path::Path, state : &mut state::State) -> Result<Self, error::Error> {
|
pub fn new(name : String, info : Option<String>, tags : Vec<String>, dependencies : Vec<Id>, priority : Option<Priority>, due : Option<chrono::NaiveDateTime>, vault_folder : &path::Path, state : &mut state::State) -> Result<Self, error::Error> {
|
||||||
|
|
||||||
if name.chars().all(|c| c.is_numeric()) {
|
if name.chars().all(|c| c.is_numeric()) {
|
||||||
@ -140,8 +141,7 @@ impl Task {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Less graceful error handling on this for task not existing. Only use this externally when
|
/// Loads a task directly from its path, for use with the temporary edit file.
|
||||||
/// in edit mode.
|
|
||||||
pub fn load_direct(path : path::PathBuf, read_only : bool) -> Result<Self, error::Error> {
|
pub fn load_direct(path : path::PathBuf, read_only : bool) -> Result<Self, error::Error> {
|
||||||
let file_contents = fs::read_to_string(&path)?;
|
let file_contents = fs::read_to_string(&path)?;
|
||||||
|
|
||||||
@ -164,14 +164,14 @@ impl Task {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The read_only flag is so that the file will not be truncated, and therefore doesn't need to
|
/// Loads a task in to memory.
|
||||||
/// be saved when finished.
|
|
||||||
pub fn load(id : Id, vault_folder : &path::Path, read_only : bool) -> Result<Self, error::Error> {
|
pub fn load(id : Id, vault_folder : &path::Path, read_only : bool) -> Result<Self, error::Error> {
|
||||||
let path = Task::check_exists(id, vault_folder)?;
|
let path = Task::check_exists(id, vault_folder)?;
|
||||||
|
|
||||||
Task::load_direct(path, read_only)
|
Task::load_direct(path, read_only)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get an iterator over the IDs of tasks in a vault.
|
||||||
fn id_iter(vault_folder : &path::Path) -> impl Iterator<Item = u64> {
|
fn id_iter(vault_folder : &path::Path) -> impl Iterator<Item = u64> {
|
||||||
fs::read_dir(vault_folder.join("notes"))
|
fs::read_dir(vault_folder.join("notes"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@ -181,6 +181,7 @@ impl Task {
|
|||||||
.filter_map(|n| n.parse::<Id>().ok())
|
.filter_map(|n| n.parse::<Id>().ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load all tasks of a vault into a `Vec`.
|
||||||
pub fn load_all(vault_folder : &path::Path, read_only : bool) -> Result<Vec<Self>, error::Error> {
|
pub fn load_all(vault_folder : &path::Path, read_only : bool) -> Result<Vec<Self>, error::Error> {
|
||||||
let ids = Task::id_iter(vault_folder);
|
let ids = Task::id_iter(vault_folder);
|
||||||
|
|
||||||
@ -192,6 +193,7 @@ impl Task {
|
|||||||
Ok(tasks)
|
Ok(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load all tasks of a vault into a `HashMap`.
|
||||||
pub fn load_all_as_map(vault_folder : &path::Path, read_only : bool) -> Result<HashMap<Id, Self>, error::Error> {
|
pub fn load_all_as_map(vault_folder : &path::Path, read_only : bool) -> Result<HashMap<Id, Self>, error::Error> {
|
||||||
let ids = Task::id_iter(vault_folder);
|
let ids = Task::id_iter(vault_folder);
|
||||||
|
|
||||||
@ -203,10 +205,8 @@ impl Task {
|
|||||||
Ok(tasks)
|
Ok(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn path(&self) -> &path::Path {
|
/// Checks that a task with the prodided ID exists in the provided vault_folder. Returns the
|
||||||
&self.path
|
/// path of that task.
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_exists(id : Id, vault_folder : &path::Path) -> Result<path::PathBuf, error::Error> {
|
pub fn check_exists(id : Id, vault_folder : &path::Path) -> Result<path::PathBuf, error::Error> {
|
||||||
let path = vault_folder.join("notes").join(format!("{}.toml", id));
|
let path = vault_folder.join("notes").join(format!("{}.toml", id));
|
||||||
if path.exists() && path.is_file() {
|
if path.exists() && path.is_file() {
|
||||||
@ -217,6 +217,7 @@ impl Task {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saves the in memory task data to the corresponding file.
|
||||||
pub fn save(self) -> Result<(), error::Error> {
|
pub fn save(self) -> Result<(), error::Error> {
|
||||||
let Self {
|
let Self {
|
||||||
path : _,
|
path : _,
|
||||||
@ -233,6 +234,7 @@ impl Task {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes the task.
|
||||||
pub fn delete(self) -> Result<(), error::Error> {
|
pub fn delete(self) -> Result<(), error::Error> {
|
||||||
let Self {
|
let Self {
|
||||||
path,
|
path,
|
||||||
@ -246,6 +248,7 @@ impl Task {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Displays a task to the terminal.
|
||||||
pub fn display(&self, vault_folder : &path::Path, state : &state::State) -> Result<(), error::Error> {
|
pub fn display(&self, vault_folder : &path::Path, state : &state::State) -> Result<(), error::Error> {
|
||||||
|
|
||||||
fn line(len : usize) {
|
fn line(len : usize) {
|
||||||
@ -424,34 +427,172 @@ fn format_due_date(due : &chrono::NaiveDateTime, include_fuzzy_period : bool, co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list(vault_folder : &path::Path) -> Result<(), error::Error> {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
pub fn list(options : super::ListOptions, vault_folder : &path::Path) -> Result<(), error::Error> {
|
||||||
|
|
||||||
|
let expected = super::ListOptions {
|
||||||
|
name : false,
|
||||||
|
tracked : false,
|
||||||
|
due : false,
|
||||||
|
tags : false,
|
||||||
|
priority : false,
|
||||||
|
status : false,
|
||||||
|
created : false,
|
||||||
|
sort_by : super::SortBy::Id,
|
||||||
|
sort_type : super::SortType::Asc,
|
||||||
|
before : None,
|
||||||
|
after : None,
|
||||||
|
due_in : None,
|
||||||
|
include_complete : false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the arguments are not given, use a set of defaults.
|
||||||
|
let options = if options == expected {
|
||||||
|
super::ListOptions::default()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
options
|
||||||
|
};
|
||||||
|
|
||||||
let mut table = comfy_table::Table::new();
|
let mut table = comfy_table::Table::new();
|
||||||
table
|
table
|
||||||
.load_preset(comfy_table::presets::UTF8_FULL)
|
.load_preset(comfy_table::presets::UTF8_FULL)
|
||||||
.apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS)
|
.apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS)
|
||||||
.set_content_arrangement(comfy_table::ContentArrangement::Dynamic);
|
.set_content_arrangement(comfy_table::ContentArrangement::Dynamic);
|
||||||
table.set_header(vec!["Id", "Name", "Tags", "Priority", "Tracked", "Due"]);
|
|
||||||
|
|
||||||
let mut tasks = Task::load_all(vault_folder, true)?;
|
|
||||||
|
let mut tasks : Box<dyn Iterator<Item = Task>> = Box::new(Task::load_all(vault_folder, true)?.into_iter());
|
||||||
|
|
||||||
|
// Filter the tasks
|
||||||
|
if let Some(before) = options.before {
|
||||||
|
tasks = Box::new(tasks.filter(move |t| t.data.created < before));
|
||||||
|
}
|
||||||
|
if let Some(after) = options.after {
|
||||||
|
tasks = Box::new(tasks.filter(move |t| t.data.created > after));
|
||||||
|
}
|
||||||
|
if let Some(due_in) = options.due_in {
|
||||||
|
let now = chrono::Local::now().naive_local();
|
||||||
|
tasks = Box::new(tasks.filter(move |t| {
|
||||||
|
if let Some(due) = t.data.due {
|
||||||
|
due < now + chrono::Duration::days(i64::from(due_in))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if !options.include_complete {
|
||||||
|
tasks = Box::new(tasks.filter(|t| t.data.completed.is_none()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tasks : Vec<Task> = tasks.collect();
|
||||||
|
|
||||||
|
|
||||||
|
// Sort the tasks
|
||||||
|
use super::{SortBy, SortType};
|
||||||
|
match options.sort_by {
|
||||||
|
SortBy::Id => {
|
||||||
|
match options.sort_type {
|
||||||
|
SortType::Asc => {
|
||||||
|
tasks.sort_by(|t1, t2| t1.data.id.cmp(&t2.data.id));
|
||||||
|
},
|
||||||
|
SortType::Desc => {
|
||||||
|
tasks.sort_by(|t1, t2| t2.data.id.cmp(&t1.data.id));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SortBy::Name => {
|
||||||
|
match options.sort_type {
|
||||||
|
SortType::Asc => {
|
||||||
|
tasks.sort_by(|t1, t2| t1.data.name.cmp(&t2.data.name));
|
||||||
|
},
|
||||||
|
SortType::Desc => {
|
||||||
|
tasks.sort_by(|t1, t2| t2.data.name.cmp(&t1.data.name));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SortBy::Due => {
|
||||||
|
match options.sort_type {
|
||||||
|
SortType::Asc => {
|
||||||
|
tasks.sort_by(|t1, t2| t1.data.due.cmp(&t2.data.due));
|
||||||
|
},
|
||||||
|
SortType::Desc => {
|
||||||
|
tasks.sort_by(|t1, t2| t2.data.due.cmp(&t1.data.due));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SortBy::Priority => {
|
||||||
|
match options.sort_type {
|
||||||
|
SortType::Asc => {
|
||||||
|
tasks.sort_by(|t1, t2| t1.data.priority.cmp(&t2.data.priority));
|
||||||
|
},
|
||||||
|
SortType::Desc => {
|
||||||
tasks.sort_by(|t1, t2| t2.data.priority.cmp(&t1.data.priority));
|
tasks.sort_by(|t1, t2| t2.data.priority.cmp(&t1.data.priority));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SortBy::Created => {
|
||||||
|
match options.sort_type {
|
||||||
|
SortType::Asc => {
|
||||||
|
tasks.sort_by(|t1, t2| t1.data.created.cmp(&t2.data.created));
|
||||||
|
},
|
||||||
|
SortType::Desc => {
|
||||||
|
tasks.sort_by(|t1, t2| t2.data.created.cmp(&t1.data.created));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the required columns
|
||||||
|
let mut headers = vec!["Id"];
|
||||||
|
|
||||||
|
if options.name { headers.push("Name") };
|
||||||
|
if options.tracked { headers.push("Tracked") };
|
||||||
|
if options.due { headers.push("Due") };
|
||||||
|
if options.tags { headers.push("Tags") };
|
||||||
|
if options.priority { headers.push("Priority") };
|
||||||
|
if options.status { headers.push("Status") };
|
||||||
|
if options.created { headers.push("Created") };
|
||||||
|
|
||||||
|
table.set_header(headers);
|
||||||
|
|
||||||
for task in tasks {
|
for task in tasks {
|
||||||
if task.data.completed.is_none() {
|
|
||||||
|
|
||||||
|
let mut row = vec![task.data.id.to_string()];
|
||||||
|
|
||||||
|
if options.name { row.push(task.data.name); }
|
||||||
|
if options.tracked {
|
||||||
let duration = TimeEntry::total(&task.data.time_entries);
|
let duration = TimeEntry::total(&task.data.time_entries);
|
||||||
|
row.push(
|
||||||
table.add_row(
|
if duration == Duration::zero() { String::new() } else { duration.to_string() }
|
||||||
vec![
|
|
||||||
task.data.id.to_string(),
|
|
||||||
task.data.name,
|
|
||||||
format_hash_set(&task.data.tags)?,
|
|
||||||
task.data.priority.to_string(),
|
|
||||||
if duration == Duration::zero() { String::new() } else { duration.to_string() },
|
|
||||||
match task.data.due { Some(due) => format_due_date(&due, task.data.completed.is_none(), false), None => String::new() },
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if options.due {
|
||||||
|
row.push(match task.data.due {
|
||||||
|
Some(due) => format_due_date(&due, task.data.completed.is_none(), false),
|
||||||
|
None => String::new()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if options.tags { row.push(format_hash_set(&task.data.tags)?); }
|
||||||
|
if options.priority { row.push(task.data.priority.to_string()); }
|
||||||
|
if options.status {
|
||||||
|
row.push(
|
||||||
|
if task.data.completed.is_some() {
|
||||||
|
String::from("complete")
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
String::from("incomplete")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if options.created {
|
||||||
|
row.push(task.data.created.round_subsecs(0).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
table.add_row(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{}", table);
|
println!("{}", table);
|
||||||
|
Loading…
Reference in New Issue
Block a user