From d42adbe27499a8ba2604fb17e3b0c6ddea110793 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Mon, 26 Jan 2026 15:25:45 -0600 Subject: [PATCH] feat: mentions in lives_with and text_body fields --- Taskfile | 2 +- migrations/demo.sql | 27 ++-- migrations/each_user/0010_more-mentions.sql | 30 ++++ src/main.rs | 45 ++---- src/models/contact.rs | 3 - src/models/journal.rs | 92 ++---------- src/switchboard.rs | 147 ++++++++++++++++++ src/web/contact.rs | 158 ++++++++++++++------ src/web/home.rs | 13 +- src/web/journal.rs | 52 ++++--- 10 files changed, 369 insertions(+), 200 deletions(-) create mode 100644 migrations/each_user/0010_more-mentions.sql create mode 100644 src/switchboard.rs diff --git a/Taskfile b/Taskfile index 5ddbe28..211d1e8 100755 --- a/Taskfile +++ b/Taskfile @@ -40,7 +40,7 @@ deploy_to_server() { } dev() { - _cargo run -- serve + find src static migrations | entr -ccdr ./Taskfile _cargo run -- serve } "$@" diff --git a/migrations/demo.sql b/migrations/demo.sql index 37e676e..48d40df 100644 --- a/migrations/demo.sql +++ b/migrations/demo.sql @@ -30,17 +30,18 @@ insert into names(contact_id, sort, name) values (3, 0, 'Eleanor Edgeworth'), (3, 1, 'Eleanor'); -insert into contacts(id, lives_with) values (4, 'Henrietta'); +insert into contacts(id, lives_with) values (4, '[[Henrietta]]'); insert into names(contact_id, sort, name) values (4, 0, 'Felicia Homeowner'); -insert into contacts(id, lives_with) values (5, 'Henrietta'); +insert into contacts(id, lives_with) values (5, '[[Henrietta]]'); insert into names(contact_id, sort, name) values (5, 0, 'Gregory Homeowner'); insert into contacts(id) values (6); insert into names(contact_id, sort, name) values - (6, 0, 'Henrietta Homeowner'); + (6, 0, 'Henrietta Homeowner'), + (6, 1, 'Henrietta'); insert into addresses(contact_id, label, value) values (6, null, '123 Main St., Realville, WI 99999'); @@ -53,13 +54,13 @@ insert into journal_entries(id, date, value) values (4, '2024-02-17', 'Friendship ended with [[Bazel]]. Now [[Eleanor Edgeworth]] is my best friend.'), (5, '2024-02-18', 'With [[Bazel]] gone, dissolved [[ABC]] Corp. Start discussions for a potential ACE, Inc. with [[Alexi]] and [[Eleanor]].'); -insert into journal_mentions values - (0, 'Bazel Bagend', 11, 27, '/contact/1'), - (1, 'Alexi', 12, 21, '/contact/0'), - (3, 'ABC', 24, 31, '/group/ABC'), - (4, 'Bazel', 22, 31, '/contact/1'), - (4, 'Eleanor Edgeworth', 37, 58, '/contact/3'), - (5, 'Eleanor', 108, 119, '/contact/3'), - (5, 'Alexi', 94, 103, '/contact/0'), - (5, 'Bazel', 5, 14, '/contact/1'), - (5, 'ABC', 31, 38, '/group/ABC'); +insert into mentions (entity_id, entity_type, input_text, byte_range_start, byte_range_end, url) values + (0, 'journal_entry', 'Bazel Bagend', 11, 27, '/contact/1'), + (1, 'journal_entry', 'Alexi', 12, 21, '/contact/0'), + (3, 'journal_entry', 'ABC', 24, 31, '/group/ABC'), + (4, 'journal_entry', 'Bazel', 22, 31, '/contact/1'), + (4, 'journal_entry', 'Eleanor Edgeworth', 37, 58, '/contact/3'), + (5, 'journal_entry', 'Eleanor', 108, 119, '/contact/3'), + (5, 'journal_entry', 'Alexi', 94, 103, '/contact/0'), + (5, 'journal_entry', 'Bazel', 5, 14, '/contact/1'), + (5, 'journal_entry', 'ABC', 31, 38, '/group/ABC'); diff --git a/migrations/each_user/0010_more-mentions.sql b/migrations/each_user/0010_more-mentions.sql new file mode 100644 index 0000000..cf2d413 --- /dev/null +++ b/migrations/each_user/0010_more-mentions.sql @@ -0,0 +1,30 @@ +create table if not exists mentions ( + entity_id integer not null, + entity_type integer not null, + url text not null, + input_text text not null, + byte_range_start integer not null, + byte_range_end integer not null +); + +insert into mentions ( + entity_id, url, input_text, byte_range_start, byte_range_end, entity_type) + select entry_id, url, input_text, byte_range_start, byte_range_end, 'journal_entry' + from journal_mentions; + +drop table journal_mentions; + +-- entity types: +-- 0: journal_entry +-- 1: contact.text_body +-- 2: contact.lives_with +create trigger if not exists cascade_delete_journal_mentions + after delete on journal_entries for each row begin + delete from mentions where entity_type = 0 and entity_id = OLD.id; + end; + +create trigger if not exists cascade_delete_contact_text_body_mentions + after delete on contacts for each row begin + delete from mentions where entity_type = 1 and entity_id = OLD.id; + delete from mentions where entity_type = 2 and entity_id = OLD.id; + end; diff --git a/src/main.rs b/src/main.rs index 39b8cd7..f5883e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,6 @@ use tower_sessions_sqlx_store::SqliteStore; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod models; -use models::contact::MentionTrie; use models::user::{Backend, User}; mod db; @@ -26,10 +25,13 @@ use db::{Database, DbId}; mod web; use web::{auth, contact, group, home, ics, journal, settings}; +mod switchboard; +use switchboard::Switchboard; + #[derive(Clone)] struct AppStateEntry { database: Arc, - contact_search: Arc>, + switchboard: Arc>, } #[derive(Clone)] @@ -37,10 +39,6 @@ struct AppState { map: Arc>>, } -struct NameReference { - name: String, - contact_id: DbId, -} impl AppState { pub fn new() -> Self { AppState { @@ -49,39 +47,14 @@ impl AppState { } pub async fn init(&mut self, user: &User) -> Result, AppError> { let database = Database::for_user(&user).await?; - let mut trie = radix_trie::Trie::new(); - let mentionable_names = sqlx::query_as!( - NameReference, - "select name, contact_id from ( - select contact_id, name, count(name) as ct from names group by name - ) where ct = 1;", - ) - .fetch_all(&database.pool) - .await?; - - for row in mentionable_names { - trie.insert( - row.name, - format!("/contact/{}", DbId::try_from(row.contact_id)?), - ); - } - - let groups: Vec<(String, String)> = - sqlx::query_as("select distinct name, slug from groups") - .fetch_all(&database.pool) - .await?; - - for (group, slug) in groups { - // TODO urlencode - trie.insert(group, format!("/group/{}", slug)); - } + let switchboard = Switchboard::new(&database.pool).await?; let mut map = self.map.write().expect("rwlock poisoned"); Ok(map.insert( user.id(), crate::AppStateEntry { database: Arc::new(database), - contact_search: Arc::new(RwLock::new(trie)), + switchboard: Arc::new(RwLock::new(switchboard)), }, )) } @@ -93,9 +66,9 @@ impl AppState { let map = self.map.read().expect("rwlock poisoned"); map.get(&user.id()).unwrap().database.clone() } - pub fn contact_search(&self, user: &impl AuthUser) -> Arc> { + pub fn switchboard(&self, user: &impl AuthUser) -> Arc> { let map = self.map.read().expect("rwlock poisoned"); - map.get(&user.id()).unwrap().contact_search.clone() + map.get(&user.id()).unwrap().switchboard.clone() } } @@ -177,7 +150,7 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> { .with( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { format!( - "{}=debug,tower_http=debug,axum=trace,sqlx=debug", + "{}=debug,tower_http=debug,axum=trace", env!("CARGO_CRATE_NAME") ) .into() diff --git a/src/models/contact.rs b/src/models/contact.rs index ebcb7f6..46353ea 100644 --- a/src/models/contact.rs +++ b/src/models/contact.rs @@ -38,9 +38,6 @@ impl HydratedContact { } } -/* name/group, url */ -pub type MentionTrie = radix_trie::Trie; - impl FromRow<'_, SqliteRow> for Contact { fn from_row(row: &SqliteRow) -> sqlx::Result { let id: DbId = row.try_get("id")?; diff --git a/src/models/journal.rs b/src/models/journal.rs index 60e35d7..a707add 100644 --- a/src/models/journal.rs +++ b/src/models/journal.rs @@ -1,15 +1,12 @@ use chrono::NaiveDate; -use maud::{Markup, PreEscaped, html}; -use regex::Regex; +use maud::{Markup, html}; 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::MentionTrie; use crate::AppError; use crate::db::DbId; +use crate::switchboard::{MentionHost, MentionHostType}; #[derive(Debug)] pub struct JournalEntry { @@ -18,84 +15,19 @@ pub struct JournalEntry { pub date: NaiveDate, } -#[derive(Debug, PartialEq, Eq, Hash, FromRow)] -pub struct Mention { - pub entry_id: DbId, - pub url: String, - pub input_text: String, - pub byte_range_start: u32, - pub byte_range_end: u32, +impl<'a> Into> for &'a JournalEntry { + fn into(self) -> MentionHost<'a> { + MentionHost { + entity_id: self.id, + entity_type: MentionHostType::JournalEntry as DbId, + input: &self.value, + } + } } impl JournalEntry { - pub fn extract_mentions(&self, trie: &MentionTrie) -> HashSet { - 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(|url| Mention { - entry_id: self.id, - url: url.to_string(), - 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>, - pool: &SqlitePool, - ) -> Result, AppError> { - let mentions = { - let trie = trie.read().unwrap(); - self.extract_mentions(&trie) - }; - - for mention in &mentions { - sqlx::query!( - "insert into journal_mentions( - entry_id, url, input_text, - byte_range_start, byte_range_end - ) values ($1, $2, $3, $4, $5)", - mention.entry_id, - mention.url, - 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 { - // 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 = sqlx::query_as( - "select * from journal_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 { - tracing::debug!("url ({})", mention.url); - value.replace_range( - (mention.byte_range_start as usize)..(mention.byte_range_end as usize), - &format!("[{}]({})", mention.input_text, mention.url), - ); - } - + let rendered = Into::::into(self).format_pool(pool).await?; let entry_url = format!("/journal_entry/{}", self.id); let date = self.date.to_string(); @@ -103,7 +35,7 @@ impl JournalEntry { .entry { .view ":class"="{ hide: edit }" { .date { (date) } - .content { (PreEscaped(markdown::to_html(&value))) } + .content { (rendered) } } 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"; diff --git a/src/switchboard.rs b/src/switchboard.rs new file mode 100644 index 0000000..8610f22 --- /dev/null +++ b/src/switchboard.rs @@ -0,0 +1,147 @@ +use maud::{Markup, PreEscaped}; +use regex::Regex; +use sqlx::QueryBuilder; +use sqlx::sqlite::SqlitePool; +use std::collections::HashSet; + +use crate::AppError; +use crate::db::DbId; + +pub struct Switchboard { + trie: radix_trie::Trie, +} + +struct Mentionable { + text: String, + uri: String, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct Mention { + pub entity_id: DbId, + pub entity_type: DbId, + pub url: String, + pub input_text: String, + pub byte_range_start: DbId, + pub byte_range_end: DbId, +} + +// must match the constants in trigger definitions in +// migrations/each_user/0010_more-mentions.sql (or future migrations) +#[derive(Copy, Clone)] +pub enum MentionHostType { + JournalEntry, + ContactTextBody, + ContactLivesWith, +} + +#[derive(Copy, Clone)] +pub struct MentionHost<'a> { + pub entity_id: DbId, + pub entity_type: DbId, + pub input: &'a String, +} + +impl MentionHost<'_> { + pub fn format<'a>( + self: &Self, + mentions: impl IntoIterator, + ) -> Result { + let mut out = self.input.clone(); + for mention in mentions.into_iter() { + out.replace_range( + (mention.byte_range_start as usize)..(mention.byte_range_end as usize), + &format!("[{}]({})", mention.input_text, mention.url), + ); + } + + Ok(PreEscaped(markdown::to_html(&out))) + } + pub async fn format_pool(self: &Self, pool: &SqlitePool) -> Result { + let mentions = sqlx::query_as!( + Mention, + "select * from mentions + where entity_id = $1 and entity_type = $2 + order by byte_range_start desc", + self.entity_id, + self.entity_type + ) + .fetch_all(pool) + .await?; + + self.format(&mentions) + } +} + +impl Switchboard { + pub async fn new(pool: &SqlitePool) -> Result { + let mut trie = radix_trie::Trie::new(); + + let mentionables = sqlx::query_as!( + Mentionable, + "select name as text, '/contact/'||contact_id as uri from ( + select contact_id, name, count(name) as ct from names group by name + ) where ct = 1 + union + select distinct name as text, '/group/'||slug as uri from groups", + ) + .fetch_all(pool) + .await?; + + for mentionable in mentionables { + trie.insert(mentionable.text, mentionable.uri); + } + + Ok(Switchboard { trie }) + } + + pub fn remove(self: &mut Self, text: &String) { + self.trie.remove(text); + } + + pub fn add_mentionable(self: &mut Self, text: String, uri: String) { + self.trie.insert(text, uri); + } + + pub fn extract_mentions<'a>(&self, host: impl Into>) -> HashSet { + let host: MentionHost = host.into(); + let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap(); + name_re + .captures_iter(host.input) + .map(|caps| { + let range = caps.get_match().range(); + self.trie.get(&caps[1]).map(|url| Mention { + entity_id: host.entity_id, + entity_type: host.entity_type, + url: url.to_string(), + input_text: caps[1].to_string(), + byte_range_start: DbId::try_from(range.start).unwrap(), + byte_range_end: DbId::try_from(range.end).unwrap(), + }) + }) + .filter(|o| o.is_some()) + .map(|o| o.unwrap()) + .collect() + } +} + +pub async fn insert_mentions<'a>( + mentions: impl IntoIterator, + pool: &SqlitePool, +) -> Result<(), AppError> { + let mut qb = QueryBuilder::::new( + "insert into mentions ( + entity_id, entity_type, url, input_text, + byte_range_start, byte_range_end) ", + ); + qb.push_values(mentions, |mut b, mention| { + b.push_bind(mention.entity_id) + .push_bind(mention.entity_type) + .push_bind(&mention.url) + .push_bind(&mention.input_text) + .push_bind(mention.byte_range_start) + .push_bind(mention.byte_range_end); + }); + qb.build().execute(pool).await?; + Ok(()) +} diff --git a/src/web/contact.rs b/src/web/contact.rs index 25ca427..e4c03f1 100644 --- a/src/web/contact.rs +++ b/src/web/contact.rs @@ -8,7 +8,7 @@ use axum::{ use axum_extra::extract::Form; use cache_bust::asset; use chrono::DateTime; -use maud::{Markup, PreEscaped, html}; +use maud::{Markup, html}; use serde::Deserialize; use serde_json::json; use slug::slugify; @@ -19,6 +19,7 @@ use super::home::journal_section; use crate::db::DbId; use crate::models::user::AuthSession; use crate::models::{HydratedContact, JournalEntry}; +use crate::switchboard::{MentionHost, MentionHostType, insert_mentions}; use crate::{AppError, AppState}; #[derive(serde::Serialize, Debug)] @@ -69,10 +70,12 @@ mod get { pub async fn contact( auth_session: AuthSession, State(state): State, - Path(contact_id): Path, + Path(contact_id): Path, layout: Layout, ) -> Result { - let pool = &state.db(&auth_session.user.unwrap()).pool; + let user = auth_session.user.unwrap(); + let pool = &state.db(&user).pool; + let contact: HydratedContact = sqlx::query_as( "select *, ( select string_agg(name,'\x1c' order by sort) @@ -87,18 +90,30 @@ mod get { let entries: Vec = sqlx::query_as( "select distinct j.id, j.value, j.date from journal_entries j - join journal_mentions cm on j.id = cm.entry_id - where cm.url = '/contact/'||$1 or cm.url in ( + join mentions m on j.id = m.entity_id + where m.entity_type = $1 and (m.url = '/contact/'||$1 or m.url in ( select '/group/'||slug from groups - where contact_id = $1 - ) + where contact_id = $2 + )) order by j.date desc ", ) + .bind(MentionHostType::JournalEntry as DbId) .bind(contact_id) .fetch_all(pool) .await?; + let lives_with = if contact.lives_with.len() > 1 { + let mention_host = MentionHost { + entity_id: contact_id, + entity_type: MentionHostType::ContactLivesWith as DbId, + input: &contact.lives_with, + }; + Some(mention_host.format_pool(pool).await?) + } else { + None + }; + let addresses: Vec
= sqlx::query_as!( Address, "select * from addresses where contact_id = $1", @@ -157,9 +172,9 @@ mod get { } } - @if contact.lives_with.len() > 0 { + @if let Some(lives_with) = lives_with { label { "lives with" } - div { (contact.lives_with) } + div { (lives_with) } } @if addresses.len() == 1 { @@ -196,7 +211,11 @@ mod get { @if let Some(text_body) = text_body { @if text_body.len() > 0 { - #text_body { (PreEscaped(markdown::to_html(&text_body))) } + #text_body { (MentionHost { + entity_id: contact_id, + entity_type: MentionHostType::ContactTextBody as DbId, + input: &text_body + }.format_pool(pool).await?) } } } @@ -208,7 +227,7 @@ mod get { pub async fn contact_edit( auth_session: AuthSession, State(state): State, - Path(contact_id): Path, + Path(contact_id): Path, layout: Layout, ) -> Result { let pool = &state.db(&auth_session.user.unwrap()).pool; @@ -218,17 +237,20 @@ mod get { from names where contact_id = c.id ) as names, ( select jes.date from journal_entries jes - join journal_mentions cms on cms.entry_id = jes.id - where cms.url = '/contact/'||c.id - or cms.url in ( - select '/group/'||name - from groups - where contact_id = c.id - ) + join mentions m on m.entity_id = jes.id + where + m.entity_type = $1 and ( + m.url = '/contact/'||c.id + or m.url in ( + select '/group/'||name + from groups + where contact_id = c.id + )) order by jes.date desc limit 1 ) as last_mention_date from contacts c - where c.id = $1", + where c.id = $2", ) + .bind(MentionHostType::JournalEntry as DbId) .bind(contact_id) .fetch_one(pool) .await?; @@ -391,6 +413,7 @@ mod put { ) -> Result { let user = auth_session.user.unwrap(); let pool = &state.db(&user).pool; + let sw_lock = state.switchboard(&user); let birthday = if payload.birthday.is_empty() { None @@ -415,8 +438,15 @@ mod put { Some(payload.text_body) }; + let old_contact = sqlx::query!("select * from contacts where id = $1", contact_id) + .fetch_one(pool) + .await?; + sqlx::query!( - "update contacts set (birthday, manually_freshened_at, lives_with, text_body) = ($1, $2, $3, $4) where id = $5", + "update contacts set + (birthday, manually_freshened_at, lives_with, text_body) = + ($1, $2, $3, $4) + where id = $5", birthday, manually_freshened_at, payload.lives_with, @@ -426,6 +456,52 @@ mod put { .execute(pool) .await?; + if old_contact.lives_with != payload.lives_with { + sqlx::query!( + "delete from mentions where entity_id = $1 and entity_type = $2", + contact_id, + MentionHostType::ContactLivesWith as DbId + ) + .execute(pool) + .await?; + + let mention_host = MentionHost { + entity_id: contact_id, + entity_type: MentionHostType::ContactLivesWith as DbId, + input: &payload.lives_with, + }; + + let mentions = { + let switchboard = sw_lock.read().unwrap(); + switchboard.extract_mentions(mention_host) + }; + insert_mentions(&mentions, pool).await?; + } + + if old_contact.text_body != text_body { + sqlx::query!( + "delete from mentions where entity_id = $1 and entity_type = $2", + contact_id, + MentionHostType::ContactTextBody as DbId + ) + .execute(pool) + .await?; + + if text_body.is_some() { + let mention_host = MentionHost { + entity_id: contact_id, + entity_type: MentionHostType::ContactTextBody as DbId, + input: &text_body.unwrap(), + }; + + let mentions = { + let switchboard = sw_lock.read().unwrap(); + switchboard.extract_mentions(mention_host) + }; + insert_mentions(&mentions, pool).await?; + } + } + // these blocks are not in functions because payload gets progressively // partially moved as we handle each field and i don't want to deal with it @@ -485,11 +561,11 @@ mod put { let old_names: Vec = old_names.into_iter().map(|(s,)| s).collect(); if old_names != new_names { - // delete and regen *all* journal mentions, not just the ones for the - // current user, since changing *this* user's names can change, *globally*, + // delete and regen *all* mentions, not just the ones for the current + // contact, since changing *this* contact's names can change, *globally*, // which names have n=1 and thus are eligible for mentioning sqlx::query!( - "delete from journal_mentions; delete from names where contact_id = $1", + "delete from mentions; delete from names where contact_id = $1", contact_id ) .execute(pool) @@ -531,14 +607,13 @@ mod put { .await?; { - let trie_mutex = state.contact_search(&user); - let mut trie = trie_mutex.write().unwrap(); + let mut switchboard = sw_lock.write().unwrap(); for name in &old_names { - trie.remove(name); + switchboard.remove(name); } for name in recalc_names { - trie.insert(name.0, format!("/contact/{}", name.1)); + switchboard.add_mentionable(name.0, format!("/contact/{}", name.1)); } } } @@ -558,7 +633,7 @@ mod put { if new_groups != old_groups { sqlx::query!( - "delete from journal_mentions; delete from groups where contact_id = $1", + "delete from mentions; delete from groups where contact_id = $1", contact_id ) .execute(pool) @@ -576,17 +651,17 @@ mod put { .await?; { - let trie_mutex = state.contact_search(&user); - let mut trie = trie_mutex.write().unwrap(); + let mut switchboard = sw_lock.write().unwrap(); for name in &old_groups { // TODO i think we care about group name vs contact name counts, // otherwise this will cause a problem (or we want to disallow // setting group names that are contact names or vice versa?) - trie.remove(name); + switchboard.remove(name); } for group in &new_groups { - trie.insert(group.clone(), format!("/group/{}", slugify(group))); + switchboard + .add_mentionable(group.clone(), format!("/group/{}", slugify(group))); } } } @@ -598,9 +673,11 @@ mod put { .await?; for entry in journal_entries { - entry - .insert_mentions(state.contact_search(&user), pool) - .await?; + let mentions = { + let switchboard = sw_lock.read().unwrap(); + switchboard.extract_mentions(&entry) + }; + insert_mentions(&mentions, pool).await?; } } } @@ -620,14 +697,9 @@ mod delete { let user = auth_session.user.unwrap(); let pool = &state.db(&user).pool; - sqlx::query( - "delete from journal_mentions where contact_id = $1; - delete from names where contact_id = $1; - delete from contacts where id = $1;", - ) - .bind(contact_id) - .execute(pool) - .await?; + sqlx::query!("delete from contacts where id = $1", contact_id) + .execute(pool) + .await?; let mut headers = HeaderMap::new(); headers.insert("HX-Redirect", "/".parse()?); diff --git a/src/web/home.rs b/src/web/home.rs index ce3ebae..c3d37a3 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -9,6 +9,7 @@ use super::Layout; use crate::db::DbId; use crate::models::user::AuthSession; use crate::models::{Birthday, HydratedContact, JournalEntry}; +use crate::switchboard::{MentionHost, MentionHostType}; use crate::{AppError, AppState}; #[derive(Debug, Clone)] @@ -109,7 +110,7 @@ pub async fn journal_section( .entries { @for entry in entries { - (entry.to_html(pool).await?) + (Into::::into(entry).format_pool(pool).await?) } } } @@ -124,18 +125,22 @@ pub mod get { State(state): State, layout: Layout, ) -> Result { - let pool = &state.db(&auth_session.user.unwrap()).pool; + let user = auth_session.user.unwrap(); + let pool = &state.db(&user).pool; + let contacts: Vec = sqlx::query_as( "select id, birthday, manually_freshened_at, ( select string_agg(name,'\x1c' order by sort) from names where contact_id = c.id ) as names, ( select jes.date from journal_entries jes - join journal_mentions cms on cms.entry_id = jes.id - where cms.url = '/contact/'||c.id + join mentions ms on ms.entity_id = jes.id + where ms.entity_type = $1 + and ms.url = '/contact/'||c.id order by jes.date desc limit 1 ) as last_mention_date from contacts c", ) + .bind(MentionHostType::JournalEntry as DbId) .fetch_all(pool) .await?; diff --git a/src/web/journal.rs b/src/web/journal.rs index 26ac93e..c27aab7 100644 --- a/src/web/journal.rs +++ b/src/web/journal.rs @@ -11,6 +11,7 @@ use serde::Deserialize; use crate::models::JournalEntry; use crate::models::user::AuthSession; +use crate::switchboard::{MentionHost, insert_mentions}; use crate::{AppError, AppState}; pub fn router() -> Router { @@ -36,6 +37,8 @@ mod post { ) -> Result { let user = auth_session.user.unwrap(); let pool = &state.db(&user).pool; + let sw_lock = state.switchboard(&user); + let now = Local::now().date_naive(); let date = if payload.date.is_empty() { @@ -73,9 +76,11 @@ mod post { .fetch_one(pool) .await?; - entry - .insert_mentions(state.contact_search(&user), pool) - .await?; + let mentions = { + let switchboard = sw_lock.read().unwrap(); + switchboard.extract_mentions(&entry) + }; + insert_mentions(&mentions, pool).await?; Ok(entry.to_html(pool).await?) } @@ -84,6 +89,7 @@ mod post { mod patch { use super::*; + #[axum::debug_handler] pub async fn entry( auth_session: AuthSession, State(state): State, @@ -92,8 +98,10 @@ mod patch { ) -> Result { let user = auth_session.user.unwrap(); let pool = &state.db(&user).pool; + let sw_lock = state.switchboard(&user); + // not a macro query, we want to use JournalEntry's custom FromRow - let entry: JournalEntry = sqlx::query_as("select * from journal_entries where id = $1") + let old_entry: JournalEntry = sqlx::query_as("select * from journal_entries where id = $1") .bind(entry_id) .fetch_one(pool) .await?; @@ -107,17 +115,24 @@ mod patch { .fetch_one(pool) .await?; - if entry.value != new_entry.value { - sqlx::query!("delete from journal_mentions where entry_id = $1", entry_id) - .execute(pool) - .await?; + if old_entry.value != new_entry.value { + sqlx::query!( + "delete from mentions where entity_id = $1 and entity_type = 'journal_entry'", + entry_id + ) + .execute(pool) + .await?; - new_entry - .insert_mentions(state.contact_search(&user), pool) - .await?; + let mentions = { + let switchboard = sw_lock.read().unwrap(); + switchboard.extract_mentions(&new_entry) + }; + insert_mentions(&mentions, pool).await?; } - Ok(new_entry.to_html(pool).await?) + Ok(Into::::into(&new_entry) + .format_pool(pool) + .await?) } } @@ -132,14 +147,11 @@ mod delete { let user = auth_session.user.unwrap(); let pool = &state.db(&user).pool; - sqlx::query( - "delete from journal_mentions where entry_id = $1; - delete from journal_entries where id = $2 returning id,date,value", - ) - .bind(entry_id) - .bind(entry_id) - .execute(pool) - .await?; + sqlx::query("delete from journal_entries where id = $2 returning id,date,value") + .bind(entry_id) + .bind(entry_id) + .execute(pool) + .await?; Ok(()) }