major features update

This commit is contained in:
Robert Perce 2025-11-27 13:45:21 -06:00
parent 519fb49901
commit 4e2fab67c5
48 changed files with 3925 additions and 208 deletions

93
src/models/birthday.rs Normal file
View file

@ -0,0 +1,93 @@
use chrono::Local;
use sqlx::sqlite::SqliteRow;
use sqlx::{FromRow, Row};
use std::fmt::Display;
use std::str::FromStr;
use crate::models::YearOptionalDate;
#[derive(Debug, Clone)]
pub struct Text {
// language: Option<String>,
pub value: String,
}
#[derive(Debug, Clone)]
pub enum Birthday {
Date(YearOptionalDate),
Text(Text),
}
impl Display for Birthday {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let str = match self {
Birthday::Date(date) => date.to_string(),
Birthday::Text(t) => t.value.clone(),
};
write!(f, "{}", str)
}
}
impl Birthday {
pub fn next_occurrence(&self) -> Option<chrono::NaiveDate> {
match &self {
Birthday::Text(_) => None,
Birthday::Date(date) => Some(date.next_month_day_occurrence()?),
}
}
pub fn until_next(&self) -> Option<chrono::TimeDelta> {
self.next_occurrence()
.map(|when| when.signed_duration_since(Local::now().date_naive()))
}
/// None if this is a text birthday or doesn't have a year
pub fn age(&self) -> Option<u32> {
match &self {
Birthday::Text(_) => None,
Birthday::Date(date) => date
.to_date_naive()
.map(|birthdate| Local::now().date_naive().years_since(birthdate))?,
}
}
pub fn serialize(&self) -> String {
match &self {
Birthday::Text(text) => text.value.clone(),
Birthday::Date(date) => date.serialize(),
}
}
}
impl FromStr for Birthday {
type Err = ();
fn from_str(str: &str) -> Result<Self, Self::Err> {
if let Some(date) = YearOptionalDate::from_str(str).ok() {
Ok(Birthday::Date(date))
} else {
Ok(Birthday::Text(super::birthday::Text {
value: str.to_string(),
}))
}
}
}
impl FromRow<'_, SqliteRow> for Birthday {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let birthday_str = row.try_get("birthday")?;
Ok(Birthday::from_str(birthday_str).unwrap())
}
}
use sqlx::{Database, Decode, Sqlite};
impl<'r> Decode<'r, Sqlite> for Birthday
where
&'r str: Decode<'r, Sqlite>,
{
fn decode(
value: <Sqlite as Database>::ValueRef<'r>,
) -> Result<Birthday, Box<dyn std::error::Error + 'static + Send + Sync>> {
let value = <&str as Decode<Sqlite>>::decode(value)?;
Ok(Birthday::from_str(value).unwrap())
}
}

86
src/models/contact.rs Normal file
View file

@ -0,0 +1,86 @@
use chrono::{DateTime, NaiveDate, Utc};
use sqlx::sqlite::SqliteRow;
use sqlx::{FromRow, Row};
use std::str::FromStr;
use super::Birthday;
use crate::db::DbId;
#[derive(Clone, Debug)]
pub struct Contact {
pub id: DbId,
pub birthday: Option<Birthday>,
pub manually_freshened_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug)]
pub struct HydratedContact {
pub contact: Contact,
pub last_mention_date: Option<NaiveDate>,
pub names: Vec<String>,
}
impl std::ops::Deref for HydratedContact {
type Target = Contact;
fn deref(&self) -> &Self::Target {
&self.contact
}
}
impl HydratedContact {
pub fn display_name(&self) -> String {
if let Some(name) = self.names.first() {
name.clone()
} else {
"(unnamed)".to_string()
}
}
}
pub type ContactTrie = radix_trie::Trie<String, DbId>;
impl FromRow<'_, SqliteRow> for Contact {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let id: DbId = row.try_get("id")?;
let birthday = Birthday::from_row(row).ok();
let manually_freshened_at = row
.try_get::<String, &str>("manually_freshened_at")
.ok()
.and_then(|str| {
DateTime::parse_from_str(&str, "%+")
.ok()
.map(|d| d.to_utc())
});
Ok(Self {
id,
birthday,
manually_freshened_at,
})
}
}
impl FromRow<'_, SqliteRow> for HydratedContact {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let contact = Contact::from_row(row)?;
let names_str: String = row.try_get("names").unwrap_or("".to_string());
let names = if names_str.is_empty() {
vec![]
} else {
names_str.split('\x1c').map(|s| s.to_string()).collect()
};
let last_mention_date = row
.try_get::<String, &str>("last_mention_date")
.ok()
.and_then(|str| NaiveDate::from_str(&str).ok());
Ok(Self {
contact,
names,
last_mention_date,
})
}
}

144
src/models/journal.rs Normal file
View file

