caltemps 0.2.0

A tool to query and report on your iCalendar data from vDirs.
use chrono::{DateTime, Datelike, Local, TimeZone};
use clap::Parser;
use config::{Config, ConfigError, Environment, File};
use icalendar::{Calendar, Component, DatePerhapsTime};
use std::ffi::OsString;
use std::fs::{self};
use std::io;
use std::path::Path;

#[derive(Debug)]
struct IcsReadError {
    path: OsString,
    //error: Box<dyn std::error::Error>,
}

#[derive(Debug, serde::Deserialize, Clone)]
#[allow(unused)]
pub struct Settings {
    pub vdir_path: String,
    pub default_filter: String,
    pub default_date_range: String,
}

impl Settings {
    pub fn new(config_file: String) -> Result<Self, ConfigError> {
        let mut builder = Config::builder()
            .set_default("vdir_path", "calendars")?
            .set_default("default_filter", "")?
            .set_default("default_date_range", "2025-01-01T00:00:00+00:00")?;
        if Path::new(&config_file).is_file() {
            builder = builder.add_source(File::with_name(&config_file))
        };
        let cfg = builder
            // Support CALTEMPS_VARIABLE env vars
            .add_source(Environment::with_prefix("caltemps"))
            .build()?;
        cfg.try_deserialize()
    }
}

#[derive(Debug)]
pub struct CalTempsDateRange {
    pub start: Option<DateTime<Local>>,
    pub end: Option<DateTime<Local>>,
}

impl CalTempsDateRange {
    pub fn new(range_str: String) -> Result<Self, Box<dyn std::error::Error>> {
        // Make this a range
        let range_parts: Vec<&str> = range_str.splitn(2, "..").collect();
        let start_str = range_parts[0];
        let end_str = if range_parts.len() > 1 {
            range_parts[1]
        } else {
            ""
        };
        Ok(CalTempsDateRange {
            start: if start_str.is_empty() {
                // Start was left open, do not limit
                None
            } else {
                Some(Self::read_in_date(start_str, false)?)
            },
            end: if range_parts.len() < 2 {
                // '..' was not used, use 'now' as a limit
                Some(Local::now())
            } else if end_str.is_empty() {
                // end was left open, do not limit
                None
            } else {
                Some(Self::read_in_date(end_str, true)?)
            },
        })
    }
    fn read_in_date(
        dstr: &str,
        _is_end: bool,
    ) -> Result<DateTime<Local>, Box<dyn std::error::Error>> {
        match DateTime::parse_from_rfc3339(dstr) {
            Ok(dt) => Ok(dt.into()),
            Err(error) => Err(Box::new(error)),
        }
    }
}

/// Query and report on your iCalendar data from vDirs.
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Cli {
    /// Path to CalTemps config file
    ///
    /// Currently the file would look like:
    ///
    ///     # This is where your calendar lives in VDir format
    ///     # See also:
    ///     # https://vdirsyncer.readthedocs.io/en/stable/vdir.html
    ///     vdir_path: '/home/user/.calendars/work'
    ///     # See also the --filter argument
    ///     default_filter: '@work'
    ///     # See also the --date-range argument
    ///     default_date_range: '2025-01-01T00:00:00+00:00'
    #[arg(short, long, verbatim_doc_comment, env="CALTEMPS_CONFIG",
        default_value_t=xdg::BaseDirectories::with_prefix("caltemps").unwrap()
            .get_config_file("config.toml").into_os_string().into_string().expect("Strange path"))]
    config: String,
    /// Filter to apply, something like '@work'
    ///
    /// Remember to quote '@' and '#' symbols on your shell if those have a
    /// special meaning.
    #[arg(short, long, env = "CALTEMPS_FILTER")]
    filter: Option<String>,
    /// The date range to filter items by.
    ///
    /// It includes both extremes.
    ///
    /// You can use '..' to separate the beginning and the end.
    /// If '..' is not found, the date is taken as the beginning of the range.
    #[arg(short, long, env = "CALTEMPS_DATE_RANGE")]
    date_range: Option<String>,
}

fn list_ics_from_dir(
    path: &Path,
) -> Result<impl Iterator<Item = OsString>, Box<dyn std::error::Error>> {
    match fs::read_dir(path) {
        Ok(dirs) => Ok(dirs.filter_map(|res| match res {
            Ok(t) => {
                if let Some(x) = t.path().extension() {
                    if x.eq_ignore_ascii_case("ics") {
                        Some(t.path().into_os_string())
                    } else {
                        None
                    }
                } else {
                    None
                }
            }
            Err(_) => None,
        })),
        Err(error) => Err(Box::new(error)),
    }
}

