Compare commits

...

11 commits
v1.2.0 ... main

Author SHA1 Message Date
Campbell Alden
ecd732dbbd Update the readme since v2 broke a lot of options 2024-12-27 09:48:28 +09:00
Campbell Alden
fb711c2810 Update the day reporter to support more arbitrary reporting from things lists and between arbitrary dates 2024-12-24 12:28:07 +09:00
Campbell Alden
9d62d4d555 Update the day reporter to use lists instead of report types 2024-12-24 11:58:22 +09:00
Campbell Alden
154793ccbe Speedup the signoff report by short circuiting when the first task not on today is found 2024-12-23 18:09:41 +09:00
Campbell Alden
c6942468b2 Allow filtering reported tasks such that certain tags are omitted 2024-05-08 11:00:26 +09:00
Campbell Alden
29d1d5a2ef Handle canceled tasks 2024-03-13 18:17:50 +09:00
Campbell Alden
f1ac5bb718 BUG FIX: Report relevant notes after a project title when there are notes on a project 2024-02-28 09:24:54 +09:00
Campbell Alden
52b869cca5 Sort tasks by completion date (newer later) 2024-01-10 10:23:26 +09:00
Campbell Alden
9fa5818fde Remove redundant clone() 2024-01-10 10:23:09 +09:00
Campbell Alden
23eaf0c5d8 Add new emojis 2023-08-04 10:43:23 +09:00
Campbell Alden
a9658a9d94 v1.2.1 2023-07-24 22:50:04 +09:00
9 changed files with 166 additions and 150 deletions

2
Cargo.lock generated
View file

@ -173,7 +173,7 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
[[package]] [[package]]
name = "day-reporter" name = "day-reporter"
version = "1.2.0" version = "2.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "day-reporter" name = "day-reporter"
version = "1.2.0" version = "2.0.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View file

@ -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 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. and formatted based on the selected format schema.
## Options
See `day-reporter -h` for a current list of options
## Basic Task format ## Basic Task format
A task is returned as a markdown list element containing its title and optionally a list of information parsed from 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. 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 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. program with a given `--tags` argument will cause related tag blocks to be included as sub-bullets.
### Example
<pre>
```MyTag
Some notes
```
</pre>
will result in "Some notes" being included with the task
## Name Sanitization ## Name Sanitization
Name tags found on projects and tasks are automatically sanitized by replacing vowel characters with lookalikes. This 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. 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 ## Area Formatting
Areas collect up projects, but instead of indenting them further, Area's are returned as section titles above the 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. 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.

View file

@ -1,15 +1,22 @@
:audioowl: :audioowl:
:ayaya: :ayaya:
:blobcat_cookie: :blobcat_cookie:
:blobcat_munch:
:boat-cat: :boat-cat:
:bouquet_owl: :bouquet_owl:
:cat-confused: :cat-confused:
:cat-cook: :cat-cook:
:cat-dance:
:cat-on-the-laptop: :cat-on-the-laptop:
:cat-peek:
:cat-roll: :cat-roll:
:cat-roomba-exceptionally-fast:
:cat-roomba:
:cat-shook: :cat-shook:
:cat-skype: :cat-skype:
:cat_blush: :cat_blush:
:cat_bobble:
:cat_clap:
:cat_type: :cat_type:
:catcat: :catcat:
:catdance: :catdance:
@ -31,22 +38,27 @@
:dancing_dog: :dancing_dog:
:deadowl: :deadowl:
:dnowl: :dnowl:
:dog_cool:
:doge-dance-kfc:
:doge: :doge:
:dogjam: :dogjam:
:eikaiwaowl: :eikaiwaowl:
:eve-owl:
:eve-owl-evil: :eve-owl-evil:
:eve-owl:
:fakeowl: :fakeowl:
:french-bulldog_massage:
:gatocat: :gatocat:
:grumpycat: :grumpycat:
:gull_scream: :gull_scream:
:heart-eyes-dog: :heart-eyes-dog:
:hungry_cat: :hungry_cat:
:i_regret_nothing: :i_regret_nothing:
:insomnia-owl:
:investigate-owl: :investigate-owl:
:jakethedog1: :jakethedog1:
:jakethedog2: :jakethedog2:
:jenkinsowl: :jenkinsowl:
:long_cat:
:look-owl: :look-owl:
:loopyowl: :loopyowl:
:love_letter_owl: :love_letter_owl:
@ -67,8 +79,8 @@
:meow_buzz: :meow_buzz:
:meow_camera: :meow_camera:
:meow_code: :meow_code:
:meow_coffee:
:meow_coffee2: :meow_coffee2:
:meow_coffee:
:meow_comfy: :meow_comfy:
:meow_comfy_coffee: :meow_comfy_coffee:
:meow_comfydonut: :meow_comfydonut:
@ -126,9 +138,12 @@
:meowth: :meowth:
:mild-surprise-owl: :mild-surprise-owl:
:nerd-cat: :nerd-cat:
:nerdcat:
:octocat1: :octocat1:
:octocat2: :octocat2:
:octocat3: :octocat3:
:ok-owl:
:omg-owl:
:owl-travel: :owl-travel:
:owl_celebration: :owl_celebration:
:owl_christmas_stocking: :owl_christmas_stocking:
@ -149,7 +164,9 @@
:realowl_back: :realowl_back:
:realowl_guruguru: :realowl_guruguru:
:realowl_side: :realowl_side:
:rodger-owl:
:sakura_owl: :sakura_owl:
:seems-good-owl:
:shrodingers-cat: :shrodingers-cat:
:steampunk-owl: :steampunk-owl:
:stopowl: :stopowl:

View file

@ -5,60 +5,42 @@ mod names;
use reporter::{MarkdownReporter, Reporter, Resolution, ReportOptions}; use reporter::{MarkdownReporter, Reporter, Resolution, ReportOptions};
use things::task::{Task, Status}; use things::task::Task;
use anyhow::Result; use anyhow::Result;
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
#[derive(ValueEnum, Copy, Clone, Eq, PartialEq)] #[derive(ValueEnum, Copy, Clone, Eq, PartialEq)]
enum ReportTypes { enum ListType {
/// report projected work for the day and a morning message /// Generate a report from the Things today list
Morning, Today,
/// report what major tasks were completed in the last cycle. /// Generate a report from the Things logbook
Cycle, Logbook,
/// report what was actually done today and a signoff message
Signoff,
} }
impl ReportTypes { impl ListType {
fn format_tasks(&self, tasks: Vec<Task>, tags: &Vec<String>, sanitize_names: bool) -> String { fn format_tasks(&self, tasks: Vec<Task>, tags: &Vec<String>, sanitize_names: bool, resolution: Resolution) -> String {
match self { match self {
ReportTypes::Morning => { ListType::Today => {
let task_report = MarkdownReporter.report(tasks, &ReportOptions { MarkdownReporter.report(tasks, &ReportOptions {
resolution: Resolution::FullTask, resolution,
tags: tags.to_vec(), tags: tags.to_vec(),
sanitize_names, sanitize_names,
}); })
format!("{}\n\n{}", emoji::pick(3).join(" "), task_report)
}, },
ReportTypes::Signoff => { ListType::Logbook => {
let task_report = MarkdownReporter.report(tasks, &ReportOptions { MarkdownReporter.report(tasks, &ReportOptions {
resolution: Resolution::FullTask, resolution,
tags: tags.to_vec(), tags: tags.to_vec(),
sanitize_names, 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::<Vec<Task>>();
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 { impl Default for ListType {
fn default() -> ReportTypes { fn default() -> ListType {
ReportTypes::Morning ListType::Today
} }
} }
@ -70,30 +52,65 @@ struct CliArgs {
#[arg(short, long)] #[arg(short, long)]
tags: Vec<String>, tags: Vec<String>,
/// 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<String>,
/// Select the type of report to generate /// 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)] #[clap(value_enum)]
report: ReportTypes, list: ListType,
/// By default, any @<name> style tags will be sanitized in the output to avoid @-mentions in /// By default, any @<name> 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 /// Slack. This is done by replacing vowel characters with unicode lookalikes. If this
/// flag is set then the names will be passed through unsanitized. /// flag is set then the names will be passed through unsanitized.
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
no_sanitize: bool, 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<()> { fn main() -> Result<()> {
let args = CliArgs::parse(); let args = CliArgs::parse();
let tasks = match args.report { let now = chrono::Local::now();
ReportTypes::Morning => Task::today(), let from = args.from.unwrap_or_else(|| now.date().and_hms(0, 0, 0).to_rfc3339());
ReportTypes::Signoff => Task::logbook_today(), let to = args.to.unwrap_or_else(|| (now + chrono::Duration::days(1)).date().and_hms(0, 0, 0).to_rfc3339());
ReportTypes::Cycle => Task::logbook_this_cycle(),
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 reported: Vec<Task> = tasks.into_iter().filter(|task| { let mut reported: Vec<Task> = 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(); }).collect();
let report = args.report.format_tasks(reported, &args.tags, !args.no_sanitize); reported.sort_by(|a, b| {
println!("{report}"); a.completion_date.cmp(&b.completion_date)
});
let report = args.list.format_tasks(reported, &args.tags, !args.no_sanitize, args.resolution);
println!("{message}\n\n{report}");
Ok(()) Ok(())
} }

View file

@ -1,4 +1,6 @@
use crate::things::task::Task; use clap::ValueEnum;
use crate::things::task::{Task, Status};
use crate::names::sanitize_names; use crate::names::sanitize_names;
/// Given a notes field and a list of possible tags for sections, return the content of triple tick /// Given a notes field and a list of possible tags for sections, return the content of triple tick
@ -59,8 +61,8 @@ pub struct ThingsTree {
impl AreaTree { impl AreaTree {
fn new(id: &str, title: &str) -> AreaTree { fn new(id: &str, title: &str) -> AreaTree {
AreaTree { AreaTree {
id: id.clone().to_string(), id: id.to_string(),
title: title.clone().to_string(), title: title.to_string(),
projects: vec![], projects: vec![],
hanging_tasks: vec![], hanging_tasks: vec![],
} }
@ -126,11 +128,18 @@ impl ThingsTree {
} }
} }
#[derive(ValueEnum, Copy, Clone, Eq, PartialEq)]
pub enum Resolution { pub enum Resolution {
FullTask, FullTask,
Project, Project,
} }
impl Default for Resolution {
fn default() -> Resolution {
Resolution::FullTask
}
}
pub struct ReportOptions { pub struct ReportOptions {
pub resolution: Resolution, pub resolution: Resolution,
pub tags: Vec<String>, pub tags: Vec<String>,
@ -176,11 +185,17 @@ impl Reporter for MarkdownReporter {
.map(|l| format!("\n{}- {}", String::from(" ").repeat(depth + 4), l)) .map(|l| format!("\n{}- {}", String::from(" ").repeat(depth + 4), l))
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(""); .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 { if options.sanitize_names {
output = sanitize_names(&output, &task.tags); output = sanitize_names(&output, &task.tags);
} }
return output;
output
} }
fn report_project(&mut self, project: &ProjectTree, depth: usize, options: &ReportOptions) -> String { fn report_project(&mut self, project: &ProjectTree, depth: usize, options: &ReportOptions) -> String {
let resolution = &options.resolution; let resolution = &options.resolution;
@ -198,7 +213,7 @@ impl Reporter for MarkdownReporter {
.map(|t| self.report_task(t, depth + 4, options)) .map(|t| self.report_task(t, depth + 4, options))
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(""); .join("");
format!("{}{}{}{}", String::from(" ").repeat(depth), relevant_notes, project.title, tasks) format!("{}{}{}{}", String::from(" ").repeat(depth), project.title, relevant_notes, tasks)
}, },
Resolution::Project => { Resolution::Project => {
format!("{}- {}{}", String::from(" ").repeat(depth), project.title, relevant_notes) format!("{}- {}{}", String::from(" ").repeat(depth), project.title, relevant_notes)
@ -209,7 +224,7 @@ impl Reporter for MarkdownReporter {
output = sanitize_names(&output, &project.tags); output = sanitize_names(&output, &project.tags);
} }
return output; output
} }
fn report_single_area(&mut self, area: &AreaTree, options: &ReportOptions) -> String { fn report_single_area(&mut self, area: &AreaTree, options: &ReportOptions) -> String {
let project_reports = area.projects let project_reports = area.projects

View file

@ -2,19 +2,11 @@ var things = Application("Things");
var logbook = things.lists.byName("Logbook").toDos(); var logbook = things.lists.byName("Logbook").toDos();
var objs = []; var objs = [];
var from = new Date(); var from = new Date($params.from);
from.setHours(0); var to = new Date($params.to);
from.setMinutes(0);
from.setSeconds(0);
var to = new Date();
to.setTime(from.getTime());
to.setHours(23);
to.setMinutes(59);
to.setSeconds(59);
logbook.filter(task => { for (const todo of logbook) {
return task.completionDate() >= from && task.completionDate() < to; if(todo.completionDate() >= from && todo.completionDate() < to) {
}).forEach(todo => {
var proj = todo.project(); var proj = todo.project();
var tags = []; var tags = [];
if (proj) { if (proj) {
@ -37,6 +29,9 @@ logbook.filter(task => {
area: area && { id: area.id(), title: area.name() }, area: area && { id: area.id(), title: area.name() },
tags: [...tags, ...todo.tagNames().split(', ')].filter(t => t), tags: [...tags, ...todo.tagNames().split(', ')].filter(t => t),
}); });
}); } else {
break;
}
}
return JSON.stringify(objs, undefined, 2); return JSON.stringify(objs, undefined, 2);

View file

@ -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);

View file

@ -1,5 +1,5 @@
use std::str::from_utf8; use std::str::from_utf8;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use anyhow::Result; use anyhow::Result;
use osascript; use osascript;
use serde_json; use serde_json;
@ -13,6 +13,8 @@ pub enum Status {
Incomplete, Incomplete,
#[serde(rename = "open")] #[serde(rename = "open")]
Open, Open,
#[serde(rename = "canceled")]
Canceled,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@ -42,27 +44,31 @@ pub struct Task {
pub area: Option<Area>, pub area: Option<Area>,
} }
#[derive(Serialize, Debug)]
pub struct TaskParams {
// ISO string for the start date
from: String,
// ISO string for the end date
to: String,
}
impl Task { impl Task {
/// A Helper for loading Tasks from json returned by an osascript /// A Helper for loading Tasks from json returned by an osascript
fn from_script(script_bytes: &[u8]) -> Result<Vec<Task>> { fn from_script(script_bytes: &[u8], params: TaskParams) -> Result<Vec<Task>> {
let script = osascript::JavaScript::new(from_utf8(script_bytes)?); 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<Task> = serde_json::from_str(&raw_json)?; let tasks: Vec<Task> = serde_json::from_str(&raw_json)?;
Ok(tasks) Ok(tasks)
} }
/// Get all tasks in the today list from Things /// Get all tasks in the today list from Things
pub fn today() -> Result<Vec<Task>> { pub fn today(from: &String, to: &String) -> Result<Vec<Task>> {
Task::from_script(include_bytes!("today.js")) 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 /// Get all tasks in the logbook list from Things
pub fn logbook_today() -> Result<Vec<Task>> { pub fn logbook(from: &String, to: &String) -> Result<Vec<Task>> {
Task::from_script(include_bytes!("logbook.js")) Task::from_script(include_bytes!("logbook.js"), TaskParams { from: from.to_string(), to: to.to_string() })
}
pub fn logbook_this_cycle() -> Result<Vec<Task>> {
Task::from_script(include_bytes!("logbook_cycle.js"))
} }
pub fn has_tag(&self, tag: &str) -> bool { pub fn has_tag(&self, tag: &str) -> bool {