@ -0,0 +1,144 @@
use chrono::NaiveDate;
use maud::{Markup, PreEscaped, html};
use regex::Regex;
use serde_json::json;
use sqlx::sqlite::{SqlitePool, SqliteRow};
use sqlx::{FromRow, Row};
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use super::contact::ContactTrie;
use crate::AppError;
use crate::db::DbId;
#[derive(Debug)]
pub struct JournalEntry {
pub id: DbId,
pub value: String,
pub date: NaiveDate,
}
#[derive(Debug, PartialEq, Eq, Hash, FromRow)]
pub struct ContactMention {
pub entry_id: DbId,
pub contact_id: DbId,
pub input_text: String,
pub byte_range_start: u32,
pub byte_range_end: u32,
}
impl JournalEntry {
pub fn extract_mentions(&self, trie: &ContactTrie) -> HashSet<ContactMention> {
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();
name_re
.captures_iter(&self.value)
.map(|caps| {
let range = caps.get_match().range();
trie.get(&caps[1]).map(|cid| ContactMention {
entry_id: self.id,
contact_id: cid.to_owned(),
input_text: caps[1].to_string(),
byte_range_start: u32::try_from(range.start).unwrap(),
byte_range_end: u32::try_from(range.end).unwrap(),
})
})
.filter(|o| o.is_some())
.map(|o| o.unwrap())
.collect()
}
pub async fn insert_mentions(
&self,
trie: Arc<RwLock<ContactTrie>>,
pool: &SqlitePool,
) -> Result<HashSet<ContactMention>, AppError> {
let mentions = {
let trie = trie.read().unwrap();
self.extract_mentions(&trie)
};
for mention in &mentions {
sqlx::query!(
"insert into contact_mentions(
entry_id, contact_id, input_text,
byte_range_start, byte_range_end
) values ($1, $2, $3, $4, $5)",
mention.entry_id,
mention.contact_id,
mention.input_text,
mention.byte_range_start,
mention.byte_range_end
)
.execute(pool)
.await?;
}
Ok(mentions)
}
pub async fn to_html(&self, pool: &SqlitePool) -> Result<Markup, AppError> {
// important to sort desc so that changing contents early in the string
// doesn't break inserting mentions at byte offsets further in
let mentions: Vec<ContactMention> = sqlx::query_as(
"select * from contact_mentions
where entry_id = $1 order by byte_range_start desc",
)
.bind(self.id)
.fetch_all(pool)
.await?;
let mut value = self.value.clone();
for mention in mentions {
value.replace_range(
(mention.byte_range_start as usize)..(mention.byte_range_end as usize),
&format!("[{}](/contact/{})", mention.input_text, mention.contact_id),
);
}
let entry_url = format!("/journal_entry/{}", self.id);
let date = self.date.to_string();
Ok(html! {
.entry {
.view ":class"="{ hide: edit }" {
.date { (date) }
.content { (PreEscaped(markdown::to_html(&value))) }
}
form .edit ":class"="{ hide: !edit }" x-data=(json!({ "date": date, "initial_date": date, "value": self.value, "initial_value": self.value })) {
input name="date" x-model="date";
.controls {
textarea name="value" x-model="value" {}
button title="Delete"
hx-delete=(entry_url)
hx-target="closest .entry"
hx-swap="delete" {
svg .icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" {
path d="M232.7 69.9C237.1 56.8 249.3 48 263.1 48L377 48C390.8 48 403 56.8 407.4 69.9L416 96L512 96C529.7 96 544 110.3 544 128C544 145.7 529.7 160 512 160L128 160C110.3 160 96 145.7 96 128C96 110.3 110.3 96 128 96L224 96L232.7 69.9zM128 208L512 208L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 208zM216 272C202.7 272 192 282.7 192 296L192 488C192 501.3 202.7 512 216 512C229.3 512 240 501.3 240 488L240 296C240 282.7 229.3 272 216 272zM320 272C306.7 272 296 282.7 296 296L296 488C296 501.3 306.7 512 320 512C333.3 512 344 501.3 344 488L344 296C344 282.7 333.3 272 320 272zM424 272C410.7 272 400 282.7 400 296L400 488C400 501.3 410.7 512 424 512C437.3 512 448 501.3 448 488L448 296C448 282.7 437.3 272 424 272z";
}
}
button x-bind:disabled="(date === initial_date) && (value === initial_value)"
x-on:click="initial_date = date; initial_value = value"
hx-patch=(entry_url)
hx-target="previous .entry"
hx-swap="outerHTML"
title="Save" { "" }
button x-bind:disabled="(date === initial_date) && (value === initial_value)"
title="Discard changes"
x-on:click="date = initial_date; value = initial_value"
{ "" }
}
}
}
})
}
}
impl FromRow<'_, SqliteRow> for JournalEntry {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let id: DbId = row.try_get("id")?;
let value: String = row.try_get("value")?;
let date_str: &str = row.try_get("date")?;
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap();
Ok(Self { id, value, date })
}
}

126
src/models/user.rs Normal file
View file

