diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c3cbcf5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,388 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "time", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + +[[package]] +name = "day-reporter" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "osascript", + "serde", + "serde_json", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + +[[package]] +name = "js-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "log" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "osascript" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38731fa859ef679f1aec66ca9562165926b442f298467f76f5990f431efe87dc" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe8a65d69dd0808184ebb5f836ab526bb259db23c657efa38711b1072ee47f0" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "serde" +version = "1.0.173" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91f70896d6720bc714a4a57d22fc91f1db634680e65c8efe13323f1fa38d53f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.173" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6250dde8342e0232232be9ca3db7aa40aceb5a3e5dd9bddbc00d99a007cde49" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d03b412469450d4404fe8499a268edd7f8b79fecb074b0d812ad64ca21f4031b" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c3457aacde3c65315de5031ec191ce46604304d2446e803d71ade03308d970" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi", + "winapi", +] + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasm-bindgen" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" diff --git a/Cargo.toml b/Cargo.toml index cc82340..ea4bd10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.72" +chrono = { version = "0.4.26", features = ["serde"] } +osascript = "0.3.0" +serde = { version = "1.0.173", features = ["derive"] } +serde_json = "1.0.103" diff --git a/src/main.rs b/src/main.rs index e7a11a9..827f9a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,15 @@ -fn main() { - println!("Hello, world!"); +mod things; +mod reporter; + +use reporter::{MarkdownReporter, Reporter}; + +use things::task::Task; +use anyhow::Result; + +fn main() -> Result<()> { + let today = Task::today()?; + let reported: Vec = today.into_iter().filter(|task| task.has_tag("Report")).collect(); + println!("{}", MarkdownReporter.report(reported)); + + Ok(()) } diff --git a/src/reporter.rs b/src/reporter.rs new file mode 100644 index 0000000..bf50433 --- /dev/null +++ b/src/reporter.rs @@ -0,0 +1,183 @@ +use crate::things::task::Task; + +#[derive(Debug)] +pub struct ProjectTree { + id: String, + title: String, + tasks: Vec, +} + +#[derive(Debug)] +pub struct AreaTree { + id: String, + title: String, + projects: Vec, + hanging_tasks: Vec, +} + +#[derive(Debug)] +pub struct ThingsTree { + areas: Vec, + hanging_tasks: Vec, +} + +impl ProjectTree { + /// Add the task to this project if it belongs here, otherwise pass it back out. + pub fn try_take_task(&mut self, mut task: Task) -> Option { + if let Some(proj) = task.project { + if proj.id == self.id { + task.project = Some(proj); + self.tasks.push(task); + return None; + } + task.project = Some(proj); + return Some(task); + } + return Some(task); + } +} + +impl AreaTree { + pub fn add_new_project_and_task(&mut self, mut task: Task) { + if let Some(proj) = task.project { + let id = proj.id.clone(); + let title = proj.title.clone(); + task.project = Some(proj); + self.projects.push(ProjectTree { + id, + title, + tasks: vec![task], + }); + } else { + self.hanging_tasks.push(task); + } + } + pub fn try_take_task(&mut self, mut task: Task) -> Option { + if let Some(area) = task.area { + if area.id == self.id { + task.area = Some(area); + let mut maybe_task = Some(task); + for proj in self.projects.iter_mut() { + if let Some(t) = maybe_task { + maybe_task = proj.try_take_task(t); + } + } + // Here, the task belongs in this area but there was no project for it. + maybe_task.map(|t| self.add_new_project_and_task(t)); + return None; + } + task.area = Some(area); + return Some(task); + } + return Some(task); + } +} + +impl ThingsTree { + pub fn new() -> ThingsTree { + ThingsTree { areas: vec![], hanging_tasks: vec![] } + } + + pub fn add_new_area_and_task(&mut self, mut task: Task) { + if let Some(area) = task.area { + let id = area.id.clone(); + let title = area.title.clone(); + task.area = Some(area); + let mut area_tree = AreaTree { + id, + title, + projects: vec![], + hanging_tasks: vec![], + }; + let took = area_tree.try_take_task(task); + if took.is_some() { + panic!("Area should have matched the task because it was created with the task"); + } + self.areas.push(area_tree); + } else { + self.hanging_tasks.push(task); + } + } + + pub fn try_take_task(&mut self, task: Task) { + let mut maybe_task = Some(task); + for area in self.areas.iter_mut() { + if let Some(t) = maybe_task { + maybe_task = area.try_take_task(t); + } + } + maybe_task.map(|t| self.add_new_area_and_task(t)); + } + + pub fn from_tasks(tasks: Vec) -> ThingsTree { + let mut tree = ThingsTree::new(); + for task in tasks.into_iter() { + tree.try_take_task(task); + } + return tree; + } +} + +pub trait Reporter { + fn report_task(&mut self, task: &Task, depth: usize) -> String; + fn report_project(&mut self, project: &ProjectTree, depth: usize) -> String; + fn report_single_area(&mut self, area: &AreaTree) -> String; + fn report_multiple_areas(&mut self, areas: &Vec) -> String; + fn report(&mut self, tasks: Vec) -> String { + let tree = ThingsTree::from_tasks(tasks); + let untracked_tasks = tree.hanging_tasks + .iter() + .map(|t| self.report_task(t, 0)) + .collect::>() + .join("\n"); + let area_tasks: String = match tree.areas.len() { + 0 => "".to_string(), + 1 => self.report_single_area(&tree.areas[0]), + _ => self.report_multiple_areas(&tree.areas), + }; + + let separator = if area_tasks == "" || untracked_tasks == "" { + "" + } else { + "\n\n" + }; + + format!("{}{}{}", area_tasks, separator, untracked_tasks) + } +} + +pub struct MarkdownReporter; + +impl Reporter for MarkdownReporter { + fn report_task(&mut self, task: &Task, depth: usize) -> String { + format!("{}- {}", String::from(" ").repeat(depth), task.title) + } + fn report_project(&mut self, project: &ProjectTree, depth: usize) -> String { + let tasks = project.tasks.iter().map(|t| self.report_task(t, depth + 4)).collect::>().join("\n"); + format!("{}{}\n{}", String::from(" ").repeat(depth), project.title, tasks) + } + fn report_single_area(&mut self, area: &AreaTree) -> String { + let project_tasks = area.projects.iter().map(|p| self.report_project(p, 0)).collect::>().join("\n"); + let untracked_tasks = area.hanging_tasks.iter().map(|t| self.report_task(t, 0)).collect::>().join("\n"); + let separator = if project_tasks == "" || untracked_tasks == "" { + "" + } else { + "\n\n" + }; + format!("{}{}{}", project_tasks, separator, untracked_tasks) + } + fn report_multiple_areas(&mut self, areas: &Vec) -> String { + match areas.len() { + 0 => "".to_string(), + 1 => self.report_single_area(&areas[0]), + _ => { + areas.iter().map(|area| { + let single = self.report_single_area(area); + format!("*{}*\n{}", area.title, single) + }) + .collect::>() + .join("\n\n") + }, + } + } +} diff --git a/src/things/logbook.js b/src/things/logbook.js new file mode 100644 index 0000000..6f014eb --- /dev/null +++ b/src/things/logbook.js @@ -0,0 +1,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); + +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); diff --git a/src/things/mod.rs b/src/things/mod.rs new file mode 100644 index 0000000..cdafe4a --- /dev/null +++ b/src/things/mod.rs @@ -0,0 +1 @@ +pub mod task; diff --git a/src/things/task.rs b/src/things/task.rs new file mode 100644 index 0000000..7aff94b --- /dev/null +++ b/src/things/task.rs @@ -0,0 +1,64 @@ +use std::str::from_utf8; +use serde::Deserialize; +use anyhow::Result; +use osascript; +use serde_json; +use chrono::{DateTime, Utc}; + +#[derive(Deserialize, Debug)] +pub enum Status { + #[serde(rename = "completed")] + Completed, + #[serde(rename = "incomplete")] + Incomplete, + #[serde(rename = "open")] + Open, +} + +#[derive(Deserialize, Debug)] +pub struct Project { + pub id: String, + pub title: String, +} + +#[derive(Deserialize, Debug)] +pub struct Area { + pub id: String, + pub title: String, +} + +#[derive(Deserialize, Debug)] +pub struct Task { + pub id: String, + pub title: String, + pub notes: Option, + pub status: Status, + pub tags: Vec, + pub completion_date: Option>, + pub project: Option, + pub area: Option, +} + +impl Task { + /// A Helper for loading Tasks from json returned by an osascript + fn from_script(script_bytes: &[u8]) -> Result> { + let script = osascript::JavaScript::new(from_utf8(script_bytes)?); + let raw_json: String = script.execute()?; + 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")) + } + + /// Get all tasks in the logbook list from Things + pub fn logbook() -> Result> { + Task::from_script(include_bytes!("logbook.js")) + } + + pub fn has_tag(&self, tag: &str) -> bool { + self.tags.contains(&String::from(tag)) + } +} diff --git a/src/things/today.js b/src/things/today.js new file mode 100644 index 0000000..129939c --- /dev/null +++ b/src/things/today.js @@ -0,0 +1,24 @@ +var things = Application("Things"); +var today = things.lists.byName("Today").toDos(); +var objs = []; + +today.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);