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 mentions = mentions.into_iter().peekable(); if mentions.peek().is_some() { 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(()) }