feat: mentions in lives_with and text_body fields
Some checks failed
/ integration-test--firefox (push) Failing after 3m7s

This commit is contained in:
Robert Perce 2026-01-26 15:25:45 -06:00
parent fd5f1899c1
commit d42adbe274
10 changed files with 369 additions and 200 deletions

View file

@ -40,7 +40,7 @@ deploy_to_server() {
}
dev() {
_cargo run -- serve
find src static migrations | entr -ccdr ./Taskfile _cargo run -- serve
}
"$@"

View file

@ -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');

View file

@ -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;

View file

@ -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<Database>,
contact_search: Arc<RwLock<MentionTrie>>,
switchboard: Arc<RwLock<Switchboard>>,
}
#[derive(Clone)]
@ -37,10 +39,6 @@ struct AppState {
map: Arc<RwLock<HashMap<DbId, AppStateEntry>>>,
}
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<Option<AppStateEntry>, 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<Id = DbId>) -> Arc<RwLock<MentionTrie>> {
pub fn switchboard(&self, user: &impl AuthUser<Id = DbId>) -> Arc<RwLock<Switchboard>> {
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()

View file

@ -38,9 +38,6 @@ impl HydratedContact {
}
}
/* name/group, url */
pub type MentionTrie = radix_trie::Trie<String, String>;
impl FromRow<'_, SqliteRow> for Contact {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let id: DbId = row.try_get("id")?;

View file

@ -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<MentionHost<'a>> 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<Mention> {
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<RwLock<MentionTrie>>,
pool: &SqlitePool,
) -> Result<HashSet<Mention>, 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<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<Mention> = 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::<MentionHost>::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";

147
src/switchboard.rs Normal file
View file

@ -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<String, String>,
}
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<Item = &'a Mention>,
) -> Result<Markup, AppError> {
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<Markup, AppError> {
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<Self, AppError> {
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<MentionHost<'a>>) -> HashSet<Mention> {
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<Item = &'a Mention>,
pool: &SqlitePool,
) -> Result<(), AppError> {
let mut qb = QueryBuilder::<sqlx::Sqlite>::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(())
}

View file

@ -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<AppState>,
Path(contact_id): Path<u32>,
Path(contact_id): Path<DbId>,
layout: Layout,
) -> Result<Markup, AppError> {
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<JournalEntry> = 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<Address> = 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<AppState>,
Path(contact_id): Path<u32>,
Path(contact_id): Path<DbId>,
layout: Layout,
) -> Result<Markup, AppError> {
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<impl IntoResponse, AppError> {
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<String> = 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()?);

View file

@ -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::<MentionHost>::into(entry).format_pool(pool).await?)
}
}
}
@ -124,18 +125,22 @@ pub mod get {
State(state): State<AppState>,
layout: Layout,
) -> Result<impl IntoResponse, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let contacts: Vec<HydratedContact> = 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?;

View file

@ -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<AppState> {
@ -36,6 +37,8 @@ mod post {
) -> Result<Markup, AppError> {
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<AppState>,
@ -92,8 +98,10 @@ mod patch {
) -> Result<impl IntoResponse, AppError> {
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::<MentionHost>::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(())
}