Compare commits

...
Sign in to create a new pull request.

8 commits
groups ... main

Author SHA1 Message Date
4a0ed99329 fix: store refresh-at as string type, not date type, for sqlx autotyping 2026-02-04 13:32:03 -06:00
c7130bbcd4 fix: don't generate broken sql 2026-02-04 13:07:26 -06:00
57177612ec refactor: break up web/contact 2026-02-01 21:56:20 -06:00
2e1fbd00be basic phone number support
Some checks failed
/ integration-test--firefox (push) Failing after 3m8s
2026-01-31 21:01:01 -06:00
84c41dda4d refactor: fewer non-macro queries
Some checks failed
/ integration-test--firefox (push) Failing after 3m5s
2026-01-26 22:14:58 -06:00
69e23fd9bb fix: broken contact query
Some checks failed
/ integration-test--firefox (push) Failing after 3m5s
2026-01-26 17:56:00 -06:00
d42adbe274 feat: mentions in lives_with and text_body fields
Some checks failed
/ integration-test--firefox (push) Failing after 3m7s
2026-01-26 15:25:45 -06:00
fd5f1899c1 feat: lives-with field
Some checks failed
/ integration-test--firefox (push) Failing after 3m8s
2026-01-24 12:29:00 -06:00
22 changed files with 759 additions and 380 deletions

View file

@ -1,4 +1,4 @@
on: [push, workflow_dispatch] on: [workflow_dispatch]
jobs: jobs:
integration-test--firefox: integration-test--firefox:
runs-on: playwright-latest runs-on: playwright-latest

View file

@ -3,6 +3,10 @@ name = "mascarpone"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[profile.release]
strip = true
lto = true
[dependencies] [dependencies]
anyhow = "1.0.100" anyhow = "1.0.100"
axum = { version = "0.8.6", features = ["macros", "form"] } axum = { version = "0.8.6", features = ["macros", "form"] }
@ -30,10 +34,10 @@ sqlx = { version = "0.8", features = ["macros", "runtim
thiserror = "2.0.17" thiserror = "2.0.17"
time = "0.3.44" time = "0.3.44"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] }
tower-http = { version = "0.6.6", features = ["fs"] } tower-http = { version = "0.6.6", features = ["fs", "trace"] }
tower-sessions = { version = "0.14.0", features = ["signed"] } tower-sessions = { version = "0.14.0", features = ["signed"] }
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] } tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
tracing = "0.1.41" tracing = { version = "0.1.41", features = ["attributes"] }
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
vcard = "0.4.13" vcard = "0.4.13"

View file

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

View file

@ -22,7 +22,6 @@ playwright:ui() {
--env DISPLAY="$DISPLAY" \ --env DISPLAY="$DISPLAY" \
--volume /tmp/.X11-unix:/tmp/.X11-unix \ --volume /tmp/.X11-unix:/tmp/.X11-unix \
--volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \ --volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \
--env ASTRO_TELEMETRY_DISABLED=1 \
"mcr.microsoft.com/playwright:$(_playwright_version)" \ "mcr.microsoft.com/playwright:$(_playwright_version)" \
/bin/bash -c "cd /e2e && ./Taskfile _test --ui $*" /bin/bash -c "cd /e2e && ./Taskfile _test --ui $*"
} }
@ -31,7 +30,6 @@ playwright:ci() {
exec docker run \ exec docker run \
--interactive --tty --rm --ipc=host --net=host \ --interactive --tty --rm --ipc=host --net=host \
--volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \ --volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \
--env ASTRO_TELEMETRY_DISABLED=1 \
"mcr.microsoft.com/playwright:$(_playwright_version)" \ "mcr.microsoft.com/playwright:$(_playwright_version)" \
bash -c "cd /e2e && ./Taskfile _test $*" bash -c "cd /e2e && ./Taskfile _test $*"
} }

View file

@ -4,7 +4,7 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "playwright test --project=firefox && playwright test" "test": "echo use Taskfile instead"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

View file

