diff --git a/Cargo.lock b/Cargo.lock
index 606110d..bac445a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -173,7 +173,7 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
[[package]]
name = "day-reporter"
-version = "1.3.0"
+version = "2.0.0"
dependencies = [
"anyhow",
"chrono",
diff --git a/Cargo.toml b/Cargo.toml
index c182791..549bcd1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "day-reporter"
-version = "1.3.0"
+version = "2.0.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
diff --git a/README.md b/README.md
index 8611e8f..660386e 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,9 @@ A small program written in rust that digests my Things tasks into a format for S
Tasks are pulled out of Things using AppleScript over a JavaScript OSA Bridge. Tasks are then filtered
and formatted based on the selected format schema.
+## Options
+See `day-reporter -h` for a current list of options
+
## Basic Task format
A task is returned as a markdown list element containing its title and optionally a list of information parsed from
related tag sections in the notes portion of the task.
@@ -12,6 +15,16 @@ Tag specific additional bullets are included using a triple backtick (like GitHu
specifying the coding style as you would (for example `typescript`) you specify the relevant tag: `MyTag`. Running the
program with a given `--tags` argument will cause related tag blocks to be included as sub-bullets.
+### Example
+
+
+```MyTag
+Some notes
+```
+
+
+will result in "Some notes" being included with the task
+
## Name Sanitization
Name tags found on projects and tasks are automatically sanitized by replacing vowel characters with lookalikes. This
behavior can be disabled with the `--no-sanitize` argument.
@@ -23,8 +36,3 @@ and project specific matched tag block comments in the project notes.
## Area Formatting
Areas collect up projects, but instead of indenting them further, Area's are returned as section titles above the
generated output. If only one area contains all of the reported tasks, then the area name is omitted entirely.
-
-## Cycle Message Formatting
-The `--mode cycle` argument will output slightly different content. All tasks are collected at a project level, and
-only the project name is given. Cycle messages look back in time at the last 6 weeks and therefore have too many values
-to meaningfully output at full resolution.
diff --git a/src/main.rs b/src/main.rs
index 914723b..20bfa04 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,60 +5,42 @@ mod names;
use reporter::{MarkdownReporter, Reporter, Resolution, ReportOptions};
-use things::task::{Task, Status};
+use things::task::Task;
use anyhow::Result;
use clap::{Parser, ValueEnum};
#[derive(ValueEnum, Copy, Clone, Eq, PartialEq)]
-enum ReportTypes {
- /// report projected work for the day and a morning message
- Morning,
- /// report what major tasks were completed in the last cycle.
- Cycle,
- /// report what was actually done today and a signoff message
- Signoff,
+enum ListType {
+ /// Generate a report from the Things today list
+ Today,
+ /// Generate a report from the Things logbook
+ Logbook,
}
-impl ReportTypes {
- fn format_tasks(&self, tasks: Vec, tags: &Vec, sanitize_names: bool) -> String {
+impl ListType {
+ fn format_tasks(&self, tasks: Vec, tags: &Vec, sanitize_names: bool, resolution: Resolution) -> String {
match self {
- ReportTypes::Morning => {
- let task_report = MarkdownReporter.report(tasks, &ReportOptions {
- resolution: Resolution::FullTask,
+ ListType::Today => {
+ MarkdownReporter.report(tasks, &ReportOptions {
+ resolution,
tags: tags.to_vec(),
sanitize_names,
- });
- format!("{}\n\n{}", emoji::pick(3).join(" "), task_report)
+ })
},
- ReportTypes::Signoff => {
- let task_report = MarkdownReporter.report(tasks, &ReportOptions {
- resolution: Resolution::FullTask,
+ ListType::Logbook => {
+ MarkdownReporter.report(tasks, &ReportOptions {
+ resolution,
tags: tags.to_vec(),
sanitize_names,
- });
- format!("Stopping now\n\n{}", task_report)
- },
- ReportTypes::Cycle => {
- let further_filtered = tasks.into_iter().filter(|t| {
- if let Some(p) = &t.project {
- return p.status == Status::Completed;
- }
- return false;
- }).collect::>();
- let task_report = MarkdownReporter.report(further_filtered, &ReportOptions {
- resolution: Resolution::Project,
- tags: tags.to_vec(),
- sanitize_names,
- });
- format!("*Cycle Report*\n\n{}", task_report)
+ })
},
}
}
}
-impl Default for ReportTypes {
- fn default() -> ReportTypes {
- ReportTypes::Morning
+impl Default for ListType {
+ fn default() -> ListType {
+ ListType::Today
}
}
@@ -70,33 +52,65 @@ struct CliArgs {
#[arg(short, long)]
tags: Vec,
+ /// A list of tags to specifically omit from the results. Only todo list items WITHOUT these
+ /// tags will be included
+ #[arg(short, long)]
+ omit: Vec,
+
/// Select the type of report to generate
- #[arg(short, long, default_value_t = ReportTypes::default())]
+ #[arg(short, long, default_value_t = ListType::default())]
#[clap(value_enum)]
- report: ReportTypes,
+ list: ListType,
/// By default, any @ style tags will be sanitized in the output to avoid @-mentions in
/// Slack. This is done by replacing vowel characters with unicode lookalikes. If this
/// flag is set then the names will be passed through unsanitized.
#[arg(long, default_value_t = false)]
no_sanitize: bool,
+
+ /// An ISO date string for when to filter tasks from.
+ /// Defaults to midnight this morning if unset. Not used for the today list
+ #[arg(long)]
+ from: Option,
+
+ /// An ISO date string for when to filter tasks until.
+ /// Defaults to 1 second before midnight tonight if unset. Not used for the today list
+ #[arg(long)]
+ to: Option,
+
+ /// An optional message to include at the beginning of the report. If omitted, 3 random emojis
+ /// will be included instead
+ #[arg(short, long)]
+ message: Option,
+
+ /// Choose a resolution for the report. This will determine how much detail is included in the
+ /// output. Default is "FullTask"
+ #[arg(short, long, default_value_t = Resolution::default())]
+ #[clap(value_enum)]
+ resolution: Resolution,
}
fn main() -> Result<()> {
let args = CliArgs::parse();
- let tasks = match args.report {
- ReportTypes::Morning => Task::today(),
- ReportTypes::Signoff => Task::logbook_today(),
- ReportTypes::Cycle => Task::logbook_this_cycle(),
+ let now = chrono::Local::now();
+ let from = args.from.unwrap_or_else(|| now.date().and_hms(0, 0, 0).to_rfc3339());
+ let to = args.to.unwrap_or_else(|| (now + chrono::Duration::days(1)).date().and_hms(0, 0, 0).to_rfc3339());
+
+ let message = args.message.unwrap_or_else(|| emoji::pick(3).join(" "));
+
+ let tasks = match args.list {
+ ListType::Today => Task::today(&from, &to),
+ ListType::Logbook => Task::logbook(&from, &to),
}?;
let mut reported: Vec = tasks.into_iter().filter(|task| {
- args.tags.iter().all(|tag| task.has_tag(tag))
+ // Filter down to tasks with all selected tags and without any of the omitted tags
+ args.tags.iter().all(|tag| task.has_tag(tag)) && !args.omit.iter().any(|tag| task.has_tag(tag))
}).collect();
reported.sort_by(|a, b| {
a.completion_date.cmp(&b.completion_date)
});
- let report = args.report.format_tasks(reported, &args.tags, !args.no_sanitize);
- println!("{report}");
+ let report = args.list.format_tasks(reported, &args.tags, !args.no_sanitize, args.resolution);
+ println!("{message}\n\n{report}");
Ok(())
}
diff --git a/src/reporter.rs b/src/reporter.rs
index d108d41..d7af026 100644
--- a/src/reporter.rs
+++ b/src/reporter.rs
@@ -1,4 +1,6 @@
-use crate::things::task::Task;
+use clap::ValueEnum;
+
+use crate::things::task::{Task, Status};
use crate::names::sanitize_names;
/// Given a notes field and a list of possible tags for sections, return the content of triple tick
@@ -126,11 +128,18 @@ impl ThingsTree {
}
}
+#[derive(ValueEnum, Copy, Clone, Eq, PartialEq)]
pub enum Resolution {
FullTask,
Project,
}
+impl Default for Resolution {
+ fn default() -> Resolution {
+ Resolution::FullTask
+ }
+}
+
pub struct ReportOptions {
pub resolution: Resolution,
pub tags: Vec,
@@ -176,10 +185,16 @@ impl Reporter for MarkdownReporter {
.map(|l| format!("\n{}- {}", String::from(" ").repeat(depth + 4), l))
.collect::>()
.join("");
- let mut output = format!("\n{}- {}{}", String::from(" ").repeat(depth), task.title, relevant_notes);
+ let title = if task.status == Status::Canceled {
+ format!("~{}~", task.title)
+ } else {
+ task.title.to_string()
+ };
+ let mut output = format!("\n{}- {}{}", String::from(" ").repeat(depth), title, relevant_notes);
if options.sanitize_names {
output = sanitize_names(&output, &task.tags);
}
+
output
}
fn report_project(&mut self, project: &ProjectTree, depth: usize, options: &ReportOptions) -> String {
diff --git a/src/things/logbook.js b/src/things/logbook.js
index dd3439e..e0828a3 100644
--- a/src/things/logbook.js
+++ b/src/things/logbook.js
@@ -2,41 +2,36 @@ var things = Application("Things");
var logbook = things.lists.byName("Logbook").toDos();
var objs = [];
-var from = new Date();
-from.setHours(0);
-from.setMinutes(0);
-from.setSeconds(0);
-var to = new Date();
-to.setTime(from.getTime());
-to.setHours(23);
-to.setMinutes(59);
-to.setSeconds(59);
+var from = new Date($params.from);
+var to = new Date($params.to);
-logbook.filter(task => {
- return task.completionDate() >= from && task.completionDate() < to;
-}).forEach(todo => {
- var proj = todo.project();
- var tags = [];
- if (proj) {
- tags.push(...proj.tagNames().split(', '));
+for (const todo of logbook) {
+ if(todo.completionDate() >= from && todo.completionDate() < to) {
+ var proj = todo.project();
+ var tags = [];
+ if (proj) {
+ tags.push(...proj.tagNames().split(', '));
+ }
+ var area = todo.area() || proj && proj.area();
+ objs.push({
+ id: todo.id(),
+ title: todo.name(),
+ notes: todo.notes() || null,
+ status: todo.status(),
+ completion_date: todo.completionDate(),
+ project: proj && {
+ id: proj.id(),
+ title: proj.name(),
+ status: proj.status(),
+ notes: proj.notes(),
+ tags: proj.tagNames().split(', '),
+ },
+ area: area && { id: area.id(), title: area.name() },
+ tags: [...tags, ...todo.tagNames().split(', ')].filter(t => t),
+ });
+ } else {
+ break;
}
- var area = todo.area() || proj && proj.area();
- objs.push({
- id: todo.id(),
- title: todo.name(),
- notes: todo.notes() || null,
- status: todo.status(),
- completion_date: todo.completionDate(),
- project: proj && {
- id: proj.id(),
- title: proj.name(),
- status: proj.status(),
- notes: proj.notes(),
- tags: proj.tagNames().split(', '),
- },
- area: area && { id: area.id(), title: area.name() },
- tags: [...tags, ...todo.tagNames().split(', ')].filter(t => t),
- });
-});
+}
return JSON.stringify(objs, undefined, 2);
diff --git a/src/things/logbook_cycle.js b/src/things/logbook_cycle.js
deleted file mode 100644
index 292a7f5..0000000
--- a/src/things/logbook_cycle.js
+++ /dev/null
@@ -1,42 +0,0 @@
-var things = Application("Things");
-var logbook = things.lists.byName("Logbook").toDos();
-var objs = [];
-
-// From 6 weeks ago
-var from = new Date(new Date().getTime() - (6 * 7 * 24 * 60 * 60 * 1000));
-from.setHours(0);
-from.setMinutes(0);
-from.setSeconds(0);
-var to = new Date();
-to.setHours(23);
-to.setMinutes(59);
-to.setSeconds(59);
-
-logbook.filter(task => {
- return task.completionDate() >= from && task.completionDate() < to;
-}).forEach(todo => {
- var proj = todo.project();
- var tags = [];
- if (proj) {
- tags.push(...proj.tagNames().split(', '));
- }
- var area = todo.area() || proj && proj.area();
- objs.push({
- id: todo.id(),
- title: todo.name(),
- notes: todo.notes() || null,
- status: todo.status(),
- completion_date: todo.completionDate(),
- project: proj && {
- id: proj.id(),
- title: proj.name(),
- status: proj.status(),
- notes: proj.notes(),
- tags: proj.tagNames().split(', '),
- },
- area: area && { id: area.id(), title: area.name() },
- tags: [...tags, ...todo.tagNames().split(', ')].filter(t => t),
- });
-});
-
-return JSON.stringify(objs, undefined, 2);
diff --git a/src/things/task.rs b/src/things/task.rs
index a8bc62d..2d9c944 100644
--- a/src/things/task.rs
+++ b/src/things/task.rs
@@ -1,5 +1,5 @@
use std::str::from_utf8;
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
use anyhow::Result;
use osascript;
use serde_json;
@@ -13,6 +13,8 @@ pub enum Status {
Incomplete,
#[serde(rename = "open")]
Open,
+ #[serde(rename = "canceled")]
+ Canceled,
}
#[derive(Deserialize, Debug)]
@@ -42,27 +44,31 @@ pub struct Task {
pub area: Option,
}
+#[derive(Serialize, Debug)]
+pub struct TaskParams {
+ // ISO string for the start date
+ from: String,
+ // ISO string for the end date
+ to: String,
+}
+
impl Task {
/// A Helper for loading Tasks from json returned by an osascript
- fn from_script(script_bytes: &[u8]) -> Result> {
+ fn from_script(script_bytes: &[u8], params: TaskParams) -> Result> {
let script = osascript::JavaScript::new(from_utf8(script_bytes)?);
- let raw_json: String = script.execute()?;
+ let raw_json: String = script.execute_with_params(params)?;
let tasks: Vec = serde_json::from_str(&raw_json)?;
Ok(tasks)
}
/// Get all tasks in the today list from Things
- pub fn today() -> Result> {
- Task::from_script(include_bytes!("today.js"))
+ pub fn today(from: &String, to: &String) -> Result> {
+ Task::from_script(include_bytes!("today.js"), TaskParams { from: from.to_string(), to: to.to_string() })
}
/// Get all tasks in the logbook list from Things
- pub fn logbook_today() -> Result> {
- Task::from_script(include_bytes!("logbook.js"))
- }
-
- pub fn logbook_this_cycle() -> Result> {
- Task::from_script(include_bytes!("logbook_cycle.js"))
+ pub fn logbook(from: &String, to: &String) -> Result> {
+ Task::from_script(include_bytes!("logbook.js"), TaskParams { from: from.to_string(), to: to.to_string() })
}
pub fn has_tag(&self, tag: &str) -> bool {