fn read_ics_from_dir(
    path: &Path,
) -> Result<impl Iterator<Item = Result<Calendar, IcsReadError>>, Box<dyn std::error::Error>> {
    match list_ics_from_dir(path) {
        Ok(ics_it) => Ok(ics_it.map(|ipath| {
            // Read file to output
            let ics_path = ipath.to_os_string();
            match &mut fs::File::open(ipath) {
                Ok(f) => {
                    let readable: &mut dyn io::Read = f;
                    let mut output = String::new();
                    match readable.read_to_string(&mut output) {
                        Ok(_) => {
                            //icalendar::parser::read_calendar(&output)
                            match output.parse::<Calendar>() {
                                Ok(read) => Ok(read),
                                Err(_error) => Err(IcsReadError {
                                    path: ics_path,
                                    //error: Box::new(error.into()),
                                }),
                            }
                        }
                        Err(_error) => Err(IcsReadError {
                            path: ics_path,
                            //error: Box::new(error.to_string()),
                        }),
                    }
                }
                Err(_error) => Err(IcsReadError {
                    path: ics_path,
                    //error: Box::new(error.to_string()),
                }),
            }
        })),
        Err(error) => Err(error),
    }
}

fn read_vdir_cal(path: &Path) -> Result<Calendar, Box<dyn std::error::Error>> {
    let mut cal = Calendar::new();
    // TODO: we can read some metadata like displayname and color
    /*if path.join("displayname").is_file() {
        cal.
    }*/
    match read_ics_from_dir(Path::new(path)) {
        Ok(entries_it) => {
            for entry in entries_it {
                match entry {
                    Ok(mut partial_cal) => cal.append(&mut partial_cal),
                    Err(error) => eprintln!("Issue reading {:#?}\n\t{:#?}", error.path, error),
                };
            }
            //let entries = entries_it.collect::<Vec<_>>();
            //println!("{:#?}", entries);
            Ok(cal)
        }
        Err(error) => Err(error),
    }
}

fn dpt_to_dt(dpt: Option<DatePerhapsTime>) -> Option<DateTime<Local>> {
    match dpt {
        Some(icalendar::DatePerhapsTime::DateTime(cdt)) => cdt.try_into_utc().map(|d| d.into()),
        Some(icalendar::DatePerhapsTime::Date(nd)) => Some(
            Local
                .with_ymd_and_hms(nd.year(), nd.month(), nd.day(), 0, 0, 0)
                .unwrap(),
        ),
        _ => None,
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // BEGIN: args
    let cli = Cli::parse();
    let cfg = Settings::new(cli.config).unwrap();
    let date_range = CalTempsDateRange::new(cli.date_range.unwrap_or(cfg.default_date_range))?;
    let active_filter = cli.filter.unwrap_or(cfg.default_filter);
    // END: args

    match read_vdir_cal(Path::new(&cfg.vdir_path)) {
        Ok(cal) => {
            let mut x = 0;
            for c in cal
                .components
                .into_iter()
                .filter_map(|c| match c {
                    icalendar::CalendarComponent::Event(e) => Some(e),
                    _ => None,
                })
                .filter(|c| {
                    if let Some(summary) = c.get_summary() {
                        if summary.contains(&active_filter) {
                            return match dpt_to_dt(c.get_start()) {
                                Some(dts) => {
                                    date_range.start.unwrap_or(dts) <= dts
                                        && match dpt_to_dt(c.get_end()) {
                                            Some(dte) => dte <= date_range.end.unwrap_or(dte),
                                            // Ignore those without end data(!)
                                            _ => false,
                                        }
                                }
                                _ => false,
                            };
                        }
                    }
                    false
                })
            {
                if let Some(ds) = dpt_to_dt(c.get_start()) {
                    if let Some(de) = dpt_to_dt(c.get_end()) {
                        x += (de - ds).num_minutes();
                    }
                }
            }
            println!("Accounted {:.2}h", x as f64 / 60.0);
            Ok(())
        }
        Err(error) => {
            eprintln!("Error working on:\t'{}'", cfg.vdir_path);
            eprintln!("{}", error);
            Err(error)
        }
    }
}