@ -16,6 +16,9 @@ type UserFields = {
export const verifyCreateUser = async (page: Page, fields: UserFields) => { export const verifyCreateUser = async (page: Page, fields: UserFields) => {
await page.getByRole('button', { name: /add contact/i }).click(); await page.getByRole('button', { name: /add contact/i }).click();
// TODO this is stupid but playwright kept filling while alpine was initializing
await page.waitForTimeout(200);
const { names, ...simple } = fields; const { names, ...simple } = fields;
for (const name of (names ?? [])) { for (const name of (names ?? [])) {
await page.getByRole('textbox', { name: 'New name' }).fill(name); await page.getByRole('textbox', { name: 'New name' }).fill(name);

View file

@ -30,6 +30,21 @@ insert into names(contact_id, sort, name) values
(3, 0, 'Eleanor Edgeworth'), (3, 0, 'Eleanor Edgeworth'),
(3, 1, 'Eleanor'); (3, 1, 'Eleanor');
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 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, 1, 'Henrietta');
insert into addresses(contact_id, label, value) values
(6, null, '123 Main St., Realville, WI 99999');
insert into journal_entries(id, date, value) values insert into journal_entries(id, date, value) values
(0, '2020-02-27', 'Lunch with [[Bazel Bagend]] and his wife'), (0, '2020-02-27', 'Lunch with [[Bazel Bagend]] and his wife'),
@ -39,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.'), (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]].'); (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 insert into mentions (entity_id, entity_type, input_text, byte_range_start, byte_range_end, url) values
(0, 'Bazel Bagend', 11, 27, '/contact/1'), (0, 0, 'Bazel Bagend', 11, 27, '/contact/1'),
(1, 'Alexi', 12, 21, '/contact/0'), (1, 0, 'Alexi', 12, 21, '/contact/0'),
(3, 'ABC', 24, 31, '/group/ABC'), (3, 0, 'ABC', 24, 31, '/group/ABC'),
(4, 'Bazel', 22, 31, '/contact/1'), (4, 0, 'Bazel', 22, 31, '/contact/1'),
(4, 'Eleanor Edgeworth', 37, 58, '/contact/3'), (4, 0, 'Eleanor Edgeworth', 37, 58, '/contact/3'),
(5, 'Eleanor', 108, 119, '/contact/3'), (5, 0, 'Eleanor', 108, 119, '/contact/3'),
(5, 'Alexi', 94, 103, '/contact/0'), (5, 0, 'Alexi', 94, 103, '/contact/0'),
(5, 'Bazel', 5, 14, '/contact/1'), (5, 0, 'Bazel', 5, 14, '/contact/1'),
(5, 'ABC', 31, 38, '/group/ABC'); (5, 0, 'ABC', 31, 38, '/group/ABC');

View file

@ -0,0 +1 @@
alter table contacts add column lives_with text not null default '';

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

@ -0,0 +1,5 @@
create table if not exists phone_numbers (
contact_id integer not null references contacts(id) on delete cascade,
label text,
phone_number text not null
);

View file

@ -0,0 +1,19 @@
PRAGMA foreign_keys=OFF;
create table if not exists new_contacts (
id integer primary key autoincrement,
birthday text,
manually_freshened_at text,
text_body text,
lives_with text not null default ''
);
insert into new_contacts (
id, birthday, manually_freshened_at, text_body, lives_with)
select id, birthday, manually_freshened_at, text_body, lives_with
from contacts;
drop table contacts;
alter table new_contacts rename to contacts;
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -23,12 +23,14 @@ impl Database {
let pool = SqlitePoolOptions::new().connect_with(db_options).await?; let pool = SqlitePoolOptions::new().connect_with(db_options).await?;
tracing::debug!("migrating...");
sqlx::migrate!("./migrations/each_user/").run(&pool).await?; sqlx::migrate!("./migrations/each_user/").run(&pool).await?;
if user.username == "demo" { if user.username == "demo" {
sqlx::query_file!("./migrations/demo.sql") sqlx::query_file!("./migrations/demo.sql")
.execute(&pool) .execute(&pool)
.await?; .await?;
}; };
tracing::debug!("...done.");
Ok(Self { pool }) Ok(Self { pool })
} }

View file

@ -17,7 +17,6 @@ use tower_sessions_sqlx_store::SqliteStore;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod models; mod models;
use models::contact::MentionTrie;
use models::user::{Backend, User}; use models::user::{Backend, User};
mod db; mod db;
@ -26,10 +25,13 @@ use db::{Database, DbId};
mod web; mod web;
use web::{auth, contact, group, home, ics, journal, settings}; use web::{auth, contact, group, home, ics, journal, settings};
mod switchboard;
use switchboard::Switchboard;
#[derive(Clone)] #[derive(Clone)]
struct AppStateEntry { struct AppStateEntry {
database: Arc<Database>, database: Arc<Database>,
contact_search: Arc<RwLock<MentionTrie>>, switchboard: Arc<RwLock<Switchboard>>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -37,10 +39,6 @@ struct AppState {
map: Arc<RwLock<HashMap<DbId, AppStateEntry>>>, map: Arc<RwLock<HashMap<DbId, AppStateEntry>>>,
} }
struct NameReference {
name: String,
contact_id: DbId,
}
impl AppState { impl AppState {
pub fn new() -> Self { pub fn new() -> Self {
AppState { AppState {
@ -49,39 +47,14 @@ impl AppState {
} }
pub async fn init(&mut self, user: &User) -> Result<Option<AppStateEntry>, AppError> { pub async fn init(&mut self, user: &User) -> Result<Option<AppStateEntry>, AppError> {
let database = Database::for_user(&user).await?; let database = Database::for_user(&user).await?;
let mut trie = radix_trie::Trie::new(); let switchboard = Switchboard::new(&database.pool).await?;
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 mut map = self.map.write().expect("rwlock poisoned"); let mut map = self.map.write().expect("rwlock poisoned");
Ok(map.insert( Ok(map.insert(
user.id(), user.id(),
crate::AppStateEntry { crate::AppStateEntry {
database: Arc::new(database), 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"); let map = self.map.read().expect("rwlock poisoned");
map.get(&user.id()).unwrap().database.clone() 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"); 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( .with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
format!( format!(
"{}=debug,tower_http=debug,axum=trace,sqlx=debug", "{}=debug,tower_http=debug,axum=trace",
env!("CARGO_CRATE_NAME") env!("CARGO_CRATE_NAME")
) )
.into() .into()
@ -197,6 +170,7 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
.merge(ics::router()) .merge(ics::router())
.nest_service("/static", ServeDir::new("./hashed_static")) .nest_service("/static", ServeDir::new("./hashed_static"))
.layer(auth_layer) .layer(auth_layer)
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state); .with_state(state);
let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?; let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?;

View file

@ -1,16 +1,50 @@
use chrono::{DateTime, NaiveDate, Utc}; use chrono::{DateTime, NaiveDate, Utc};
use sqlx::sqlite::SqliteRow; use sqlx::sqlite::SqlitePool;
use sqlx::{FromRow, Row};
use std::str::FromStr; use std::str::FromStr;
use super::Birthday; use super::Birthday;
use crate::AppError;
use crate::db::DbId; use crate::db::DbId;
use crate::switchboard::MentionHostType;
struct RawContact {
id: DbId,
birthday: Option<String>,
manually_freshened_at: Option<String>,
lives_with: String,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Contact { pub struct Contact {
pub id: DbId, pub id: DbId,
pub birthday: Option<Birthday>, pub birthday: Option<Birthday>,
pub manually_freshened_at: Option<DateTime<Utc>>, pub manually_freshened_at: Option<DateTime<Utc>>,
pub lives_with: String,
}
impl Into<Contact> for RawContact {
fn into(self) -> Contact {
Contact {
id: self.id,
birthday: self
.birthday
.and_then(|s| Birthday::from_str(s.as_ref()).ok()),
manually_freshened_at: self
.manually_freshened_at
.and_then(|str| DateTime::parse_from_str(str.as_ref(), "%+").ok())
.map(|d| d.to_utc()),
lives_with: self.lives_with,
}
}
}
struct RawHydratedContact {
id: DbId,
birthday: Option<String>,
manually_freshened_at: Option<String>,
lives_with: String,
last_mention_date: Option<String>,
names: Option<String>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -20,6 +54,28 @@ pub struct HydratedContact {
pub names: Vec<String>, pub names: Vec<String>,
} }
impl Into<HydratedContact> for RawHydratedContact {
fn into(self) -> HydratedContact {
HydratedContact {
contact: Into::<Contact>::into(RawContact {
id: self.id,
birthday: self.birthday,
manually_freshened_at: self.manually_freshened_at,
lives_with: self.lives_with,
}),
names: self
.names
.unwrap_or(String::new())
.split('\x1c')
.map(|s| s.to_string())
.collect::<Vec<String>>(),
last_mention_date: self
.last_mention_date
.and_then(|str| NaiveDate::from_str(str.as_ref()).ok()),
}
}
}
impl std::ops::Deref for HydratedContact { impl std::ops::Deref for HydratedContact {
type Target = Contact; type Target = Contact;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
@ -35,54 +91,60 @@ impl HydratedContact {
"(unnamed)".to_string() "(unnamed)".to_string()
} }
} }
}
/* name/group, url */ pub async fn load(id: DbId, pool: &SqlitePool) -> Result<Self, AppError> {
pub type MentionTrie = radix_trie::Trie<String, String>; // copy-paste the query from 'all', then add "where c.id = $2" to the last line
let raw = sqlx::query_as!(
RawHydratedContact,
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
select string_agg(name,x'1c' order by sort)
from names where contact_id = c.id
) as names, (
select jes.date from journal_entries jes
join mentions ms on ms.entity_id = jes.id
where ms.entity_type = $1
and ms.url = '/contact/'||c.id
or ms.url in (
select '/group/'||slug from groups where
contact_id = c.id
)
order by jes.date desc limit 1
) as last_mention_date from contacts c
where c.id = $2"#,
MentionHostType::JournalEntry as DbId,
id
)
.fetch_one(pool)
.await?;
impl FromRow<'_, SqliteRow> for Contact { Ok(Into::<HydratedContact>::into(raw))
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> { }
let id: DbId = row.try_get("id")?;
let birthday = Birthday::from_row(row).ok(); pub async fn all(pool: &SqlitePool) -> Result<Vec<Self>, AppError> {
let contacts = sqlx::query_as!(
RawHydratedContact,
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
select string_agg(name,x'1c' order by sort)
from names where contact_id = c.id
) as names, (
select jes.date from journal_entries jes
join mentions ms on ms.entity_id = jes.id
where ms.entity_type = $1
and ms.url = '/contact/'||c.id
or ms.url in (
select '/group/'||slug from groups where
contact_id = c.id
)
order by jes.date desc limit 1
) as last_mention_date from contacts c"#,
MentionHostType::JournalEntry as DbId
)
.fetch_all(pool)
.await?;
let manually_freshened_at = row Ok(contacts
.try_get::<String, &str>("manually_freshened_at") .into_iter()
.ok() .map(|raw| Into::<HydratedContact>::into(raw))
.and_then(|str| { .collect())
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,
})
} }
} }

View file

@ -1,15 +1,12 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use maud::{Markup, PreEscaped, html}; use maud::{Markup, html};
use regex::Regex;
use serde_json::json; use serde_json::json;
use sqlx::sqlite::{SqlitePool, SqliteRow}; use sqlx::sqlite::{SqlitePool, SqliteRow};
use sqlx::{FromRow, Row}; use sqlx::{FromRow, Row};
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use super::contact::MentionTrie;
use crate::AppError; use crate::AppError;
use crate::db::DbId; use crate::db::DbId;
use crate::switchboard::{MentionHost, MentionHostType};
#[derive(Debug)] #[derive(Debug)]
pub struct JournalEntry { pub struct JournalEntry {
@ -18,92 +15,27 @@ pub struct JournalEntry {
pub date: NaiveDate, pub date: NaiveDate,
} }
#[derive(Debug, PartialEq, Eq, Hash, FromRow)] impl<'a> Into<MentionHost<'a>> for &'a JournalEntry {
pub struct Mention { fn into(self) -> MentionHost<'a> {
pub entry_id: DbId, MentionHost {
pub url: String, entity_id: self.id,
pub input_text: String, entity_type: MentionHostType::JournalEntry as DbId,
pub byte_range_start: u32, input: &self.value,
pub byte_range_end: u32, }
}
} }
impl JournalEntry { 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> { pub async fn to_html(&self, pool: &SqlitePool) -> Result<Markup, AppError> {
// important to sort desc so that changing contents early in the string let rendered = Into::<MentionHost>::into(self).format_pool(pool).await?;
// 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 entry_url = format!("/journal_entry/{}", self.id); let entry_url = format!("/journal_entry/{}", self.id);
let date = self.date.to_string(); let date = self.date.to_string();
Ok(html! { Ok(html! {
.entry { .entry hx-target="this" {
.view ":class"="{ hide: edit }" { .view ":class"="{ hide: edit }" {
.date { (date) } .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 })) { 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"; input name="date" x-model="date";
@ -111,7 +43,6 @@ impl JournalEntry {
textarea name="value" x-model="value" {} textarea name="value" x-model="value" {}
button title="Delete" button title="Delete"
hx-delete=(entry_url) hx-delete=(entry_url)
hx-target="closest .entry"
hx-swap="delete" { hx-swap="delete" {
svg .icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" { 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"; 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";
@ -120,7 +51,6 @@ impl JournalEntry {
button x-bind:disabled="(date === initial_date) && (value === initial_value)" button x-bind:disabled="(date === initial_date) && (value === initial_value)"
x-on:click="initial_date = date; initial_value = value" x-on:click="initial_date = date; initial_value = value"
hx-patch=(entry_url) hx-patch=(entry_url)
hx-target="closest .entry"
hx-swap="outerHTML" hx-swap="outerHTML"
title="Save" { "" } title="Save" { "" }
button x-bind:disabled="(date === initial_date) && (value === initial_value)" button x-bind:disabled="(date === initial_date) && (value === initial_value)"

150
src/switchboard.rs Normal file
View file

@ -0,0 +1,150 @@
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 mentions = mentions.into_iter().peekable();
if mentions.peek().is_some() {
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(())
}

131
src/web/contact/fields.rs Normal file
View file

@ -0,0 +1,131 @@
use maud::{Markup, html};
use serde_json::json;
use sqlx::sqlite::SqlitePool;
use crate::AppError;
use crate::db::DbId;
pub mod addresses {
use super::*;
#[derive(serde::Serialize, Debug)]
pub struct Address {
pub id: DbId,
pub contact_id: DbId,
pub label: Option<String>,
pub value: String,
}
async fn all(pool: &SqlitePool, contact_id: DbId) -> Result<Vec<Address>, sqlx::Error> {
sqlx::query_as!(
Address,
"select * from addresses where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await
}
pub async fn get(pool: &SqlitePool, contact_id: DbId) -> Result<Markup, AppError> {
let addresses: Vec<Address> = addresses::all(pool, contact_id).await?;
Ok(html! {
@if addresses.len() == 1 {
label { "address" }
#addresses {
.label {}
.value { (addresses[0].value) }
}
} @else if addresses.len() > 0 {
label { "addresses" }
#addresses {
@for address in addresses {
@let lbl = address.label.unwrap_or(String::new());
.label data-is-empty=(lbl.len() == 0) {
(lbl)
}
.value { (address.value) }
}
}
}
})
}
pub async fn edit(pool: &SqlitePool, contact_id: DbId) -> Result<Markup, AppError> {
let addresses: Vec<Address> = addresses::all(pool, contact_id).await?;
Ok(html! {
label { "addresses" }
div x-data=(json!({ "addresses": addresses, "new_label": "", "new_address": "" })) {
template x-for="(address, index) in addresses" x-bind:key="index" {
.address-input {
input name="address_label" x-show="addresses.length" x-model="address.label" placeholder="label";
.grow-wrap x-bind:data-replicated-value="address.value" {
textarea name="address_value" x-model="address.value" placeholder="address" {}
}
}
}
.address-input {
input x-show="addresses.length" name="address_label" x-model="new_label" placeholder="label";
.grow-wrap x-bind:data-replicated-value="new_address" {
textarea name="address_value" x-model="new_address" placeholder="new address" {}
}
}
input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''";
}
})
}
}
pub mod groups {
use super::*;
#[derive(serde::Serialize, Debug)]
pub struct Group {
pub contact_id: DbId,
pub name: String,
pub slug: String,
}
async fn all(pool: &SqlitePool, contact_id: DbId) -> Result<Vec<Group>, sqlx::Error> {
sqlx::query_as!(
Group,
"select * from groups where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await
}
pub async fn get(pool: &SqlitePool, contact_id: DbId) -> Result<Markup, AppError> {
let groups: Vec<Group> = groups::all(pool, contact_id).await?;
Ok(html! {
@if groups.len() > 0 {
label { "in groups" }
#groups {
@for group in groups {
a .group href=(format!("/group/{}", group.slug)) {
(group.name)
}
}
}
}
})
}
pub async fn edit(pool: &SqlitePool, contact_id: DbId) -> Result<Markup, AppError> {
let groups: Vec<Group> = groups::all(pool, contact_id).await?;
Ok(html! {
label { "groups" }
#groups x-data=(json!({ "groups": groups, "new_group": "" })) {
template x-for="(group, index) in groups" x-bind:key="index" {
input name="group" x-model="group.name" placeholder="group name";
}
input name="group" x-model="new_group" placeholder="group name";
input type="button" value="Add" x-on:click="groups.push({ name: new_group }); new_group = ''";
}
})
}
}

View file

@ -8,7 +8,7 @@ use axum::{
use axum_extra::extract::Form; use axum_extra::extract::Form;
use cache_bust::asset; use cache_bust::asset;
use chrono::DateTime; use chrono::DateTime;
use maud::{Markup, PreEscaped, html}; use maud::{Markup, html};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use slug::slugify; use slug::slugify;
@ -19,21 +19,16 @@ use super::home::journal_section;
use crate::db::DbId; use crate::db::DbId;
use crate::models::user::AuthSession; use crate::models::user::AuthSession;
use crate::models::{HydratedContact, JournalEntry}; use crate::models::{HydratedContact, JournalEntry};
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions};
use crate::{AppError, AppState}; use crate::{AppError, AppState};
#[derive(serde::Serialize, Debug)] pub mod fields;
pub struct Address {
pub id: DbId,
pub contact_id: DbId,
pub label: Option<String>,
pub value: String,
}
#[derive(serde::Serialize, Debug)] #[derive(serde::Serialize, Debug)]
pub struct Group { pub struct PhoneNumber {
pub contact_id: DbId, pub contact_id: DbId,
pub name: String, pub label: Option<String>,
pub slug: String, pub phone_number: String,
} }
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
@ -69,51 +64,47 @@ mod get {
pub async fn contact( pub async fn contact(
auth_session: AuthSession, auth_session: AuthSession,
State(state): State<AppState>, State(state): State<AppState>,
Path(contact_id): Path<u32>, Path(contact_id): Path<DbId>,
layout: Layout, layout: Layout,
) -> Result<Markup, AppError> { ) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool; let user = auth_session.user.unwrap();
let contact: HydratedContact = sqlx::query_as( let pool = &state.db(&user).pool;
"select id, birthday, manually_freshened_at, (
select string_agg(name,'\x1c' order by sort) let contact = HydratedContact::load(contact_id, pool).await?;
from names where contact_id = c.id
) as names
from contacts c
where c.id = $1",
)
.bind(contact_id)
.fetch_one(pool)
.await?;
let entries: Vec<JournalEntry> = sqlx::query_as( let entries: Vec<JournalEntry> = sqlx::query_as(
"select distinct j.id, j.value, j.date from journal_entries j "select distinct j.id, j.value, j.date from journal_entries j
join journal_mentions cm on j.id = cm.entry_id join mentions m on j.id = m.entity_id
where cm.url = '/contact/'||$1 or cm.url in ( where m.entity_type = $1 and (m.url = '/contact/'||$1 or m.url in (
select '/group/'||slug from groups select '/group/'||slug from groups
where contact_id = $1 where contact_id = $2
) ))
order by j.date desc order by j.date desc
", ",
) )
.bind(MentionHostType::JournalEntry as DbId)
.bind(contact_id) .bind(contact_id)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
let addresses: Vec<Address> = sqlx::query_as!( let phone_numbers: Vec<PhoneNumber> = sqlx::query_as!(
Address, PhoneNumber,
"select * from addresses where contact_id = $1", "select * from phone_numbers where contact_id = $1",
contact_id contact_id
) )
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
let groups: Vec<Group> = sqlx::query_as!( let lives_with = if contact.lives_with.len() > 1 {
Group, let mention_host = MentionHost {
"select * from groups where contact_id = $1", entity_id: contact_id,
contact_id entity_type: MentionHostType::ContactLivesWith as DbId,
) input: &contact.lives_with,
.fetch_all(pool) };
.await?; Some(mention_host.format_pool(pool).await?)
} else {
None
};
let text_body: Option<String> = let text_body: Option<String> =
sqlx::query!("select text_body from contacts where id = $1", contact_id) sqlx::query!("select text_body from contacts where id = $1", contact_id)
@ -156,41 +147,37 @@ mod get {
"(never)" "(never)"
} }
} }
@if addresses.len() == 1 {
label { "address" } @if phone_numbers.len() > 0 {
#addresses { label { "phone" }
.label {} #phone_numbers {
.value { (addresses[0].value) } @for phone_number in phone_numbers {
@let lbl = phone_number.label.unwrap_or(String::new());
.label data-is-empty=(lbl.len() == 0) { (lbl) }
.phone_nunber {
a href=(format!("tel:{}", phone_number.phone_number)) { (phone_number.phone_number) }
} }
} @else if addresses.len() > 0 {
label { "addresses" }
#addresses {
@for address in addresses {
@let lbl = address.label.unwrap_or(String::new());
.label data-is-empty=(lbl.len() == 0) {
(lbl)
}
.value { (address.value) }
} }
} }
} }
@if groups.len() > 0 { @if let Some(lives_with) = lives_with {
label { "in groups" } label { "lives with" }
#groups { div { (lives_with) }
@for group in groups {
a .group href=(format!("/group/{}", group.slug)) {
(group.name)
}
}
}
} }
(fields::addresses::get(pool, contact_id).await?)
(fields::groups::get(pool, contact_id).await?)
} }
@if let Some(text_body) = text_body { @if let Some(text_body) = text_body {
@if text_body.len() > 0 { @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?) }
} }
} }
@ -202,29 +189,15 @@ mod get {
pub async fn contact_edit( pub async fn contact_edit(
auth_session: AuthSession, auth_session: AuthSession,
State(state): State<AppState>, State(state): State<AppState>,
Path(contact_id): Path<u32>, Path(contact_id): Path<DbId>,
layout: Layout, layout: Layout,
) -> Result<Markup, AppError> { ) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool; let pool = &state.db(&auth_session.user.unwrap()).pool;
let contact: HydratedContact = sqlx::query_as( let contact = HydratedContact::load(contact_id, pool).await?;
"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
order by jes.date desc limit 1
) as last_mention_date from contacts c
where c.id = $1",
)
.bind(contact_id)
.fetch_one(pool)
.await?;
let addresses: Vec<Address> = sqlx::query_as!( let phone_numbers: Vec<PhoneNumber> = sqlx::query_as!(
Address, PhoneNumber,
"select * from addresses where contact_id = $1", "select * from phone_numbers where contact_id = $1",
contact_id contact_id
) )
.fetch_all(pool) .fetch_all(pool)
@ -236,17 +209,6 @@ mod get {
.clone() .clone()
.map_or("".to_string(), |m| m.to_rfc3339()); .map_or("".to_string(), |m| m.to_rfc3339());
let groups: Vec<String> = sqlx::query_as!(
Group,
"select * from groups where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await?
.into_iter()
.map(|group| group.name)
.collect();
let text_body: String = let text_body: String =
sqlx::query!("select text_body from contacts where id = $1", contact_id) sqlx::query!("select text_body from contacts where id = $1", contact_id)
.fetch_one(pool) .fetch_one(pool)
@ -289,32 +251,26 @@ mod get {
span x-text="date.length ? date.split('T')[0] : '(never)'" {} span x-text="date.length ? date.split('T')[0] : '(never)'" {}
input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()"; input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()";
} }
label { "addresses" } label { "phone" }
div x-data=(json!({ "addresses": addresses, "new_label": "", "new_address": "" })) { #phone_numbers x-data=(json!({ "phones": phone_numbers, "new_label": "", "new_number": "" })) {
template x-for="(address, index) in addresses" x-bind:key="index" { template x-for="(phone, index) in phones" x-bind:key="index" {
.address-input { .phone_input {
input name="address_label" x-show="addresses.length" x-model="address.label" placeholder="label"; input name="phone_label" x-model="phone.label" placeholder="home/work/mobile";
.grow-wrap x-bind:data-replicated-value="address.value" { input name="phone_number" x-model="phone.phone_number" placeholder="number";
textarea name="address_value" x-model="address.value" placeholder="address" {}
} }
} }
.phone_input {
input name="phone_label" x-model="new_label" placeholder="home/work/mobile";
input name="phone_number" x-model="new_number" placeholder="number";
} }
.address-input { input type="button" value="Add" x-on:click="phones.push({ label: new_label, phone_number: new_number }); new_label=''; new_number = ''";
input x-show="addresses.length" name="address_label" x-model="new_label" placeholder="label";
.grow-wrap x-bind:data-replicated-value="new_address" {
textarea name="address_value" x-model="new_address" placeholder="new address" {}
} }
label { "lives with" }
div {
input name="lives_with" value=(contact.lives_with);
} }
input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''"; (fields::addresses::edit(pool, contact_id).await?)
} (fields::groups::edit(pool, contact_id).await?)
label { "groups" }
#groups x-data=(json!({ "groups": groups, "new_group": "" })) {
template x-for="(group, index) in groups" x-bind:key="index" {
input name="group" x-model="group" placeholder="group name";
}
input name="group" x-model="new_group" placeholder="group name";
input type="button" value="Add" x-on:click="groups.push(new_group); new_group = ''";
}
} }
div #text_body { div #text_body {
div { "Free text (supports markdown)" } div { "Free text (supports markdown)" }
@ -339,7 +295,7 @@ mod post {
let user = auth_session.user.unwrap(); let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool; let pool = &state.db(&user).pool;
let contact_id: (u32,) = let contact_id: (DbId,) =
sqlx::query_as("insert into contacts (birthday) values (null) returning id") sqlx::query_as("insert into contacts (birthday) values (null) returning id")
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@ -361,6 +317,9 @@ mod put {
name: Option<Vec<String>>, name: Option<Vec<String>>,
birthday: String, birthday: String,
manually_freshened_at: String, manually_freshened_at: String,
lives_with: String,
phone_label: Option<Vec<String>>,
phone_number: Option<Vec<String>>,
address_label: Option<Vec<String>>, address_label: Option<Vec<String>>,
address_value: Option<Vec<String>>, address_value: Option<Vec<String>>,
group: Option<Vec<String>>, group: Option<Vec<String>>,
@ -375,6 +334,7 @@ mod put {
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap(); let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool; let pool = &state.db(&user).pool;
let sw_lock = state.switchboard(&user);
let birthday = if payload.birthday.is_empty() { let birthday = if payload.birthday.is_empty() {
None None
@ -399,19 +359,114 @@ mod put {
Some(payload.text_body) Some(payload.text_body)
}; };
let old_contact = sqlx::query!("select * from contacts where id = $1", contact_id)
.fetch_one(pool)
.await?;
sqlx::query!( sqlx::query!(
"update contacts set (birthday, manually_freshened_at, text_body) = ($1, $2, $3) where id = $4", "update contacts set
(birthday, manually_freshened_at, lives_with, text_body) =
($1, $2, $3, $4)
where id = $5",
birthday, birthday,
manually_freshened_at, manually_freshened_at,
payload.lives_with,
text_body, text_body,
contact_id contact_id
) )
.execute(pool) .execute(pool)
.await?; .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 // 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 // partially moved as we handle each field and i don't want to deal with it
{
// update phone numbers
let new_numbers = payload.phone_number.clone().map_or(vec![], |numbers| {
let labels: Vec<String> = payload.phone_label.clone().unwrap();
// TODO sanitize down to linkable on input
labels
.into_iter()
.zip(numbers)
.filter(|(_, val)| val.len() > 0)
.collect::<Vec<(String, String)>>()
});
let old_numbers: Vec<(String, String)> = sqlx::query_as(
"select label, phone_number from phone_numbers where contact_id = $1",
)
.bind(contact_id)
.fetch_all(pool)
.await?;
if new_numbers != old_numbers {
sqlx::query!(
"delete from phone_numbers where contact_id = $1",
contact_id
)
.execute(pool)
.await?;
// trailing space in query intentional
QueryBuilder::new("insert into phone_numbers (contact_id, label, phone_number) ")
.push_values(new_numbers, |mut b, (label, phone_number)| {
b.push_bind(contact_id)
.push_bind(label)
.push_bind(phone_number);
})
.build()
.execute(pool)
.await?;
}
}
{ {
// update addresses // update addresses
let new_addresses = payload.address_value.clone().map(|values| { let new_addresses = payload.address_value.clone().map(|values| {
@ -468,11 +523,11 @@ mod put {
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect(); let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
if old_names != new_names { if old_names != new_names {
// delete and regen *all* journal mentions, not just the ones for the // delete and regen *all* mentions, not just the ones for the current
// current user, since changing *this* user's names can change, *globally*, // contact, since changing *this* contact's names can change, *globally*,
// which names have n=1 and thus are eligible for mentioning // which names have n=1 and thus are eligible for mentioning
sqlx::query!( 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 contact_id
) )
.execute(pool) .execute(pool)
@ -514,14 +569,13 @@ mod put {
.await?; .await?;
{ {
let trie_mutex = state.contact_search(&user); let mut switchboard = sw_lock.write().unwrap();
let mut trie = trie_mutex.write().unwrap();
for name in &old_names { for name in &old_names {
trie.remove(name); switchboard.remove(name);
} }
for name in recalc_names { for name in recalc_names {
trie.insert(name.0, format!("/contact/{}", name.1)); switchboard.add_mentionable(name.0, format!("/contact/{}", name.1));
} }
} }
} }
@ -541,12 +595,13 @@ mod put {
if new_groups != old_groups { if new_groups != old_groups {
sqlx::query!( 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 contact_id
) )
.execute(pool) .execute(pool)
.await?; .await?;
if new_groups.len() > 0 {
QueryBuilder::new("insert into groups (contact_id, name, slug) ") QueryBuilder::new("insert into groups (contact_id, name, slug) ")
.push_values(&new_groups, |mut b, name| { .push_values(&new_groups, |mut b, name| {
b.push_bind(contact_id) b.push_bind(contact_id)
@ -557,19 +612,20 @@ mod put {
.persistent(false) .persistent(false)
.execute(pool) .execute(pool)
.await?; .await?;
}
{ {
let trie_mutex = state.contact_search(&user); let mut switchboard = sw_lock.write().unwrap();
let mut trie = trie_mutex.write().unwrap();
for name in &old_groups { for name in &old_groups {
// TODO i think we care about group name vs contact name counts, // TODO i think we care about group name vs contact name counts,
// otherwise this will cause a problem (or we want to disallow // otherwise this will cause a problem (or we want to disallow
// setting group names that are contact names or vice versa?) // setting group names that are contact names or vice versa?)
trie.remove(name); switchboard.remove(name);
} }
for group in &new_groups { for group in &new_groups {
trie.insert(group.clone(), format!("/group/{}", slugify(group))); switchboard
.add_mentionable(group.clone(), format!("/group/{}", slugify(group)));
} }
} }
} }
@ -581,9 +637,11 @@ mod put {
.await?; .await?;
for entry in journal_entries { for entry in journal_entries {
entry let mentions = {
.insert_mentions(state.contact_search(&user), pool) let switchboard = sw_lock.read().unwrap();
.await?; switchboard.extract_mentions(&entry)
};
insert_mentions(&mentions, pool).await?;
} }
} }
} }
@ -598,17 +656,12 @@ mod delete {
pub async fn contact( pub async fn contact(
auth_session: AuthSession, auth_session: AuthSession,
State(state): State<AppState>, State(state): State<AppState>,
Path(contact_id): Path<u32>, Path(contact_id): Path<DbId>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap(); let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool; let pool = &state.db(&user).pool;
sqlx::query( sqlx::query!("delete from contacts where id = $1", contact_id)
"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) .execute(pool)
.await?; .await?;

View file

@ -84,6 +84,7 @@ fn birthdays_section(
}) })
} }
#[tracing::instrument(level = "info")]
pub async fn journal_section( pub async fn journal_section(
pool: &SqlitePool, pool: &SqlitePool,
entries: &Vec<JournalEntry>, entries: &Vec<JournalEntry>,
@ -101,12 +102,14 @@ pub async fn journal_section(
are now, or leave everything blank to default to 'today'. Entries will be are now, or leave everything blank to default to 'today'. Entries will be
added to the top of the list regardless of date; refresh the page to re-sort." added to the top of the list regardless of date; refresh the page to re-sort."
} }
form hx-post="/journal_entry" hx-target="next .entries" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" { form hx-post="/journal_entry" hx-target="next .entries" hx-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" {
input name="date" placeholder=(Local::now().date_naive().to_string()); input name="date" placeholder=(Local::now().date_naive().to_string());
textarea name="value" placeholder="New entry..." autofocus {} textarea name="value" placeholder="New entry..." autofocus {}
input type="submit" value="Add Entry"; input type="submit" value="Add Entry";
} }
#journal-error {}
.entries { .entries {
@for entry in entries { @for entry in entries {
(entry.to_html(pool).await?) (entry.to_html(pool).await?)
@ -124,21 +127,10 @@ pub mod get {
State(state): State<AppState>, State(state): State<AppState>,
layout: Layout, layout: Layout,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool; let user = auth_session.user.unwrap();
let contacts: Vec<HydratedContact> = sqlx::query_as( let pool = &state.db(&user).pool;
"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
order by jes.date desc limit 1
) as last_mention_date from contacts c",
)
.fetch_all(pool)
.await?;
let contacts = HydratedContact::all(&pool).await?;
let mut freshens: Vec<ContactFreshness> = contacts let mut freshens: Vec<ContactFreshness> = contacts
.clone() .clone()
.into_iter() .into_iter()

View file

@ -55,15 +55,9 @@ mod get {
let mut calendar = Calendar::new(); let mut calendar = Calendar::new();
calendar.name(&calname); calendar.name(&calname);
calendar.append_property(("PRODID", "Mascarpone CRM")); calendar.append_property(("PRODID", "Mascarpone CRM"));
let contacts: Vec<HydratedContact> = sqlx::query_as(
"select id, birthday, ( // TODO; this does some db work to pull in last_modified_date that we don't use
select string_agg(name,'\x1c' order by sort) let contacts = HydratedContact::all(&pool).await?;
from names where contact_id = c.id
) as names
from contacts c",
)
.fetch_all(&pool)
.await?;
for contact in &contacts { for contact in &contacts {
if let Some(Birthday::Date(yo_date)) = &contact.birthday { if let Some(Birthday::Date(yo_date)) = &contact.birthday {
if let Some(date) = NaiveDate::from_ymd_opt( if let Some(date) = NaiveDate::from_ymd_opt(

View file

@ -11,6 +11,7 @@ use serde::Deserialize;
use crate::models::JournalEntry; use crate::models::JournalEntry;
use crate::models::user::AuthSession; use crate::models::user::AuthSession;
use crate::switchboard::{MentionHost, insert_mentions};
use crate::{AppError, AppState}; use crate::{AppError, AppState};
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
@ -36,6 +37,8 @@ mod post {
) -> Result<Markup, AppError> { ) -> Result<Markup, AppError> {
let user = auth_session.user.unwrap(); let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool; let pool = &state.db(&user).pool;
let sw_lock = state.switchboard(&user);
let now = Local::now().date_naive(); let now = Local::now().date_naive();
let date = if payload.date.is_empty() { let date = if payload.date.is_empty() {
@ -73,9 +76,12 @@ mod post {
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
entry let mentions = {
.insert_mentions(state.contact_search(&user), pool) let switchboard = sw_lock.read().unwrap();
.await?; switchboard.extract_mentions(&entry)
};
tracing::debug!("{:?}", mentions);
insert_mentions(&mentions, pool).await?;
Ok(entry.to_html(pool).await?) Ok(entry.to_html(pool).await?)
} }
@ -84,6 +90,7 @@ mod post {
mod patch { mod patch {
use super::*; use super::*;
#[axum::debug_handler]
pub async fn entry( pub async fn entry(
auth_session: AuthSession, auth_session: AuthSession,
State(state): State<AppState>, State(state): State<AppState>,
@ -92,8 +99,10 @@ mod patch {
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap(); let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool; let pool = &state.db(&user).pool;
let sw_lock = state.switchboard(&user);
// not a macro query, we want to use JournalEntry's custom FromRow // 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) .bind(entry_id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@ -107,17 +116,24 @@ mod patch {
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
if entry.value != new_entry.value { if old_entry.value != new_entry.value {
sqlx::query!("delete from journal_mentions where entry_id = $1", entry_id) sqlx::query!(
"delete from mentions where entity_id = $1 and entity_type = 'journal_entry'",
entry_id
)
.execute(pool) .execute(pool)
.await?; .await?;
new_entry let mentions = {
.insert_mentions(state.contact_search(&user), pool) let switchboard = sw_lock.read().unwrap();
.await?; 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,10 +148,7 @@ mod delete {
let user = auth_session.user.unwrap(); let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool; let pool = &state.db(&user).pool;
sqlx::query( sqlx::query("delete from journal_entries where id = $2 returning id,date,value")
"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)
.bind(entry_id) .bind(entry_id)
.execute(pool) .execute(pool)

View file

@ -3,10 +3,10 @@ use axum::extract::FromRequestParts;
use cache_bust::asset; use cache_bust::asset;
use http::request::Parts; use http::request::Parts;
use maud::{DOCTYPE, Markup, html}; use maud::{DOCTYPE, Markup, html};
use sqlx::FromRow;
use super::models::user::{AuthSession, User}; use super::models::user::{AuthSession, User};
use super::{AppError, AppState}; use super::{AppError, AppState};
use crate::db::DbId;
pub mod auth; pub mod auth;
pub mod contact; pub mod contact;
@ -16,11 +16,13 @@ pub mod ics;
pub mod journal; pub mod journal;
pub mod settings; pub mod settings;
#[derive(Debug, FromRow)] #[derive(Debug)]
struct ContactLink { struct ContactLink {
name: String, name: String,
contact_id: u32, contact_id: DbId,
} }
#[derive(Debug)]
pub struct Layout { pub struct Layout {
contact_links: Vec<ContactLink>, contact_links: Vec<ContactLink>,
user: User, user: User,
@ -39,7 +41,8 @@ impl FromRequestParts<AppState> for Layout {
.map_err(|_| anyhow::Error::msg("could not get session"))?; .map_err(|_| anyhow::Error::msg("could not get session"))?;
let user = auth_session.user.unwrap(); let user = auth_session.user.unwrap();
let contact_links: Vec<ContactLink> = sqlx::query_as( let contact_links = sqlx::query_as!(
ContactLink,
"select c.id as contact_id, "select c.id as contact_id,
coalesce(n.name, '(unnamed)') as name coalesce(n.name, '(unnamed)') as name
from contacts c from contacts c