This commit is contained in:
parent
cd4096b2ff
commit
a0afb6dfd3
24 changed files with 536 additions and 274 deletions
|
|
@ -11,6 +11,7 @@ use chrono::DateTime;
|
|||
use maud::{Markup, PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use slug::slugify;
|
||||
use sqlx::{QueryBuilder, Sqlite};
|
||||
|
||||
use super::Layout;
|
||||
|
|
@ -32,9 +33,9 @@ pub struct Address {
|
|||
pub struct Group {
|
||||
pub contact_id: DbId,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/contact/new", post(self::post::contact))
|
||||
|
|
@ -85,9 +86,14 @@ mod get {
|
|||
.await?;
|
||||
|
||||
let entries: Vec<JournalEntry> = sqlx::query_as(
|
||||
"select j.id, j.value, j.date from journal_entries j
|
||||
join contact_mentions cm on j.id = cm.entry_id
|
||||
where cm.contact_id = $1",
|
||||
"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 (
|
||||
select '/group/'||slug from groups
|
||||
where contact_id = $1
|
||||
)
|
||||
order by j.date desc
|
||||
",
|
||||
)
|
||||
.bind(contact_id)
|
||||
.fetch_all(pool)
|
||||
|
|
@ -104,10 +110,11 @@ mod get {
|
|||
let groups: Vec<Group> = sqlx::query_as!(
|
||||
Group,
|
||||
"select * from groups where contact_id = $1",
|
||||
contact_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
contact_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let text_body: Option<String> =
|
||||
sqlx::query!("select text_body from contacts where id = $1", contact_id)
|
||||
.fetch_one(pool)
|
||||
|
|
@ -172,7 +179,9 @@ mod get {
|
|||
label { "in groups" }
|
||||
#groups {
|
||||
@for group in groups {
|
||||
.group { (group.name) }
|
||||
a .group href=(format!("/group/{}", group.slug)) {
|
||||
(group.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -203,8 +212,8 @@ mod get {
|
|||
from names where contact_id = c.id
|
||||
) as names, (
|
||||
select jes.date from journal_entries jes
|
||||
join contact_mentions cms on cms.entry_id = jes.id
|
||||
where cms.contact_id = c.id
|
||||
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",
|
||||
|
|
@ -230,12 +239,13 @@ mod get {
|
|||
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();
|
||||
contact_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|group| group.name)
|
||||
.collect();
|
||||
|
||||
let text_body: String =
|
||||
sqlx::query!("select text_body from contacts where id = $1", contact_id)
|
||||
|
|
@ -353,13 +363,14 @@ mod put {
|
|||
manually_freshened_at: String,
|
||||
address_label: Option<Vec<String>>,
|
||||
address_value: Option<Vec<String>>,
|
||||
group: Option<Vec<String>>,
|
||||
text_body: String,
|
||||
}
|
||||
|
||||
pub async fn contact(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
Path(contact_id): Path<u32>,
|
||||
Path(contact_id): Path<DbId>,
|
||||
Form(payload): Form<PutContact>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user = auth_session.user.unwrap();
|
||||
|
|
@ -398,100 +409,183 @@ mod put {
|
|||
.execute(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
|
||||
|
||||
{
|
||||
// update addresses
|
||||
sqlx::query!("delete from addresses where contact_id = $1", contact_id)
|
||||
let new_addresses = payload.address_value.clone().map(|values| {
|
||||
let labels: Vec<String> = if values.len() == 1 {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
payload.address_label.clone().unwrap_or(vec![])
|
||||
};
|
||||
|
||||
labels
|
||||
.into_iter()
|
||||
.zip(values)
|
||||
.filter(|(_, val)| val.len() > 0)
|
||||
.collect::<Vec<(String, String)>>()
|
||||
});
|
||||
let new_addresses = new_addresses.unwrap_or(vec![]);
|
||||
|
||||
let old_addresses: Vec<(String, String)> =
|
||||
sqlx::query_as("select label, value from addresses where contact_id = $1")
|
||||
.bind(contact_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
if new_addresses != old_addresses {
|
||||
sqlx::query!("delete from addresses where contact_id = $1", contact_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// trailing space in query intentional
|
||||
QueryBuilder::new("insert into addresses (contact_id, label, value) ")
|
||||
.push_values(new_addresses, |mut b, (label, value)| {
|
||||
b.push_bind(contact_id).push_bind(label).push_bind(value);
|
||||
})
|
||||
.build()
|
||||
.persistent(false)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// recalculate all contact mentions and name trie if name-list changed
|
||||
let new_names: Vec<String> = payload
|
||||
.name
|
||||
.unwrap_or(vec![])
|
||||
.into_iter()
|
||||
.filter(|n| n.len() > 0)
|
||||
.collect();
|
||||
let old_names: Vec<(String,)> =
|
||||
sqlx::query_as("select name from names where contact_id = $1")
|
||||
.bind(contact_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
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*,
|
||||
// which names have n=1 and thus are eligible for mentioning
|
||||
sqlx::query!(
|
||||
"delete from journal_mentions; delete from names where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(values) = payload.address_value {
|
||||
let labels = if values.len() == 1 {
|
||||
Some(vec![String::new()])
|
||||
} else {
|
||||
payload.address_label
|
||||
};
|
||||
if let Some(labels) = labels {
|
||||
let new_addresses = labels
|
||||
.into_iter()
|
||||
.zip(values)
|
||||
.filter(|(_, val)| val.len() > 0);
|
||||
for (label, value) in new_addresses {
|
||||
sqlx::query!(
|
||||
"insert into addresses (contact_id, label, value) values ($1, $2, $3)",
|
||||
contact_id,
|
||||
label,
|
||||
value
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
|
||||
"select name, contact_id from (
|
||||
select name, contact_id, count(name) as ct from names where name in (",
|
||||
);
|
||||
let mut name_list = recalc_counts.separated(", ");
|
||||
for name in &old_names {
|
||||
name_list.push_bind(name);
|
||||
}
|
||||
|
||||
if !new_names.is_empty() {
|
||||
for name in &new_names {
|
||||
name_list.push_bind(name.clone());
|
||||
}
|
||||
|
||||
let mut name_insert: QueryBuilder<Sqlite> =
|
||||
QueryBuilder::new("insert into names (contact_id, sort, name) ");
|
||||
name_insert.push_values(
|
||||
new_names.iter().enumerate(),
|
||||
|mut builder, (sort, name)| {
|
||||
builder
|
||||
.push_bind(contact_id)
|
||||
.push_bind(DbId::try_from(sort).unwrap())
|
||||
.push_bind(name);
|
||||
},
|
||||
);
|
||||
name_insert.build().persistent(false).execute(pool).await?;
|
||||
}
|
||||
|
||||
name_list.push_unseparated(") group by name) where ct = 1");
|
||||
let recalc_names: Vec<(String, DbId)> = recalc_counts
|
||||
.build_query_as()
|
||||
.persistent(false)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let trie_mutex = state.contact_search(&user);
|
||||
let mut trie = trie_mutex.write().unwrap();
|
||||
for name in &old_names {
|
||||
trie.remove(name);
|
||||
}
|
||||
|
||||
for name in recalc_names {
|
||||
trie.insert(name.0, format!("/contact/{}", name.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let old_names: Vec<(String,)> = sqlx::query_as(
|
||||
"delete from contact_mentions;
|
||||
delete from names where contact_id = $1 returning name;",
|
||||
)
|
||||
.bind(contact_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let new_groups: Vec<String> = payload
|
||||
.group
|
||||
.unwrap_or(vec![])
|
||||
.into_iter()
|
||||
.filter(|n| n.len() > 0)
|
||||
.collect();
|
||||
let old_groups: Vec<(String,)> =
|
||||
sqlx::query_as("select name from groups where contact_id = $1")
|
||||
.bind(contact_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect();
|
||||
|
||||
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
|
||||
"select name, contact_id from (
|
||||
select name, contact_id, count(name) as ct from names where name in (",
|
||||
);
|
||||
let mut name_list = recalc_counts.separated(", ");
|
||||
for (name,) in &old_names {
|
||||
name_list.push_bind(name);
|
||||
}
|
||||
|
||||
if let Some(names) = payload.name {
|
||||
let names: Vec<String> = names.into_iter().filter(|n| n.len() > 0).collect();
|
||||
if !names.is_empty() {
|
||||
for name in &names {
|
||||
name_list.push_bind(name.clone());
|
||||
}
|
||||
|
||||
let mut name_insert: QueryBuilder<Sqlite> =
|
||||
QueryBuilder::new("insert into names (contact_id, sort, name) ");
|
||||
name_insert.push_values(names.iter().enumerate(), |mut builder, (sort, name)| {
|
||||
builder
|
||||
.push_bind(contact_id)
|
||||
.push_bind(DbId::try_from(sort).unwrap())
|
||||
.push_bind(name);
|
||||
});
|
||||
name_insert.build().persistent(false).execute(pool).await?;
|
||||
}
|
||||
}
|
||||
|
||||
name_list.push_unseparated(") group by name) where ct = 1");
|
||||
let recalc_names: Vec<(String, DbId)> = recalc_counts
|
||||
.build_query_as()
|
||||
.persistent(false)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let trie_mutex = state.contact_search(&user);
|
||||
let mut trie = trie_mutex.write().unwrap();
|
||||
for name in &old_names {
|
||||
trie.remove(&name.0);
|
||||
}
|
||||
|
||||
for name in recalc_names {
|
||||
trie.insert(name.0, name.1);
|
||||
}
|
||||
}
|
||||
|
||||
let journal_entries: Vec<JournalEntry> = sqlx::query_as("select * from journal_entries")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
for entry in journal_entries {
|
||||
entry
|
||||
.insert_mentions(state.contact_search(&user), pool)
|
||||
if new_groups != old_groups {
|
||||
sqlx::query!(
|
||||
"delete from journal_mentions; delete from groups where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
QueryBuilder::new("insert into groups (contact_id, name, slug) ")
|
||||
.push_values(&new_groups, |mut b, name| {
|
||||
b.push_bind(contact_id)
|
||||
.push_bind(name)
|
||||
.push_bind(slugify(name));
|
||||
})
|
||||
.build()
|
||||
.persistent(false)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let trie_mutex = state.contact_search(&user);
|
||||
let mut trie = trie_mutex.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);
|
||||
}
|
||||
|
||||
for group in &new_groups {
|
||||
trie.insert(group.clone(), format!("/group/{}", slugify(group)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if new_names != old_names || new_groups != old_groups {
|
||||
let journal_entries: Vec<JournalEntry> =
|
||||
sqlx::query_as("select * from journal_entries")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
for entry in journal_entries {
|
||||
entry
|
||||
.insert_mentions(state.contact_search(&user), pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
|
|
@ -510,7 +604,7 @@ mod delete {
|
|||
let pool = &state.db(&user).pool;
|
||||
|
||||
sqlx::query(
|
||||
"delete from contact_mentions where contact_id = $1;
|
||||
"delete from journal_mentions where contact_id = $1;
|
||||
delete from names where contact_id = $1;
|
||||
delete from contacts where id = $1;",
|
||||
)
|
||||
|
|
|
|||
72
src/web/group.rs
Normal file
72
src/web/group.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use axum::{
|
||||
Router,
|
||||
extract::{State, path::Path},
|
||||
routing::get,
|
||||
};
|
||||
use cache_bust::asset;
|
||||
use maud::{Markup, html};
|
||||
|
||||
use super::Layout;
|
||||
use crate::db::DbId;
|
||||
use crate::models::user::AuthSession;
|
||||
use crate::{AppError, AppState};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/group/{slug}", get(self::get::group))
|
||||
}
|
||||
|
||||
mod get {
|
||||
use super::*;
|
||||
|
||||
struct ContactLink {
|
||||
id: DbId,
|
||||
primary_name: String,
|
||||
}
|
||||
|
||||
pub async fn group(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
layout: Layout,
|
||||
) -> Result<Markup, AppError> {
|
||||
let pool = &state.db(&auth_session.user.unwrap()).pool;
|
||||
|
||||
let name: String = sqlx::query!("select name from groups where slug = $1 limit 1", slug)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
.name;
|
||||
|
||||
let contacts = sqlx::query_as!(
|
||||
ContactLink,
|
||||
"select
|
||||
c.id as id, coalesce(
|
||||
(select n.name from names n
|
||||
where n.contact_id = c.id limit 1)
|
||||
, '(unknown)') as primary_name
|
||||
from contacts c
|
||||
join groups g on c.id = g.contact_id
|
||||
where g.slug = $1
|
||||
order by primary_name asc",
|
||||
slug
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(layout.render(
|
||||
Some(vec![asset!("group.css")]),
|
||||
html! {
|
||||
h1 { (name) }
|
||||
p { "Group members:" }
|
||||
ul #groups {
|
||||
@for link in contacts {
|
||||
li {
|
||||
a href=(format!("/contact/{}", link.id)) {
|
||||
(link.primary_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -131,8 +131,8 @@ pub mod get {
|
|||
from names where contact_id = c.id
|
||||
) as names, (
|
||||
select jes.date from journal_entries jes
|
||||
join contact_mentions cms on cms.entry_id = jes.id
|
||||
where cms.contact_id = c.id
|
||||
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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -98,26 +98,26 @@ mod patch {
|
|||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"update journal_entries set date = $1, value = $2 where id = $3",
|
||||
payload.date,
|
||||
payload.value,
|
||||
entry_id
|
||||
let new_entry: JournalEntry = sqlx::query_as(
|
||||
"update journal_entries set date = $1, value = $2 where id = $3 returning *",
|
||||
)
|
||||
.execute(pool)
|
||||
.bind(&payload.date)
|
||||
.bind(&payload.value)
|
||||
.bind(entry_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if entry.value != payload.value {
|
||||
sqlx::query!("delete from contact_mentions where entry_id = $1", entry_id)
|
||||
if entry.value != new_entry.value {
|
||||
sqlx::query!("delete from journal_mentions where entry_id = $1", entry_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
entry
|
||||
new_entry
|
||||
.insert_mentions(state.contact_search(&user), pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(entry.to_html(pool).await?)
|
||||
Ok(new_entry.to_html(pool).await?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +133,7 @@ mod delete {
|
|||
let pool = &state.db(&user).pool;
|
||||
|
||||
sqlx::query(
|
||||
"delete from contact_mentions where entry_id = $1;
|
||||
"delete from journal_mentions where entry_id = $1;
|
||||
delete from journal_entries where id = $2 returning id,date,value",
|
||||
)
|
||||
.bind(entry_id)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use super::{AppError, AppState};
|
|||
|
||||
pub mod auth;
|
||||
pub mod contact;
|
||||
pub mod group;
|
||||
pub mod home;
|
||||
pub mod ics;
|
||||
pub mod journal;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue