From a0afb6dfd35b305994c943d9fb85a0aac9311e1b Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Fri, 23 Jan 2026 21:20:27 -0600 Subject: [PATCH] feat: full group support --- Cargo.lock | 17 ++ Cargo.toml | 1 + e2e/README.md | 22 ++ e2e/Taskfile | 2 +- e2e/package.json | 4 +- e2e/pages/journal.spec.ts | 162 +++++----- e2e/pages/util.ts | 32 +- e2e/pnpm-lock.yaml | 2 +- e2e/static | 1 + e2e/users.db | Bin 32768 -> 0 bytes migrations/demo.sql | 78 +++-- migrations/each_user/0007_contact-groups.sql | 5 + migrations/each_user/0008_group-slugs.sql | 1 + src/db.rs | 4 +- src/main.rs | 31 +- src/models/contact.rs | 4 +- src/models/journal.rs | 31 +- src/web/contact.rs | 298 ++++++++++++------- src/web/group.rs | 72 +++++ src/web/home.rs | 4 +- src/web/journal.rs | 22 +- src/web/mod.rs | 1 + static/contact.css | 6 + static/group.css | 10 + 24 files changed, 536 insertions(+), 274 deletions(-) create mode 100644 e2e/README.md create mode 120000 e2e/static delete mode 100644 e2e/users.db create mode 100644 migrations/each_user/0008_group-slugs.sql create mode 100644 src/web/group.rs create mode 100644 static/group.css diff --git a/Cargo.lock b/Cargo.lock index e2bad46..b300c23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -609,6 +609,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -1431,6 +1437,7 @@ dependencies = [ "serde", "serde_json", "short-uuid", + "slug", "sqlx", "thiserror 2.0.17", "time", @@ -2305,6 +2312,16 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index 845a3ea..9f7d62b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ rpassword = "7.4.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" short-uuid = "0.2.0" +slug = "0.1.6" sqlx = { version = "0.8", features = ["macros", "runtime-tokio", "sqlite", "tls-rustls"] } thiserror = "2.0.17" time = "0.3.44" diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..1ad0e5f --- /dev/null +++ b/e2e/README.md @@ -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`. + + diff --git a/e2e/Taskfile b/e2e/Taskfile index 2d467df..3d0be95 100755 --- a/e2e/Taskfile +++ b/e2e/Taskfile @@ -3,7 +3,7 @@ PATH=$PATH:node_modules/.bin SCRIPT_DIR=$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) _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() { diff --git a/e2e/package.json b/e2e/package.json index 8942e27..7baf433 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -10,8 +10,8 @@ "author": "", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.56.1", + "@playwright/test": "=1.57.0", "@types/node": "^24.9.1" }, - "packageManager": "pnpm@11.0.0-dev.1005+sha512.91f84a392eea348ea4852a182912d2520273a4336f933b78cc44bc931eb999923c097e9433a9b355adc1f725725ea99082fc9f032a559df832632e764c92c798" + "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316" } diff --git a/e2e/pages/journal.spec.ts b/e2e/pages/journal.spec.ts index fec74e9..f30efa3 100644 --- a/e2e/pages/journal.spec.ts +++ b/e2e/pages/journal.spec.ts @@ -2,116 +2,116 @@ import { test, expect } from '@playwright/test'; import { login, verifyCreateUser, todate } from './util'; test('can add journal entries', async ({ page }) => { - await login(page); + await login(page); - const entryBox = page.getByPlaceholder(/new entry/i); - await entryBox.fill('banana banana banana'); - await page.getByRole('button', { name: /add entry/i }).click(); + const entryBox = page.getByPlaceholder(/new entry/i); + await entryBox.fill('banana banana banana'); + await page.getByRole('button', { name: /add entry/i }).click(); - await expect(entryBox).toBeEmpty(); - await expect(page.getByText('banana banana banana')).toBeVisible(); + await expect(entryBox).toBeEmpty(); + await expect(page.getByText('banana banana banana')).toBeVisible(); }); test('journal entries autolink', async ({ page }) => { - await login(page); - await verifyCreateUser(page, { names: ['John Contact'] }); - await page.getByRole('link', { name: 'Mascarpone' }).click(); + await login(page); + await verifyCreateUser(page, { names: ['John Contact'] }); + await page.getByRole('link', { name: 'Mascarpone' }).click(); - await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]'); - await page.getByRole('button', { name: /add entry/i }).click(); + await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]'); + 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 }) => { - await login(page); - await verifyCreateUser(page, { names: ['John Contact'] }); - await verifyCreateUser(page, { names: ['Jack Contact'] }); + await login(page); + await verifyCreateUser(page, { names: ['John Contact'] }); + await verifyCreateUser(page, { names: ['Jack Contact'] }); - await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]'); - await page.getByRole('button', { name: /add entry/i }).click(); + await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]'); + await page.getByRole('button', { name: /add entry/i }).click(); - const nav = page.getByRole('navigation'); - const journal = page.locator('#journal'); + const nav = page.getByRole('navigation'); + 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 - await nav.getByRole("link", { name: 'John Contact' }).click(); - await page.getByRole('link', { name: /edit/i }).click(); - await page.getByRole('textbox', { name: 'New name' }).fill('JC'); - await page.getByRole('button', { name: 'Add', exact: true }).click(); - await page.getByRole('button', { name: /save/i }).click(); - await page.getByRole('link', { name: 'Mascarpone' }).click(); - console.log(await journal.innerHTML()); - await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); + // add a new name + await nav.getByRole("link", { name: 'John Contact' }).click(); + await page.getByRole('link', { name: /edit/i }).click(); + await page.getByRole('textbox', { name: 'New name' }).fill('JC'); + await page.getByRole('button', { name: 'Add' }).nth(1).click(); + await page.getByRole('button', { name: /save/i }).click(); + await page.getByRole('link', { name: 'Mascarpone' }).click(); + console.log(await journal.innerHTML()); + await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); - // delete an existing name - await nav.getByRole("link", { name: 'John Contact' }).click(); - await page.getByRole('link', { name: /edit/i }).click(); - await page.getByRole('button', { name: '×', disabled: false }).click(); - await page.getByRole('button', { name: /save/i }).click(); - await page.getByRole('link', { name: 'Mascarpone' }).click(); - await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); + // delete an existing name + await nav.getByRole("link", { name: 'John Contact' }).click(); + await page.getByRole('link', { name: /edit/i }).click(); + await page.getByRole('button', { name: '×', disabled: false }).click(); + await page.getByRole('button', { name: /save/i }).click(); + await page.getByRole('link', { name: 'Mascarpone' }).click(); + await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); - // put it back, then... - await nav.getByRole("link", { name: 'John Contact' }).click(); - await page.getByRole('link', { name: /edit/i }).click(); - await page.getByRole('textbox', { name: 'New name' }).fill('JC'); - await page.getByRole('button', { name: 'Add', exact: true }).click(); - await page.getByRole('button', { name: /save/i }).click(); - await page.getByRole('link', { name: 'Mascarpone' }).click(); - await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); + // put it back, then... + await nav.getByRole("link", { name: 'John Contact' }).click(); + await page.getByRole('link', { name: /edit/i }).click(); + await page.getByRole('textbox', { name: 'New name' }).fill('JC'); + await page.getByRole('button', { name: 'Add' }).nth(1).click(); + await page.getByRole('button', { name: /save/i }).click(); + await page.getByRole('link', { name: 'Mascarpone' }).click(); + await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); - // ...add a name that makes it no longer n=1 - await nav.getByRole("link", { name: 'Jack Contact' }).click(); - await page.getByRole('link', { name: /edit/i }).click(); - await page.getByRole('textbox', { name: 'New name' }).fill('JC'); - await page.getByRole('button', { name: 'Add', exact: true }).click(); - await page.getByRole('button', { name: /save/i }).click(); - await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); + // ...add a name that makes it no longer n=1 + await nav.getByRole("link", { name: 'Jack Contact' }).click(); + await page.getByRole('link', { name: /edit/i }).click(); + await page.getByRole('textbox', { name: 'New name' }).fill('JC'); + await page.getByRole('button', { name: 'Add' }).nth(1).click(); + await page.getByRole('button', { name: /save/i }).click(); + await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0); - // delete a name that makes it now n=1 - await nav.getByRole("link", { name: 'John Contact' }).click(); - await page.getByRole('link', { name: /edit/i }).click(); - await page.getByRole('button', { name: '×', disabled: false }).click(); - await page.getByRole('button', { name: /save/i }).click(); - await page.getByRole('link', { name: 'Mascarpone' }).click(); - await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); + // delete a name that makes it now n=1 + await nav.getByRole("link", { name: 'John Contact' }).click(); + await page.getByRole('link', { name: /edit/i }).click(); + await page.getByRole('button', { name: '×', disabled: false }).click(); + await page.getByRole('button', { name: /save/i }).click(); + await page.getByRole('link', { name: 'Mascarpone' }).click(); + await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); }); test('can edit existing journal entries on home page', async ({ page }) => { - await login(page); - await verifyCreateUser(page, { names: ['John Contact'] }); - await page.getByRole('link', { name: 'Mascarpone' }).click(); + await login(page); + await verifyCreateUser(page, { names: ['John Contact'] }); + await page.getByRole('link', { name: 'Mascarpone' }).click(); - await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana"); - await page.getByRole('button', { name: /add entry/i }).click(); + await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana"); + await page.getByRole('button', { name: /add entry/i }).click(); - await page.reload(); + await page.reload(); - await page.getByRole('checkbox', { name: /edit/i }).click(); - const textbox = page.locator('form').filter({ - has: page.getByRole('button', { name: '✓' }) - }).locator('textarea'); - await textbox.fill('met with [[John Contact]]'); + await page.getByRole('checkbox', { name: /edit/i }).click(); + const textbox = page.locator('form').filter({ + has: page.getByRole('button', { name: '✓' }) + }).locator('textarea'); + 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 expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toHaveCount(1); + await page.getByRole('checkbox', { name: /edit/i }).click(); + await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toHaveCount(1); }); test('can have multiple links', async ({ page }) => { - await login(page); - await verifyCreateUser(page, { names: ['alice'] }); - await verifyCreateUser(page, { names: ['bob'] }); - await page.getByRole('link', { name: 'Mascarpone' }).click(); + await login(page); + await verifyCreateUser(page, { names: ['alice'] }); + await verifyCreateUser(page, { names: ['bob'] }); + await page.getByRole('link', { name: 'Mascarpone' }).click(); - 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.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids'); + await page.getByRole('button', { name: /add entry/i }).click(); - const journal = page.locator('#journal'); - await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1); - await expect.soft(journal.getByRole('link', { name: 'bob' })).toHaveCount(1); + const journal = page.locator('#journal'); + await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1); + await expect.soft(journal.getByRole('link', { name: 'bob' })).toHaveCount(1); }); diff --git a/e2e/pages/util.ts b/e2e/pages/util.ts index 021bf4e..eeb5fca 100644 --- a/e2e/pages/util.ts +++ b/e2e/pages/util.ts @@ -1,31 +1,31 @@ import type { Page } from '@playwright/test'; export const login = async (page: Page) => { - await page.goto('/'); - await page.getByLabel("Username").fill("test"); - await page.getByLabel("Password").fill("test"); - await page.getByRole("button", { name: /login/i }).click(); + await page.goto('/'); + await page.getByLabel("Username").fill("test"); + await page.getByLabel("Password").fill("test"); + await page.getByRole("button", { name: /login/i }).click(); }; export const todate = () => new Date().toISOString().split('T')[0]; type UserFields = { - names?: Array, - birthday?: string, + names?: Array, + birthday?: string, }; 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; - for (const name of (names ?? [])) { - await page.getByRole('textbox', { name: 'New name' }).fill(name); - await page.getByRole('button', { name: 'Add', exact: true }).click(); - } + const { names, ...simple } = fields; + for (const name of (names ?? [])) { + await page.getByRole('textbox', { name: 'New name' }).fill(name); + await page.getByRole('button', { name: 'Add' }).nth(1).click(); + } - for (const [label, value] of Object.entries(simple)) { - await page.getByLabel(label).fill(value); - } + for (const [label, value] of Object.entries(simple)) { + await page.getByLabel(label).fill(value); + } - await page.getByRole('button', { name: /save/i }).click(); + await page.getByRole('button', { name: /save/i }).click(); }; diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index ee28a30..88405bd 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -9,7 +9,7 @@ importers: .: devDependencies: '@playwright/test': - specifier: ^1.56.1 + specifier: '=1.57.0' version: 1.57.0 '@types/node': specifier: ^24.9.1 diff --git a/e2e/static b/e2e/static new file mode 120000 index 0000000..4dab164 --- /dev/null +++ b/e2e/static @@ -0,0 +1 @@ +../static \ No newline at end of file diff --git a/e2e/users.db b/e2e/users.db deleted file mode 100644 index a90902a449d858152c610264abee791f50ec214b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmWFz^vNtqRY=P(%1ta$FlG>7U}R))P*7lCVBlh4VBlat0A>aT1{MUDff0#~i^;{H zS5?5v|AT>@r+|UqkN+&+AKrex-8==nLAbPxijRiCXb6mkz-S1JhQMeDjE2By2#kin zXb23K5NM2NV;9%dWNfM|NleN~Ey*uWEs8HrEiTT?&nrgdu{#I3I)=C^gg83+xGJE^ zaVa1H4HO=%A=zLa`AVQZ_fCNlgY7r=O6`VZX zJ^exy0)jk!9fKkjyj>$9L6MSLoLrPy0J1Jy^i92ykl>K76ZR|3&qT$-GmT3oE)gWg8 z2a!$ANKMWzF3nYN^6_^K))W}QBP1Pw*E!3=KWEU3~XKaKRSz4T0 zR17AVP_%=E6f`nZ;3)|?okG$|a#3n-YF>$s0!T5)e0XY8D9y_(EKSu>C`c?WF3&GY zfy-!WGO>x8VsnXRFvO0KPm zfu)&FiLJ3tfvurRcx8oWWTL_OMrGdXp*0i{|5vAkD(Pdqplqdfzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@R7!85Z5Fi`^?98H^pn(O*{Qoxw{%?f!jH(z7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Ex1!z`@8Y&I#TK0O|iPW#C^rl-xh+*3l3c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5EzCb5Xs2SscOub!<<}{npl#m3+}la85o)B z8XD*tnJE~USQ!~x85?hMPjy)_t7fC_oT&{DJe_%DtS-&@^~B`;$DQ{-JPy38m0Wc0 z|D)GyQz9-%MzcpwdoB9R53>IM4+H<7VHj?sUK$O7(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@RU|?qEw&F3jq4LDN^^B{^3yZ(^imQ_5}Q^gmX>71XCxM9++m2M*&%n!K@;Zj znJJ7j&$7Qf%V^4Lt#OWhPwzAa1_sD1x}mOtX^5e*m8qqbiGiM>k)^q%xv7y^RAW3N z*oV3mm8ph#rXH5r0U@~tnZ90rSxJSClaC0jxP3#V$7A+o=Wp(H*XGIm9yC5w*FVR% zA!Id%4~;FYOw9F+Ow3G-%?*rAqd@cjO#G)A_@D8g#umq;Vxu828UmvsFd71*Aut*O rqaiRF0;3@?8UmvsFd71*Aut*OgDM21m@OHx55+J^GFx(DAHx6u99Asd diff --git a/migrations/demo.sql b/migrations/demo.sql index bef2714..a81f905 100644 --- a/migrations/demo.sql +++ b/migrations/demo.sql @@ -1,33 +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'); -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 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 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 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 contact_mentions( - entry_id, contact_id, input_text, - byte_range_start, byte_range_end -) values (0, 1, 'Bazel Bagend', 11, 27); +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'); -insert into contact_mentions( - entry_id, contact_id, input_text, - byte_range_start, byte_range_end -) values (1, 0, 'Alexi', 12, 21); +-- 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'); diff --git a/migrations/each_user/0007_contact-groups.sql b/migrations/each_user/0007_contact-groups.sql index ca70062..fdf25fe 100644 --- a/migrations/each_user/0007_contact-groups.sql +++ b/migrations/each_user/0007_contact-groups.sql @@ -2,3 +2,8 @@ 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; diff --git a/migrations/each_user/0008_group-slugs.sql b/migrations/each_user/0008_group-slugs.sql new file mode 100644 index 0000000..dd5afae --- /dev/null +++ b/migrations/each_user/0008_group-slugs.sql @@ -0,0 +1 @@ +alter table groups add column slug text not null default ''; diff --git a/src/db.rs b/src/db.rs index d844b6b..359a3a3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -23,9 +23,7 @@ impl Database { let pool = SqlitePoolOptions::new().connect_with(db_options).await?; - sqlx::migrate!("./migrations/each_user/") - .run(&pool) - .await?; + sqlx::migrate!("./migrations/each_user/").run(&pool).await?; if user.username == "demo" { sqlx::query_file!("./migrations/demo.sql") .execute(&pool) diff --git a/src/main.rs b/src/main.rs index ffeed81..39b8cd7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,19 +17,19 @@ use tower_sessions_sqlx_store::SqliteStore; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod models; -use models::contact::ContactTrie; +use models::contact::MentionTrie; use models::user::{Backend, User}; mod db; use db::{Database, DbId}; mod web; -use web::{auth, contact, home, ics, journal, settings}; +use web::{auth, contact, group, home, ics, journal, settings}; #[derive(Clone)] struct AppStateEntry { database: Arc, - contact_search: Arc>, + contact_search: Arc>, } #[derive(Clone)] @@ -50,7 +50,7 @@ impl AppState { pub async fn init(&mut self, user: &User) -> Result, AppError> { let database = Database::for_user(&user).await?; let mut trie = radix_trie::Trie::new(); - let rows = sqlx::query_as!( + let mentionable_names = sqlx::query_as!( NameReference, "select name, contact_id from ( select contact_id, name, count(name) as ct from names group by name @@ -59,8 +59,21 @@ impl AppState { .fetch_all(&database.pool) .await?; - for row in rows { - trie.insert(row.name, DbId::try_from(row.contact_id)?); + for row in mentionable_names { + 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"); @@ -78,10 +91,9 @@ impl AppState { } pub fn db(&self, user: &impl AuthUser) -> Arc { let map = self.map.read().expect("rwlock poisoned"); - map.get(&user.id()).unwrap().database.clone() } - pub fn contact_search(&self, user: &impl AuthUser) -> Arc> { + pub fn contact_search(&self, user: &impl AuthUser) -> Arc> { let map = self.map.read().expect("rwlock poisoned"); map.get(&user.id()).unwrap().contact_search.clone() } @@ -165,7 +177,7 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> { .with( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { format!( - "{}=debug,tower_http=debug,axum=trace", + "{}=debug,tower_http=debug,axum=trace,sqlx=debug", env!("CARGO_CRATE_NAME") ) .into() @@ -177,6 +189,7 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> { let app = Router::new() .route("/", get(home::get::home)) .merge(contact::router()) + .merge(group::router()) .merge(journal::router()) .merge(settings::router()) .route_layer(login_required!(Backend, login_url = "/login")) diff --git a/src/models/contact.rs b/src/models/contact.rs index 1c8b472..ca2bf98 100644 --- a/src/models/contact.rs +++ b/src/models/contact.rs @@ -36,7 +36,9 @@ impl HydratedContact { } } } -pub type ContactTrie = radix_trie::Trie; + +/* name/group, url */ +pub type MentionTrie = radix_trie::Trie; impl FromRow<'_, SqliteRow> for Contact { fn from_row(row: &SqliteRow) -> sqlx::Result { diff --git a/src/models/journal.rs b/src/models/journal.rs index ae6cb89..60e35d7 100644 --- a/src/models/journal.rs +++ b/src/models/journal.rs @@ -7,7 +7,7 @@ use sqlx::{FromRow, Row}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; -use super::contact::ContactTrie; +use super::contact::MentionTrie; use crate::AppError; use crate::db::DbId; @@ -19,24 +19,24 @@ pub struct JournalEntry { } #[derive(Debug, PartialEq, Eq, Hash, FromRow)] -pub struct ContactMention { +pub struct Mention { pub entry_id: DbId, - pub contact_id: DbId, + pub url: String, pub input_text: String, pub byte_range_start: u32, pub byte_range_end: u32, } impl JournalEntry { - pub fn extract_mentions(&self, trie: &ContactTrie) -> HashSet { + pub fn extract_mentions(&self, trie: &MentionTrie) -> HashSet { let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap(); name_re .captures_iter(&self.value) .map(|caps| { let range = caps.get_match().range(); - trie.get(&caps[1]).map(|cid| ContactMention { + trie.get(&caps[1]).map(|url| Mention { entry_id: self.id, - contact_id: cid.to_owned(), + url: url.to_string(), input_text: caps[1].to_string(), byte_range_start: u32::try_from(range.start).unwrap(), byte_range_end: u32::try_from(range.end).unwrap(), @@ -49,9 +49,9 @@ impl JournalEntry { pub async fn insert_mentions( &self, - trie: Arc>, + trie: Arc>, pool: &SqlitePool, - ) -> Result, AppError> { + ) -> Result, AppError> { let mentions = { let trie = trie.read().unwrap(); self.extract_mentions(&trie) @@ -59,12 +59,12 @@ impl JournalEntry { for mention in &mentions { sqlx::query!( - "insert into contact_mentions( - entry_id, contact_id, input_text, + "insert into journal_mentions( + entry_id, url, input_text, byte_range_start, byte_range_end ) values ($1, $2, $3, $4, $5)", mention.entry_id, - mention.contact_id, + mention.url, mention.input_text, mention.byte_range_start, mention.byte_range_end @@ -79,8 +79,8 @@ impl JournalEntry { pub async fn to_html(&self, pool: &SqlitePool) -> Result { // important to sort desc so that changing contents early in the string // doesn't break inserting mentions at byte offsets further in - let mentions: Vec = sqlx::query_as( - "select * from contact_mentions + let mentions: Vec = sqlx::query_as( + "select * from journal_mentions where entry_id = $1 order by byte_range_start desc", ) .bind(self.id) @@ -89,9 +89,10 @@ impl JournalEntry { let mut value = self.value.clone(); for mention in mentions { + tracing::debug!("url ({})", mention.url); value.replace_range( (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)" x-on:click="initial_date = date; initial_value = value" hx-patch=(entry_url) - hx-target="previous .entry" + hx-target="closest .entry" hx-swap="outerHTML" title="Save" { "✓" } button x-bind:disabled="(date === initial_date) && (value === initial_value)" diff --git a/src/web/contact.rs b/src/web/contact.rs index c391ca7..1808025 100644 --- a/src/web/contact.rs +++ b/src/web/contact.rs @@ -11,6 +11,7 @@ use chrono::DateTime; use maud::{Markup, PreEscaped, html}; use serde::Deserialize; use serde_json::json; +use slug::slugify; use sqlx::{QueryBuilder, Sqlite}; use super::Layout; @@ -32,9 +33,9 @@ pub struct Address { pub struct Group { pub contact_id: DbId, pub name: String, + pub slug: String, } - pub fn router() -> Router { Router::new() .route("/contact/new", post(self::post::contact)) @@ -85,9 +86,14 @@ mod get { .await?; let entries: Vec = sqlx::query_as( - "select j.id, j.value, j.date from journal_entries j - join contact_mentions cm on j.id = cm.entry_id - where cm.contact_id = $1", + "select distinct j.id, j.value, j.date from journal_entries j + join journal_mentions cm on j.id = cm.entry_id + where cm.url = '/contact/'||$1 or cm.url in ( + select '/group/'||slug from groups + where contact_id = $1 + ) + order by j.date desc + ", ) .bind(contact_id) .fetch_all(pool) @@ -104,10 +110,11 @@ mod get { let groups: Vec = sqlx::query_as!( Group, "select * from groups where contact_id = $1", - contact_id) - .fetch_all(pool) - .await?; - + contact_id + ) + .fetch_all(pool) + .await?; + let text_body: Option = sqlx::query!("select text_body from contacts where id = $1", contact_id) .fetch_one(pool) @@ -172,7 +179,9 @@ mod get { label { "in groups" } #groups { @for group in groups { - .group { (group.name) } + a .group href=(format!("/group/{}", group.slug)) { + (group.name) + } } } } @@ -203,8 +212,8 @@ mod get { from names where contact_id = c.id ) as names, ( select jes.date from journal_entries jes - join contact_mentions cms on cms.entry_id = jes.id - where cms.contact_id = c.id + join journal_mentions cms on cms.entry_id = jes.id + where cms.url = '/contact/'||c.id order by jes.date desc limit 1 ) as last_mention_date from contacts c where c.id = $1", @@ -230,12 +239,13 @@ mod get { let groups: Vec = sqlx::query_as!( Group, "select * from groups where contact_id = $1", - contact_id) - .fetch_all(pool) - .await? - .into_iter() - .map(|group| group.name) - .collect(); + contact_id + ) + .fetch_all(pool) + .await? + .into_iter() + .map(|group| group.name) + .collect(); let text_body: String = sqlx::query!("select text_body from contacts where id = $1", contact_id) @@ -353,13 +363,14 @@ mod put { manually_freshened_at: String, address_label: Option>, address_value: Option>, + group: Option>, text_body: String, } pub async fn contact( auth_session: AuthSession, State(state): State, - Path(contact_id): Path, + Path(contact_id): Path, Form(payload): Form, ) -> Result { let user = auth_session.user.unwrap(); @@ -398,100 +409,183 @@ mod put { .execute(pool) .await?; + // these blocks are not in functions because payload gets progressively + // partially moved as we handle each field and i don't want to deal with it + { // update addresses - sqlx::query!("delete from addresses where contact_id = $1", contact_id) + let new_addresses = payload.address_value.clone().map(|values| { + let labels: Vec = 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::>() + }); + 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 = 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 = old_names.into_iter().map(|(s,)| s).collect(); + + if old_names != new_names { + // delete and regen *all* journal mentions, not just the ones for the + // current user, since changing *this* user's names can change, *globally*, + // which names have n=1 and thus are eligible for mentioning + sqlx::query!( + "delete from journal_mentions; delete from names where contact_id = $1", + contact_id + ) .execute(pool) .await?; - if let Some(values) = payload.address_value { - let labels = if values.len() == 1 { - Some(vec![String::new()]) - } else { - payload.address_label - }; - if let Some(labels) = labels { - let new_addresses = labels - .into_iter() - .zip(values) - .filter(|(_, val)| val.len() > 0); - for (label, value) in new_addresses { - sqlx::query!( - "insert into addresses (contact_id, label, value) values ($1, $2, $3)", - contact_id, - label, - value - ) - .execute(pool) - .await?; + let mut recalc_counts: QueryBuilder = QueryBuilder::new( + "select name, contact_id from ( + select name, contact_id, count(name) as ct from names where name in (", + ); + let mut name_list = recalc_counts.separated(", "); + for name in &old_names { + name_list.push_bind(name); + } + + if !new_names.is_empty() { + for name in &new_names { + name_list.push_bind(name.clone()); + } + + let mut name_insert: QueryBuilder = + QueryBuilder::new("insert into names (contact_id, sort, name) "); + name_insert.push_values( + new_names.iter().enumerate(), + |mut builder, (sort, name)| { + builder + .push_bind(contact_id) + .push_bind(DbId::try_from(sort).unwrap()) + .push_bind(name); + }, + ); + name_insert.build().persistent(false).execute(pool).await?; + } + + name_list.push_unseparated(") group by name) where ct = 1"); + let recalc_names: Vec<(String, DbId)> = recalc_counts + .build_query_as() + .persistent(false) + .fetch_all(pool) + .await?; + + { + let trie_mutex = state.contact_search(&user); + let mut trie = trie_mutex.write().unwrap(); + for name in &old_names { + trie.remove(name); + } + + for name in recalc_names { + trie.insert(name.0, format!("/contact/{}", name.1)); } } } - } - let old_names: Vec<(String,)> = sqlx::query_as( - "delete from contact_mentions; - delete from names where contact_id = $1 returning name;", - ) - .bind(contact_id) - .fetch_all(pool) - .await?; + let new_groups: Vec = 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 = old_groups.into_iter().map(|(s,)| s).collect(); - let mut recalc_counts: QueryBuilder = QueryBuilder::new( - "select name, contact_id from ( - select name, contact_id, count(name) as ct from names where name in (", - ); - let mut name_list = recalc_counts.separated(", "); - for (name,) in &old_names { - name_list.push_bind(name); - } - - if let Some(names) = payload.name { - let names: Vec = 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 = - 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 = sqlx::query_as("select * from journal_entries") - .fetch_all(pool) - .await?; - - for entry in journal_entries { - entry - .insert_mentions(state.contact_search(&user), pool) + if new_groups != old_groups { + sqlx::query!( + "delete from journal_mentions; delete from groups where contact_id = $1", + contact_id + ) + .execute(pool) .await?; + + QueryBuilder::new("insert into groups (contact_id, name, slug) ") + .push_values(&new_groups, |mut b, name| { + b.push_bind(contact_id) + .push_bind(name) + .push_bind(slugify(name)); + }) + .build() + .persistent(false) + .execute(pool) + .await?; + + { + let trie_mutex = state.contact_search(&user); + let mut trie = trie_mutex.write().unwrap(); + for name in &old_groups { + // TODO i think we care about group name vs contact name counts, + // otherwise this will cause a problem (or we want to disallow + // setting group names that are contact names or vice versa?) + trie.remove(name); + } + + for group in &new_groups { + trie.insert(group.clone(), format!("/group/{}", slugify(group))); + } + } + } + + if new_names != old_names || new_groups != old_groups { + let journal_entries: Vec = + sqlx::query_as("select * from journal_entries") + .fetch_all(pool) + .await?; + + for entry in journal_entries { + entry + .insert_mentions(state.contact_search(&user), pool) + .await?; + } + } } let mut headers = HeaderMap::new(); @@ -510,7 +604,7 @@ mod delete { let pool = &state.db(&user).pool; sqlx::query( - "delete from contact_mentions where contact_id = $1; + "delete from journal_mentions where contact_id = $1; delete from names where contact_id = $1; delete from contacts where id = $1;", ) diff --git a/src/web/group.rs b/src/web/group.rs new file mode 100644 index 0000000..e1ae24c --- /dev/null +++ b/src/web/group.rs @@ -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 { + 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, + Path(slug): Path, + layout: Layout, + ) -> Result { + 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) + } + } + } + } + }, + )) + } +} diff --git a/src/web/home.rs b/src/web/home.rs index 4f3264c..ce3ebae 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -131,8 +131,8 @@ pub mod get { from names where contact_id = c.id ) as names, ( select jes.date from journal_entries jes - join contact_mentions cms on cms.entry_id = jes.id - where cms.contact_id = c.id + join journal_mentions cms on cms.entry_id = jes.id + where cms.url = '/contact/'||c.id order by jes.date desc limit 1 ) as last_mention_date from contacts c", ) diff --git a/src/web/journal.rs b/src/web/journal.rs index 76bb454..26ac93e 100644 --- a/src/web/journal.rs +++ b/src/web/journal.rs @@ -98,26 +98,26 @@ mod patch { .fetch_one(pool) .await?; - sqlx::query!( - "update journal_entries set date = $1, value = $2 where id = $3", - payload.date, - payload.value, - entry_id + let new_entry: JournalEntry = sqlx::query_as( + "update journal_entries set date = $1, value = $2 where id = $3 returning *", ) - .execute(pool) + .bind(&payload.date) + .bind(&payload.value) + .bind(entry_id) + .fetch_one(pool) .await?; - if entry.value != payload.value { - sqlx::query!("delete from contact_mentions where entry_id = $1", entry_id) + if entry.value != new_entry.value { + sqlx::query!("delete from journal_mentions where entry_id = $1", entry_id) .execute(pool) .await?; - entry + new_entry .insert_mentions(state.contact_search(&user), pool) .await?; } - Ok(entry.to_html(pool).await?) + Ok(new_entry.to_html(pool).await?) } } @@ -133,7 +133,7 @@ mod delete { let pool = &state.db(&user).pool; sqlx::query( - "delete from contact_mentions where entry_id = $1; + "delete from journal_mentions where entry_id = $1; delete from journal_entries where id = $2 returning id,date,value", ) .bind(entry_id) diff --git a/src/web/mod.rs b/src/web/mod.rs index 317c3e6..97c0d1d 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -10,6 +10,7 @@ use super::{AppError, AppState}; pub mod auth; pub mod contact; +pub mod group; pub mod home; pub mod ics; pub mod journal; diff --git a/static/contact.css b/static/contact.css index 2cc6ce0..d725284 100644 --- a/static/contact.css +++ b/static/contact.css @@ -44,6 +44,12 @@ main { } } +#groups { + display: flex; + flex-direction: column; + width: min-content; +} + #text_body { margin-top: 1em; diff --git a/static/group.css b/static/group.css new file mode 100644 index 0000000..f8b1037 --- /dev/null +++ b/static/group.css @@ -0,0 +1,10 @@ +main { + h1 { + margin-block: 0.83em; + font-size: 1.50em; + } + + li { + list-style: disc inside; + } +}