use chrono::{DateTime, Datelike, Utc}; use chrono_tz::US::Central; use clap::Parser; use lettre::{ message::{header::ContentType, Attachment, Message, MultiPart, SinglePart}, SendmailTransport, Transport, }; use log::{debug, error}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use std::{path::PathBuf, process::Command}; fn main() -> Result<(), Box> { // --- Loading and setup --- let args = CliArgs::parse(); simplelog::SimpleLogger::init( match args.debug { true => simplelog::LevelFilter::Debug, false => simplelog::LevelFilter::Info, }, simplelog::Config::default(), )?; debug!("Opening Config file: {}", args.config_file.display()); debug!( "Config file exists: {}", std::fs::metadata(&args.config_file).is_ok() ); let file_contents = match std::fs::read_to_string(&args.config_file) { Ok(val) => val, Err(e) => { error!("Could not read config file: {e}"); panic!("{e}"); } }; let cfg: Config = match toml::from_str(&file_contents) { Ok(val) => val, Err(e) => { error!("Could not parse config file: {e}"); panic!("{e}"); } }; // --- END Loading and setup --- let mut do_email_summary: bool = false; let mut do_headscale_reset: bool = false; // --- Get checked and unchecked --- let primary_note = Client::new() .get(format!( "https://{}/index.php/apps/notes/api/v1/notes/{}", &cfg.server_url, &cfg.primary_note_id )) .header("Accept", "application/json") .header("Content-Type", "application/json") .basic_auth(&cfg.user, Some(&cfg.pswd)) .send()? .json::()?; let primary_note_lines: Vec<&str> = primary_note.content.lines().collect(); let mut unchecked: Vec = vec![]; let mut checked: Vec = vec![]; for line in primary_note_lines.iter() { if line.starts_with("- [x] Send Summary") { debug!("send summary flag caught"); do_email_summary = true; continue; } if line.starts_with("- [x] Restart Headscale :(") { debug!("restart headscale flag caught"); do_headscale_reset = true; continue; } if line.starts_with("- [ ] Send Summary") { continue; } // if line is a checkbox if line.starts_with("- [") { if line.starts_with("- [ ] ") { unchecked.push(line.replace("- [ ] ", "")); } else if line.starts_with("- [x] ") { checked.push(line.replace("- [x] ", "")); } } } // --- END Get checked and unchecked --- // --- Get current log --- let logging_note = Client::new() .get(format!( "https://{}/index.php/apps/notes/api/v1/notes/{}", &cfg.server_url, &cfg.logging_note_id )) .header("Accept", "application/json") .header("Content-Type", "application/json") .basic_auth(&cfg.user, Some(&cfg.pswd)) .send()? .json::()?; let now = Utc::now().with_timezone(&Central); let mut body_content: Vec = vec![format!( "*Last Updated:* {} Central Time", now.format("%D - %H:%M:%S") )]; let logging_note_lines: Vec<&str> = logging_note.content.lines().collect(); for line in logging_note_lines { if line.starts_with("*Last Updated") { continue; } if line.starts_with("# ") { body_content.push(line.to_string()); continue; } if line.starts_with("### ") { body_content.push(line.to_string()); continue; } // --- Assume it is a started log --- let split: Vec<&str> = line.split('|').collect(); let item = match split.first() { Some(val) => val, None => { error!("Couldn't split correctly, item 1"); panic!("Couldn't split correctly, item 1"); } } .replace('#', "") .trim() .to_string(); if unchecked.contains(&item) { let timestamp = match split.get(1) { Some(val) => val, None => { error!("Couldn't split correctly, item 2"); panic!("Couldn't split correctly, item 2"); } }; let start_time = match DateTime::parse_from_rfc2822(timestamp) { Ok(val) => val.with_timezone(&Central), Err(e) => { error!("Error parsing time: '{timestamp}' : {e}"); panic!("Error parsing time: '{timestamp}' : {e}"); } }; let elapsed_time: chrono::Duration = now - start_time; body_content.push(format!( "### {item} | {}h {}m | {}/{}/{}", elapsed_time.num_hours(), elapsed_time.num_minutes() - elapsed_time.num_hours() * 60, // yes, I'm lazy now.month(), now.day(), now.year() )); } else if !line.is_empty() { body_content.push(line.to_string()); } checked.retain(|val| val != &item); } checked = checked .into_iter() .map(|val| { format!( "## {val} | {}", Utc::now().with_timezone(&Central).to_rfc2822() ) }) .collect(); debug!("Creating new logs for: {:#?}", checked); body_content.splice(2..2, checked); debug!("{:#?}", body_content); Client::new() .put(format!( "https://{}/index.php/apps/notes/api/v1/notes/{}", &cfg.server_url, &cfg.logging_note_id )) .header("Accept", "application/json") .header("Content-Type", "application/json") .basic_auth(&cfg.user, Some(&cfg.pswd)) .body(serde_json::to_string(&NoteUpdate { content: body_content.join("\n"), })?) .send()?; if do_email_summary { debug!("Send email selected"); reset_checkboxes(&cfg); send_email_summary(&cfg, body_content, &args); } if do_headscale_reset { debug!("Restarting Headscale"); reset_checkboxes(&cfg); reset_headscale(); } Ok(()) } #[derive(Serialize, Deserialize, Debug)] struct Note { id: usize, etag: String, readonly: bool, modified: u64, title: String, category: String, content: String, favorite: bool, } #[derive(Serialize, Deserialize, Debug)] struct NoteUpdate { content: String, } #[derive(Serialize, Deserialize, Default)] struct Config { user: String, pswd: String, email_addr: String, primary_note_id: String, logging_note_id: String, server_url: String, } #[derive(Parser, Debug)] #[command(author, version, about, long_about=None)] struct CliArgs { /// Path to config .toml file #[arg(short, long)] config_file: PathBuf, #[arg(short, long)] from_addr: String, #[arg(short, long)] debug: bool, } fn reset_headscale() { let _ = Command::new("systemctl").arg("restart").arg("headscale").output(); } // All unwraps are unrecoverable errors // All calls after depend on the success of the one before, and cannot run // without. So it would be too much work to make an error type to handle an error // that would cause a crash anyways fn reset_checkboxes(config: &Config) { let primary_note = Client::new() .get(format!( "https://{}/index.php/apps/notes/api/v1/notes/{}", &config.server_url, &config.primary_note_id )) .header("Accept", "application/json") .header("Content-Type", "application/json") .basic_auth(&config.user, Some(&config.pswd)) .send() .unwrap() .json::() .unwrap(); let primary_note_lines: Vec<&str> = primary_note.content.lines().collect(); let mut body_content: Vec<&str> = vec![]; for line in primary_note_lines.iter() { if line.contains("Send Summary") { debug!("summary line: '{}'", line); } if line.starts_with("- [x] Send Summary") { body_content.push("- [ ] Send Summary"); } else if line.starts_with("- [x] Restart Headscale :(") { body_content.push("- [ ] Restart Headscale :(") } else { body_content.push(line); } } Client::new() .put(format!( "https://{}/index.php/apps/notes/api/v1/notes/{}", &config.server_url, &config.primary_note_id )) .header("Accept", "application/json") .header("Content-Type", "application/json") .basic_auth(&config.user, Some(&config.pswd)) .body( serde_json::to_string(&NoteUpdate { content: body_content.join("\n"), }) .unwrap(), ) .send() .unwrap(); } #[derive(Serialize, Deserialize, Debug)] struct SummaryRow { date: String, total_time: i64, task_name: String, } // All unwraps are unrecoverable errors // All calls after depend on the success of the one before, and cannot run // without. So it would be too much work to make an error type to handle an error // that would cause a crash anyways fn send_email_summary(config: &Config, body_content: Vec, cliargs: &CliArgs) { let mut wtr = csv::Writer::from_writer(vec![]); for line in body_content { if line.starts_with("### ") { let mut split: Vec<&str> = line.split('|').collect(); if split.len() != 3 { error!("There was an issue with this line. 3 splits not found: {line}"); continue; } let date: String = match split.pop() { Some(val) => val, None => continue, } .to_string(); let time: i64 = { // This should never error as the len should always be 3 let data = match split.pop() { Some(val) => val, None => continue, } .trim(); // There should always be an h and an m in the second item let h_index = match data.chars().position(|c| c == 'h') { Some(val) => val, None => continue, }; let m_index = match data.chars().position(|c| c == 'm') { Some(val) => val, None => continue, }; // this should always be the "10" in "10h" let hours = match data[0..h_index].parse::() { Ok(val) => val, Err(_) => continue, }; // this should always be the "20" in "10h 20m" let minutes = match data[(h_index + 2)..m_index].parse::() { Ok(val) => val, Err(_) => continue, }; hours * 60 + minutes }; let task_name: String = match split.pop() { Some(val) => val.trim(), None => continue, } .to_string() .replace("### ", ""); match wtr.serialize(SummaryRow { date: date.trim().to_string(), total_time: time, task_name, }) { Ok(_) => continue, Err(e) => { error!("There was an error serializing the csv, aborting: {e}"); return; } }; } } let data = match String::from_utf8(wtr.into_inner().unwrap()) { Ok(val) => val, Err(e) => { error!("There was an error converting the csv writer to a string, aborting: {e}"); return; } }; debug!("{:#?}", data); let attachment = Attachment::new("TimeSummary.csv".to_string()) // The unwrap is on a constant value .body(data, ContentType::parse("text/csv").unwrap()); const HTML: &str = r#" ChronoTrack Export

Prepared with care, and sent through the horrors of the interwebs, ChronoTrack presents! (see attached)

"#; let email = Message::builder() .from(match cliargs.from_addr.parse() { Ok(val) => val, Err(e) => { error!("The provided from_email address was unparsable:\n{e}"); std::process::exit(1); } }) .to(match config.email_addr.parse() { Ok(val) => val, Err(e) => { error!( "The provided destination email in the configuration file was unparsable:\n{e}" ); std::process::exit(1); } }) .subject("ChronoTrack Export") .multipart( MultiPart::mixed() .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(String::from("This is an automated email from ChronoTrack")), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(String::from(HTML)), ), ) .singlepart(attachment), ) .unwrap(); let mailer = SendmailTransport::new(); match mailer.send(&email) { Ok(val) => debug!("email sent: {:?}", val), Err(e) => error!("Couldn't send email {}", e), }; }