Compare commits
No commits in common. "79a054ab40387ea3d093845e67aba738bbd5e4a7" and "4a0ed993298ebe4bbd9a7b1eff087dc8207e4fc9" have entirely different histories.
79a054ab40
...
4a0ed99329
24 changed files with 143 additions and 333 deletions
3
Taskfile
3
Taskfile
|
|
@ -12,8 +12,7 @@ refresh_sqlx_db() {
|
||||||
rm -f some_user.db
|
rm -f some_user.db
|
||||||
for migration in migrations/each_user/*.sql; do
|
for migration in migrations/each_user/*.sql; do
|
||||||
echo "Applying $migration..."
|
echo "Applying $migration..."
|
||||||
echo "BEGIN TRANSACTION;$(cat "$migration");COMMIT TRANSACTION;"\
|
sqlite3 some_user.db < "$migration"
|
||||||
| sqlite3 some_user.db
|
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { expect, type Locator } from '@playwright/test';
|
|
||||||
expect.extend({
|
|
||||||
async toBeAbove(self: Locator, other: Locator) {
|
|
||||||
const name = 'toBeAbove';
|
|
||||||
let pass: boolean;
|
|
||||||
let matcherResult: any;
|
|
||||||
let selfY: number | null = null;
|
|
||||||
let otherY: number | null = null;
|
|
||||||
try {
|
|
||||||
selfY = (await self.boundingBox())?.y ?? null;
|
|
||||||
otherY = (await self.boundingBox())?.y ?? null;
|
|
||||||
pass = selfY !== null && otherY !== null && (selfY < otherY);
|
|
||||||
} catch (e: any) {
|
|
||||||
matcherResult = e.matcherResult;
|
|
||||||
pass = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isNot) {
|
|
||||||
pass =!pass;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = () => this.utils.matcherHint(name, undefined, undefined, { isNot: this.isNot }) +
|
|
||||||
'\n\n' +
|
|
||||||
`Locator: ${self}\n` +
|
|
||||||
`Expected: above ${other} (y=${this.utils.printExpected(otherY)})\n` +
|
|
||||||
(matcherResult ? `Received: y=${this.utils.printReceived(selfY)}` : '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
message,
|
|
||||||
pass,
|
|
||||||
name,
|
|
||||||
expected: (this.isNot ? '>=' : '<') + otherY,
|
|
||||||
actual: selfY,
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { login, verifyCreateUser, todate } from './util';
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await login(page);
|
|
||||||
await verifyCreateUser(page, { names: ['Test Testerson'] });
|
|
||||||
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manual-freshen date is editable', async ({ page }) => {
|
|
||||||
await page.getByRole('link', { name: /edit/i }).click();
|
|
||||||
await expect(page.getByRole('textbox', { name: /freshened/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('last-contact date on display resolves journal mentions and manual-freshen', async ({ page }) => {
|
|
||||||
const today = new Date().toISOString().split("T")[0];
|
|
||||||
const todayRe = new RegExp(today.substring(0, today.length - 1) + ".");
|
|
||||||
const entryDate = page.getByPlaceholder(todayRe);
|
|
||||||
const entryBox = page.getByPlaceholder(/new entry/i);
|
|
||||||
await entryDate.fill("2025-05-05");
|
|
||||||
await entryBox.fill("[[Test Testerson]]");
|
|
||||||
await page.getByRole('button', { name: /add entry/i }).click();
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.locator('#fields')).toContainText("freshened2025-05-05");
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skip("groups wrap nicely", async ({ page }) => {
|
|
||||||
await page.getByRole('link', { name: /edit/i }).click();
|
|
||||||
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
|
|
||||||
|
|
||||||
const groupBox = page.getByPlaceholder(/group name/i);
|
|
||||||
await groupBox.fill('this is a long group name');
|
|
||||||
await page.getByRole('button', { name: /save/i }).click();
|
|
||||||
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
|
|
||||||
|
|
||||||
// TODO: this drives to the right location but i can't figure out how to assert
|
|
||||||
// that the text is all on one line. Manual inspection looks good at time of writing.
|
|
||||||
});
|
|
||||||
|
|
||||||
test('allow marking as hidden', async ({ page }) => {
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
test('allow exempting from stale', async ({ page }) => {
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bullet points in free text display well', async ({ page }) => {
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
twst('page title has contact primary name', async ({ page }) => {
|
|
||||||
await expect(page.title()).toContain("Test Testerson");
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
home: contact list scrolls in screen, not off screen
|
|
||||||
home: clicking off contact list closes it
|
|
||||||
home: contact list is sorted ignoring case
|
|
||||||
home: contact list should scroll to current contact in center of view
|
|
||||||
journal: bullet points don't display
|
|
||||||
*/
|
|
||||||
|
|
@ -1,26 +1,28 @@
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { login, verifyCreateUser, todate } from './util';
|
import { login, verifyCreateUser, todate } from './util';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await login(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('can log out', async ({ page }) => {
|
test('can log out', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
|
||||||
await page.getByText("Logout").click();
|
await page.getByText("Logout").click();
|
||||||
await expect(page.getByLabel("Username")).toBeVisible();
|
await expect(page.getByLabel("Username")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has no contacts', async ({ page }) => {
|
test('has no contacts', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
|
||||||
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0);
|
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can add contacts', async ({ page }) => {
|
test('can add contacts', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
await verifyCreateUser(page, { names: ['John Contact'] });
|
await verifyCreateUser(page, { names: ['John Contact'] });
|
||||||
await verifyCreateUser(page, { names: ['Jack Contact'] });
|
await verifyCreateUser(page, { names: ['Jack Contact'] });
|
||||||
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
|
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows "never" for unfreshened contacts', async ({ page }) => {
|
test('shows "never" for unfreshened contacts', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
await verifyCreateUser(page, { names: ['John Contact'] });
|
await verifyCreateUser(page, { names: ['John Contact'] });
|
||||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||||
|
|
||||||
|
|
@ -28,6 +30,7 @@ test('shows "never" for unfreshened contacts', async ({ page }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows the date for fresh contacts', async ({ page }) => {
|
test('shows the date for fresh contacts', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
await verifyCreateUser(page, { names: ['John Contact'] });
|
await verifyCreateUser(page, { names: ['John Contact'] });
|
||||||
await page.getByRole('link', { name: /edit/i }).click();
|
await page.getByRole('link', { name: /edit/i }).click();
|
||||||
await page.getByRole('button', { name: /fresh/i }).click();
|
await page.getByRole('button', { name: /fresh/i }).click();
|
||||||
|
|
@ -37,6 +40,7 @@ test('shows the date for fresh contacts', async ({ page }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sidebar is sorted alphabetically', async ({ page }) => {
|
test('sidebar is sorted alphabetically', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
await verifyCreateUser(page, { names: ['Zulu'] });
|
await verifyCreateUser(page, { names: ['Zulu'] });
|
||||||
await verifyCreateUser(page, { names: ['Alfa'] });
|
await verifyCreateUser(page, { names: ['Alfa'] });
|
||||||
await verifyCreateUser(page, { names: ['Golf'] });
|
await verifyCreateUser(page, { names: ['Golf'] });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
import 'custom-expects';
|
|
||||||
|
|
||||||
// purposefully not using ??: we want to replace empty empty string with default
|
// purposefully not using ??: we want to replace empty empty string with default
|
||||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
|
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
-- foreign_keys can only up/down outside of transactions
|
|
||||||
-- so we first pre-commit the one started by sqlx...
|
|
||||||
COMMIT TRANSACTION;
|
|
||||||
|
|
||||||
-- turn off foreign keys...
|
|
||||||
PRAGMA foreign_keys=OFF;
|
PRAGMA foreign_keys=OFF;
|
||||||
|
|
||||||
-- start our own transaction...
|
|
||||||
BEGIN TRANSACTION;
|
|
||||||
create table if not exists new_contacts (
|
create table if not exists new_contacts (
|
||||||
id integer primary key autoincrement,
|
id integer primary key autoincrement,
|
||||||
birthday text,
|
birthday text,
|
||||||
|
|
@ -23,12 +16,4 @@ insert into new_contacts (
|
||||||
drop table contacts;
|
drop table contacts;
|
||||||
alter table new_contacts rename to contacts;
|
alter table new_contacts rename to contacts;
|
||||||
PRAGMA foreign_key_check;
|
PRAGMA foreign_key_check;
|
||||||
|
|
||||||
-- commit our own transaction...
|
|
||||||
COMMIT TRANSACTION;
|
|
||||||
|
|
||||||
-- put our own pragmas back...
|
|
||||||
PRAGMA foreign_keys=ON;
|
PRAGMA foreign_keys=ON;
|
||||||
|
|
||||||
-- and start a dummy transaction so sqlx's COMMIT doesn't explode
|
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
|
||||||
57
src/main.rs
57
src/main.rs
|
|
@ -3,8 +3,7 @@ use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum_login::AuthUser;
|
use axum_login::AuthUser;
|
||||||
use axum_login::{AuthManagerLayerBuilder, login_required};
|
use axum_login::{AuthManagerLayerBuilder, login_required};
|
||||||
use cache_bust::asset;
|
use clap::{Parser, Subcommand, arg, command};
|
||||||
use clap::{Parser, Subcommand};
|
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
@ -12,7 +11,7 @@ use std::sync::{Arc, RwLock};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
use tokio::task::AbortHandle;
|
use tokio::task::AbortHandle;
|
||||||
use tower_http::services::{ServeDir,ServeFile};
|
use tower_http::services::ServeDir;
|
||||||
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
|
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
|
||||||
use tower_sessions_sqlx_store::SqliteStore;
|
use tower_sessions_sqlx_store::SqliteStore;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
@ -109,21 +108,10 @@ enum Commands {
|
||||||
port: u32,
|
port: u32,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// set password of user, creating if necessary
|
|
||||||
SetPassword {
|
SetPassword {
|
||||||
/// username to create or set password
|
/// username to create or set password
|
||||||
username: String,
|
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> {
|
async fn serve(port: &u32) -> Result<(), anyhow::Error> {
|
||||||
|
|
@ -181,7 +169,6 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
|
||||||
.merge(auth::router())
|
.merge(auth::router())
|
||||||
.merge(ics::router())
|
.merge(ics::router())
|
||||||
.nest_service("/static", ServeDir::new("./hashed_static"))
|
.nest_service("/static", ServeDir::new("./hashed_static"))
|
||||||
.nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))))
|
|
||||||
.layer(auth_layer)
|
.layer(auth_layer)
|
||||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
@ -231,46 +218,6 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||||
println!("No update was made; probably something went wrong.");
|
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 }) => {
|
Some(Commands::Serve { port }) => {
|
||||||
serve(port).await?;
|
serve(port).await?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ impl MentionHost<'_> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Switchboard {
|
impl Switchboard {
|
||||||
pub async fn gen_trie(pool: &SqlitePool) -> Result<radix_trie::Trie<String,String>, AppError> {
|
pub async fn new(pool: &SqlitePool) -> Result<Self, AppError> {
|
||||||
let mut trie = radix_trie::Trie::new();
|
let mut trie = radix_trie::Trie::new();
|
||||||
|
|
||||||
let mentionables = sqlx::query_as!(
|
let mentionables = sqlx::query_as!(
|
||||||
|
|
@ -92,23 +92,9 @@ impl Switchboard {
|
||||||
trie.insert(mentionable.text, mentionable.uri);
|
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 })
|
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) {
|
pub fn remove(self: &mut Self, text: &String) {
|
||||||
self.trie.remove(text);
|
self.trie.remove(text);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,6 @@ mod get {
|
||||||
(DOCTYPE)
|
(DOCTYPE)
|
||||||
html {
|
html {
|
||||||
head {
|
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";
|
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.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" {}
|
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::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, Switchboard};
|
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions};
|
||||||
use crate::{AppError, AppState};
|
use crate::{AppError, AppState};
|
||||||
|
|
||||||
pub mod fields;
|
pub mod fields;
|
||||||
|
|
@ -75,7 +75,7 @@ mod get {
|
||||||
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 mentions m on j.id = m.entity_id
|
join mentions m on j.id = m.entity_id
|
||||||
where m.entity_type = $1 and (m.url = '/contact/'||$2 or m.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 = $2
|
where contact_id = $2
|
||||||
))
|
))
|
||||||
|
|
@ -87,11 +87,6 @@ mod get {
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.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!(
|
let phone_numbers: Vec<PhoneNumber> = sqlx::query_as!(
|
||||||
PhoneNumber,
|
PhoneNumber,
|
||||||
"select * from phone_numbers where contact_id = $1",
|
"select * from phone_numbers where contact_id = $1",
|
||||||
|
|
@ -118,7 +113,6 @@ mod get {
|
||||||
.text_body;
|
.text_body;
|
||||||
|
|
||||||
Ok(layout.render(
|
Ok(layout.render(
|
||||||
contact.names.get(0).unwrap_or(&String::from("(unknown)")),
|
|
||||||
Some(vec![asset!("contact.css"), asset!("journal.css")]),
|
Some(vec![asset!("contact.css"), asset!("journal.css")]),
|
||||||
html! {
|
html! {
|
||||||
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
|
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
|
||||||
|
|
@ -147,8 +141,8 @@ mod get {
|
||||||
}
|
}
|
||||||
label { "freshened" }
|
label { "freshened" }
|
||||||
div {
|
div {
|
||||||
@if let Some(freshened) = freshened {
|
@if let Some(when) = &contact.manually_freshened_at {
|
||||||
(freshened.to_string())
|
(when.date_naive().to_string())
|
||||||
} @else {
|
} @else {
|
||||||
"(never)"
|
"(never)"
|
||||||
}
|
}
|
||||||
|
|
@ -222,10 +216,7 @@ mod get {
|
||||||
.text_body
|
.text_body
|
||||||
.unwrap_or(String::new());
|
.unwrap_or(String::new());
|
||||||
|
|
||||||
Ok(layout.render(
|
Ok(layout.render(Some(vec![asset!("contact.css")]), html! {
|
||||||
format!("Edit: {}", contact.names.get(0).unwrap_or(&String::from("(unknown)"))),
|
|
||||||
Some(vec![asset!("contact.css")]),
|
|
||||||
html! {
|
|
||||||
form hx-ext="response-targets" {
|
form hx-ext="response-targets" {
|
||||||
div {
|
div {
|
||||||
input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error";
|
input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error";
|
||||||
|
|
@ -386,7 +377,50 @@ mod put {
|
||||||
.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 {
|
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
|
||||||
|
|
@ -474,6 +508,7 @@ mod put {
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
// recalculate all contact mentions and name trie if name-list changed
|
||||||
let new_names: Vec<String> = payload
|
let new_names: Vec<String> = payload
|
||||||
.name
|
.name
|
||||||
.unwrap_or(vec![])
|
.unwrap_or(vec![])
|
||||||
|
|
@ -488,25 +523,60 @@ 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* 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!(
|
sqlx::query!(
|
||||||
"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)
|
||||||
.await?;
|
.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() {
|
if !new_names.is_empty() {
|
||||||
QueryBuilder::new(
|
for name in &new_names {
|
||||||
"insert into names (contact_id, sort, name) "
|
name_list.push_bind(name.clone());
|
||||||
).push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
|
}
|
||||||
b
|
|
||||||
|
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(contact_id)
|
||||||
.push_bind(DbId::try_from(sort).unwrap())
|
.push_bind(DbId::try_from(sort).unwrap())
|
||||||
.push_bind(name);
|
.push_bind(name);
|
||||||
}).build()
|
},
|
||||||
|
);
|
||||||
|
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)
|
.persistent(false)
|
||||||
.execute(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -525,7 +595,7 @@ mod put {
|
||||||
|
|
||||||
if new_groups != old_groups {
|
if new_groups != old_groups {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"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)
|
||||||
|
|
@ -543,72 +613,24 @@ mod put {
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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 = {
|
if new_names != old_names || new_groups != old_groups {
|
||||||
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 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> =
|
let journal_entries: Vec<JournalEntry> =
|
||||||
sqlx::query_as("select * from journal_entries")
|
sqlx::query_as("select * from journal_entries")
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
|
|
@ -622,6 +644,7 @@ mod put {
|
||||||
insert_mentions(&mentions, pool).await?;
|
insert_mentions(&mentions, pool).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert("HX-Redirect", format!("/contact/{}", contact_id).parse()?);
|
headers.insert("HX-Redirect", format!("/contact/{}", contact_id).parse()?);
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ mod get {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(layout.render(
|
Ok(layout.render(
|
||||||
format!("Group: {}", name),
|
|
||||||
Some(vec![asset!("group.css")]),
|
Some(vec![asset!("group.css")]),
|
||||||
html! {
|
html! {
|
||||||
h1 { (name) }
|
h1 { (name) }
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,6 @@ pub mod get {
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(layout.render(
|
Ok(layout.render(
|
||||||
"Home",
|
|
||||||
Some(vec![asset!("home.css"), asset!("journal.css")]),
|
Some(vec![asset!("home.css"), asset!("journal.css")]),
|
||||||
html! {
|
html! {
|
||||||
(freshness_section(&freshens)?)
|
(freshness_section(&freshens)?)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ 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, MentionHostType, insert_mentions};
|
use crate::switchboard::{MentionHost, insert_mentions};
|
||||||
use crate::{AppError, AppState, DbId};
|
use crate::{AppError, AppState};
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|
@ -80,6 +80,7 @@ mod post {
|
||||||
let switchboard = sw_lock.read().unwrap();
|
let switchboard = sw_lock.read().unwrap();
|
||||||
switchboard.extract_mentions(&entry)
|
switchboard.extract_mentions(&entry)
|
||||||
};
|
};
|
||||||
|
tracing::debug!("{:?}", mentions);
|
||||||
insert_mentions(&mentions, pool).await?;
|
insert_mentions(&mentions, pool).await?;
|
||||||
|
|
||||||
Ok(entry.to_html(pool).await?)
|
Ok(entry.to_html(pool).await?)
|
||||||
|
|
@ -117,9 +118,8 @@ mod patch {
|
||||||
|
|
||||||
if old_entry.value != new_entry.value {
|
if old_entry.value != new_entry.value {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"delete from mentions where entity_id = $1 and entity_type = $2",
|
"delete from mentions where entity_id = $1 and entity_type = 'journal_entry'",
|
||||||
entry_id,
|
entry_id
|
||||||
MentionHostType::JournalEntry as DbId
|
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -131,8 +131,9 @@ mod patch {
|
||||||
insert_mentions(&mentions, pool).await?;
|
insert_mentions(&mentions, pool).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(Into::<MentionHost>::into(&new_entry)
|
||||||
Ok(new_entry.to_html(pool).await?)
|
.format_pool(pool)
|
||||||
|
.await?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,16 +61,11 @@ impl FromRequestParts<AppState> for Layout {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Layout {
|
impl Layout {
|
||||||
pub fn render(&self, title: impl AsRef<str>, css: Option<Vec<&str>>, content: Markup) -> Markup {
|
pub fn render(&self, css: Option<Vec<&str>>, content: Markup) -> Markup {
|
||||||
html! {
|
html! {
|
||||||
(DOCTYPE)
|
(DOCTYPE)
|
||||||
html {
|
html {
|
||||||
head {
|
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")));
|
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
|
||||||
meta name="viewport" content="width=device-width";
|
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.org@2.0.7/dist/htmx.min.js" {}
|
||||||
|
|
@ -107,7 +102,6 @@ impl Layout {
|
||||||
(content)
|
(content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
template #alpine-loaded x-cloak {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ mod get {
|
||||||
let ics_path: Option<String> = ics_path.0;
|
let ics_path: Option<String> = ics_path.0;
|
||||||
|
|
||||||
Ok(layout.render(
|
Ok(layout.render(
|
||||||
"Settings",
|
|
||||||
Some(vec![asset!("settings.css")]),
|
Some(vec![asset!("settings.css")]),
|
||||||
html! {
|
html! {
|
||||||
h2 { "Birthdays Calendar URL" }
|
h2 { "Birthdays Calendar URL" }
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
|
|
@ -47,7 +47,7 @@ main {
|
||||||
#groups {
|
#groups {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: fit-content;
|
width: min-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
#text_body {
|
#text_body {
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 662 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"short_name": "CRM",
|
|
||||||
"name": "Mascarpone CRM",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"start_url": ".",
|
|
||||||
"display": "standalone",
|
|
||||||
"theme_color": "#8b687f",
|
|
||||||
"background_color": "#f2f3f6"
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue