Compare commits
No commits in common. "a0afb6dfd35b305994c943d9fb85a0aac9311e1b" and "e3e77cbae311c9d66bd4e03877d0cfc88638ffac" have entirely different histories.
a0afb6dfd3
...
e3e77cbae3
30 changed files with 301 additions and 570 deletions
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>,
|
||||
birthday?: string,
|
||||
names?: Array<string>,
|
||||
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();
|
||||
};
|
||||
|
||||
|
|
|
|||
2
e2e/pnpm-lock.yaml
generated
2
e2e/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
../static
|
||||
BIN
e2e/users.db
Normal file
BIN
e2e/users.db
Normal file
Binary file not shown.
12
migrations/demo.db/0001_contact-tables.sql
Normal file
12
migrations/demo.db/0001_contact-tables.sql
Normal file
|
|
@ -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
|
||||
);
|
||||
13
migrations/demo.db/0002_journal-entry-tables.sql
Normal file
13
migrations/demo.db/0002_journal-entry-tables.sql
Normal file
|
|
@ -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
|
||||
);
|
||||
33
migrations/demo.db/0003_demo-data.sql
Normal file
33
migrations/demo.db/0003_demo-data.sql
Normal file
|
|
@ -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);
|
||||
6
migrations/demo.db/0004_user-settings.sql
Normal file
6
migrations/demo.db/0004_user-settings.sql
Normal file
|
|
@ -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;
|
||||
6
migrations/demo.db/0005_address-tables.sql
Normal file
6
migrations/demo.db/0005_address-tables.sql
Normal file
|
|
@ -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
|
||||
);
|
||||
3
migrations/demo.db/0006_contact-text-body.sql
Normal file
3
migrations/demo.db/0006_contact-text-body.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
alter table contacts
|
||||
add column
|
||||
text_body text;
|
||||
|
|
@ -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');
|
||||
|
|
@ -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;
|
||||
|
|
@ -1 +0,0 @@
|
|||
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?;
|
||||
|
||||
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 })
|
||||
}
|
||||
|
|
|
|||
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};
|
||||
|
||||
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<Database>,
|
||||
contact_search: Arc<RwLock<MentionTrie>>,
|
||||
contact_search: Arc<RwLock<ContactTrie>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -50,7 +50,7 @@ impl AppState {
|
|||
pub async fn init(&mut self, user: &User) -> Result<Option<AppStateEntry>, 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<Id = DbId>) -> Arc<Database> {
|
||||
let map = self.map.read().expect("rwlock poisoned");
|
||||
|
||||
map.get(&user.id()).unwrap().database.clone()
|
||||
}
|
||||
pub fn contact_search(&self, user: &impl AuthUser<Id = DbId>) -> Arc<RwLock<MentionTrie>> {
|
||||
pub fn contact_search(&self, user: &impl AuthUser<Id = DbId>) -> Arc<RwLock<ContactTrie>> {
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -36,9 +36,7 @@ impl HydratedContact {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* name/group, url */
|
||||
pub type MentionTrie = radix_trie::Trie<String, String>;
|
||||
pub type ContactTrie = radix_trie::Trie<String, DbId>;
|
||||
|
||||
impl FromRow<'_, SqliteRow> for Contact {
|
||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||
|
|
|
|||
|
|
@ -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<Mention> {
|
||||
pub fn extract_mentions(&self, trie: &ContactTrie) -> HashSet<ContactMention> {
|
||||
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<RwLock<MentionTrie>>,
|
||||
trie: Arc<RwLock<ContactTrie>>,
|
||||
pool: &SqlitePool,
|
||||
) -> Result<HashSet<Mention>, AppError> {
|
||||
) -> Result<HashSet<ContactMention>, 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<Markup, AppError> {
|
||||
// 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<Mention> = sqlx::query_as(
|
||||
"select * from journal_mentions
|
||||
let mentions: Vec<ContactMention> = 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)"
|
||||
|
|
|
|||
|
|
@ -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<AppState> {
|
||||
Router::new()
|
||||
.route("/contact/new", post(self::post::contact))
|
||||
|
|
@ -86,14 +78,9 @@ mod get {
|
|||
.await?;
|
||||
|
||||
let entries: Vec<JournalEntry> = 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<Group> = sqlx::query_as!(
|
||||
Group,
|
||||
"select * from groups where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let text_body: Option<String> =
|
||||
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<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 =
|
||||
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<Vec<String>>,
|
||||
address_value: Option<Vec<String>>,
|
||||
group: Option<Vec<String>>,
|
||||
text_body: String,
|
||||
}
|
||||
|
||||
pub async fn contact(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
Path(contact_id): Path<DbId>,
|
||||
Path(contact_id): Path<u32>,
|
||||
Form(payload): Form<PutContact>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<String> = 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::<Vec<(String, String)>>()
|
||||
});
|
||||
let new_addresses = new_addresses.unwrap_or(vec![]);
|
||||
|
||||
let old_addresses: Vec<(String, String)> =
|
||||
sqlx::query_as("select label, value from addresses where contact_id = $1")
|
||||
.bind(contact_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
if new_addresses != old_addresses {
|
||||
sqlx::query!("delete from addresses where contact_id = $1", contact_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// trailing space in query intentional
|
||||
QueryBuilder::new("insert into addresses (contact_id, label, value) ")
|
||||
.push_values(new_addresses, |mut b, (label, value)| {
|
||||
b.push_bind(contact_id).push_bind(label).push_bind(value);
|
||||
})
|
||||
.build()
|
||||
.persistent(false)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
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<String> = payload
|
||||
.name
|
||||
.unwrap_or(vec![])
|
||||
.into_iter()
|
||||
.filter(|n| n.len() > 0)
|
||||
.collect();
|
||||
let old_names: Vec<(String,)> =
|
||||
sqlx::query_as("select name from names where contact_id = $1")
|
||||
.bind(contact_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
|
||||
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<Sqlite> = QueryBuilder::new(
|
||||
"select name, contact_id from (
|
||||
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
|
||||
"select name, contact_id from (
|
||||
select name, contact_id, count(name) as ct from names where name in (",
|
||||
);
|
||||
let mut name_list = recalc_counts.separated(", ");
|
||||
for name in &old_names {
|
||||
name_list.push_bind(name);
|
||||
);
|
||||
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<String> = 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<Sqlite> =
|
||||
QueryBuilder::new("insert into names (contact_id, sort, name) ");
|
||||
name_insert.push_values(names.iter().enumerate(), |mut builder, (sort, name)| {
|
||||
builder
|
||||
.push_bind(contact_id)
|
||||
.push_bind(DbId::try_from(sort).unwrap())
|
||||
.push_bind(name);
|
||||
});
|
||||
name_insert.build().persistent(false).execute(pool).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut name_insert: QueryBuilder<Sqlite> =
|
||||
QueryBuilder::new("insert into names (contact_id, sort, name) ");
|
||||
name_insert.push_values(
|
||||
new_names.iter().enumerate(),
|
||||
|mut builder, (sort, name)| {
|
||||
builder
|
||||
.push_bind(contact_id)
|
||||
.push_bind(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<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();
|
||||
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<JournalEntry> = sqlx::query_as("select * from journal_entries")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
for entry in journal_entries {
|
||||
entry
|
||||
.insert_mentions(state.contact_search(&user), pool)
|
||||
.await?;
|
||||
|
||||
QueryBuilder::new("insert into groups (contact_id, name, slug) ")
|
||||
.push_values(&new_groups, |mut b, name| {
|
||||
b.push_bind(contact_id)
|
||||
.push_bind(name)
|
||||
.push_bind(slugify(name));
|
||||
})
|
||||
.build()
|
||||
.persistent(false)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let trie_mutex = state.contact_search(&user);
|
||||
let mut trie = trie_mutex.write().unwrap();
|
||||
for name in &old_groups {
|
||||
// TODO i think we care about group name vs contact name counts,
|
||||
// otherwise this will cause a problem (or we want to disallow
|
||||
// setting group names that are contact names or vice versa?)
|
||||
trie.remove(name);
|
||||
}
|
||||
|
||||
for group in &new_groups {
|
||||
trie.insert(group.clone(), format!("/group/{}", slugify(group)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if new_names != old_names || new_groups != old_groups {
|
||||
let journal_entries: Vec<JournalEntry> =
|
||||
sqlx::query_as("select * from journal_entries")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
for entry in journal_entries {
|
||||
entry
|
||||
.insert_mentions(state.contact_search(&user), pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
|
|
@ -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;",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
) 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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -44,12 +44,6 @@ main {
|
|||
}
|
||||
}
|
||||
|
||||
#groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
#text_body {
|
||||
margin-top: 1em;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
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