diff --git a/Cargo.lock b/Cargo.lock index b300c23..e2bad46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -609,12 +609,6 @@ 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" @@ -1437,7 +1431,6 @@ dependencies = [ "serde", "serde_json", "short-uuid", - "slug", "sqlx", "thiserror 2.0.17", "time", @@ -2312,16 +2305,6 @@ 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 9f7d62b..845a3ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ 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 deleted file mode 100644 index 1ad0e5f..0000000 --- a/e2e/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# 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 3d0be95..2d467df 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 7baf433..8942e27 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -10,8 +10,8 @@ "author": "", "license": "ISC", "devDependencies": { - "@playwright/test": "=1.57.0", + "@playwright/test": "^1.56.1", "@types/node": "^24.9.1" }, - "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316" + "packageManager": "pnpm@11.0.0-dev.1005+sha512.91f84a392eea348ea4852a182912d2520273a4336f933b78cc44bc931eb999923c097e9433a9b355adc1f725725ea99082fc9f032a559df832632e764c92c798" } diff --git a/e2e/pages/journal.spec.ts b/e2e/pages/journal.spec.ts index f30efa3..fec74e9 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' }).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); + // 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); - // 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' }).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); + // 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); - // ...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); + // ...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); - // 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 eeb5fca..021bf4e 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' }).nth(1).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(); + } - 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 88405bd..ee28a30 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -9,7 +9,7 @@ importers: .: devDependencies: '@playwright/test': - specifier: '=1.57.0' + specifier: ^1.56.1 version: 1.57.0 '@types/node': specifier: ^24.9.1 diff --git a/e2e/static b/e2e/static deleted file mode 120000 index 4dab164..0000000 --- a/e2e/static +++ /dev/null @@ -1 +0,0 @@ -../static \ No newline at end of file diff --git a/e2e/users.db b/e2e/users.db new file mode 100644 index 0000000..a90902a Binary files /dev/null and b/e2e/users.db differ diff --git a/migrations/demo.db/0001_contact-tables.sql b/migrations/demo.db/0001_contact-tables.sql new file mode 100644 index 0000000..f88f0cd --- /dev/null +++ b/migrations/demo.db/0001_contact-tables.sql @@ -0,0 +1,12 @@ +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 +); diff --git a/migrations/demo.db/0002_journal-entry-tables.sql b/migrations/demo.db/0002_journal-entry-tables.sql new file mode 100644 index 0000000..3b66cb7 --- /dev/null +++ b/migrations/demo.db/0002_journal-entry-tables.sql @@ -0,0 +1,13 @@ +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 +); diff --git a/migrations/demo.db/0003_demo-data.sql b/migrations/demo.db/0003_demo-data.sql new file mode 100644 index 0000000..bef2714 --- /dev/null +++ b/migrations/demo.db/0003_demo-data.sql @@ -0,0 +1,33 @@ +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); diff --git a/migrations/demo.db/0004_user-settings.sql b/migrations/demo.db/0004_user-settings.sql new file mode 100644 index 0000000..035a34f --- /dev/null +++ b/migrations/demo.db/0004_user-settings.sql @@ -0,0 +1,6 @@ +create table if not exists settings ( + id integer primary key, + ics_path text +); + +insert into settings (id) values (1) on conflict (id) do nothing; diff --git a/migrations/demo.db/0005_address-tables.sql b/migrations/demo.db/0005_address-tables.sql new file mode 100644 index 0000000..138382a --- /dev/null +++ b/migrations/demo.db/0005_address-tables.sql @@ -0,0 +1,6 @@ +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 +); diff --git a/migrations/demo.db/0006_contact-text-body.sql b/migrations/demo.db/0006_contact-text-body.sql new file mode 100644 index 0000000..65eb200 --- /dev/null +++ b/migrations/demo.db/0006_contact-text-body.sql @@ -0,0 +1,3 @@ +alter table contacts + add column + text_body text; diff --git a/migrations/demo.sql b/migrations/demo.sql deleted file mode 100644 index a81f905..0000000 --- a/migrations/demo.sql +++ /dev/null @@ -1,51 +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'), - (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'); diff --git a/migrations/each_user/0007_contact-groups.sql b/migrations/each_user/0007_contact-groups.sql deleted file mode 100644 index fdf25fe..0000000 --- a/migrations/each_user/0007_contact-groups.sql +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index dd5afae..0000000 --- a/migrations/each_user/0008_group-slugs.sql +++ /dev/null @@ -1 +0,0 @@ -alter table groups add column slug text not null default ''; diff --git a/src/db.rs b/src/db.rs index 359a3a3..8b3dd18 100644 --- a/src/db.rs +++ b/src/db.rs @@ -23,12 +23,12 @@ impl Database { let pool = SqlitePoolOptions::new().connect_with(db_options).await?; - sqlx::migrate!("./migrations/each_user/").run(&pool).await?; - if user.username == "demo" { - sqlx::query_file!("./migrations/demo.sql") - .execute(&pool) - .await?; + let migrator = if user.username == "demo" { + sqlx::migrate!("./migrations/demo.db/") + } else { + sqlx::migrate!("./migrations/each_user/") }; + migrator.run(&pool).await?; Ok(Self { pool }) } diff --git a/src/main.rs b/src/main.rs index 39b8cd7..ffeed81 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::MentionTrie; +use models::contact::ContactTrie; use models::user::{Backend, User}; mod db; use db::{Database, DbId}; mod web; -use web::{auth, contact, group, home, ics, journal, settings}; +use web::{auth, contact, 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 mentionable_names = sqlx::query_as!( + let rows = sqlx::query_as!( NameReference, "select name, contact_id from ( select contact_id, name, count(name) as ct from names group by name @@ -59,21 +59,8 @@ impl AppState { .fetch_all(&database.pool) .await?; - 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)); + for row in rows { + trie.insert(row.name, DbId::try_from(row.contact_id)?); } let mut map = self.map.write().expect("rwlock poisoned"); @@ -91,9 +78,10 @@ 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() } @@ -177,7 +165,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,sqlx=debug", + "{}=debug,tower_http=debug,axum=trace", env!("CARGO_CRATE_NAME") ) .into() @@ -189,7 +177,6 @@ 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 ca2bf98..1c8b472 100644 --- a/src/models/contact.rs +++ b/src/models/contact.rs @@ -36,9 +36,7 @@ impl HydratedContact { } } } - -/* name/group, url */ -pub type MentionTrie = radix_trie::Trie; +pub type ContactTrie = 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 60e35d7..ae6cb89 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::MentionTrie; +use super::contact::ContactTrie; use crate::AppError; use crate::db::DbId; @@ -19,24 +19,24 @@ pub struct JournalEntry { } #[derive(Debug, PartialEq, Eq, Hash, FromRow)] -pub struct Mention { +pub struct ContactMention { pub entry_id: DbId, - pub url: String, + pub contact_id: DbId, pub input_text: String, pub byte_range_start: u32, pub byte_range_end: u32, } impl JournalEntry { - pub fn extract_mentions(&self, trie: &MentionTrie) -> HashSet { + pub fn extract_mentions(&self, trie: &ContactTrie) -> 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(|url| Mention { + trie.get(&caps[1]).map(|cid| ContactMention { entry_id: self.id, - url: url.to_string(), + contact_id: cid.to_owned(), 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 journal_mentions( - entry_id, url, input_text, + "insert into contact_mentions( + entry_id, contact_id, input_text, byte_range_start, byte_range_end ) values ($1, $2, $3, $4, $5)", mention.entry_id, - mention.url, + mention.contact_id, 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 journal_mentions + let mentions: Vec = sqlx::query_as( + "select * from contact_mentions where entry_id = $1 order by byte_range_start desc", ) .bind(self.id) @@ -89,10 +89,9 @@ 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!("[{}]({})", mention.input_text, mention.url), + &format!("[{}](/contact/{})", mention.input_text, mention.contact_id), ); } @@ -120,7 +119,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="closest .entry" + hx-target="previous .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 1808025..8b1fb8d 100644 --- a/src/web/contact.rs +++ b/src/web/contact.rs @@ -11,7 +11,6 @@ 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; @@ -29,13 +28,6 @@ pub struct Address { pub value: String, } -#[derive(serde::Serialize, Debug)] -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)) @@ -86,14 +78,9 @@ mod get { .await?; let entries: Vec = sqlx::query_as( - "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 - ", + "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", ) .bind(contact_id) .fetch_all(pool) @@ -107,14 +94,6 @@ mod get { .fetch_all(pool) .await?; - let groups: Vec = sqlx::query_as!( - Group, - "select * from groups where contact_id = $1", - 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) @@ -174,17 +153,6 @@ mod get { } } } - - @if groups.len() > 0 { - label { "in groups" } - #groups { - @for group in groups { - a .group href=(format!("/group/{}", group.slug)) { - (group.name) - } - } - } - } } @@ -212,8 +180,8 @@ mod get { from names where contact_id = c.id ) as names, ( select jes.date from journal_entries jes - join journal_mentions cms on cms.entry_id = jes.id - where cms.url = '/contact/'||c.id + join contact_mentions cms on cms.entry_id = jes.id + where cms.contact_id = c.id order by jes.date desc limit 1 ) as last_mention_date from contacts c where c.id = $1", @@ -236,17 +204,6 @@ mod get { .clone() .map_or("".to_string(), |m| m.to_rfc3339()); - 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(); - let text_body: String = sqlx::query!("select text_body from contacts where id = $1", contact_id) .fetch_one(pool) @@ -307,14 +264,6 @@ mod get { } 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 { "Free text (supports markdown)" } @@ -363,14 +312,13 @@ 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(); @@ -409,183 +357,100 @@ 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 - let new_addresses = payload.address_value.clone().map(|values| { - let labels: Vec = if values.len() == 1 { - vec![String::new()] + sqlx::query!("delete from addresses 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.clone().unwrap_or(vec![]) + payload.address_label }; - - 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?; + 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?; + } + } } } - { - // 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(); + 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?; - 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 = QueryBuilder::new( - "select name, contact_id from ( + 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); + ); + 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()); } - 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(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?; + } + } - 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?; - 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 trie_mutex = state.contact_search(&user); + let mut trie = trie_mutex.write().unwrap(); + for name in &old_names { + trie.remove(&name.0); } - 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(); + for name in recalc_names { + trie.insert(name.0, name.1); + } + } - if new_groups != old_groups { - sqlx::query!( - "delete from journal_mentions; delete from groups where contact_id = $1", - contact_id - ) - .execute(pool) + 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?; - - 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(); @@ -604,7 +469,7 @@ mod delete { let pool = &state.db(&user).pool; sqlx::query( - "delete from journal_mentions where contact_id = $1; + "delete from contact_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 deleted file mode 100644 index e1ae24c..0000000 --- a/src/web/group.rs +++ /dev/null @@ -1,72 +0,0 @@ -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 ce3ebae..4f3264c 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 journal_mentions cms on cms.entry_id = jes.id - where cms.url = '/contact/'||c.id + join contact_mentions cms on cms.entry_id = jes.id + where cms.contact_id = 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 26ac93e..76bb454 100644 --- a/src/web/journal.rs +++ b/src/web/journal.rs @@ -98,26 +98,26 @@ mod patch { .fetch_one(pool) .await?; - let new_entry: JournalEntry = sqlx::query_as( - "update journal_entries set date = $1, value = $2 where id = $3 returning *", + sqlx::query!( + "update journal_entries set date = $1, value = $2 where id = $3", + payload.date, + payload.value, + entry_id ) - .bind(&payload.date) - .bind(&payload.value) - .bind(entry_id) - .fetch_one(pool) + .execute(pool) .await?; - if entry.value != new_entry.value { - sqlx::query!("delete from journal_mentions where entry_id = $1", entry_id) + if entry.value != payload.value { + sqlx::query!("delete from contact_mentions where entry_id = $1", entry_id) .execute(pool) .await?; - new_entry + entry .insert_mentions(state.contact_search(&user), pool) .await?; } - Ok(new_entry.to_html(pool).await?) + Ok(entry.to_html(pool).await?) } } @@ -133,7 +133,7 @@ mod delete { let pool = &state.db(&user).pool; sqlx::query( - "delete from journal_mentions where entry_id = $1; + "delete from contact_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 97c0d1d..317c3e6 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -10,7 +10,6 @@ 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 d725284..2cc6ce0 100644 --- a/static/contact.css +++ b/static/contact.css @@ -44,12 +44,6 @@ 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 deleted file mode 100644 index f8b1037..0000000 --- a/static/group.css +++ /dev/null @@ -1,10 +0,0 @@ -main { - h1 { - margin-block: 0.83em; - font-size: 1.50em; - } - - li { - list-style: disc inside; - } -}