2026-01-26 15:25:45 -06:00
|
|
|
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 {
|
2026-04-03 11:54:36 -05:00
|
|
|
pub async fn gen_trie(pool: &SqlitePool) -> Result<radix_trie::Trie<String, String>, AppError> {
|
2026-01-26 15:25:45 -06:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:35:59 -06:00
|
|
|
Ok(trie)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn new(pool: &SqlitePool) -> Result<Self, AppError> {
|
|
|
|
|
let trie = Self::gen_trie(pool).await?;
|
2026-01-26 15:25:45 -06:00
|
|
|
Ok(Switchboard { trie })
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:35:59 -06:00
|
|
|
pub fn check_and_assign(self: &mut Self, trie: radix_trie::Trie<String, String>) -> bool {
|
|
|
|
|
if trie != self.trie {
|
|
|
|
|
self.trie = trie;
|
|
|
|
|
true
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 15:25:45 -06:00
|
|
|
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> {
|
2026-02-04 13:07:26 -06:00
|
|
|
let mut mentions = mentions.into_iter().peekable();
|
|
|
|
|
if mentions.peek().is_some() {
|
|
|
|
|
let mut qb = QueryBuilder::<sqlx::Sqlite>::new(
|
|
|
|
|
"insert into mentions (
|
2026-01-26 15:25:45 -06:00
|
|
|
entity_id, entity_type, url, input_text,
|
|
|
|
|
byte_range_start, byte_range_end) ",
|
2026-02-04 13:07:26 -06:00
|
|
|
);
|
|
|
|
|
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?;
|
|
|
|
|
}
|
2026-01-26 15:25:45 -06:00
|
|
|
Ok(())
|
|
|
|
|
}
|