fix,feat: mention behavior and page titles
This commit is contained in:
parent
7e2f5d0a18
commit
79a054ab40
22 changed files with 314 additions and 140 deletions
57
src/main.rs
57
src/main.rs
|
|
@ -3,7 +3,8 @@ use axum::response::{IntoResponse, Response};
|
|||
use axum::routing::get;
|
||||
use axum_login::AuthUser;
|
||||
use axum_login::{AuthManagerLayerBuilder, login_required};
|
||||
use clap::{Parser, Subcommand, arg, command};
|
||||
use cache_bust::asset;
|
||||
use clap::{Parser, Subcommand};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
|
@ -11,7 +12,7 @@ use std::sync::{Arc, RwLock};
|
|||
use tokio::net::TcpListener;
|
||||
use tokio::signal;
|
||||
use tokio::task::AbortHandle;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::services::{ServeDir,ServeFile};
|
||||
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
|
||||
use tower_sessions_sqlx_store::SqliteStore;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
|
@ -108,10 +109,21 @@ enum Commands {
|
|||
port: u32,
|
||||
},
|
||||
|
||||
/// set password of user, creating if necessary
|
||||
SetPassword {
|
||||
/// username to create or set password
|
||||
username: String,
|
||||
},
|
||||
|
||||
/// set a user's ephemerality
|
||||
SetEphemeral {
|
||||
/// username to set ephemerality
|
||||
username: String,
|
||||
|
||||
#[arg(action = clap::ArgAction::Set)]
|
||||
ephemeral: bool,
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
async fn serve(port: &u32) -> Result<(), anyhow::Error> {
|
||||
|
|
@ -169,6 +181,7 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
|
|||
.merge(auth::router())
|
||||
.merge(ics::router())
|
||||
.nest_service("/static", ServeDir::new("./hashed_static"))
|
||||
.nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))))
|
||||
.layer(auth_layer)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
|
@ -218,6 +231,46 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||
println!("No update was made; probably something went wrong.");
|
||||
}
|
||||
}
|
||||
Some(Commands::SetEphemeral { username, ephemeral }) => {
|
||||
let users_db = {
|
||||
let db_options = SqliteConnectOptions::from_str("users.db")?
|
||||
.create_if_missing(true)
|
||||
.to_owned();
|
||||
|
||||
let db = SqlitePoolOptions::new().connect_with(db_options).await?;
|
||||
sqlx::migrate!("./migrations/users.db").run(&db).await?;
|
||||
db
|
||||
};
|
||||
|
||||
let eph: Option<bool> = sqlx::query_scalar(
|
||||
"select ephemeral from users where username = ?"
|
||||
)
|
||||
.bind(&username)
|
||||
.fetch_optional(&users_db)
|
||||
.await?;
|
||||
if let Some(eph) = eph {
|
||||
if eph == *ephemeral {
|
||||
println!("User {} is already {}.", username, if eph { "ephemeral" } else { "not ephemeral" });
|
||||
} else {
|
||||
let update = sqlx::query(
|
||||
"update users set ephemeral=$1 where username = $2",
|
||||
)
|
||||
.bind(ephemeral)
|
||||
.bind(&username)
|
||||
.execute(&users_db)
|
||||
.await?;
|
||||
|
||||
if update.rows_affected() > 0 {
|
||||
println!("Updated ephemerality for {}.", username);
|
||||
} else {
|
||||
println!("No update was made; probably something went wrong.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("User {} does not exist. Create them first with set-password.", username);
|
||||
}
|
||||
|
||||
}
|
||||
Some(Commands::Serve { port }) => {
|
||||
serve(port).await?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ impl MentionHost<'_> {
|
|||
}
|
||||
|
||||
impl Switchboard {
|
||||
pub async fn new(pool: &SqlitePool) -> Result<Self, AppError> {
|
||||
pub async fn gen_trie(pool: &SqlitePool) -> Result<radix_trie::Trie<String,String>, AppError> {
|
||||
let mut trie = radix_trie::Trie::new();
|
||||
|
||||
let mentionables = sqlx::query_as!(
|
||||
|
|
@ -92,9 +92,23 @@ impl Switchboard {
|
|||
trie.insert(mentionable.text, mentionable.uri);
|
||||
}
|
||||
|
||||
Ok(trie)
|
||||
}
|
||||
|
||||
pub async fn new(pool: &SqlitePool) -> Result<Self, AppError> {
|
||||
let trie = Self::gen_trie(pool).await?;
|
||||
Ok(Switchboard { trie })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(self: &mut Self, text: &String) {
|
||||
self.trie.remove(text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,11 @@ mod get {
|
|||
(DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
link rel="apple-touch-icon" sizes="180x180" href=(format!("/static/{}", asset!("apple-touch-icon.png")));
|
||||
link rel="icon" type="image/png" sizes="32x32" href=(format!("/static/{}", asset!("favicon-32x32.png")));
|
||||
link rel="icon" type="image/png" sizes="16x16" href=(format!("/static/{}", asset!("favicon-16x16.png")));
|
||||
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
|
||||
title { "Mascarpone CRM" }
|
||||
meta name="viewport" content="width=device-width";
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
|
||||
|
|
|
|||
|
|
@ -19,7 +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::switchboard::{MentionHost, MentionHostType, insert_mentions, Switchboard};
|
||||
use crate::{AppError, AppState};
|
||||
|
||||
pub mod fields;
|
||||
|
|
@ -87,6 +87,11 @@ mod get {
|
|||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let freshened = std::cmp::max(
|
||||
contact.manually_freshened_at.map(|when| when.date_naive()),
|
||||
entries.get(0).map(|entry| entry.date),
|
||||
);
|
||||
|
||||
let phone_numbers: Vec<PhoneNumber> = sqlx::query_as!(
|
||||
PhoneNumber,
|
||||
"select * from phone_numbers where contact_id = $1",
|
||||
|
|
@ -113,6 +118,7 @@ mod get {
|
|||
.text_body;
|
||||
|
||||
Ok(layout.render(
|
||||
contact.names.get(0).unwrap_or(&String::from("(unknown)")),
|
||||
Some(vec![asset!("contact.css"), asset!("journal.css")]),
|
||||
html! {
|
||||
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
|
||||
|
|
@ -141,8 +147,8 @@ mod get {
|
|||
}
|
||||
label { "freshened" }
|
||||
div {
|
||||
@if let Some(when) = &contact.manually_freshened_at {
|
||||
(when.date_naive().to_string())
|
||||
@if let Some(freshened) = freshened {
|
||||
(freshened.to_string())
|
||||
} @else {
|
||||
"(never)"
|
||||
}
|
||||
|
|
@ -216,7 +222,10 @@ mod get {
|
|||
.text_body
|
||||
.unwrap_or(String::new());
|
||||
|
||||
Ok(layout.render(Some(vec![asset!("contact.css")]), html! {
|
||||
Ok(layout.render(
|
||||
format!("Edit: {}", contact.names.get(0).unwrap_or(&String::from("(unknown)"))),
|
||||
Some(vec![asset!("contact.css")]),
|
||||
html! {
|
||||
form hx-ext="response-targets" {
|
||||
div {
|
||||
input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error";
|
||||
|
|
@ -377,50 +386,7 @@ 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
|
||||
|
|
@ -508,7 +474,6 @@ mod put {
|
|||
}
|
||||
|
||||
{
|
||||
// recalculate all contact mentions and name trie if name-list changed
|
||||
let new_names: Vec<String> = payload
|
||||
.name
|
||||
.unwrap_or(vec![])
|
||||
|
|
@ -523,60 +488,25 @@ mod put {
|
|||
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
|
||||
|
||||
if old_names != new_names {
|
||||
// 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 mentions; delete from names where contact_id = $1",
|
||||
"delete from names where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
.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()
|
||||
QueryBuilder::new(
|
||||
"insert into names (contact_id, sort, name) "
|
||||
).push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
|
||||
b
|
||||
.push_bind(contact_id)
|
||||
.push_bind(DbId::try_from(sort).unwrap())
|
||||
.push_bind(name);
|
||||
}).build()
|
||||
.persistent(false)
|
||||
.fetch_all(pool)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let mut switchboard = sw_lock.write().unwrap();
|
||||
for name in &old_names {
|
||||
switchboard.remove(name);
|
||||
}
|
||||
|
||||
for name in recalc_names {
|
||||
switchboard.add_mentionable(name.0, format!("/contact/{}", name.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -595,7 +525,7 @@ mod put {
|
|||
|
||||
if new_groups != old_groups {
|
||||
sqlx::query!(
|
||||
"delete from mentions; delete from groups where contact_id = $1",
|
||||
"delete from groups where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
.execute(pool)
|
||||
|
|
@ -613,36 +543,83 @@ mod put {
|
|||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
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?)
|
||||
switchboard.remove(name);
|
||||
}
|
||||
|
||||
for group in &new_groups {
|
||||
switchboard
|
||||
.add_mentionable(group.clone(), format!("/group/{}", slugify(group)));
|
||||
}
|
||||
}
|
||||
let regen_all_mentions = {
|
||||
let trie = Switchboard::gen_trie(pool).await?;
|
||||
let mut swb = sw_lock.write().unwrap();
|
||||
swb.check_and_assign(trie)
|
||||
};
|
||||
let regen_lives_with = old_contact.lives_with != payload.lives_with;
|
||||
let regen_text_body = old_contact.text_body != text_body;
|
||||
if regen_all_mentions {
|
||||
sqlx::query("delete from mentions").execute(pool).await?;
|
||||
} else {
|
||||
if regen_lives_with {
|
||||
sqlx::query!(
|
||||
"delete from mentions where entity_id = $1 and entity_type = $2",
|
||||
contact_id,
|
||||
MentionHostType::ContactLivesWith as DbId
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
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 {
|
||||
let mentions = {
|
||||
let switchboard = sw_lock.read().unwrap();
|
||||
switchboard.extract_mentions(&entry)
|
||||
};
|
||||
insert_mentions(&mentions, pool).await?;
|
||||
}
|
||||
if regen_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 regen_all_mentions || regen_lives_with {
|
||||
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 regen_all_mentions || regen_text_body {
|
||||
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?;
|
||||
}
|
||||
}
|
||||
|
||||
if regen_all_mentions {
|
||||
let journal_entries: Vec<JournalEntry> =
|
||||
sqlx::query_as("select * from journal_entries")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
for entry in journal_entries {
|
||||
let mentions = {
|
||||
let switchboard = sw_lock.read().unwrap();
|
||||
switchboard.extract_mentions(&entry)
|
||||
};
|
||||
insert_mentions(&mentions, pool).await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ mod get {
|
|||
.await?;
|
||||
|
||||
Ok(layout.render(
|
||||
format!("Group: {}", name),
|
||||
Some(vec![asset!("group.css")]),
|
||||
html! {
|
||||
h1 { (name) }
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ pub mod get {
|
|||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(layout.render(
|
||||
"Home",
|
||||
Some(vec![asset!("home.css"), asset!("journal.css")]),
|
||||
html! {
|
||||
(freshness_section(&freshens)?)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ use serde::Deserialize;
|
|||
|
||||
use crate::models::JournalEntry;
|
||||
use crate::models::user::AuthSession;
|
||||
use crate::switchboard::{MentionHost, insert_mentions};
|
||||
use crate::{AppError, AppState};
|
||||
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions};
|
||||
use crate::{AppError, AppState, DbId};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
|
|
@ -80,7 +80,6 @@ mod post {
|
|||
let switchboard = sw_lock.read().unwrap();
|
||||
switchboard.extract_mentions(&entry)
|
||||
};
|
||||
tracing::debug!("{:?}", mentions);
|
||||
insert_mentions(&mentions, pool).await?;
|
||||
|
||||
Ok(entry.to_html(pool).await?)
|
||||
|
|
@ -118,8 +117,9 @@ mod patch {
|
|||
|
||||
if old_entry.value != new_entry.value {
|
||||
sqlx::query!(
|
||||
"delete from mentions where entity_id = $1 and entity_type = 'journal_entry'",
|
||||
entry_id
|
||||
"delete from mentions where entity_id = $1 and entity_type = $2",
|
||||
entry_id,
|
||||
MentionHostType::JournalEntry as DbId
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
|
@ -131,9 +131,8 @@ mod patch {
|
|||
insert_mentions(&mentions, pool).await?;
|
||||
}
|
||||
|
||||
Ok(Into::<MentionHost>::into(&new_entry)
|
||||
.format_pool(pool)
|
||||
.await?)
|
||||
|
||||
Ok(new_entry.to_html(pool).await?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,11 +61,16 @@ impl FromRequestParts<AppState> for Layout {
|
|||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn render(&self, css: Option<Vec<&str>>, content: Markup) -> Markup {
|
||||
pub fn render(&self, title: impl AsRef<str>, css: Option<Vec<&str>>, content: Markup) -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
title { (format!("{} | Mascarpone CRM", title.as_ref())) }
|
||||
link rel="apple-touch-icon" sizes="180x180" href=(format!("/static/{}", asset!("apple-touch-icon.png")));
|
||||
link rel="icon" type="image/png" sizes="32x32" href=(format!("/static/{}", asset!("favicon-32x32.png")));
|
||||
link rel="icon" type="image/png" sizes="16x16" href=(format!("/static/{}", asset!("favicon-16x16.png")));
|
||||
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
|
||||
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
|
||||
meta name="viewport" content="width=device-width";
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
|
||||
|
|
@ -102,6 +107,7 @@ impl Layout {
|
|||
(content)
|
||||
}
|
||||
}
|
||||
template #alpine-loaded x-cloak {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ mod get {
|
|||
let ics_path: Option<String> = ics_path.0;
|
||||
|
||||
Ok(layout.render(
|
||||
"Settings",
|
||||
Some(vec![asset!("settings.css")]),
|
||||
html! {
|
||||
h2 { "Birthdays Calendar URL" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue