Compare commits
20 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd732dbbd | ||
|
|
fb711c2810 | ||
|
|
9d62d4d555 | ||
|
|
154793ccbe | ||
|
|
c6942468b2 | ||
|
|
29d1d5a2ef | ||
|
|
f1ac5bb718 | ||
|
|
52b869cca5 | ||
|
|
9fa5818fde | ||
|
|
23eaf0c5d8 | ||
|
|
a9658a9d94 | ||
|
|
30b22de2b9 | ||
|
|
b7cedea8eb | ||
|
|
d232763850 | ||
|
|
e6ffc866c4 | ||
|
|
2e1957d154 | ||
|
|
0144071046 | ||
|
|
469320a77f | ||
|
|
1de78cafef | ||
|
|
b6497d499f |
12 changed files with 363 additions and 183 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -173,7 +173,7 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "day-reporter"
|
name = "day-reporter"
|
||||||
version = "0.1.0"
|
version = "2.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "day-reporter"
|
name = "day-reporter"
|
||||||
version = "0.1.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
|
||||||
|
|
|
||||||
65
README.md
65
README.md
|
|
@ -1,41 +1,38 @@
|
||||||
# things-3-report
|
# things-3-report
|
||||||
A small script to generate bulleted reports from my Things 3 tasks
|
A small program written in rust that digests my Things tasks into a format for Slack reporting.
|
||||||
|
|
||||||
## Usage
|
Tasks are pulled out of Things using AppleScript over a JavaScript OSA Bridge. Tasks are then filtered
|
||||||
The environment should be provided by `nix-shell`. Alternatively you can install the requirements directly with
|
and formatted based on the selected format schema.
|
||||||
```bash
|
|
||||||
$ pip install -r requirements.txt
|
## 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.
|
||||||
|
|
||||||
|
Tag specific additional bullets are included using a triple backtick (like GitHub code block markdown). Then instead of
|
||||||
|
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
|
||||||
|
|
||||||
|
<pre>
|
||||||
|
```MyTag
|
||||||
|
Some notes
|
||||||
```
|
```
|
||||||
To run the script, pass in the tag or list of tags to filter on. Projects and Tasks lacking one of the specified tags will be excluded from the generated report. I have a `Report` tag which I use for this. If a project is tagged with the tag, all tasks are considered tagged even if not explicitly done so.
|
</pre>
|
||||||
|
|
||||||
### Specific functionality
|
will result in "Some notes" being included with the task
|
||||||
I have also included a few pieces of additional functionality:
|
|
||||||
- The project is headed with a set of 3 randomly selected emojis from `emojis.txt`
|
|
||||||
- If you have a person's name in the task or project title, you can add an `@<Persons Name>` tag to the task or project and the script will automatically replace the vowels in their name with unicode that looks similar. This allows you to post the list on slack without generating mentions.
|
|
||||||
- Adding a code block that begins with `report` will cause the script to dump the contents into the end of the generated message. This can be used to add notes to the generated output.
|
|
||||||
- Be default the script draws from today's current list of tasks. If you specify `--signoff` it will instead draw on completed tasks from your logbook. (This is useful for an end of the day message).
|
|
||||||
|
|
||||||
## Example Usage:
|
## Name Sanitization
|
||||||
Given a set of tasks tagged `Report`
|
Name tags found on projects and tasks are automatically sanitized by replacing vowel characters with lookalikes. This
|
||||||
```txt
|
behavior can be disabled with the `--no-sanitize` argument.
|
||||||
Project named "Some Project" tagged as Report:
|
|
||||||
containing two tasks (not necessarily tagged) "Task 1" and "Task 2"
|
|
||||||
|
|
||||||
A top level task called some task with a notes filed containing
|
## Project Formatting
|
||||||
```report
|
Projects make up a bullet level above tasks. Any tasks in the project will be nested under the project title following
|
||||||
(#9999)[https://example.com]
|
and project specific matched tag block comments in the project notes.
|
||||||
`` ` <-- No space there but it's hard to escape these ticks
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
## Area Formatting
|
||||||
$ python dump.py Report
|
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.
|
||||||
will generate:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
:emoji: :emoji: :emoji:
|
|
||||||
- Some Project
|
|
||||||
- Task 1
|
|
||||||
- Task 2
|
|
||||||
- Some Task (#9999)(https://example.com)
|
|
||||||
```
|
|
||||||
|
|
|
||||||
15
REFACTOR.md
15
REFACTOR.md
|
|
@ -1,15 +0,0 @@
|
||||||
# Refactor Goals
|
|
||||||
1. Speed things up. Relying on things.py (and python in general) makes the code slow. Add in the fact that the
|
|
||||||
`nix-shell` logic tends to run every day and pulls down the universe, the current day reporter is quite slow
|
|
||||||
2. Clean up the code. Using a better language will help some, but in particular using a bonafide templating library
|
|
||||||
should improve the code.
|
|
||||||
3. Stability. Using `osascript` with the Things AppleScript API will avoid future problems that `things.py` is
|
|
||||||
constantly running into because they are overly coupled to the Things sqlite database schema.
|
|
||||||
|
|
||||||
# Ideas
|
|
||||||
- Move to rust using `osascript` to drive interactions with things
|
|
||||||
- Write a small library of scripts in JS that rely on the OSA infrastructure to call out to things, and package up
|
|
||||||
content into JSON for ease of use in rust.
|
|
||||||
|
|
||||||
# Resources
|
|
||||||
I found a really great gists for working with osascript and it even has Things examples: https://gist.github.com/tommorris/99bcbbcf445bb6475797
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
109
src/main.rs
109
src/main.rs
|
|
@ -1,65 +1,116 @@
|
||||||
mod things;
|
mod things;
|
||||||
mod reporter;
|
mod reporter;
|
||||||
mod emoji;
|
mod emoji;
|
||||||
|
mod names;
|
||||||
|
|
||||||
use reporter::{MarkdownReporter, Reporter};
|
use reporter::{MarkdownReporter, Reporter, Resolution, ReportOptions};
|
||||||
|
|
||||||
use things::task::Task;
|
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 Modes {
|
enum ListType {
|
||||||
/// Generate a report containing projected work for the day and a morning message
|
/// Generate a report from the Things today list
|
||||||
Morning,
|
Today,
|
||||||
/// Generate a report that is intended to be used for sharing what major tasks were completed
|
/// Generate a report from the Things logbook
|
||||||
/// in the last cycle.
|
Logbook,
|
||||||
Cycle,
|
|
||||||
/// Generate a report for what was actually done today and a signoff message
|
|
||||||
Signoff,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Modes {
|
impl ListType {
|
||||||
fn format_tasks(&self, task_report: &str) -> String {
|
fn format_tasks(&self, tasks: Vec<Task>, tags: &Vec<String>, sanitize_names: bool, resolution: Resolution) -> String {
|
||||||
match self {
|
match self {
|
||||||
Modes::Morning => format!("{}\n\n{}", emoji::pick(3).join(" "), task_report),
|
ListType::Today => {
|
||||||
Modes::Signoff => format!("Stopping now\n\n{}", task_report),
|
MarkdownReporter.report(tasks, &ReportOptions {
|
||||||
Modes::Cycle => format!("*Cycle Report*\n\n{}", task_report),
|
resolution,
|
||||||
|
tags: tags.to_vec(),
|
||||||
|
sanitize_names,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
ListType::Logbook => {
|
||||||
|
MarkdownReporter.report(tasks, &ReportOptions {
|
||||||
|
resolution,
|
||||||
|
tags: tags.to_vec(),
|
||||||
|
sanitize_names,
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Modes {
|
impl Default for ListType {
|
||||||
fn default() -> Modes {
|
fn default() -> ListType {
|
||||||
Modes::Morning
|
ListType::Today
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A program that generates Slack flavor markdown reports from Things 3 todo list items.
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(author, version, about, long_about = None)]
|
#[command(author, version, about, long_about = None)]
|
||||||
struct CliArgs {
|
struct CliArgs {
|
||||||
/// A list of tags to filter requests on
|
/// A list of tags to filter todos by. Only todo list items with every tag will be reported
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
|
|
||||||
/// Control what type of report to generate
|
/// A list of tags to specifically omit from the results. Only todo list items WITHOUT these
|
||||||
#[arg(short, long, default_value_t = Modes::default())]
|
/// tags will be included
|
||||||
|
#[arg(short, long)]
|
||||||
|
omit: Vec<String>,
|
||||||
|
|
||||||
|
/// Select the type of report to generate
|
||||||
|
#[arg(short, long, default_value_t = ListType::default())]
|
||||||
#[clap(value_enum)]
|
#[clap(value_enum)]
|
||||||
mode: Modes,
|
list: ListType,
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// 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<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.mode {
|
let now = chrono::Local::now();
|
||||||
Modes::Morning => Task::today(),
|
let from = args.from.unwrap_or_else(|| now.date().and_hms(0, 0, 0).to_rfc3339());
|
||||||
Modes::Signoff => Task::logbook_today(),
|
let to = args.to.unwrap_or_else(|| (now + chrono::Duration::days(1)).date().and_hms(0, 0, 0).to_rfc3339());
|
||||||
Modes::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.mode.format_tasks(&MarkdownReporter.report(reported));
|
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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
src/names.rs
Normal file
39
src/names.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
fn sanitize(src: &str) -> String {
|
||||||
|
// NOTE: The values on the right are _NOT_ the characters they appear to be.
|
||||||
|
src.replace("a", "а")
|
||||||
|
.replace("e", "e")
|
||||||
|
.replace("i", "і")
|
||||||
|
.replace("o", "о")
|
||||||
|
.replace("u", "ս")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace vowel characters with look alikes to avoid Slack mention logic
|
||||||
|
/// # Args
|
||||||
|
/// - `src`: The source text to modify
|
||||||
|
/// - `names`: The set of names to sanitize
|
||||||
|
fn sanitize_strings<'a>(src: &'a str, names: &Vec<String>) -> String {
|
||||||
|
let sanitization_strings = names.iter()
|
||||||
|
.map(|name| (name, sanitize(name)))
|
||||||
|
.collect::<Vec<(&String, String)>>();
|
||||||
|
let mut dest = src.to_string();
|
||||||
|
for (name, replacement) in sanitization_strings {
|
||||||
|
dest = dest.replace(name, &replacement);
|
||||||
|
}
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_names_from_tags(tags: &Vec<String>) -> Vec<String> {
|
||||||
|
tags
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.starts_with("@"))
|
||||||
|
.map(|t| {
|
||||||
|
let formatted_name = t.strip_prefix("@").expect(&format!("{t} should have started with @"));
|
||||||
|
String::from(formatted_name)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sanitize_names(src: &str, tags: &Vec<String>) -> String {
|
||||||
|
let name_tags = extract_names_from_tags(tags);
|
||||||
|
sanitize_strings(src, &name_tags)
|
||||||
|
}
|
||||||
159
src/reporter.rs
159
src/reporter.rs
|
|
@ -1,9 +1,45 @@
|
||||||
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
|
||||||
|
/// blocks containing those tags
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```
|
||||||
|
/// extract_tagged_notes(
|
||||||
|
/// "\`\`\`report
|
||||||
|
/// Something
|
||||||
|
/// \`\`\`",
|
||||||
|
/// vec![String::from("report")],
|
||||||
|
/// ); // -> "Something"
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
fn extract_tagged_notes(notes: &str, tags: &Vec<String>) -> Vec<String> {
|
||||||
|
notes
|
||||||
|
.split("```")
|
||||||
|
.into_iter()
|
||||||
|
.map(|section| -> (&str, Option<&String>) {
|
||||||
|
(section, tags.iter().find(|t| section.starts_with(*t)))
|
||||||
|
})
|
||||||
|
.filter(|(_section, tag)| { tag.is_some() })
|
||||||
|
.map(|(section, tag)| {
|
||||||
|
let start_tag = tag.unwrap();
|
||||||
|
section.strip_prefix(start_tag)
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.expect("Failed to strip start tag prefix").to_string()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ProjectTree {
|
pub struct ProjectTree {
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
notes: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
tasks: Vec<Task>,
|
tasks: Vec<Task>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,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![],
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +75,8 @@ impl AreaTree {
|
||||||
self.projects.push(ProjectTree {
|
self.projects.push(ProjectTree {
|
||||||
id: project.id.clone(),
|
id: project.id.clone(),
|
||||||
title: project.title.clone(),
|
title: project.title.clone(),
|
||||||
|
notes: project.notes.clone(),
|
||||||
|
tags: project.tags.clone(),
|
||||||
tasks: vec![task],
|
tasks: vec![task],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +107,8 @@ impl ThingsTree {
|
||||||
self.hanging_projects.push(ProjectTree {
|
self.hanging_projects.push(ProjectTree {
|
||||||
id: project.id.clone(),
|
id: project.id.clone(),
|
||||||
title: project.title.clone(),
|
title: project.title.clone(),
|
||||||
|
notes: project.notes.clone(),
|
||||||
|
tags: project.tags.clone(),
|
||||||
tasks: vec![task],
|
tasks: vec![task],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -88,22 +128,40 @@ 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<String>,
|
||||||
|
pub sanitize_names: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait Reporter {
|
pub trait Reporter {
|
||||||
fn report_task(&mut self, task: &Task, depth: usize) -> String;
|
fn report_task(&mut self, task: &Task, depth: usize, options: &ReportOptions) -> String;
|
||||||
fn report_project(&mut self, project: &ProjectTree, depth: usize) -> String;
|
fn report_project(&mut self, project: &ProjectTree, depth: usize, options: &ReportOptions) -> String;
|
||||||
fn report_single_area(&mut self, area: &AreaTree) -> String;
|
fn report_single_area(&mut self, area: &AreaTree, options: &ReportOptions) -> String;
|
||||||
fn report_multiple_areas(&mut self, areas: &Vec<AreaTree>) -> String;
|
fn report_multiple_areas(&mut self, areas: &Vec<AreaTree>, options: &ReportOptions) -> String;
|
||||||
fn report(&mut self, tasks: Vec<Task>) -> String {
|
fn report(&mut self, tasks: Vec<Task>, options: &ReportOptions) -> String {
|
||||||
let tree = ThingsTree::from_tasks(tasks);
|
let tree = ThingsTree::from_tasks(tasks);
|
||||||
let untracked_tasks = tree.hanging_tasks
|
let untracked_tasks = tree.hanging_tasks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| self.report_task(t, 0))
|
.map(|t| self.report_task(t, 0, options))
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
let area_tasks: String = match tree.areas.len() {
|
let area_tasks: String = match tree.areas.len() {
|
||||||
0 => "".to_string(),
|
0 => "".to_string(),
|
||||||
1 => self.report_single_area(&tree.areas[0]),
|
1 => self.report_single_area(&tree.areas[0], options),
|
||||||
_ => self.report_multiple_areas(&tree.areas),
|
_ => self.report_multiple_areas(&tree.areas, options),
|
||||||
};
|
};
|
||||||
|
|
||||||
let separator = if area_tasks == "" || untracked_tasks == "" {
|
let separator = if area_tasks == "" || untracked_tasks == "" {
|
||||||
|
|
@ -119,30 +177,83 @@ pub trait Reporter {
|
||||||
pub struct MarkdownReporter;
|
pub struct MarkdownReporter;
|
||||||
|
|
||||||
impl Reporter for MarkdownReporter {
|
impl Reporter for MarkdownReporter {
|
||||||
fn report_task(&mut self, task: &Task, depth: usize) -> String {
|
fn report_task(&mut self, task: &Task, depth: usize, options: &ReportOptions) -> String {
|
||||||
format!("{}- {}", String::from(" ").repeat(depth), task.title)
|
let relevant_notes = task.notes.clone()
|
||||||
|
.map(|notes| extract_tagged_notes(¬es, &options.tags))
|
||||||
|
.unwrap_or(vec![])
|
||||||
|
.iter()
|
||||||
|
.map(|l| format!("\n{}- {}", String::from(" ").repeat(depth + 4), l))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("");
|
||||||
|
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) -> String {
|
fn report_project(&mut self, project: &ProjectTree, depth: usize, options: &ReportOptions) -> String {
|
||||||
let tasks = project.tasks.iter().map(|t| self.report_task(t, depth + 4)).collect::<Vec<String>>().join("\n");
|
let resolution = &options.resolution;
|
||||||
format!("{}{}\n{}", String::from(" ").repeat(depth), project.title, tasks)
|
let relevant_notes = project.notes.clone()
|
||||||
|
.map(|notes| extract_tagged_notes(¬es, &options.tags))
|
||||||
|
.unwrap_or(vec![])
|
||||||
|
.iter()
|
||||||
|
.map(|l| format!("\n{}- {}", String::from(" ").repeat(depth + 4), l))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("");
|
||||||
|
let mut output = match resolution {
|
||||||
|
Resolution::FullTask => {
|
||||||
|
let tasks = project.tasks
|
||||||
|
.iter()
|
||||||
|
.map(|t| self.report_task(t, depth + 4, options))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("");
|
||||||
|
format!("{}{}{}{}", String::from(" ").repeat(depth), project.title, relevant_notes, tasks)
|
||||||
|
},
|
||||||
|
Resolution::Project => {
|
||||||
|
format!("{}- {}{}", String::from(" ").repeat(depth), project.title, relevant_notes)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if options.sanitize_names {
|
||||||
|
output = sanitize_names(&output, &project.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
}
|
}
|
||||||
fn report_single_area(&mut self, area: &AreaTree) -> String {
|
fn report_single_area(&mut self, area: &AreaTree, options: &ReportOptions) -> String {
|
||||||
let project_tasks = area.projects.iter().map(|p| self.report_project(p, 0)).collect::<Vec<String>>().join("\n");
|
let project_reports = area.projects
|
||||||
let untracked_tasks = area.hanging_tasks.iter().map(|t| self.report_task(t, 0)).collect::<Vec<String>>().join("\n");
|
.iter()
|
||||||
let separator = if project_tasks == "" || untracked_tasks == "" {
|
.map(|p| self.report_project(p, 0, options))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("\n");
|
||||||
|
let untracked_tasks = area.hanging_tasks
|
||||||
|
.iter()
|
||||||
|
.map(|t| self.report_task(t, 0, options))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("");
|
||||||
|
let separator = if project_reports == "" || untracked_tasks == "" {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
"\n\n"
|
"\n\n"
|
||||||
};
|
};
|
||||||
format!("{}{}{}", project_tasks, separator, untracked_tasks)
|
match options.resolution {
|
||||||
|
Resolution::FullTask => format!("{}{}{}", project_reports, separator, untracked_tasks),
|
||||||
|
Resolution::Project => format!("{}", project_reports)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn report_multiple_areas(&mut self, areas: &Vec<AreaTree>) -> String {
|
fn report_multiple_areas(&mut self, areas: &Vec<AreaTree>, options: &ReportOptions) -> String {
|
||||||
match areas.len() {
|
match areas.len() {
|
||||||
0 => "".to_string(),
|
0 => "".to_string(),
|
||||||
1 => self.report_single_area(&areas[0]),
|
1 => self.report_single_area(&areas[0], options),
|
||||||
_ => {
|
_ => {
|
||||||
areas.iter().map(|area| {
|
areas.iter().map(|area| {
|
||||||
let single = self.report_single_area(area);
|
let single = self.report_single_area(area, options);
|
||||||
format!("*{}*\n{}", area.title, single)
|
format!("*{}*\n{}", area.title, single)
|
||||||
})
|
})
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
|
|
|
||||||
|
|
@ -2,35 +2,36 @@ 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) {
|
tags.push(...proj.tagNames().split(', '));
|
||||||
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() },
|
|
||||||
area: area && { id: area.id(), title: area.name() },
|
|
||||||
tags: [...tags, ...todo.tagNames().split(', ')].filter(t => t),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(objs, undefined, 2);
|
return JSON.stringify(objs, undefined, 2);
|
||||||
|
|
|
||||||
|
|
@ -1,36 +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() },
|
|
||||||
area: area && { id: area.id(), title: area.name() },
|
|
||||||
tags: [...tags, ...todo.tagNames().split(', ')].filter(t => t),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(objs, undefined, 2);
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
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;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug, Eq, PartialEq)]
|
||||||
pub enum Status {
|
pub enum Status {
|
||||||
#[serde(rename = "completed")]
|
#[serde(rename = "completed")]
|
||||||
Completed,
|
Completed,
|
||||||
|
|
@ -13,12 +13,17 @@ pub enum Status {
|
||||||
Incomplete,
|
Incomplete,
|
||||||
#[serde(rename = "open")]
|
#[serde(rename = "open")]
|
||||||
Open,
|
Open,
|
||||||
|
#[serde(rename = "canceled")]
|
||||||
|
Canceled,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub status: Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
|
|
@ -39,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 {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,13 @@ today.forEach(todo => {
|
||||||
notes: todo.notes() || null,
|
notes: todo.notes() || null,
|
||||||
status: todo.status(),
|
status: todo.status(),
|
||||||
completion_date: todo.completionDate(),
|
completion_date: todo.completionDate(),
|
||||||
project: proj && { id: proj.id(), title: proj.name() },
|
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() },
|
area: area && { id: area.id(), title: area.name() },
|
||||||
tags: [...tags, ...todo.tagNames().split(', ')].filter(t => t),
|
tags: [...tags, ...todo.tagNames().split(', ')].filter(t => t),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Reference in a new issue