5.6 KiB · text History aea77cc
use crate::entities::prelude::{Ppl, SigDate};
use crate::notes::{self, Kind, NoteOps, Occurrence};
use crate::PplError;
use chrono::{Local, NaiveDate};
use clap::{Args, Subcommand};
use interim::{parse_date_string, Dialect};
use sea_orm::{DatabaseConnection, EntityTrait};
use std::io;

#[derive(Args, Debug)]
pub struct NoteArgs {
    #[command(subcommand)]
    pub action: Option<NoteAction>,
}

#[derive(Subcommand, Debug)]
pub enum NoteAction {
    /// Notes and todos from today on
    List {
        /// Past ones too
        #[arg(long, short)]
        all: bool,
    },
    /// Add a note to the next occurrence of an event, e.g. `add thanksgiving bring pie`
    Add {
        /// Words from its name, like thanksgiving, "1:1" or "anji birthday" (quote several)
        event: String,
        #[arg(required = true, trailing_var_arg = true)]
        text: Vec<String>,
        /// A todo, which can be ticked off, rather than a note
        #[arg(long, short)]
        todo: bool,
        /// The occurrence on this day instead of the next one: 2026-12-25, "next friday"
        #[arg(long, short)]
        date: Option<String>,
    },
    /// Tick a todo off (or back on)
    Done { id: i32 },
    /// Remove a note or todo
    Rm { id: i32 },
}

fn invalid(msg: String) -> PplError {
    io::Error::new(io::ErrorKind::InvalidInput, msg).into()
}

pub async fn do_notes(args: &NoteArgs, db: &DatabaseConnection) -> Result<(), PplError> {
    match &args.action {
        None => list(db, false).await,
        Some(NoteAction::List { all }) => list(db, *all).await,
        Some(NoteAction::Add {
            event,
            text,
            todo,
            date,
        }) => add(db, event, &text.join(" "), *todo, date.as_deref()).await,
        Some(NoteAction::Done { id }) => {
            let n = NoteOps::toggle(db, *id).await?;
            println!("{}", notes::line(&n));
            Ok(())
        }
        Some(NoteAction::Rm { id }) => {
            NoteOps::delete(db, *id).await?;
            println!("removed note {}", id);
            Ok(())
        }
    }
}

async fn add(
    db: &DatabaseConnection,
    query: &str,
    text: &str,
    todo: bool,
    date: Option<&str>,
) -> Result<(), PplError> {
    let today = Local::now().date_naive();
    let (from, days) = match date {
        Some(d) => (parse_day(d)?, 0),
        None => (today, 400),
    };
    // Every word has to match, so "anji birthday" picks one of Anji's dates
    let words = query.to_lowercase();
    let words = words.split_whitespace().collect::<Vec<_>>();
    let found = notes::upcoming(db, from, days)
        .await?
        .into_iter()
        .filter(|o| {
            let title = o.title.to_lowercase();
            words.iter().all(|w| title.contains(w))
        })
        .collect::<Vec<_>>();
    let Some(first) = found.first() else {
        return Err(invalid(match date {
            Some(d) => format!("nothing matching '{}' on {}", query, d),
            None => format!("nothing matching '{}' in the next year", query),
        }));
    };
    // Different events matched: say which, rather than guess
    let mut events: Vec<&Occurrence> = vec![];
    for o in &found {
        if !events.iter().any(|e| e.target == o.target) {
            events.push(o);
        }
    }
    if events.len() > 1 {
        let list = events
            .iter()
            .take(8)
            .map(|o| format!("  {}  {}", o.date.format("%a %Y-%m-%d"), o.title))
            .collect::<Vec<_>>()
            .join("\n");
        return Err(invalid(format!(
            "'{}' matches more than one event; be more specific:\n{}",
            query, list
        )));
    }
    let kind = if todo { Kind::Todo } else { Kind::Note };
    let n = NoteOps::add(db, first, kind, text).await?;
    println!(
        "{} on {}, {} (#{})",
        notes::line(&n),
        first.title,
        first.date.format("%a %Y-%m-%d"),
        n.id
    );
    if date.is_none() && found.len() > 1 {
        println!("(that's the next one; --date picks another)");
    }
    Ok(())
}

fn parse_day(s: &str) -> Result<NaiveDate, PplError> {
    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
        return Ok(d);
    }
    parse_date_string(s, Local::now(), Dialect::Us)
        .map(|d| d.date_naive())
        .map_err(|_| invalid(format!("can't read '{}' as a date; try YYYY-MM-DD", s)))
}

async fn list(db: &DatabaseConnection, all: bool) -> Result<(), PplError> {
    let today = Local::now().date_naive();
    let mut all_notes = NoteOps::list(db).await?;
    all_notes.retain(|n| all || n.date >= today);
    all_notes.sort_by_key(|n| (n.date, n.id));
    if all_notes.is_empty() {
        println!("no notes; add one with `ppl notes add <event> <text>`");
        return Ok(());
    }
    let ppl = Ppl::find().all(db).await?;
    let dates = SigDate::find().all(db).await?;
    let title = |n: &crate::entities::note::Model| match (&n.calendar, &n.event, n.sig_date_id) {
        (Some(c), Some(e), _) => format!("{} ({})", e, c),
        (_, _, Some(id)) => dates
            .iter()
            .find(|d| d.id == id)
            .and_then(|d| {
                let p = ppl.iter().find(|p| p.id == d.ppl_id)?;
                Some(Occurrence::of_person(d, n.date, p).title)
            })
            .unwrap_or_else(|| "(a date that's gone)".to_string()),
        _ => "?".to_string(),
    };
    let mut last: Option<(NaiveDate, String)> = None;
    for n in &all_notes {
        let heading = (n.date, title(n));
        if last.as_ref() != Some(&heading) {
            println!("{}  {}", heading.0.format("%a %Y-%m-%d"), heading.1);
            last = Some(heading);
        }
        println!("  #{:<4} {}", n.id, notes::line(n));
    }
    Ok(())
}