Compare commits
3 commits
e3e77cbae3
...
a0afb6dfd3
| Author | SHA1 | Date | |
|---|---|---|---|
| a0afb6dfd3 | |||
| cd4096b2ff | |||
| 1f05189ec6 |
30 changed files with 576 additions and 307 deletions
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
22
e2e/README.md
Normal 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`.
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
|
||||||
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());
|
||||||
|
|
@ -58,7 +58,7 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
|
||||||
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);
|
||||||
|
|
@ -67,7 +67,7 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
|
||||||
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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export const verifyCreateUser = async (page: Page, fields: UserFields) => {
|
||||||
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)) {
|
||||||
|
|
|
||||||
2
e2e/pnpm-lock.yaml
generated
2
e2e/pnpm-lock.yaml
generated
|
|
@ -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
1
e2e/static
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../static
|
||||||
BIN
e2e/users.db
BIN
e2e/users.db
Binary file not shown.
|
|
@ -1,12 +0,0 @@
|
||||||
create table if not exists contacts (
|
|
||||||
id integer primary key autoincrement,
|
|
||||||
birthday text,
|
|
||||||
manually_freshened_at date -- text, iso8601 date
|
|
||||||
);
|
|
||||||
|
|
||||||
create table if not exists names (
|
|
||||||
id integer primary key,
|
|
||||||
contact_id integer not null references contacts(id) on delete cascade,
|
|
||||||
sort integer not null,
|
|
||||||
name text not null
|
|
||||||
);
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
create table if not exists journal_entries (
|
|
||||||
id integer primary key autoincrement,
|
|
||||||
value text not null,
|
|
||||||
date text not null
|
|
||||||
);
|
|
||||||
|
|
||||||
create table if not exists contact_mentions (
|
|
||||||
entry_id integer not null references journal_entries(id) on delete cascade,
|
|
||||||
contact_id integer not null references contacts(id) on delete cascade,
|
|
||||||
input_text text not null,
|
|
||||||
byte_range_start integer not null,
|
|
||||||
byte_range_end integer not null
|
|
||||||
);
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
INSERT INTO contacts(id, birthday, manually_freshened_at)
|
|
||||||
values (0, '--0415', '2000-01-01T12:00:00');
|
|
||||||
INSERT INTO names(contact_id, sort, name)
|
|
||||||
values (0, 0, 'Alex Aaronson');
|
|
||||||
INSERT INTO names(contact_id, sort, name)
|
|
||||||
values (0, 1, 'Alexi');
|
|
||||||
INSERT INTO names(contact_id, sort, name)
|
|
||||||
values (0, 2, 'Алексей');
|
|
||||||
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)
|
|
||||||
values (0, '2020-02-27', 'Lunch with [[Bazel Bagend]] and his wife');
|
|
||||||
insert into journal_entries(id, value, date)
|
|
||||||
values (1, 'Dinner with [[Alexi]]', '2025-10-10');
|
|
||||||
insert into journal_entries(id, date, value)
|
|
||||||
values (2, '2022-06-06', 'Movies with [[Daniel Doesn''texist]] et al');
|
|
||||||
|
|
||||||
insert into contact_mentions(
|
|
||||||
entry_id, contact_id, input_text,
|
|
||||||
byte_range_start, byte_range_end
|
|
||||||
) values (0, 1, 'Bazel Bagend', 11, 27);
|
|
||||||
|
|
||||||
insert into contact_mentions(
|
|
||||||
entry_id, contact_id, input_text,
|
|
||||||
byte_range_start, byte_range_end
|
|
||||||
) values (1, 0, 'Alexi', 12, 21);
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
create table if not exists settings (
|
|
||||||
id integer primary key,
|
|
||||||
ics_path text
|
|
||||||
);
|
|
||||||
|
|
||||||
insert into settings (id) values (1) on conflict (id) do nothing;
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
create table if not exists addresses (
|
|
||||||
id integer primary key,
|
|
||||||
contact_id integer not null references contacts(id) on delete cascade,
|
|
||||||
label text,
|
|
||||||
value text not null
|
|
||||||
);
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
alter table contacts
|
|
||||||
add column
|
|
||||||
text_body text;
|
|
||||||
51
migrations/demo.sql
Normal file
51
migrations/demo.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
insert into contacts(id, birthday, manually_freshened_at) values
|
||||||
|
(0, '--0415', '2000-01-01T12:00:00');
|
||||||
|
insert into names(contact_id, sort, name) values
|
||||||
|
(0, 0, 'Alex Aaronson'),
|
||||||
|
(0, 1, 'Alexi'),
|
||||||
|
(0, 2, 'Алексей');
|
||||||
|
insert into groups(contact_id, name, slug) values
|
||||||
|
(0, 'ABC', 'abc');
|
||||||
|
|
||||||
|
insert into contacts(id, birthday) values
|
||||||
|
(1, 'April?');
|
||||||
|
insert into names(contact_id, sort, name) values
|
||||||
|
(1, 0, 'Bazel Bagend'),
|
||||||
|
(1, 1, 'Bazel');
|
||||||
|
insert into groups(contact_id, name, slug) values
|
||||||
|
(1, 'ABC', 'abc');
|
||||||
|
|
||||||
|
insert into contacts(id, birthday) values
|
||||||
|
(2, '19951018');
|
||||||
|
insert into names(contact_id, sort, name) values
|
||||||
|
(2, 0, 'Charlie Certaindate');
|
||||||
|
insert into groups(contact_id, name, slug) values
|
||||||
|
(2, 'ABC', 'abc');
|
||||||
|
|
||||||
|
-- D skipped intentionally
|
||||||
|
|
||||||
|
insert into contacts(id) values
|
||||||
|
(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');
|
||||||
9
migrations/each_user/0007_contact-groups.sql
Normal file
9
migrations/each_user/0007_contact-groups.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
create table if not exists groups (
|
||||||
|
contact_id integer not null references contacts(id) on delete cascade,
|
||||||
|
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;
|
||||||
1
migrations/each_user/0008_group-slugs.sql
Normal file
1
migrations/each_user/0008_group-slugs.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
alter table groups add column slug text not null default '';
|
||||||
10
src/db.rs
10
src/db.rs
|
|
@ -23,12 +23,12 @@ impl Database {
|
||||||
|
|
||||||
let pool = SqlitePoolOptions::new().connect_with(db_options).await?;
|
let pool = SqlitePoolOptions::new().connect_with(db_options).await?;
|
||||||
|
|
||||||
let migrator = if user.username == "demo" {
|
sqlx::migrate!("./migrations/each_user/").run(&pool).await?;
|
||||||
sqlx::migrate!("./migrations/demo.db/")
|
if user.username == "demo" {
|
||||||
} else {
|
sqlx::query_file!("./migrations/demo.sql")
|
||||||
sqlx::migrate!("./migrations/each_user/")
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
};
|
};
|
||||||
migrator.run(&pool).await?;
|
|
||||||
|
|
||||||
Ok(Self { pool })
|
Ok(Self { pool })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/main.rs
31
src/main.rs
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -28,6 +29,13 @@ pub struct Address {
|
||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, Debug)]
|
||||||
|
pub struct Group {
|
||||||
|
pub contact_id: DbId,
|
||||||
|
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))
|
||||||
|
|
@ -78,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)
|
||||||
|
|
@ -94,6 +107,14 @@ mod get {
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let groups: Vec<Group> = sqlx::query_as!(
|
||||||
|
Group,
|
||||||
|
"select * from groups where contact_id = $1",
|
||||||
|
contact_id
|
||||||
|
)
|
||||||
|
.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)
|
||||||
|
|
@ -153,6 +174,17 @@ mod get {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if groups.len() > 0 {
|
||||||
|
label { "in groups" }
|
||||||
|
#groups {
|
||||||
|
@for group in groups {
|
||||||
|
a .group href=(format!("/group/{}", group.slug)) {
|
||||||
|
(group.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -180,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",
|
||||||
|
|
@ -204,6 +236,17 @@ mod get {
|
||||||
.clone()
|
.clone()
|
||||||
.map_or("".to_string(), |m| m.to_rfc3339());
|
.map_or("".to_string(), |m| m.to_rfc3339());
|
||||||
|
|
||||||
|
let groups: Vec<String> = sqlx::query_as!(
|
||||||
|
Group,
|
||||||
|
"select * from groups where contact_id = $1",
|
||||||
|
contact_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|group| group.name)
|
||||||
|
.collect();
|
||||||
|
|
||||||
let text_body: String =
|
let text_body: String =
|
||||||
sqlx::query!("select text_body from contacts where id = $1", contact_id)
|
sqlx::query!("select text_body from contacts where id = $1", contact_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|
@ -264,6 +307,14 @@ mod get {
|
||||||
}
|
}
|
||||||
input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''";
|
input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''";
|
||||||
}
|
}
|
||||||
|
label { "groups" }
|
||||||
|
#groups x-data=(json!({ "groups": groups, "new_group": "" })) {
|
||||||
|
template x-for="(group, index) in groups" x-bind:key="index" {
|
||||||
|
input name="group" x-model="group" placeholder="group name";
|
||||||
|
}
|
||||||
|
input name="group" x-model="new_group" placeholder="group name";
|
||||||
|
input type="button" value="Add" x-on:click="groups.push(new_group); new_group = ''";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div #text_body {
|
div #text_body {
|
||||||
div { "Free text (supports markdown)" }
|
div { "Free text (supports markdown)" }
|
||||||
|
|
@ -312,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();
|
||||||
|
|
@ -357,72 +409,102 @@ 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
|
||||||
|
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)
|
sqlx::query!("delete from addresses where contact_id = $1", contact_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(values) = payload.address_value {
|
// trailing space in query intentional
|
||||||
let labels = if values.len() == 1 {
|
QueryBuilder::new("insert into addresses (contact_id, label, value) ")
|
||||||
Some(vec![String::new()])
|
.push_values(new_addresses, |mut b, (label, value)| {
|
||||||
} else {
|
b.push_bind(contact_id).push_bind(label).push_bind(value);
|
||||||
payload.address_label
|
})
|
||||||
};
|
.build()
|
||||||
if let Some(labels) = labels {
|
.persistent(false)
|
||||||
let new_addresses = labels
|
|
||||||
.into_iter()
|
|
||||||
.zip(values)
|
|
||||||
.filter(|(_, val)| val.len() > 0);
|
|
||||||
for (label, value) in new_addresses {
|
|
||||||
sqlx::query!(
|
|
||||||
"insert into addresses (contact_id, label, value) values ($1, $2, $3)",
|
|
||||||
contact_id,
|
|
||||||
label,
|
|
||||||
value
|
|
||||||
)
|
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let old_names: Vec<(String,)> = sqlx::query_as(
|
{
|
||||||
"delete from contact_mentions;
|
// recalculate all contact mentions and name trie if name-list changed
|
||||||
delete from names where contact_id = $1 returning name;",
|
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)
|
.bind(contact_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
|
||||||
|
|
||||||
|
if old_names != new_names {
|
||||||
|
// delete and regen *all* journal mentions, not just the ones for the
|
||||||
|
// current user, since changing *this* user's names can change, *globally*,
|
||||||
|
// which names have n=1 and thus are eligible for mentioning
|
||||||
|
sqlx::query!(
|
||||||
|
"delete from journal_mentions; delete from names where contact_id = $1",
|
||||||
|
contact_id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
|
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
|
||||||
"select name, contact_id from (
|
"select name, contact_id from (
|
||||||
select name, contact_id, count(name) as ct from names where name in (",
|
select name, contact_id, count(name) as ct from names where name in (",
|
||||||
);
|
);
|
||||||
let mut name_list = recalc_counts.separated(", ");
|
let mut name_list = recalc_counts.separated(", ");
|
||||||
for (name,) in &old_names {
|
for name in &old_names {
|
||||||
name_list.push_bind(name);
|
name_list.push_bind(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(names) = payload.name {
|
if !new_names.is_empty() {
|
||||||
let names: Vec<String> = names.into_iter().filter(|n| n.len() > 0).collect();
|
for name in &new_names {
|
||||||
if !names.is_empty() {
|
|
||||||
for name in &names {
|
|
||||||
name_list.push_bind(name.clone());
|
name_list.push_bind(name.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut name_insert: QueryBuilder<Sqlite> =
|
let mut name_insert: QueryBuilder<Sqlite> =
|
||||||
QueryBuilder::new("insert into names (contact_id, sort, name) ");
|
QueryBuilder::new("insert into names (contact_id, sort, name) ");
|
||||||
name_insert.push_values(names.iter().enumerate(), |mut builder, (sort, name)| {
|
name_insert.push_values(
|
||||||
|
new_names.iter().enumerate(),
|
||||||
|
|mut builder, (sort, name)| {
|
||||||
builder
|
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);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
name_insert.build().persistent(false).execute(pool).await?;
|
name_insert.build().persistent(false).execute(pool).await?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
name_list.push_unseparated(") group by name) where ct = 1");
|
name_list.push_unseparated(") group by name) where ct = 1");
|
||||||
let recalc_names: Vec<(String, DbId)> = recalc_counts
|
let recalc_names: Vec<(String, DbId)> = recalc_counts
|
||||||
|
|
@ -435,15 +517,66 @@ mod put {
|
||||||
let trie_mutex = state.contact_search(&user);
|
let trie_mutex = state.contact_search(&user);
|
||||||
let mut trie = trie_mutex.write().unwrap();
|
let mut trie = trie_mutex.write().unwrap();
|
||||||
for name in &old_names {
|
for name in &old_names {
|
||||||
trie.remove(&name.0);
|
trie.remove(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in recalc_names {
|
for name in recalc_names {
|
||||||
trie.insert(name.0, name.1);
|
trie.insert(name.0, format!("/contact/{}", name.1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let journal_entries: Vec<JournalEntry> = sqlx::query_as("select * from journal_entries")
|
let new_groups: Vec<String> = payload
|
||||||
|
.group
|
||||||
|
.unwrap_or(vec![])
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| n.len() > 0)
|
||||||
|
.collect();
|
||||||
|
let old_groups: Vec<(String,)> =
|
||||||
|
sqlx::query_as("select name from groups where contact_id = $1")
|
||||||
|
.bind(contact_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect();
|
||||||
|
|
||||||
|
if new_groups != old_groups {
|
||||||
|
sqlx::query!(
|
||||||
|
"delete from journal_mentions; delete from groups where contact_id = $1",
|
||||||
|
contact_id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
QueryBuilder::new("insert into groups (contact_id, name, slug) ")
|
||||||
|
.push_values(&new_groups, |mut b, name| {
|
||||||
|
b.push_bind(contact_id)
|
||||||
|
.push_bind(name)
|
||||||
|
.push_bind(slugify(name));
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.persistent(false)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let trie_mutex = state.contact_search(&user);
|
||||||
|
let mut trie = trie_mutex.write().unwrap();
|
||||||
|
for name in &old_groups {
|
||||||
|
// TODO i think we care about group name vs contact name counts,
|
||||||
|
// otherwise this will cause a problem (or we want to disallow
|
||||||
|
// setting group names that are contact names or vice versa?)
|
||||||
|
trie.remove(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for group in &new_groups {
|
||||||
|
trie.insert(group.clone(), format!("/group/{}", slugify(group)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_names != old_names || new_groups != old_groups {
|
||||||
|
let journal_entries: Vec<JournalEntry> =
|
||||||
|
sqlx::query_as("select * from journal_entries")
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -452,6 +585,8 @@ mod put {
|
||||||
.insert_mentions(state.contact_search(&user), pool)
|
.insert_mentions(state.contact_search(&user), pool)
|
||||||
.await?;
|
.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()?);
|
||||||
|
|
@ -469,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
72
src/web/group.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::{State, path::Path},
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
use cache_bust::asset;
|
||||||
|
use maud::{Markup, html};
|
||||||
|
|
||||||
|
use super::Layout;
|
||||||
|
use crate::db::DbId;
|
||||||
|
use crate::models::user::AuthSession;
|
||||||
|
use crate::{AppError, AppState};
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new().route("/group/{slug}", get(self::get::group))
|
||||||
|
}
|
||||||
|
|
||||||
|
mod get {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct ContactLink {
|
||||||
|
id: DbId,
|
||||||
|
primary_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn group(
|
||||||
|
auth_session: AuthSession,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
layout: Layout,
|
||||||
|
) -> Result<Markup, AppError> {
|
||||||
|
let pool = &state.db(&auth_session.user.unwrap()).pool;
|
||||||
|
|
||||||
|
let name: String = sqlx::query!("select name from groups where slug = $1 limit 1", slug)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
.name;
|
||||||
|
|
||||||
|
let contacts = sqlx::query_as!(
|
||||||
|
ContactLink,
|
||||||
|
"select
|
||||||
|
c.id as id, coalesce(
|
||||||
|
(select n.name from names n
|
||||||
|
where n.contact_id = c.id limit 1)
|
||||||
|
, '(unknown)') as primary_name
|
||||||
|
from contacts c
|
||||||
|
join groups g on c.id = g.contact_id
|
||||||
|
where g.slug = $1
|
||||||
|
order by primary_name asc",
|
||||||
|
slug
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(layout.render(
|
||||||
|
Some(vec![asset!("group.css")]),
|
||||||
|
html! {
|
||||||
|
h1 { (name) }
|
||||||
|
p { "Group members:" }
|
||||||
|
ul #groups {
|
||||||
|
@for link in contacts {
|
||||||
|
li {
|
||||||
|
a href=(format!("/contact/{}", link.id)) {
|
||||||
|
(link.primary_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -131,8 +131,8 @@ pub mod get {
|
||||||
from names where contact_id = c.id
|
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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
|
.bind(&payload.date)
|
||||||
|
.bind(&payload.value)
|
||||||
|
.bind(entry_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if entry.value != new_entry.value {
|
||||||
|
sqlx::query!("delete from journal_mentions where entry_id = $1", entry_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if entry.value != payload.value {
|
new_entry
|
||||||
sqlx::query!("delete from contact_mentions where entry_id = $1", entry_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
10
static/group.css
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
main {
|
||||||
|
h1 {
|
||||||
|
margin-block: 0.83em;
|
||||||
|
font-size: 1.50em;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
list-style: disc inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue