feat: full group support
Some checks failed
/ integration-test--firefox (push) Failing after 3m6s

This commit is contained in:
Robert Perce 2026-01-23 21:20:27 -06:00
parent cd4096b2ff
commit a0afb6dfd3
24 changed files with 536 additions and 274 deletions

17
Cargo.lock generated
View file

@ -609,6 +609,12 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -1431,6 +1437,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"short-uuid", "short-uuid",
"slug",
"sqlx", "sqlx",
"thiserror 2.0.17", "thiserror 2.0.17",
"time", "time",
@ -2305,6 +2312,16 @@ version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]]
name = "slug"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
dependencies = [
"deunicode",
"wasm-bindgen",
]
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.1"

View file

@ -25,6 +25,7 @@ rpassword = "7.4.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145" serde_json = "1.0.145"
short-uuid = "0.2.0" short-uuid = "0.2.0"
slug = "0.1.6"
sqlx = { version = "0.8", features = ["macros", "runtime-tokio", "sqlite", "tls-rustls"] } sqlx = { version = "0.8", features = ["macros", "runtime-tokio", "sqlite", "tls-rustls"] }
thiserror = "2.0.17" thiserror = "2.0.17"
time = "0.3.44" time = "0.3.44"

22
e2e/README.md Normal file
View file

@ -0,0 +1,22 @@
# e2e
Install deps with `corepack pnpm i`.
Ensure that if you update `@playwright/test` that
(a) it remains a devdep, and
(b) it is a `=`-type dependency.
Start a dev server with `cargo run`. Tests expect an ephemeral user with username and
password both `test`. Achieve this with
```
cargo run set-password test
```
then
```
sqlite3 users.db "update users set ephemeral=true where username='test'"
```
Run tests in the docker image with the right browsers installed with `./Taskfile
playwright:local` or `./Taskfile playwright:ui`.

View file

@ -3,7 +3,7 @@ PATH=$PATH:node_modules/.bin
SCRIPT_DIR=$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) SCRIPT_DIR=$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)
_playwright_version() { _playwright_version() {
jq -r '.devDependencies["@playwright/test"]' "$SCRIPT_DIR/package.json" | sed -e 's/\^/v/' jq -r '.devDependencies["@playwright/test"]' "$SCRIPT_DIR/package.json" | sed -e 's/\=/v/'
} }
playwright:local() { playwright:local() {

View file

@ -10,8 +10,8 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.56.1", "@playwright/test": "=1.57.0",
"@types/node": "^24.9.1" "@types/node": "^24.9.1"
}, },
"packageManager": "pnpm@11.0.0-dev.1005+sha512.91f84a392eea348ea4852a182912d2520273a4336f933b78cc44bc931eb999923c097e9433a9b355adc1f725725ea99082fc9f032a559df832632e764c92c798" "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
} }

View file

@ -2,116 +2,116 @@ import { test, expect } from '@playwright/test';
import { login, verifyCreateUser, todate } from './util'; import { login, verifyCreateUser, todate } from './util';
test('can add journal entries', async ({ page }) => { test('can add journal entries', async ({ page }) => {
await login(page); await login(page);
const entryBox = page.getByPlaceholder(/new entry/i); const entryBox = page.getByPlaceholder(/new entry/i);
await entryBox.fill('banana banana banana'); await entryBox.fill('banana banana banana');
await page.getByRole('button', { name: /add entry/i }).click(); await page.getByRole('button', { name: /add entry/i }).click();
await expect(entryBox).toBeEmpty(); await expect(entryBox).toBeEmpty();
await expect(page.getByText('banana banana banana')).toBeVisible(); await expect(page.getByText('banana banana banana')).toBeVisible();
}); });
test('journal entries autolink', async ({ page }) => { test('journal entries autolink', async ({ page }) => {
await login(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();
await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]'); await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]');
await page.getByRole('button', { name: /add entry/i }).click(); await page.getByRole('button', { name: /add entry/i }).click();
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible(); await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible();
}); });
test("changing a contact's names updates journal entries", async ({ page }) => { test("changing a contact's names updates journal entries", async ({ page }) => {
await login(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 page.getByPlaceholder(/new entry/i).fill('met with [[JC]]'); await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]');
await page.getByRole('button', { name: /add entry/i }).click(); await page.getByRole('button', { name: /add entry/i }).click();
const nav = page.getByRole('navigation'); const nav = page.getByRole('navigation');
const journal = page.locator('#journal'); const journal = page.locator('#journal');
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// add a new name // add a new name
await nav.getByRole("link", { name: 'John Contact' }).click(); await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC'); await page.getByRole('textbox', { name: 'New name' }).fill('JC');
await page.getByRole('button', { name: 'Add', exact: true }).click(); await page.getByRole('button', { name: 'Add' }).nth(1).click();
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
console.log(await journal.innerHTML()); console.log(await journal.innerHTML());
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// delete an existing name // delete an existing name
await nav.getByRole("link", { name: 'John Contact' }).click(); await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: '×', disabled: false }).click(); await page.getByRole('button', { name: '×', disabled: false }).click();
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// put it back, then... // put it back, then...
await nav.getByRole("link", { name: 'John Contact' }).click(); await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC'); await page.getByRole('textbox', { name: 'New name' }).fill('JC');
await page.getByRole('button', { name: 'Add', exact: true }).click(); await page.getByRole('button', { name: 'Add' }).nth(1).click();
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// ...add a name that makes it no longer n=1 // ...add a name that makes it no longer n=1
await nav.getByRole("link", { name: 'Jack Contact' }).click(); await nav.getByRole("link", { name: 'Jack Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC'); await page.getByRole('textbox', { name: 'New name' }).fill('JC');
await page.getByRole('button', { name: 'Add', exact: true }).click(); await page.getByRole('button', { name: 'Add' }).nth(1).click();
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// delete a name that makes it now n=1 // delete a name that makes it now n=1
await nav.getByRole("link", { name: 'John Contact' }).click(); await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: '×', disabled: false }).click(); await page.getByRole('button', { name: '×', disabled: false }).click();
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
}); });
test('can edit existing journal entries on home page', async ({ page }) => { test('can edit existing journal entries on home page', async ({ page }) => {
await login(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();
await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana"); await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana");
await page.getByRole('button', { name: /add entry/i }).click(); await page.getByRole('button', { name: /add entry/i }).click();
await page.reload(); await page.reload();
await page.getByRole('checkbox', { name: /edit/i }).click(); await page.getByRole('checkbox', { name: /edit/i }).click();
const textbox = page.locator('form').filter({ const textbox = page.locator('form').filter({
has: page.getByRole('button', { name: '✓' }) has: page.getByRole('button', { name: '✓' })
}).locator('textarea'); }).locator('textarea');
await textbox.fill('met with [[John Contact]]'); await textbox.fill('met with [[John Contact]]');
await page.getByRole('button', { name: '✓' }).click(); await page.getByRole('button', { name: '✓' }).click();
await page.getByRole('checkbox', { name: /edit/i }).click(); await page.getByRole('checkbox', { name: /edit/i }).click();
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toHaveCount(1); await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toHaveCount(1);
}); });
test('can have multiple links', async ({ page }) => { test('can have multiple links', async ({ page }) => {
await login(page); await login(page);
await verifyCreateUser(page, { names: ['alice'] }); await verifyCreateUser(page, { names: ['alice'] });
await verifyCreateUser(page, { names: ['bob'] }); await verifyCreateUser(page, { names: ['bob'] });
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids'); await page.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids');
await page.getByRole('button', { name: /add entry/i }).click(); await page.getByRole('button', { name: /add entry/i }).click();
const journal = page.locator('#journal'); const journal = page.locator('#journal');
await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1); await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1);
await expect.soft(journal.getByRole('link', { name: 'bob' })).toHaveCount(1); await expect.soft(journal.getByRole('link', { name: 'bob' })).toHaveCount(1);
}); });

View file

@ -1,31 +1,31 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
export const login = async (page: Page) => { export const login = async (page: Page) => {
await page.goto('/'); await page.goto('/');
await page.getByLabel("Username").fill("test"); await page.getByLabel("Username").fill("test");
await page.getByLabel("Password").fill("test"); await page.getByLabel("Password").fill("test");
await page.getByRole("button", { name: /login/i }).click(); await page.getByRole("button", { name: /login/i }).click();
}; };
export const todate = () => new Date().toISOString().split('T')[0]; export const todate = () => new Date().toISOString().split('T')[0];
type UserFields = { type UserFields = {
names?: Array<string>, names?: Array<string>,
birthday?: string, birthday?: string,
}; };
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();
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);
await page.getByRole('button', { name: 'Add', exact: true }).click(); await page.getByRole('button', { name: 'Add' }).nth(1).click();
} }
for (const [label, value] of Object.entries(simple)) { for (const [label, value] of Object.entries(simple)) {
await page.getByLabel(label).fill(value); await page.getByLabel(label).fill(value);
} }
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
}; };

2
e2e/pnpm-lock.yaml generated
View file

@ -9,7 +9,7 @@ importers:
.: .:
devDependencies: devDependencies:
'@playwright/test': '@playwright/test':
specifier: ^1.56.1 specifier: '=1.57.0'
version: 1.57.0 version: 1.57.0
'@types/node': '@types/node':
specifier: ^24.9.1 specifier: ^24.9.1

1
e2e/static Symbolic link
View file

@ -0,0 +1 @@
../static

Binary file not shown.

View file

@ -1,33 +1,51 @@
INSERT INTO contacts(id, birthday, manually_freshened_at) insert into contacts(id, birthday, manually_freshened_at) values
values (0, '--0415', '2000-01-01T12:00:00'); (0, '--0415', '2000-01-01T12:00:00');
INSERT INTO names(contact_id, sort, name) insert into names(contact_id, sort, name) values
values (0, 0, 'Alex Aaronson'); (0, 0, 'Alex Aaronson'),
INSERT INTO names(contact_id, sort, name) (0, 1, 'Alexi'),
values (0, 1, 'Alexi'); (0, 2, 'Алексей');
INSERT INTO names(contact_id, sort, name) insert into groups(contact_id, name, slug) values
values (0, 2, 'Алексей'); (0, 'ABC', 'abc');
INSERT INTO contacts(id, birthday)
values (1, 'April?');
INSERT INTO names(contact_id, sort, name)
values (1, 0, 'Bazel Bagend');
INSERT INTO contacts(id, birthday)
values (2, '19951018');
INSERT INTO names(contact_id, sort, name)
values (2, 0, 'Charlie Certaindate');
insert into journal_entries(id, date, value) insert into contacts(id, birthday) values
values (0, '2020-02-27', 'Lunch with [[Bazel Bagend]] and his wife'); (1, 'April?');
insert into journal_entries(id, value, date) insert into names(contact_id, sort, name) values
values (1, 'Dinner with [[Alexi]]', '2025-10-10'); (1, 0, 'Bazel Bagend'),
insert into journal_entries(id, date, value) (1, 1, 'Bazel');
values (2, '2022-06-06', 'Movies with [[Daniel Doesn''texist]] et al'); insert into groups(contact_id, name, slug) values
(1, 'ABC', 'abc');
insert into contact_mentions( insert into contacts(id, birthday) values
entry_id, contact_id, input_text, (2, '19951018');
byte_range_start, byte_range_end insert into names(contact_id, sort, name) values
) values (0, 1, 'Bazel Bagend', 11, 27); (2, 0, 'Charlie Certaindate');
insert into groups(contact_id, name, slug) values
(2, 'ABC', 'abc');
insert into contact_mentions( -- D skipped intentionally
entry_id, contact_id, input_text,
byte_range_start, byte_range_end insert into contacts(id) values
) values (1, 0, 'Alexi', 12, 21); (3);
insert into names(contact_id, sort, name) values
(3, 0, 'Eleanor Edgeworth'),
(3, 1, 'Eleanor');
insert into journal_entries(id, date, value) values
(0, '2020-02-27', 'Lunch with [[Bazel Bagend]] and his wife'),
(1, '2025-10-10', 'Dinner with [[Alexi]]'),
(2, '2022-06-06', 'Movies with [[Daniel Doesn''texist]] et al'),
(3, '2024-02-02', 'Started a business with [[ABC]]'),
(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]].');
insert into journal_mentions values
(0, 'Bazel Bagend', 11, 27, '/contact/1'),
(1, 'Alexi', 12, 21, '/contact/0'),
(3, 'ABC', 24, 31, '/group/ABC'),
(4, 'Bazel', 22, 31, '/contact/1'),
(4, 'Eleanor Edgeworth', 37, 58, '/contact/3'),
(5, 'Eleanor', 108, 119, '/contact/3'),
(5, 'Alexi', 94, 103, '/contact/0'),
(5, 'Bazel', 5, 14, '/contact/1'),
(5, 'ABC', 31, 38, '/group/ABC');

View file

@ -2,3 +2,8 @@ create table if not exists groups (
contact_id integer not null references contacts(id) on delete cascade, contact_id integer not null references contacts(id) on delete cascade,
name text not null name text not null
); );
alter table contact_mentions rename to journal_mentions;
alter table journal_mentions add column url text not null default '';
update journal_mentions set url = '/contact/'||contact_id;
alter table journal_mentions drop column contact_id;

View file

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

View file

@ -23,9 +23,7 @@ impl Database {
let pool = SqlitePoolOptions::new().connect_with(db_options).await?; let pool = SqlitePoolOptions::new().connect_with(db_options).await?;
sqlx::migrate!("./migrations/each_user/") sqlx::migrate!("./migrations/each_user/").run(&pool).await?;
.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)

View file

@ -17,19 +17,19 @@ 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::ContactTrie; use models::contact::MentionTrie;
use models::user::{Backend, User}; use models::user::{Backend, User};
mod db; mod db;
use db::{Database, DbId}; use db::{Database, DbId};
mod web; mod web;
use web::{auth, contact, home, ics, journal, settings}; use web::{auth, contact, group, home, ics, journal, settings};
#[derive(Clone)] #[derive(Clone)]
struct AppStateEntry { struct AppStateEntry {
database: Arc<Database>, database: Arc<Database>,
contact_search: Arc<RwLock<ContactTrie>>, contact_search: Arc<RwLock<MentionTrie>>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -50,7 +50,7 @@ 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 mut trie = radix_trie::Trie::new();
let rows = sqlx::query_as!( let mentionable_names = sqlx::query_as!(
NameReference, NameReference,
"select name, contact_id from ( "select name, contact_id from (
select contact_id, name, count(name) as ct from names group by name select contact_id, name, count(name) as ct from names group by name
@ -59,8 +59,21 @@ impl AppState {
.fetch_all(&database.pool) .fetch_all(&database.pool)
.await?; .await?;
for row in rows { for row in mentionable_names {
trie.insert(row.name, DbId::try_from(row.contact_id)?); 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");
@ -78,10 +91,9 @@ impl AppState {
} }
pub fn db(&self, user: &impl AuthUser<Id = DbId>) -> Arc<Database> { pub fn db(&self, user: &impl AuthUser<Id = DbId>) -> Arc<Database> {
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<ContactTrie>> { pub fn contact_search(&self, user: &impl AuthUser<Id = DbId>) -> Arc<RwLock<MentionTrie>> {
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().contact_search.clone()
} }
@ -165,7 +177,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", "{}=debug,tower_http=debug,axum=trace,sqlx=debug",
env!("CARGO_CRATE_NAME") env!("CARGO_CRATE_NAME")
) )
.into() .into()
@ -177,6 +189,7 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
let app = Router::new() let app = Router::new()
.route("/", get(home::get::home)) .route("/", get(home::get::home))
.merge(contact::router()) .merge(contact::router())
.merge(group::router())
.merge(journal::router()) .merge(journal::router())
.merge(settings::router()) .merge(settings::router())
.route_layer(login_required!(Backend, login_url = "/login")) .route_layer(login_required!(Backend, login_url = "/login"))

View file

@ -36,7 +36,9 @@ impl HydratedContact {
} }
} }
} }
pub type ContactTrie = radix_trie::Trie<String, DbId>;
/* name/group, url */
pub type MentionTrie = radix_trie::Trie<String, String>;
impl FromRow<'_, SqliteRow> for Contact { impl FromRow<'_, SqliteRow> for Contact {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> { fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {

View file

@ -7,7 +7,7 @@ use sqlx::{FromRow, Row};
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use super::contact::ContactTrie; use super::contact::MentionTrie;
use crate::AppError; use crate::AppError;
use crate::db::DbId; use crate::db::DbId;
@ -19,24 +19,24 @@ pub struct JournalEntry {
} }
#[derive(Debug, PartialEq, Eq, Hash, FromRow)] #[derive(Debug, PartialEq, Eq, Hash, FromRow)]
pub struct ContactMention { pub struct Mention {
pub entry_id: DbId, pub entry_id: DbId,
pub contact_id: DbId, pub url: String,
pub input_text: String, pub input_text: String,
pub byte_range_start: u32, pub byte_range_start: u32,
pub byte_range_end: u32, pub byte_range_end: u32,
} }
impl JournalEntry { impl JournalEntry {
pub fn extract_mentions(&self, trie: &ContactTrie) -> HashSet<ContactMention> { pub fn extract_mentions(&self, trie: &MentionTrie) -> HashSet<Mention> {
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap(); let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();
name_re name_re
.captures_iter(&self.value) .captures_iter(&self.value)
.map(|caps| { .map(|caps| {
let range = caps.get_match().range(); let range = caps.get_match().range();
trie.get(&caps[1]).map(|cid| ContactMention { trie.get(&caps[1]).map(|url| Mention {
entry_id: self.id, entry_id: self.id,
contact_id: cid.to_owned(), url: url.to_string(),
input_text: caps[1].to_string(), input_text: caps[1].to_string(),
byte_range_start: u32::try_from(range.start).unwrap(), byte_range_start: u32::try_from(range.start).unwrap(),
byte_range_end: u32::try_from(range.end).unwrap(), byte_range_end: u32::try_from(range.end).unwrap(),
@ -49,9 +49,9 @@ impl JournalEntry {
pub async fn insert_mentions( pub async fn insert_mentions(
&self, &self,
trie: Arc<RwLock<ContactTrie>>, trie: Arc<RwLock<MentionTrie>>,
pool: &SqlitePool, pool: &SqlitePool,
) -> Result<HashSet<ContactMention>, AppError> { ) -> Result<HashSet<Mention>, AppError> {
let mentions = { let mentions = {
let trie = trie.read().unwrap(); let trie = trie.read().unwrap();
self.extract_mentions(&trie) self.extract_mentions(&trie)
@ -59,12 +59,12 @@ impl JournalEntry {
for mention in &mentions { for mention in &mentions {
sqlx::query!( sqlx::query!(
"insert into contact_mentions( "insert into journal_mentions(
entry_id, contact_id, input_text, entry_id, url, input_text,
byte_range_start, byte_range_end byte_range_start, byte_range_end
) values ($1, $2, $3, $4, $5)", ) values ($1, $2, $3, $4, $5)",
mention.entry_id, mention.entry_id,
mention.contact_id, mention.url,
mention.input_text, mention.input_text,
mention.byte_range_start, mention.byte_range_start,
mention.byte_range_end mention.byte_range_end
@ -79,8 +79,8 @@ impl JournalEntry {
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 // important to sort desc so that changing contents early in the string
// doesn't break inserting mentions at byte offsets further in // doesn't break inserting mentions at byte offsets further in
let mentions: Vec<ContactMention> = sqlx::query_as( let mentions: Vec<Mention> = sqlx::query_as(
"select * from contact_mentions "select * from journal_mentions
where entry_id = $1 order by byte_range_start desc", where entry_id = $1 order by byte_range_start desc",
) )
.bind(self.id) .bind(self.id)
@ -89,9 +89,10 @@ impl JournalEntry {
let mut value = self.value.clone(); let mut value = self.value.clone();
for mention in mentions { for mention in mentions {
tracing::debug!("url ({})", mention.url);
value.replace_range( value.replace_range(
(mention.byte_range_start as usize)..(mention.byte_range_end as usize), (mention.byte_range_start as usize)..(mention.byte_range_end as usize),
&format!("[{}](/contact/{})", mention.input_text, mention.contact_id), &format!("[{}]({})", mention.input_text, mention.url),
); );
} }
@ -119,7 +120,7 @@ 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="previous .entry" 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)"

View file

@ -11,6 +11,7 @@ use chrono::DateTime;
use maud::{Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use slug::slugify;
use sqlx::{QueryBuilder, Sqlite}; use sqlx::{QueryBuilder, Sqlite};
use super::Layout; use super::Layout;
@ -32,9 +33,9 @@ pub struct Address {
pub struct Group { pub struct Group {
pub contact_id: DbId, pub contact_id: DbId,
pub name: String, pub name: String,
pub slug: String,
} }
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/contact/new", post(self::post::contact)) .route("/contact/new", post(self::post::contact))
@ -85,9 +86,14 @@ mod get {
.await?; .await?;
let entries: Vec<JournalEntry> = sqlx::query_as( let entries: Vec<JournalEntry> = sqlx::query_as(
"select j.id, j.value, j.date from journal_entries j "select distinct j.id, j.value, j.date from journal_entries j
join contact_mentions cm on j.id = cm.entry_id join journal_mentions cm on j.id = cm.entry_id
where cm.contact_id = $1", 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) .bind(contact_id)
.fetch_all(pool) .fetch_all(pool)
@ -104,10 +110,11 @@ mod get {
let groups: Vec<Group> = sqlx::query_as!( let groups: Vec<Group> = sqlx::query_as!(
Group, Group,
"select * from groups where contact_id = $1", "select * from groups where contact_id = $1",
contact_id) contact_id
.fetch_all(pool) )
.await?; .fetch_all(pool)
.await?;
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)
.fetch_one(pool) .fetch_one(pool)
@ -172,7 +179,9 @@ mod get {
label { "in groups" } label { "in groups" }
#groups { #groups {
@for group in 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 from names where contact_id = c.id
) as names, ( ) as names, (
select jes.date from journal_entries jes select jes.date from journal_entries jes
join contact_mentions cms on cms.entry_id = jes.id join journal_mentions cms on cms.entry_id = jes.id
where cms.contact_id = c.id where cms.url = '/contact/'||c.id
order by jes.date desc limit 1 order by jes.date desc limit 1
) as last_mention_date from contacts c ) as last_mention_date from contacts c
where c.id = $1", where c.id = $1",
@ -230,12 +239,13 @@ mod get {
let groups: Vec<String> = sqlx::query_as!( let groups: Vec<String> = sqlx::query_as!(
Group, Group,
"select * from groups where contact_id = $1", "select * from groups where contact_id = $1",
contact_id) contact_id
.fetch_all(pool) )
.await? .fetch_all(pool)
.into_iter() .await?
.map(|group| group.name) .into_iter()
.collect(); .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)
@ -353,13 +363,14 @@ mod put {
manually_freshened_at: String, manually_freshened_at: 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>>,
text_body: String, text_body: String,
} }
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>,
Form(payload): Form<PutContact>, Form(payload): Form<PutContact>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap(); let user = auth_session.user.unwrap();
@ -398,100 +409,183 @@ mod put {
.execute(pool) .execute(pool)
.await?; .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 // 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) .execute(pool)
.await?; .await?;
if let Some(values) = payload.address_value { let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
let labels = if values.len() == 1 { "select name, contact_id from (
Some(vec![String::new()]) select name, contact_id, count(name) as ct from names where name in (",
} else { );
payload.address_label let mut name_list = recalc_counts.separated(", ");
}; for name in &old_names {
if let Some(labels) = labels { name_list.push_bind(name);
let new_addresses = labels }
.into_iter()
.zip(values) if !new_names.is_empty() {
.filter(|(_, val)| val.len() > 0); for name in &new_names {
for (label, value) in new_addresses { name_list.push_bind(name.clone());
sqlx::query!( }
"insert into addresses (contact_id, label, value) values ($1, $2, $3)",
contact_id, let mut name_insert: QueryBuilder<Sqlite> =
label, QueryBuilder::new("insert into names (contact_id, sort, name) ");
value name_insert.push_values(
) new_names.iter().enumerate(),
.execute(pool) |mut builder, (sort, name)| {
.await?; 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( let new_groups: Vec<String> = payload
"delete from contact_mentions; .group
delete from names where contact_id = $1 returning name;", .unwrap_or(vec![])
) .into_iter()
.bind(contact_id) .filter(|n| n.len() > 0)
.fetch_all(pool) .collect();
.await?; 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( if new_groups != old_groups {
"select name, contact_id from ( sqlx::query!(
select name, contact_id, count(name) as ct from names where name in (", "delete from journal_mentions; delete from groups where contact_id = $1",
); contact_id
let mut name_list = recalc_counts.separated(", "); )
for (name,) in &old_names { .execute(pool)
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)
.await?; .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(); let mut headers = HeaderMap::new();
@ -510,7 +604,7 @@ mod delete {
let pool = &state.db(&user).pool; let pool = &state.db(&user).pool;
sqlx::query( 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 names where contact_id = $1;
delete from contacts where id = $1;", delete from contacts where id = $1;",
) )

72
src/web/group.rs Normal file
View 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)
}
}
}
}
},
))
}
}

View file

@ -131,8 +131,8 @@ pub mod get {
from names where contact_id = c.id from names where contact_id = c.id
) as names, ( ) as names, (
select jes.date from journal_entries jes select jes.date from journal_entries jes
join contact_mentions cms on cms.entry_id = jes.id join journal_mentions cms on cms.entry_id = jes.id
where cms.contact_id = c.id where cms.url = '/contact/'||c.id
order by jes.date desc limit 1 order by jes.date desc limit 1
) as last_mention_date from contacts c", ) as last_mention_date from contacts c",
) )

View file

@ -98,26 +98,26 @@ mod patch {
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
sqlx::query!( let new_entry: JournalEntry = sqlx::query_as(
"update journal_entries set date = $1, value = $2 where id = $3", "update journal_entries set date = $1, value = $2 where id = $3 returning *",
payload.date,
payload.value,
entry_id
) )
.execute(pool) .bind(&payload.date)
.bind(&payload.value)
.bind(entry_id)
.fetch_one(pool)
.await?; .await?;
if entry.value != payload.value { if entry.value != new_entry.value {
sqlx::query!("delete from contact_mentions where entry_id = $1", entry_id) sqlx::query!("delete from journal_mentions where entry_id = $1", entry_id)
.execute(pool) .execute(pool)
.await?; .await?;
entry new_entry
.insert_mentions(state.contact_search(&user), pool) .insert_mentions(state.contact_search(&user), pool)
.await?; .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; let pool = &state.db(&user).pool;
sqlx::query( 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", delete from journal_entries where id = $2 returning id,date,value",
) )
.bind(entry_id) .bind(entry_id)

View file

@ -10,6 +10,7 @@ use super::{AppError, AppState};
pub mod auth; pub mod auth;
pub mod contact; pub mod contact;
pub mod group;
pub mod home; pub mod home;
pub mod ics; pub mod ics;
pub mod journal; pub mod journal;

View file

@ -44,6 +44,12 @@ main {
} }
} }
#groups {
display: flex;
flex-direction: column;
width: min-content;
}
#text_body { #text_body {
margin-top: 1em; margin-top: 1em;

10
static/group.css Normal file
View file

@ -0,0 +1,10 @@
main {
h1 {
margin-block: 0.83em;
font-size: 1.50em;
}
li {
list-style: disc inside;
}
}