use clap::{Parser, Subcommand};
use color_eyre::eyre::ErrReport;
use interim::DateError;
use log::{debug, info};
use sea_orm::*;
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
use std::io::{self};
mod astro;
mod color;
mod data;
mod db;
mod do_add;
mod do_calendars;
mod do_edit;
mod do_init;
mod do_shell;
mod do_tiers;
mod do_traits;
mod do_tui;
mod entities;
mod holidays;
mod migrator;
mod tui_add;
mod tui_calendars;
use crate::data::{anniversary_in, CalendarOps, MotdRemindEnum, ReminderTarget};
use crate::do_add::{do_add, AddArgs};
use crate::do_calendars::{do_calendars, CalendarArgs};
use crate::do_edit::{do_edit, EditArgs};
use crate::do_shell::{do_shell, ShellArgs};
use crate::do_tiers::{do_tiers, TierArgs};
use crate::do_traits::{do_traits, TraitArgs};
use crate::entities::ppl::Column::Me;
use entities::prelude::*;
#[derive(Debug)]
enum PplError {
DateError(DateError),
DbError(DbErr),
EyreError(ErrReport),
Std(io::Error),
XDG(xdg::BaseDirectoriesError),
}
impl std::fmt::Display for PplError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PplError::DateError(e) => write!(f, "{:?}", e),
PplError::DbError(e) => write!(f, "{}", e),
PplError::EyreError(e) => write!(f, "{}", e),
PplError::Std(e) => write!(f, "{}", e),
PplError::XDG(e) => write!(f, "{}", e),
}
}
}
impl From<io::Error> for PplError {
fn from(value: io::Error) -> Self {
PplError::Std(value)
}
}
impl From<DbErr> for PplError {
fn from(value: DbErr) -> Self {
PplError::DbError(value)
}
}
impl From<ErrReport> for PplError {
fn from(value: ErrReport) -> Self {
PplError::EyreError(value)
}
}
impl From<DateError> for PplError {
fn from(value: DateError) -> Self {
PplError::DateError(value)
}
}
impl From<xdg::BaseDirectoriesError> for PplError {
fn from(value: xdg::BaseDirectoriesError) -> Self {
PplError::XDG(value)
}
}
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize ppl
Init,
/// TUI
Tui,
/// Calendar of upcoming events
Calendar,
/// Show MOTD Version
MOTD,
/// Add `ppl motd` to your shell's rc file (bash, zsh, fish)
Shell(ShellArgs),
/// Add ppl
Add(AddArgs),
/// Aug ppl
Aug,
/// Edit a person's name or nickname
Edit(EditArgs),
/// Show ppl
Show,
/// Tiers and their motd reminder windows
Tiers(TierArgs),
/// Traits ppl can have (dates and contacts too)
Traits(TraitArgs),
/// Built-in holiday calendars: countries, states and religious calendars
Calendars(CalendarArgs),
/// Stats
Stats,
}
#[tokio::main]
async fn main() -> Result<(), PplError> {
let xdg_dirs = xdg::BaseDirectories::with_prefix("pplapp")?;
xdg_dirs.create_data_directory("")?;
let data_home_path = xdg_dirs.get_data_home();
let database_url: String = format!(
"sqlite://{}/db.db?mode=rwc",
data_home_path.to_str().unwrap()
);
env_logger::init();
color_eyre::install()?;
let db = Database::connect(database_url).await?;
db::check_migrations(&db).await?;
let cli = Cli::parse();
let is_init = Ppl::find().filter(Me.eq(true)).one(&db).await?.is_some();
match &cli.command {
Some(Commands::Edit(args)) => {
do_edit(args, &db).await?;
}
None => {
println!("ppl");
// show().expect("failed2draw")
}
Some(Commands::MOTD) => {
motd(&db).await?;
motd_holidays(&db).await?;
}
Some(Commands::Shell(args)) => {
do_shell(args)?;
}
Some(Commands::Init) => match is_init {
false => {
info!("Uninitialized");
let terminal = ratatui::init();
let result = do_init::run_init(terminal, db).await;
ratatui::restore();
drop(result);
info!("Init complete");
}
true => {
println!("ppl has been initialized already");
}
},
Some(Commands::Add(args)) => {
let _ = do_add(args, db).await;
}
Some(Commands::Aug) => {}
Some(Commands::Tui) => {
let terminal = ratatui::init();
let result = do_tui::run_tui(terminal, db).await;
ratatui::restore();
drop(result);
}
Some(Commands::Calendar) => {}
Some(Commands::Show) => match is_init {
true => {
let ppl = Ppl::find().all(&db).await?;
for p in ppl {
println!("{:?}", p)
}
}
false => {
info!("pls run init");
}
},
Some(Commands::Tiers(args)) => match is_init {
true => {
do_tiers(args, &db).await?;
}
false => {
info!("pls run init");
}
},
Some(Commands::Traits(args)) => match is_init {
true => {
do_traits(args, &db).await?;
}
false => {
info!("pls run init");
}
},
Some(Commands::Calendars(args)) => match is_init {
true => {
do_calendars(args, &db).await?;
}
false => {
info!("pls run init");
}
},
Some(Commands::Stats) => match is_init {
true => {
let cnt = Ppl::find().all(&db).await?.len();
println!("{:?} ppl", cnt)
}
false => {
info!("pls run init");
}
},
}
Ok(())
}
/// Whether a remind mode lets something show now, given when it was last shown
fn should_show(
remind: MotdRemindEnum,
last_day: Option<String>,
last_hour: Option<String>,
today: chrono::NaiveDate,
this_hour: &str,
) -> bool {
match remind {
MotdRemindEnum::Never => false,
MotdRemindEnum::Always => true,
MotdRemindEnum::Randomly(percent) => {
// std's RandomState is randomly seeded per instance, good enough for a coin flip
let roll = RandomState::new().build_hasher().finish() % 100;
roll < percent as u64
}
// Only show if last reminded was before today
MotdRemindEnum::OnlyOnce => last_day
.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok())
.is_none_or(|d| d < today),
// Only show if not already shown during this clock hour
MotdRemindEnum::OnceHourly => last_hour.is_none_or(|h| h != this_hour),
}
}
/// Upcoming holidays from the calendars that are on, each calendar with its own window and mode
async fn motd_holidays(db: &DatabaseConnection) -> Result<(), PplError> {
use chrono::{Datelike, Local};
use colored::Colorize;
let enabled = CalendarOps::enabled(db).await?;
if enabled.is_empty() {
return Ok(());
}
let rows = CalendarOps::rows(db).await?;
let now = Local::now();
let today = now.date_naive();
let this_hour = now.format("%Y-%m-%dT%H").to_string();
let mut upcoming = holidays::holidays_in(today.year(), &enabled);
upcoming.extend(holidays::holidays_in(today.year() + 1, &enabled));
let mut lines = vec![];
let mut seen = std::collections::HashSet::new();
for def in enabled.iter().filter_map(|k| holidays::find(k)) {
let reminder = ReminderTarget::Calendar(def).reminder(&rows);
let row = rows.iter().find(|r| r.key == def.key);
let meta_str = |key: &str| {
row.and_then(|r| r.meta.as_ref())
.and_then(|m| m.get(key))
.and_then(|v| v.as_str())
.map(|v| v.to_string())
};
let mode = reminder.mode();
if !should_show(
mode,
meta_str("last_reminded"),
meta_str("last_reminded_hour"),
today,
&this_hour,
) {
debug!(
"DEBUG: Skipping calendar '{}' - remind mode {}",
def.key, mode
);
continue;
}
let mut any = false;
for h in upcoming.iter().filter(|h| h.calendar.key == def.key) {
let days = (h.date - today).num_days();
if days < 0 || days > reminder.window() as i64 {
continue;
}
// The same day from two calendars, like Good Friday in a state and in Christian
if !seen.insert((h.date, h.name.clone())) {
continue;
}
let mark = match def.kind {
holidays::Kind::Religious => "◆".magenta(),
holidays::Kind::Other => "◆".yellow(),
_ => "◆".cyan(),
};
let when = match days {
0 => format!("today: {}", h.name),
1 => format!("tomorrow: {}", h.name),
n => format!("{} days until {}", n, h.name),
};
lines.push((h.date, format!("{} {} ({})", mark, when, def.name)));
any = true;
}
if any && matches!(mode, MotdRemindEnum::OnlyOnce | MotdRemindEnum::OnceHourly) {
if let Some(row) = row {
let day = today.format("%Y-%m-%d").to_string();
if let Err(e) = CalendarOps::mark_reminded(db, row, &day, &this_hour).await {
debug!("DEBUG: Failed to update calendar meta: {}", e);
}
}
}
}
lines.sort_by_key(|(d, _)| *d);
for (_, line) in lines {
println!("{}", line);
}
Ok(())
}
async fn motd(db: &DatabaseConnection) -> Result<(), PplError> {
use chrono::{Datelike, Local, NaiveDate};
let today = Local::now().date_naive();
debug!("DEBUG: Today is {}", today);
// Get all people
let people = Ppl::find().all(db).await?;
debug!("DEBUG: Found {} people", people.len());
// Get all tier defaults
let tier_defaults = TierDefaults::find().all(db).await?;
debug!("DEBUG: Found {} tier defaults", tier_defaults.len());
// Get all trait defaults for date matching
let trait_defaults = TraitDefaults::find()
.filter(entities::trait_defaults::Column::IsDate.eq(true))
.all(db)
.await?;
debug!("DEBUG: Found {} date trait defaults", trait_defaults.len());
let now = Local::now();
let this_hour = now.format("%Y-%m-%dT%H").to_string();
for person in people {
// Get all sigdates for this person
let sigdates = SigDate::find()
.filter(entities::sig_date::Column::PplId.eq(person.id))
.all(db)
.await?;
debug!(
"DEBUG: Person '{}' has {} sigdates",
person.name,
sigdates.len()
);
// Get all tiers for this person
let person_tiers = Tier::find()
.filter(entities::tier::Column::PplId.eq(person.id))
.all(db)
.await?;
debug!(
"DEBUG: Person '{}' has {} tiers",
person.name,
person_tiers.len()
);
// Get the tier color and symbol for display
let mut tier_color: Option<String> = None;
let mut tier_symbol: Option<String> = None;
for tier in &person_tiers {
if tier_color.is_none() {
tier_color = tier.color.clone();
}
if tier_symbol.is_none() {
tier_symbol = tier.symbol.clone();
}
}
// Remind mode: the person's own tier first, then the defaults for their tiers, then OnlyOnce
let remind = person_tiers
.iter()
.filter_map(|t| t.sig_remind_enum.as_deref())
.chain(
tier_defaults
.iter()
.filter(|td| person_tiers.iter().any(|t| t.name == td.key))
.filter_map(|td| td.sig_remind_enum.as_deref()),
)
.find_map(|r| match r.parse::<MotdRemindEnum>() {
Ok(r) => Some(r),
Err(e) => {
debug!("DEBUG: Ignoring remind mode for '{}': {}", person.name, e);
None
}
})
.unwrap_or_default();
debug!("DEBUG: Person '{}' remind mode {}", person.name, remind);
let meta_str = |key: &str| {
person
.meta
.as_ref()
.and_then(|m| m.get(key))
.and_then(|v| v.as_str())
.map(|v| v.to_string())
};
let should_show = should_show(
remind,
meta_str("last_reminded"),
meta_str("last_reminded_hour"),
today,
&this_hour,
);
if !should_show {
debug!(
"DEBUG: Skipping person '{}' - remind mode {}",
person.name, remind
);
continue;
}
// Track if we showed any sigdates for this person
let mut showed_sigdate = false;
for sigdate in sigdates {
debug!(
"DEBUG: Processing sigdate: {} on {}",
sigdate.event, sigdate.date
);
if !sigdate.do_remind {
debug!("DEBUG: Skipping sigdate - reminders are off for it");
continue;
}
let mut tier_delta: Option<u32> = None;
// First check person's assigned tiers
for tier in &person_tiers {
debug!(
"DEBUG: Checking tier '{}' with delta {:?}",
tier.name, tier.sig_date_delta
);
if let Some(delta) = tier.sig_date_delta {
tier_delta = Some(delta);
break;
}
}
// If no tier delta found, check the tier_defaults for the person's tiers
if tier_delta.is_none() {
for tier_default in tier_defaults
.iter()
.filter(|td| person_tiers.iter().any(|t| t.name == td.key))
{
debug!(
"DEBUG: Checking tier_default '{}' with delta {:?}",
tier_default.key, tier_default.sig_date_delta
);
if let Some(delta) = tier_default.sig_date_delta {
tier_delta = Some(delta);
break;
}
}
}
// If we have a tier delta, check if we're within the reminder window for this year's anniversary
if let Some(delta) = tier_delta {
// Calculate this year's anniversary
let this_year_anniversary = anniversary_in(sigdate.date, today.year());
if let Some(anniversary) = this_year_anniversary {
let days_until = (anniversary - today).num_days();
debug!(
"DEBUG: This year's anniversary is {}, days until: {}",
anniversary, days_until
);
// Show if the anniversary is upcoming and within the delta window
if days_until >= 0 && days_until <= delta as i64 {
let years_since = today.year() - sigdate.date.year();
let display_name = person.nick.as_ref().unwrap_or(&person.name);
// Format the person's name with tier symbol and color if available
let formatted_name = if let Some(symbol) = &tier_symbol {
if let Some(color_str) = &tier_color {
use colored::Colorize;
let colored_name = match color_str.to_uppercase().as_str() {
"PINK" => display_name.magenta(),
"GREEN" => display_name.green(),
"CYAN" => display_name.cyan(),
"RED" => display_name.red(),
"BLUE" => display_name.blue(),
"YELLOW" => display_name.yellow(),
"PURPLE" => display_name.purple(),
"WHITE" => display_name.white(),
"BLACK" => display_name.black(),
"BRIGHT_RED" => display_name.bright_red(),
"BRIGHT_GREEN" => display_name.bright_green(),
"BRIGHT_YELLOW" => display_name.bright_yellow(),
"BRIGHT_BLUE" => display_name.bright_blue(),
"BRIGHT_MAGENTA" => display_name.bright_magenta(),
"BRIGHT_CYAN" => display_name.bright_cyan(),
"BRIGHT_WHITE" => display_name.bright_white(),
_ => display_name.normal(), // default to no color
};
format!("{} {}", symbol, colored_name)
} else {
format!("{} {}", symbol, display_name)
}
} else {
display_name.to_string()
};
// Find matching trait_default for this sigdate event
let trait_info = trait_defaults.iter().find(|t| t.key == sigdate.event);
if let Some(trait_def) = trait_info {
println!(
"{} {} days until {} for {} ({}{})",
trait_def.symbol,
days_until,
sigdate.event,
formatted_name,
years_since,
if years_since == 1 { " year" } else { " years" }
);
} else {
println!(
"{} days until {} for {} ({}{})",
days_until,
sigdate.event,
formatted_name,
years_since,
if years_since == 1 { " year" } else { " years" }
);
}
showed_sigdate = true;
} else if days_until < 0 {
// Check next year's anniversary
let next_year_anniversary = anniversary_in(sigdate.date, today.year() + 1);
if let Some(next_anniversary) = next_year_anniversary {
let days_until_next = (next_anniversary - today).num_days();
debug!(
"DEBUG: Next year's anniversary is {}, days until: {}",
next_anniversary, days_until_next
);
if days_until_next >= 0 && days_until_next <= delta as i64 {
let years_since = (today.year() + 1) - sigdate.date.year();
let display_name = person.nick.as_ref().unwrap_or(&person.name);
// Format the person's name with tier symbol and color if available
let formatted_name = if let Some(symbol) = &tier_symbol {
if let Some(color_str) = &tier_color {
use colored::Colorize;
let colored_name = match color_str.to_uppercase().as_str() {
"PINK" => display_name.magenta(),
"GREEN" => display_name.green(),
"CYAN" => display_name.cyan(),
"RED" => display_name.red(),
"BLUE" => display_name.blue(),
"YELLOW" => display_name.yellow(),
"PURPLE" => display_name.purple(),
"WHITE" => display_name.white(),
"BLACK" => display_name.black(),
"BRIGHT_RED" => display_name.bright_red(),
"BRIGHT_GREEN" => display_name.bright_green(),
"BRIGHT_YELLOW" => display_name.bright_yellow(),
"BRIGHT_BLUE" => display_name.bright_blue(),
"BRIGHT_MAGENTA" => display_name.bright_magenta(),
"BRIGHT_CYAN" => display_name.bright_cyan(),
"BRIGHT_WHITE" => display_name.bright_white(),
_ => display_name.normal(), // default to no color
};
format!("{} {}", symbol, colored_name)
} else {
format!("{} {}", symbol, display_name)
}
} else {
display_name.to_string()
};
// Find matching trait_default for this sigdate event
let trait_info =
trait_defaults.iter().find(|t| t.key == sigdate.event);
if let Some(trait_def) = trait_info {
println!(
"{} {} days until {} for {} ({}{})",
trait_def.symbol,
days_until_next,
sigdate.event,
formatted_name,
years_since,
if years_since == 1 { " year" } else { " years" }
);
} else {
println!(
"{} days until {} for {} ({}{})",
days_until_next,
sigdate.event,
formatted_name,
years_since,
if years_since == 1 { " year" } else { " years" }
);
}
showed_sigdate = true;
} else {
debug!("DEBUG: Next year's anniversary days until ({}) is outside delta window (0 to {})", days_until_next, delta);
}
}
} else {
debug!(
"DEBUG: Days until this year ({}) is outside delta window (0 to {})",
days_until, delta
);
}
} else {
debug!(
"DEBUG: Could not calculate anniversary for {}",
sigdate.date
);
}
} else {
debug!("DEBUG: No tier delta found for this sigdate");
}
}
// Record when we showed them, so OnlyOnce and OnceHourly can skip them next time
if showed_sigdate
&& matches!(
remind,
MotdRemindEnum::OnlyOnce | MotdRemindEnum::OnceHourly
)
{
use sea_orm::ActiveValue::Set;
// Keep any other keys already in meta
let mut meta_value = match &person.meta {
Some(serde_json::Value::Object(m)) => m.clone(),
_ => serde_json::Map::new(),
};
meta_value.insert(
"last_reminded".to_string(),
today.format("%Y-%m-%d").to_string().into(),
);
meta_value.insert("last_reminded_hour".to_string(), this_hour.clone().into());
let mut person_active: entities::ppl::ActiveModel = person.into();
person_active.meta = Set(Some(serde_json::Value::Object(meta_value)));
if let Err(e) = person_active.update(db).await {
debug!("DEBUG: Failed to update person meta: {}", e);
} else {
debug!("DEBUG: Updated last_reminded for person to {}", this_hour);
}
}
}
Ok(())
}
//
// fn show() -> io::Result<()> {
// enable_raw_mode()?;
// stdout().execute(EnterAlternateScreen)?;
// let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
//
// let mut should_quit = false;
// while !should_quit {
// terminal.draw(ui)?;
// should_quit = handle_events()?;
// }
//
// disable_raw_mode()?;
// // stdout().execute(LeaveAlternateScreen)?;
// Ok(())
// }
//
// fn handle_events() -> io::Result<bool> {
// if event::poll(std::time::Duration::from_millis(50))? {
// if let Event::Key(key) = event::read()? {
// if key.kind == event::KeyEventKind::Press && key.code == KeyCode::Char('q') {
// return Ok(true);
// }
// }
// }
// Ok(false)
// }
//
// fn ui(frame: &mut Frame) {
// frame.render_widget(
// Paragraph::new("Hello World!")
// .block(Block::default().title("Greeting").borders(Borders::ALL)),
// frame.size(),
// );
// }
//
//
// fn main() {
// let cli = Cli::parse();
//
// // You can check for the existence of subcommands, and if found use their
// // matches just as you would the top level cmd
// match &cli.command {
// Some(Commands::Edit { name }) => {
// println!("'myapp add' was used, name is: {:?}", name)
// }
// Some(Commands::Show {}) => {
// println!("showing but selected");
// show().expect("failed2draw")
// }
// None => {
// println!("Showing PPL");
// show().expect("failed2draw")
// }
// Some(Commands::MOTD { .. }) => {}
// }
// }
⭐
Log in to mark this repository.
26.9 KiB · text
History
d9460a8