@ -0,0 +1,126 @@
use axum_login::{AuthUser, AuthnBackend, UserId};
use password_auth::verify_password;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use tokio::task;
#[derive(Clone, Serialize, Deserialize, FromRow)]
pub struct User {
id: i64,
pub username: String,
password: String,
pub ephemeral: bool,
}
// Here we've implemented `Debug` manually to avoid accidentally logging the
// password hash.
impl std::fmt::Debug for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User")
.field("id", &self.id)
.field("username", &self.username)
.field("password", &"[redacted]")
.field("ephemeral", &self.ephemeral)
.finish()
}
}
impl AuthUser for User {
type Id = i64;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
// We use the password hash as the auth hash--what this means
// is when the user changes their password the auth session becomes invalid.
self.password.as_bytes()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone)]
pub struct Backend {
db: SqlitePool,
}
impl Backend {
pub fn new(db: SqlitePool) -> Self {
Self { db }
}
pub async fn set_password(&self, creds: Credentials) -> Result<(), anyhow::Error> {
if creds.username != "demo" {
sqlx::query("update users set password=$2 where username=$1")
.bind(creds.username)
.bind(password_auth::generate_hash(creds.password))
.execute(&self.db)
.await?;
}
Ok(())
}
pub async fn find_user(
&self,
username: impl AsRef<str>,
) -> Result<Option<User>, anyhow::Error> {
let user = sqlx::query_as("select * from users where username = ?")
.bind(username.as_ref())
.fetch_optional(&self.db)
.await?;
Ok(user)
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error(transparent)]
TaskJoin(#[from] task::JoinError),
}
impl AuthnBackend for Backend {
type User = User;
type Credentials = Credentials;
type Error = Error;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user: Option<Self::User> = sqlx::query_as("select * from users where username = $1")
.bind(creds.username)
.fetch_optional(&self.db)
.await?;
// Verifying the password is blocking and potentially slow, so we'll do so via
// `spawn_blocking`.
task::spawn_blocking(|| {
// We're using password-based authentication--this works by comparing our form
// input with an argon2 password hash.
Ok(user.filter(|user| verify_password(creds.password, &user.password).is_ok()))
})
.await?
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let user = sqlx::query_as("select * from users where id = ?")
.bind(user_id)
.fetch_optional(&self.db)
.await?;
Ok(user)
}
}
pub type AuthSession = axum_login::AuthSession<Backend>;

View file

@ -0,0 +1,114 @@
use chrono::{Datelike, Local, NaiveDate};
use regex::Regex;
use sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull};
use std::fmt::Display;
use std::str::FromStr;
#[derive(Debug, Clone)]
pub struct YearOptionalDate {
pub year: Option<i32>,
pub month: u32,
pub day: u32,
}
impl YearOptionalDate {
pub fn prev_month_day_occurrence(&self) -> Option<NaiveDate> {
let now = Local::now();
let year = now.year();
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
if let Some(real_date) = date {
if real_date >= now.date_naive() {
date = NaiveDate::from_ymd_opt(year - 1, self.month, self.day);
}
}
date
}
pub fn next_month_day_occurrence(&self) -> Option<NaiveDate> {
let now = Local::now();
let year = now.year();
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
if let Some(real_date) = date {
if real_date < now.date_naive() {
date = NaiveDate::from_ymd_opt(year + 1, self.month, self.day);
}
}
date
}
pub fn to_date_naive(&self) -> Option<NaiveDate> {
if let Some(year) = self.year {
NaiveDate::from_ymd_opt(year, self.month, self.day)
} else {
None
}
}
pub fn serialize(&self) -> String {
format!(
"{}{:0>2}{:0>2}",
self.year.map_or("--".to_string(), |y| format!("{:0>4}", y)),
self.month,
self.day
)
}
}
impl Display for YearOptionalDate {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
if let Some(year) = self.year {
write!(f, "{:0>4}-", year)?;
}
write!(f, "{:0>2}-{:0>2}", self.month, self.day)
}
}
impl FromStr for YearOptionalDate {
type Err = anyhow::Error;
fn from_str(str: &str) -> Result<Self, Self::Err> {
let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap();
if let Some(caps) = date_re.captures(str) {
let year_str = &caps[1];
let month = u32::from_str(&caps[2]).unwrap();
let day = u32::from_str(&caps[3]).unwrap();
let year = if year_str == "--" {
None
} else {
Some(i32::from_str(year_str).unwrap())
};
return Ok(Self { year, month, day });
}
Err(anyhow::Error::msg(format!(
"parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}|--)[0-9]{{4}}/",
str
)))
}
}
// `'r` is the lifetime of the `Row` being decoded
impl<'r, DB: Database> Decode<'r, DB> for YearOptionalDate
where
// we want to delegate some of the work to string decoding so let's make sure strings
// are supported by the database
&'r str: Decode<'r, DB>,
{
fn decode(
value: <DB as Database>::ValueRef<'r>,
) -> Result<Self, Box<dyn std::error::Error + 'static + Send + Sync>> {
let value = <&str as Decode<DB>>::decode(value)?;
Ok(value.parse()?)
}
}
impl<'r> Encode<'r, Sqlite> for YearOptionalDate
where
&'r str: Encode<'r, Sqlite>,
{
fn encode_by_ref(
&self,
buf: &mut <Sqlite as Database>::ArgumentBuffer<'r>,
) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
<String as Encode<'r, Sqlite>>::encode(self.serialize(), buf)
}
}