use std::{fmt::Display, path::PathBuf};
use anyhow::Result;
use chrono::{DateTime, Duration, Local};
use chrono_english::parse_date_string;
use clap::{CommandFactory, Parser, ValueEnum};
use futures::Stream;
use now::DateTimeNow;
use crate::{
daemon::storage::{entities::UsageIntervalEntity, record_storage::RecordStorageImpl},
utils::{
percentage::{duration_percentage, Percentage, WholePercentage},
time::next_day_start,
},
};
use super::{
Args, create_application_default_path,
output::{
self,
analysis::{analyze_processes, analyze_windows},
extract_between,
sliding_grouping::{SlidingInterval, TimeOption, sliding_interval_grouping},
},
};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum DateStyle {
Uk,
Us,
}
impl From<DateStyle> for chrono_english::Dialect {
fn from(value: DateStyle) -> Self {
match value {
DateStyle::Uk => Self::Uk,
DateStyle::Us => Self::Us,
}
}
}
impl Display for DateStyle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DateStyle::Uk => write!(f, "uk"),
DateStyle::Us => write!(f, "us"),
}
}
}
#[derive(Debug, Parser)]
pub struct TimelineCommand {
#[arg(
long = "start",
short,
help = "Start of the range. Examples are \"yesterday\", \"1 hour ago\", \"15/03/2025\", \"12:00 16/03/2025\", \"12 AM 16/03/2025\""
)]
start_date: Option<String>,
#[arg(
long = "end",
short,
help = "End of the range. Examples are \"yesterday\", \"1 hour ago\", \"15/03/2025\", \"12:00 16/03/2025\", \"12 AM 16/03/2025\""
)]
end_date: Option<String>,
#[arg(long, default_value_t = DateStyle::Uk, help = "Style of dates used during parsing. For Uk it's day/month/year. For Us it's month/day/year")]
date_style: DateStyle,
#[arg(
long = "days",
default_value_t = false,
help = "Take inputs as whole days. For example if start and end are both 15/03/2025 this option allows to extract the whole day"
)]
treat_as_days: bool,
#[command(flatten)]
interval: PrintInterval,
#[arg(short = 'p', long = "percentage", help = "Filter apps to have at least specified percentage", default_value_t = Percentage::new_opt(1.).unwrap()) ]
min_percentage: Percentage,
#[arg(short, long = "processes", help = "Ignore window names")]
use_processes: bool,
#[arg(
short,
long,
help = "Include time afk. Person is considered afk after 2 minutes of idle time."
)]
afk: bool,
}
#[derive(Parser, Debug)]
struct DaemonParams {
#[arg(long)]
dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, clap::Args)]
#[command(flatten_help = true)]
pub struct PrintInterval {
#[arg(
short,
help = "Duration of interval. Combines with option to create interval -d 15 -o minutes"
)]
duration: u32,
#[arg(
short,
help = "Time option of interval. Combines with option to create interval -d 15 -o minutes"
)]
option: TimeOption,
}
const DEFAULT_PRINTED_INTERVALS: i32 = 10;
pub async fn process_timeline_command(
TimelineCommand {
start_date,
end_date,
date_style,
interval,
treat_as_days,
min_percentage,
use_processes,
afk,
}: TimelineCommand,
) -> Result<()> {
let ParamParseResult {
interval,
start,
end,
show_time,
} = match parse_values(start_date, end_date, date_style, interval, treat_as_days) {
Ok(value) => value,
Err(value) => return Err(value),
};
let application = RecordStorageImpl::new(create_application_default_path()?.join("records"))?;
let results = extract_between(
application,
output::ExtractConfig {
start: start.into(),
end: end.into(),
},
);
if use_processes {
print_processes_grouping(interval, min_percentage, afk, show_time, results).await?;
} else {
print_window_grouping(interval, min_percentage, afk, show_time, results).await?;
}
Ok(())
}
struct ParamParseResult {
interval: SlidingInterval,
start: DateTime<Local>,
end: DateTime<Local>,
show_time: bool,
}
fn parse_values(
start_date: Option<String>,
end_date: Option<String>,
date_style: DateStyle,
interval: PrintInterval,
treat_as_days: bool,
) -> Result<ParamParseResult> {
let treat_as_days = treat_as_days || interval.option <= TimeOption::Days;
let Some(interval) = SlidingInterval::new_opt(interval.duration, interval.option) else {
return Err(Args::command()
.error(
clap::error::ErrorKind::ValueValidation,
format!(
"Can't create an interval using {} and {}",
interval.duration, interval.option
),
)
.into());
};
let now = Local::now();
let dialect: chrono_english::Dialect = date_style.into();
let mut start = match start_date.map(|s| parse_date_string(&s, now, dialect)) {
Some(Ok(v)) => v.with_timezone(&Local),
Some(Err(e)) => {
return Err(Args::command()
.error(
clap::error::ErrorKind::ValueValidation,
format!("Failed to valiate start date {e}"),
)
.into());
}
None => Local::now() - interval.as_duration() * DEFAULT_PRINTED_INTERVALS,
};
let mut end = match end_date.map(|s| parse_date_string(&s, now, dialect)) {
Some(Ok(v)) => v.with_timezone(&Local),
Some(Err(e)) => {
return Err(Args::command()
.error(
clap::error::ErrorKind::ValueValidation,
format!("Failed to valiate end date {e}"),
)
.into());
}
None => Local::now(),
};
if treat_as_days {
start = start.beginning_of_day();
end = next_day_start(end);
}
let show_time = *interval.time() > TimeOption::Days;
Ok(ParamParseResult {
interval,
start,
end,
show_time,
})
}
async fn print_processes_grouping(
interval: SlidingInterval,
min_percentage: Percentage,
afk: bool,
show_time: bool,
results: impl Stream<Item = std::result::Result<UsageIntervalEntity, anyhow::Error>>,
) -> Result<()> {
let intervals = sliding_interval_grouping::<_, Local>(results, interval, |v| {
analyze_processes(v, min_percentage, afk)
})
.await?;
for (time, value) in intervals {
let Some((analyzed, computer_on_duration)) = value else {
continue;
};
let time = time.with_timezone(&Local);
let time_format = if show_time { "%x %H:%M:%S" } else { "%x" };
let process_style = ansi_term::Style::new().italic();
if !analyzed.is_empty() {
for entry in analyzed {
let percentage = WholePercentage::from(duration_percentage(entry.duration, computer_on_duration));
println!(
"{} {:4} {:10} {}",
time.format(time_format),
percentage.to_string(),
format_duration(entry.duration),
process_style.paint(clean_process_name(&entry.process_name)),
);
}
println!();
}
}
Ok(())
}
async fn print_window_grouping(
interval: SlidingInterval,
min_percentage: Percentage,
afk: bool,
show_time: bool,
results: impl Stream<Item = std::result::Result<UsageIntervalEntity, anyhow::Error>>,
) -> Result<()> {
let intervals = sliding_interval_grouping::<_, Local>(results, interval, |v| {
analyze_windows(v, min_percentage, afk)
})
.await?;
for (time, value) in intervals {
let Some((analyzed, computer_on_duration)) = value else {
continue;
};
let time = time.with_timezone(&Local);
let time_format = if show_time { "%x %H:%M:%S" } else { "%x" };
let process_style = ansi_term::Style::new().italic();
let window_style = ansi_term::Style::new().underline();
if !analyzed.is_empty() {
for entry in analyzed {
let percentage = WholePercentage::from(duration_percentage(entry.duration, computer_on_duration));
println!(
"{} {:4} {:10} {} {}",
time.format(time_format),
percentage.to_string(),
format_duration(entry.duration),
process_style.paint(clean_process_name(&entry.process_name)),
window_style.paint(&*entry.window_name)
);
}
println!();
}
}
Ok(())
}
fn format_duration(v: Duration) -> String {
if v.num_hours() > 0 {
format!(
"{}h{}m{}s",
v.num_hours(),
v.num_minutes() % 60,
v.num_seconds() % 60
)
} else if v.num_minutes() > 0 {
format!("{}m{}s", v.num_minutes() % 60, v.num_seconds() % 60)
} else {
format!("{}s", v.num_seconds() % 60)
}
}
pub fn clean_process_name(value: &str) -> String {
PathBuf::from(value)
.file_name()
.map(|v| v.to_string_lossy().to_string())
.unwrap_or_else(|| value.to_string())
}