use crate::color::colo;
use crate::do_tui::{Editable, Editablez};
use crate::entities::ppl::Model;
use crate::entities::prelude::Tier;
use crate::entities::prelude::Traits;
use crate::entities::prelude::{
Calendar, Contact, Ppl, Relation, SigDate, TierDefaults, TraitDefaults,
};
use crate::entities::{
calendar, contact, ppl, relation, sig_date, tier, tier_defaults, trait_defaults, traits,
};
use crate::holidays::{self, CalendarDef, Group};
use crate::PplError;
use chrono::{Local, NaiveDate};
use color_eyre::owo_colors::OwoColorize;
use log::warn;
use ratatui::style::palette::tailwind::PURPLE;
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, NotSet, QueryFilter,
};
use std::fmt;
use std::io;
use std::str::FromStr;
fn bool_from_string(input: Option<String>) -> bool {
if input.is_none() {
return false;
}
let input = input.unwrap_or("false".to_string());
// Check if the input is an empty string
if input.is_empty() {
return false;
}
// Convert the input to lowercase for case-insensitive comparison
let input = input.to_lowercase();
// Check if the input matches any of the bool-like values
match input.as_str() {
"true" | "yes" | "t" | "y" => true,
"false" | "no" | "f" | "n" => false,
_ => false,
}
}
/// The date's anniversary in `year`; Feb 29 falls on Feb 28 in non-leap years.
pub fn anniversary_in(date: NaiveDate, year: i32) -> Option<NaiveDate> {
use chrono::Datelike;
NaiveDate::from_ymd_opt(year, date.month(), date.day())
.or_else(|| NaiveDate::from_ymd_opt(year, date.month(), date.day() - 1))
}
fn option_naive_date_from_option_string(input: Option<String>) -> Option<NaiveDate> {
match input {
Some(s) => chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok(),
None => None,
}
}
pub struct ContactOps {}
impl ContactOps {
pub async fn create(
db: &DatabaseConnection,
ppl_id: i32,
typ: String,
designator: String,
value: String,
) -> Result<(), PplError> {
let p = contact::ActiveModel {
id: Default::default(),
ppl_id: Set(ppl_id),
r#type: Set(typ),
designator: Set(Option::from(designator)),
value: Set(value),
date_acq: Set(Some(Local::now().date_naive())),
date_ins: Set(Local::now().date_naive()),
date_up: Set(Local::now().date_naive()),
};
let pp = p.insert(db).await;
match pp {
Ok(item) => {
let (r, g, b) = colo(PURPLE.c500);
warn!("contact {:?}", item.id.truecolor(r, g, b));
Ok(())
}
Err(err) => {
warn!("contact {}", err.red());
Err(err.into())
}
}
}
pub async fn list(db: &DatabaseConnection) -> Result<Vec<contact::Model>, DbErr> {
Contact::find().all(db).await
}
pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
Self::update(
db,
editable.id,
editable.first,
editable.second,
editable.third.unwrap_or("".to_string()),
None,
)
.await
}
pub async fn update(
db: &DatabaseConnection,
model_id: i32,
typ: String,
designator: Option<String>,
value: String,
date_acq: Option<String>,
) -> Result<(), PplError> {
let loaded_model = Contact::find_by_id(model_id).one(db).await?;
match loaded_model {
None => Ok(()),
Some(instance) => {
let mut am: contact::ActiveModel = instance.into();
match date_acq {
None => {}
Some(d) => {
am.date_acq = Set(option_naive_date_from_option_string(Option::from(d)))
}
}
am.designator = Set(designator);
am.r#type = Set(typ);
am.value = Set(value);
am.date_up = Set(Local::now().date_naive());
am.update(db).await?;
Ok(())
}
}
}
}
pub struct PplOps {}
impl PplOps {
pub async fn create_me(db: &DatabaseConnection, name: String) -> Result<ppl::Model, PplError> {
let p = ppl::ActiveModel {
id: Default::default(),
name: Set(name),
nick: NotSet,
me: Set(true),
date_ins: Set(Local::now().date_naive()),
date_up: Set(Local::now().date_naive()),
meta: NotSet,
};
let pp = p.insert(db).await;
match pp {
Ok(item) => {
warn!("ppl {:?}", item.id);
Ok(item)
}
Err(err) => {
warn!("ppl {}", err.red());
Err(err.into())
}
}
}
pub async fn create(db: &DatabaseConnection, name: String) -> Result<ppl::Model, PplError> {
let p = ppl::ActiveModel {
id: Default::default(),
name: Set(name),
me: Set(false),
nick: NotSet,
date_ins: Set(Local::now().date_naive()),
date_up: Set(Local::now().date_naive()),
meta: NotSet,
};
let pp = p.insert(db).await;
match pp {
Ok(item) => {
warn!("ppl {:?}", item.id);
Ok(item)
}
Err(err) => {
warn!("ppl {}", err.red());
Err(err.into())
}
}
}
pub async fn list(db: &DatabaseConnection) -> Result<Vec<Model>, DbErr> {
Ppl::find().all(db).await
}
pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
// An empty nick clears it
let nick = editable
.second
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty());
Self::update(db, editable.id, Some(editable.first), Some(nick)).await
}
/// None leaves a field alone; for nick, Some(None) clears it.
pub async fn update(
db: &DatabaseConnection,
id: i32,
name: Option<String>,
nick: Option<Option<String>>,
) -> Result<(), PplError> {
let loaded_model = Ppl::find_by_id(id).one(db).await?;
match loaded_model {
None => Ok(()),
Some(instance) => {
let mut am: ppl::ActiveModel = instance.into();
match name.map(|n| n.trim().to_string()) {
None => {}
Some(n) if n.is_empty() => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"name can't be empty",
)
.into())
}
Some(n) => am.name = Set(n),
}
if let Some(nick) = nick {
am.nick = Set(nick);
}
am.date_up = Set(Local::now().date_naive());
am.update(db).await?;
Ok(())
}
}
}
}
/// Find a person by id, or by name or nick ignoring case.
pub fn find_person<'a>(ppl: &'a [ppl::Model], query: &str) -> Result<&'a ppl::Model, PplError> {
let query = query.trim();
if let Ok(id) = query.parse::<i32>() {
if let Some(p) = ppl.iter().find(|p| p.id == id) {
return Ok(p);
}
}
let matches = ppl
.iter()
.filter(|p| {
p.name.trim().eq_ignore_ascii_case(query)
|| p.nick
.as_deref()
.is_some_and(|n| n.trim().eq_ignore_ascii_case(query))
})
.collect::<Vec<_>>();
match matches.as_slice() {
[p] => Ok(p),
[] => Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no person named '{}'", query),
)
.into()),
many => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"'{}' matches several ppl, use an id: {}",
query,
many.iter()
.map(|p| format!("{} ({})", p.name.trim(), p.id))
.collect::<Vec<_>>()
.join(", ")
),
)
.into()),
}
}
pub struct RelationOps {}
impl RelationOps {
pub async fn create(
db: &DatabaseConnection,
a: i32,
b: i32,
typ: String,
superseded: bool,
entered: Option<NaiveDate>,
ended: Option<NaiveDate>,
) -> Result<(), PplError> {
let p = relation::ActiveModel {
id: Default::default(),
ppl_id_a: Set(a),
ppl_id_b: Set(b),
r#type: Set(typ),
date_entered: Set(entered),
date_ended: Set(ended),
date_ins: Set(Local::now().date_naive()),
date_up: Set(Local::now().date_naive()),
superseded: Set(superseded),
};
let pp = p.insert(db).await;
match pp {
Ok(item) => {
warn!("relation {:?}", item.id);
Ok(())
}
Err(err) => {
warn!("relation {}", err.red());
Err(err.into())
}
}
}
pub async fn list(db: &DatabaseConnection) -> Result<Vec<relation::Model>, DbErr> {
Relation::find().all(db).await
}
pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
Self::update(
db,
editable.id,
None,
None,
editable.first,
None,
option_naive_date_from_option_string(editable.second),
option_naive_date_from_option_string(editable.third),
)
.await
}
pub async fn update(
db: &DatabaseConnection,
model_id: i32,
a: Option<i32>,
b: Option<i32>,
typ: String,
superseded: Option<bool>,
entered: Option<NaiveDate>,
ended: Option<NaiveDate>,
) -> Result<(), PplError> {
let loaded_model = Relation::find_by_id(model_id).one(db).await?;
match loaded_model {
None => Ok(()),
Some(instance) => {
let mut am: relation::ActiveModel = instance.into();
match a {
None => {}
Some(i) => am.ppl_id_a = Set(i),
}
match b {
None => {}
Some(j) => am.ppl_id_b = Set(j),
}
match superseded {
None => {}
Some(bool_val) => am.superseded = Set(bool_val),
}
match entered {
None => {}
Some(date) => am.date_entered = Set(Option::from(date)),
}
match ended {
None => {}
Some(date) => am.date_ended = Set(Option::from(date)),
}
am.r#type = Set(typ);
am.date_up = Set(Local::now().date_naive());
am.update(db).await?;
Ok(())
}
}
}
}
pub struct SigDateOps {}
impl SigDateOps {
pub async fn create(
db: &DatabaseConnection,
ppl_id: i32,
date: NaiveDate,
event: String,
do_remind: bool,
) -> Result<(), PplError> {
let p = sig_date::ActiveModel {
id: Default::default(),
ppl_id: Set(ppl_id),
date: Set(date),
event: Set(event),
do_remind: Set(do_remind),
with_ppl: Default::default(),
date_ins: Set(Local::now().date_naive()),
date_up: Set(Local::now().date_naive()),
};
let pp = p.insert(db).await;
match pp {
Ok(item) => {
let (r, g, b) = colo(PURPLE.c500);
warn!("sigDate {:?}", item.id.truecolor(r, g, b));
Ok(())
}
Err(err) => {
warn!("sigDate {}", err.red());
Err(err.into())
}
}
}
pub async fn list(db: &DatabaseConnection) -> Result<Vec<sig_date::Model>, DbErr> {
SigDate::find().all(db).await
}
pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
Self::update(
db,
editable.id,
editable.first,
option_naive_date_from_option_string(editable.second),
bool_from_string(editable.third),
None,
)
.await
}
pub async fn update(
db: &DatabaseConnection,
id: i32,
event: String,
date: Option<NaiveDate>,
do_remind: bool,
_with_ppl: Option<String>,
) -> Result<(), PplError> {
let loaded_model = SigDate::find_by_id(id).one(db).await?;
match loaded_model {
None => Ok(()),
Some(instance) => {
let Some(date) = date else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"date must look like YYYY-MM-DD",
)
.into());
};
let mut am: sig_date::ActiveModel = instance.into();
am.event = Set(event);
am.date = Set(date);
am.do_remind = Set(do_remind);
am.date_up = Set(Local::now().date_naive());
am.update(db).await?;
Ok(())
}
}
}
}
pub struct TierOps {}
impl TierOps {
pub async fn create(
db: &DatabaseConnection,
ppl_id: i32,
tier: String,
sig_date_delta: Option<u32>,
) -> Result<(), PplError> {
let t = tier::ActiveModel {
id: Default::default(),
ppl_id: Set(ppl_id),
name: Set(tier.to_string()),
color: Default::default(),
symbol: Default::default(),
date_ins: Set(Local::now().date_naive()),
date_up: Set(Local::now().date_naive()),
sig_date_delta: Set(sig_date_delta),
sig_remind_enum: NotSet,
};
let tt = t.insert(db).await;
match tt {
Ok(item) => {
warn!("tier {:?}", item.id);
Ok(())
}
Err(err) => {
warn!("tier {}", err.red());
Err(err.into())
}
}
}
pub async fn list(db: &DatabaseConnection) -> Result<Vec<tier::Model>, DbErr> {
Tier::find().all(db).await
}
pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
Self::update(
db,
editable.id,
editable.first,
editable.second,
editable.third,
)
.await
}
pub async fn update(
db: &DatabaseConnection,
model_id: i32,
name: String,
color: Option<String>,
symbol: Option<String>,
) -> Result<(), PplError> {
let loaded_model = Tier::find_by_id(model_id).one(db).await?;
match loaded_model {
None => Ok(()),
Some(instance) => {
let mut am: tier::ActiveModel = instance.into();
am.name = Set(name);
am.color = Set(color);
am.symbol = Set(symbol);
am.date_up = Set(Local::now().date_naive());
am.update(db).await?;
Ok(())
}
}
}
/// Per-person override of the tier default's reminder window; None falls back to the default.
pub async fn set_sig_date_delta(
db: &DatabaseConnection,
model_id: i32,
sig_date_delta: Option<u32>,
) -> Result<(), PplError> {
let loaded_model = Tier::find_by_id(model_id).one(db).await?;
match loaded_model {
None => Ok(()),
Some(instance) => {
let mut am: tier::ActiveModel = instance.into();
am.sig_date_delta = Set(sig_date_delta);
am.date_up = Set(Local::now().date_naive());
am.update(db).await?;
Ok(())
}
}
}
/// Per-person override of the tier default's remind mode; None falls back to the default.
pub async fn set_sig_remind_enum(
db: &DatabaseConnection,
model_id: i32,
remind: Option<MotdRemindEnum>,
) -> Result<(), PplError> {
let loaded_model = Tier::find_by_id(model_id).one(db).await?;
match loaded_model {
None => Ok(()),
Some(instance) => {
let mut am: tier::ActiveModel = instance.into();
am.sig_remind_enum = Set(remind.map(|r| r.to_string()));
am.date_up = Set(Local::now().date_naive());
am.update(db).await?;
Ok(())
}
}
}
}
pub struct TraitOps {}
impl TraitOps {
pub async fn create(
db: &DatabaseConnection,
ppl_id: i32,
key: String,
value: String,
hidden: bool,
) -> Result<(), PplError> {
let t = traits::ActiveModel {
id: Default::default(),
ppl_id: Set(ppl_id),
key: Set(key.to_string()),
value: Set(value),
hidden: Set(hidden),
date_ins: Set(Local::now().date_naive()),
date_up: Set(Local::now().date_naive()),
};
let tt = t.insert(db).await;
match tt {
Ok(item) => {
warn!("trait {:?}", item.id);
Ok(())
}
Err(err) => {
warn!("trait {}", err.red());
Err(err.into())
}
}
}
pub async fn list(db: &DatabaseConnection) -> Result<Vec<traits::Model>, DbErr> {
Traits::find().all(db).await
}
pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
Self::update(
db,
editable.id,
Option::from(editable.first),
editable.second,
bool_from_string(editable.third),
)
.await
}
pub async fn update(
db: &DatabaseConnection,
model_id: i32,
key: Option<String>,
value: Option<String>,
hidden: bool,
) -> Result<(), PplError> {
let loaded_model = Traits::find_by_id(model_id).one(db).await?;
match loaded_model {
None => Ok(()),
Some(instance) => {
let mut am: traits::ActiveModel = instance.into();
match key {
None => {}
Some(i) => am.key = Set(i),
}
match value {
None => {}
Some(j) => am.value = Set(j),
}
am.hidden = Set(hidden);
am.date_up = Set(Local::now().date_naive());
am.update(db).await?;
Ok(())
}
}
}
}
/// How often the motd shows a person's upcoming dates, stored as `sig_remind_enum` on tiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MotdRemindEnum {
#[default]
OnlyOnce, // Default, Only the first time the motd is run that day
OnceHourly, // Only once per hour, resetting at the top of the hour
Randomly(u8), // Will show this motd person randomly u8% every run
Always, // Will always show this person's upcoming
Never, // Never show this person's upcoming
}
impl MotdRemindEnum {
/// Next mode, for cycling through them in the TUI
pub fn next(self) -> Self {
match self {
MotdRemindEnum::OnlyOnce => MotdRemindEnum::OnceHourly,
MotdRemindEnum::OnceHourly => MotdRemindEnum::Randomly(50),
MotdRemindEnum::Randomly(_) => MotdRemindEnum::Always,
MotdRemindEnum::Always => MotdRemindEnum::Never,
MotdRemindEnum::Never => MotdRemindEnum::OnlyOnce,
}
}
/// Short label for lists
pub fn label(&self) -> String {
match self {
MotdRemindEnum::OnlyOnce => "daily".to_string(),
MotdRemindEnum::OnceHourly => "hourly".to_string(),
MotdRemindEnum::Randomly(p) => format!("random {}%", p),
MotdRemindEnum::Always => "always".to_string(),
MotdRemindEnum::Never => "never".to_string(),
}
}
}
impl fmt::Display for MotdRemindEnum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MotdRemindEnum::OnlyOnce => write!(f, "OnlyOnce"),
MotdRemindEnum::OnceHourly => write!(f, "OnceHourly"),
MotdRemindEnum::Randomly(p) => write!(f, "Randomly({})", p),
MotdRemindEnum::Always => write!(f, "Always"),
MotdRemindEnum::Never => write!(f, "Never"),
}
}
}
impl FromStr for MotdRemindEnum {
type Err = String;
/// Accepts the stored form (`OnlyOnce`, `Randomly(30)`) and short forms (`daily`, `random:30`)
fn from_str(s: &str) -> Result<Self, Self::Err> {
let lower = s.trim().to_lowercase();
let percent = lower
.strip_prefix("randomly(")
.and_then(|r| r.strip_suffix(')'))
.or_else(|| lower.strip_prefix("random:"))
.or_else(|| lower.strip_prefix("random "));
if let Some(p) = percent {
return match p.trim().parse::<u8>() {
Ok(p) if p <= 100 => Ok(MotdRemindEnum::Randomly(p)),
_ => Err(format!("random percent must be 0-100, got '{}'", p)),
};
}
match lower.as_str() {
"onlyonce" | "once" | "daily" => Ok(MotdRemindEnum::OnlyOnce),
"oncehourly" | "hourly" => Ok(MotdRemindEnum::OnceHourly),
"always" => Ok(MotdRemindEnum::Always),
"never" => Ok(MotdRemindEnum::Never),
_ => Err(format!(
"unknown remind mode '{}', expected one of: daily, hourly, random:N, always, never",
s
)),
}
}
}
/// Color names the TUI knows how to draw (see `do_init::lcolor`)
pub const COLORS: &[&str] = &[
"red", "gold", "amber", "green", "teal", "cyan", "blue", "violet", "pink", "slate",
];
fn invalid(msg: String) -> PplError {
io::Error::new(io::ErrorKind::InvalidInput, msg).into()
}
/// Empty is fine (no color); otherwise it has to be one of COLORS
fn check_color(color: &str) -> Result<String, PplError> {
let color = color.trim().to_lowercase();
if color.is_empty() || COLORS.contains(&color.as_str()) {
Ok(color)
} else {
Err(invalid(format!(
"unknown color '{}', expected one of: {}",
color,
COLORS.join(", ")
)))
}
}
/// What a trait default describes: a plain trait, a significant date, or a way to contact someone
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TraitKind {
#[default]
Trait,
Date,
Contact,
}
impl FromStr for TraitKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_lowercase().as_str() {
"" | "trait" => Ok(TraitKind::Trait),
"date" => Ok(TraitKind::Date),
"contact" => Ok(TraitKind::Contact),
other => Err(format!(
"unknown kind '{}', expected one of: trait, date, contact",
other
)),
}
}
}
/// Remove the row behind a TUI detail line. People themselves aren't deleted this way.
pub async fn delete_editable(db: &DatabaseConnection, editable: &Editable) -> Result<(), PplError> {
match editable.tgt {
Editablez::Ppl => return Err(invalid("people can't be removed from here".to_string())),
Editablez::Trait => Traits::delete_by_id(editable.id).exec(db).await?,
Editablez::Tier => Tier::delete_by_id(editable.id).exec(db).await?,
Editablez::Contact => Contact::delete_by_id(editable.id).exec(db).await?,
Editablez::Relation => Relation::delete_by_id(editable.id).exec(db).await?,
Editablez::SigDate => SigDate::delete_by_id(editable.id).exec(db).await?,
};
Ok(())
}
pub struct CalendarOps {}
impl CalendarOps {
/// Keys of the calendars that are switched on, built-in and subscribed
pub async fn enabled(db: &DatabaseConnection) -> Result<Vec<String>, DbErr> {
Ok(Self::rows(db)
.await?
.into_iter()
.filter(|c| c.enabled)
.filter_map(|c| holidays::find(&c.key).map(|d| d.key.to_string()))
.collect())
}
/// Switch a calendar on or off. A state switches its country on with it, and a
/// country switched off takes its states with it. Returns every calendar changed.
pub async fn set(
db: &DatabaseConnection,
key: &str,
on: bool,
) -> Result<Vec<&'static CalendarDef>, PplError> {
Self::rows(db).await?;
let Some(def) = holidays::find(key) else {
return Err(invalid(format!(
"no calendar '{}', see `ppl calendars` for the list",
key
)));
};
let mut affected = vec![def];
match on {
true => affected.extend(def.parent.and_then(holidays::find)),
false => affected.extend(holidays::regions(def.key)),
}
let enabled = Self::enabled(db).await?;
let mut changed = vec![];
for d in affected {
if enabled.iter().any(|k| k == d.key) == on {
continue;
}
Self::upsert(db, d.key, |row| row.enabled = Set(on)).await?;
changed.push(d);
}
Ok(changed)
}
/// Every row: calendars that have been switched on or given reminder settings, group
/// defaults and subscriptions. Reading them makes the subscriptions findable.
pub async fn rows(db: &DatabaseConnection) -> Result<Vec<calendar::Model>, DbErr> {
let rows = Calendar::find().all(db).await?;
for r in &rows {
if let Some(url) = &r.url {
holidays::register_subscription(&r.key, url);
}
}
Ok(rows)
}
/// Subscribed calendars, as (key, url or path)
pub async fn subscriptions(db: &DatabaseConnection) -> Result<Vec<(String, String)>, DbErr> {
Ok(Self::rows(db)
.await?
.into_iter()
.filter_map(|r| Some((r.key, r.url?)))
.collect())
}
/// Subscribe to an iCal calendar, switched on. It's fetched (or read) first, so a bad
/// URL is refused rather than saved. Returns how many events it has.
pub async fn subscribe(
db: &DatabaseConnection,
name: &str,
source: &str,
) -> Result<usize, PplError> {
let name = name.trim().to_lowercase();
let valid = !name.is_empty()
&& name.len() <= 24
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if !valid {
return Err(invalid(
"a calendar name is up to 24 lowercase letters, digits and dashes, like work or family-ical".to_string(),
));
}
if holidays::is_builtin(&name) {
return Err(invalid(format!(
"'{}' is a built-in calendar or group; pick another name",
name
)));
}
let rows = Self::rows(db).await?;
if rows.iter().any(|r| r.key == name && r.url.is_some()) {
return Err(invalid(format!(
"already subscribed to '{}'; `ppl calendars unsubscribe {}` first to change it",
name, name
)));
}
let count = crate::ical::sync(&name, source.trim())?;
Self::upsert(db, &name, |row| {
row.url = Set(Some(source.trim().to_string()));
row.enabled = Set(true);
})
.await?;
Self::rows(db).await?;
Ok(count)
}
pub async fn unsubscribe(db: &DatabaseConnection, name: &str) -> Result<(), PplError> {
let rows = Self::rows(db).await?;
let Some(row) = rows
.into_iter()
.find(|r| r.key.eq_ignore_ascii_case(name) && r.url.is_some())
else {
return Err(invalid(format!("not subscribed to '{}'", name)));
};
Calendar::delete_by_id(row.id).exec(db).await?;
crate::ical::remove_cache(&row.key);
holidays::unregister_subscription(&row.key);
Ok(())
}
/// Set the motd window and mode of a calendar or group; None goes back to the default
pub async fn set_reminder(
db: &DatabaseConnection,
target: ReminderTarget,
delta: Option<u32>,
remind: Option<MotdRemindEnum>,
) -> Result<(), PplError> {
Self::upsert(db, &target.row_key(), |row| {
row.sig_date_delta = Set(delta);
row.sig_remind_enum = Set(remind.map(|r| r.to_string()));
})
.await
}
/// Note that the motd just showed this calendar, for the daily and hourly modes
pub async fn mark_reminded(
db: &DatabaseConnection,
row: &calendar::Model,
day: &str,
hour: &str,
) -> Result<(), PplError> {
let mut meta = match &row.meta {
Some(serde_json::Value::Object(m)) => m.clone(),
_ => serde_json::Map::new(),
};
meta.insert("last_reminded".to_string(), day.into());
meta.insert("last_reminded_hour".to_string(), hour.into());
Self::upsert(db, &row.key, |r| {
r.meta = Set(Some(serde_json::Value::Object(meta)))
})
.await
}
/// Change a row, creating it (switched off) if it isn't there yet
async fn upsert(
db: &DatabaseConnection,
key: &str,
change: impl FnOnce(&mut calendar::ActiveModel),
) -> Result<(), PplError> {
let today = Local::now().date_naive();
let existing = Calendar::find()
.filter(calendar::Column::Key.eq(key))
.one(db)
.await?;
let exists = existing.is_some();
let mut row = match existing {
Some(row) => row.into(),
None => calendar::ActiveModel {
id: NotSet,
key: Set(key.to_string()),
enabled: Set(false),
sig_date_delta: Set(None),
sig_remind_enum: Set(None),
meta: Set(None),
url: Set(None),
date_ins: Set(today),
date_up: Set(today),
},
};
change(&mut row);
row.date_up = Set(today);
match exists {
true => row.update(db).await?,
false => row.insert(db).await?,
};
Ok(())
}
}
/// What a motd reminder setting is attached to: one calendar, or a group's default
#[derive(Clone, Copy, Debug)]
pub enum ReminderTarget {
Calendar(&'static CalendarDef),
Group(&'static Group),
}
impl ReminderTarget {
/// A calendar key, or a group name: countries, religious or other
pub fn parse(s: &str) -> Result<Self, PplError> {
if let Some(g) = holidays::GROUPS
.iter()
.find(|g| g.key.eq_ignore_ascii_case(s))
{
return Ok(ReminderTarget::Group(g));
}
holidays::find(s)
.map(ReminderTarget::Calendar)
.ok_or_else(|| {
let groups = holidays::GROUPS.iter().map(|g| g.key).collect::<Vec<_>>();
invalid(format!(
"no calendar or group '{}' (groups: {}; calendars: `ppl calendars list --all`)",
s,
groups.join(", ")
))
})
}
fn row_key(&self) -> String {
match self {
ReminderTarget::Calendar(c) => c.key.to_string(),
ReminderTarget::Group(g) => format!("group:{}", g.key),
}
}
pub fn label(&self) -> &'static str {
match self {
ReminderTarget::Calendar(c) => c.name,
ReminderTarget::Group(g) => g.name,
}
}
/// The setting itself, and what it falls back to: a state uses its country's, then a
/// calendar uses its group's, then holidays show on the day, once a day
pub fn reminder(&self, rows: &[calendar::Model]) -> CalendarReminder {
let row = |key: &str| rows.iter().find(|r| r.key == key);
let parse = |r: &calendar::Model| {
r.sig_remind_enum
.as_deref()
.and_then(|m| m.parse::<MotdRemindEnum>().ok())
};
let own = row(&self.row_key());
let mut fallbacks = vec![];
if let ReminderTarget::Calendar(c) = self {
fallbacks.extend(c.parent.and_then(row));
fallbacks.extend(row(&format!("group:{}", holidays::group_of(c).key)));
}
CalendarReminder {
delta: own.and_then(|r| r.sig_date_delta),
remind: own.and_then(parse),
default_delta: fallbacks.iter().find_map(|r| r.sig_date_delta).or(Some(0)),
default_remind: fallbacks
.iter()
.find_map(|r| parse(r))
.or(Some(MotdRemindEnum::OnlyOnce)),
}
}
}
/// A calendar's or group's motd settings, with the defaults they fall back to
#[derive(Clone, Copy, Debug)]
pub struct CalendarReminder {
pub delta: Option<u32>,
pub remind: Option<MotdRemindEnum>,
pub default_delta: Option<u32>,
pub default_remind: Option<MotdRemindEnum>,
}
impl CalendarReminder {
pub fn window(&self) -> u32 {
self.delta.or(self.default_delta).unwrap_or(0)
}
pub fn mode(&self) -> MotdRemindEnum {
self.remind.or(self.default_remind).unwrap_or_default()
}
}
pub struct TierDefaultOps {}
impl TierDefaultOps {
pub async fn create(
db: &DatabaseConnection,
key: String,
default: bool,
enabled: bool,
color: String,
symbol: String,
sig_date_delta: Option<u32>,
) -> Result<(), PplError> {
let t = tier_defaults::ActiveModel {
id: Default::default(),
key: Set(key),
default: Set(default),
enabled: Set(enabled),
color: Set(Option::from(color)),
symbol: Set(Option::from(symbol)),
date_ins: Set(Local::now().date_naive()),
date_up: Set(Local::now().date_naive()),
sig_date_delta: Set(sig_date_delta),
sig_remind_enum: NotSet,
};
let tt = t.insert(db).await;
match tt {
Ok(item) => {
warn!("tierDefault {:?}", item.id);
Ok(())
}
Err(err) => {
warn!("tierDefault {}", err.red());
Err(err.into())
}
}
}
pub async fn list(db: &DatabaseConnection) -> Result<Vec<tier_defaults::Model>, DbErr> {
TierDefaults::find().all(db).await
}
/// Add a user-defined tier, checking the key is new and the color is known
pub async fn add(
db: &DatabaseConnection,
key: &str,
symbol: &str,
color: &str,
sig_date_delta: Option<u32>,
) -> Result<String, PplError> {
let key = key.trim();
if key.is_empty() {
return Err(invalid("tier needs a name".to_string()));
}
if let Some(t) = Self::list(db)
.await?
.iter()
.find(|t| t.key.eq_ignore_ascii_case(key))
{
return Err(invalid(format!("tier '{}' already exists", t.key)));
}
let color = check_color(color)?;
Self::create(
db,
key.to_string(),
false,
true,
color,
symbol.trim().to_string(),
sig_date_delta,
)
.await?;
Ok(key.to_string())
}
/// Set how many days ahead the motd starts reminding for this tier's dates; None clears it.
pub async fn set_sig_date_delta(
db: &DatabaseConnection,
model_id: i32,
sig_date_delta: Option<u32>,
) -> Result<(), PplError> {
let loaded_model = TierDefaults::find_by_id(model_id).one(db).await?;
match loaded_model {
None => Ok(()),
Some(instance) => {
let mut am: tier_defaults::ActiveModel = instance.into();
am.sig_date_delta = Set(sig_date_delta);
am.date_up = Set(Local::now().date_naive());
am.update(db).await?;
Ok(())
}
}
}
/// Set how often the motd shows ppl in this tier; None falls back to OnlyOnce.
pub async fn set_sig_remind_enum(
db: &DatabaseConnection,
model_id: i32,
remind: Option<MotdRemindEnum>,
) -> Result<(), PplError> {
let loaded_model = TierDefaults::find_by_id(model_id).one(db).await?;
match loaded_model {
None => Ok(()),
Some(instance) => {
let mut am: tier_defaults::ActiveModel = instance.into();
am.sig_remind_enum = Set(remind.map(|r| r.to_string()));
am.date_up = Set(Local::now().date_naive());
am.update(db).await?;
Ok(())
}
}
}
}
pub struct TraitDefaultOps {}
impl TraitDefaultOps {
pub async fn create(
db: &DatabaseConnection,
key: String,
color: String,
symbol: String,
default: bool,
enabled: bool,
is_date: bool,
is_contact: bool,
) -> Result<(), PplError> {
let t = trait_defaults::ActiveModel {
id: Default::default(),
key: Set(key.to_string()),
default: Set(default),
enabled: Set(enabled),
is_date: Set(is_date),
is_contact: Set(is_contact),
color: Set(color),
symbol: Set(symbol),
date_ins: Set(Local::now().date_naive()),
date_up: Set(Local::now().date_naive()),
};
let tt = t.insert(db).await;
match tt {
Ok(item) => {
warn!("traitDefault {:?}", item.id);
Ok(())
}
Err(err) => {
warn!("traitDefault {}", err.red());
Err(err.into())
}
}
}
pub async fn list(db: &DatabaseConnection) -> Result<Vec<trait_defaults::Model>, DbErr> {
TraitDefaults::find().all(db).await
}
/// Add a user-defined trait, checking the key is new and the color is known
pub async fn add(
db: &DatabaseConnection,
key: &str,
symbol: &str,
color: &str,
kind: TraitKind,
) -> Result<String, PplError> {
let key = key.trim();
if key.is_empty() {
return Err(invalid("trait needs a name".to_string()));
}
if let Some(t) = Self::list(db)
.await?
.iter()
.find(|t| t.key.eq_ignore_ascii_case(key))
{
return Err(invalid(format!("trait '{}' already exists", t.key)));
}
let color = check_color(color)?;
Self::create(
db,
key.to_string(),
color,
symbol.trim().to_string(),
false,
true,
kind == TraitKind::Date,
kind == TraitKind::Contact,
)
.await?;
Ok(key.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(key: &str, delta: Option<u32>, remind: Option<&str>) -> calendar::Model {
let today = NaiveDate::from_ymd_opt(2026, 10, 19).unwrap();
calendar::Model {
id: 0,
key: key.to_string(),
enabled: true,
sig_date_delta: delta,
sig_remind_enum: remind.map(|r| r.to_string()),
meta: None,
url: None,
date_ins: today,
date_up: today,
}
}
#[test]
fn calendar_reminders_fall_back() {
let rows = vec![
row("group:religious", Some(7), None),
row("us", Some(3), None),
row("us-ny", None, Some("OnceHourly")),
];
let get = |t: &str| ReminderTarget::parse(t).unwrap().reminder(&rows);
// A state takes its country's window, and keeps its own mode
let ny = get("us-ny");
assert_eq!((ny.window(), ny.mode()), (3, MotdRemindEnum::OnceHourly));
assert_eq!(ny.delta, None);
let jewish = get("jewish");
assert_eq!(
(jewish.window(), jewish.mode()),
(7, MotdRemindEnum::OnlyOnce)
);
// With nothing set: on the day, once a day
let lunar = get("lunar");
assert_eq!(
(lunar.window(), lunar.mode()),
(0, MotdRemindEnum::OnlyOnce)
);
assert!(ReminderTarget::parse("nope").is_err());
assert!(matches!(
ReminderTarget::parse("Religious"),
Ok(ReminderTarget::Group(_))
));
}
}
⭐
Log in to mark this repository.
40.9 KiB · text
History
2c2327e