1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
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)
}
}
}