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",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "deunicode"
|
|
||||||
version = "1.6.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
|
|
@ -1437,7 +1431,6 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"short-uuid",
|
"short-uuid",
|
||||||
"slug",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"time",
|
"time",
|
||||||
|
|
@ -2312,16 +2305,6 @@ version = "0.4.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "slug"
|
|
||||||
version = "0.1.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
|
|
||||||
dependencies = [
|
|
||||||
"deunicode",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ rpassword = "7.4.0"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
short-uuid = "0.2.0"
|
short-uuid = "0.2.0"
|
||||||
slug = "0.1.6"
|
|
||||||
sqlx = { version = "0.8", features = ["macros", "runtime-tokio", "sqlite", "tls-rustls"] }
|
sqlx = { version = "0.8", features = ["macros", "runtime-tokio", "sqlite", "tls-rustls"] }
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
time = "0.3.44"
|
time = "0.3.44"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
SCRIPT_DIR=$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)
|
||||||
|
|
||||||
_playwright_version() {
|
_playwright_version() {
|
||||||
jq -r '.devDependencies["@playwright/test"]' "$SCRIPT_DIR/package.json" | sed -e 's/\=/v/'
|
jq -r '.devDependencies["@playwright/test"]' "$SCRIPT_DIR/package.json" | sed -e 's/\^/v/'
|
||||||
}
|
}
|
||||||
|
|
||||||
playwright:local() {
|
playwright:local() {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "=1.57.0",
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/node": "^24.9.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';
|
import { login, verifyCreateUser, todate } from './util';
|
||||||
|
|
||||||
test('can add journal entries', async ({ page }) => {
|
test('can add journal entries', async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
|
|
||||||
const entryBox = page.getByPlaceholder(/new entry/i);
|
const entryBox = page.getByPlaceholder(/new entry/i);
|
||||||
await entryBox.fill('banana banana banana');
|
await entryBox.fill('banana banana banana');
|
||||||
await page.getByRole('button', { name: /add entry/i }).click();
|
await page.getByRole('button', { name: /add entry/i }).click();
|
||||||
|
|
||||||
await expect(entryBox).toBeEmpty();
|
await expect(entryBox).toBeEmpty();
|
||||||
await expect(page.getByText('banana banana banana')).toBeVisible();
|
await expect(page.getByText('banana banana banana')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('journal entries autolink', async ({ page }) => {
|
test('journal entries autolink', async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await verifyCreateUser(page, { names: ['John Contact'] });
|
await verifyCreateUser(page, { names: ['John Contact'] });
|
||||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||||
|
|
||||||
await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]');
|
await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]');
|
||||||
await page.getByRole('button', { name: /add entry/i }).click();
|
await page.getByRole('button', { name: /add entry/i }).click();
|
||||||
|
|
||||||
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible();
|
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("changing a contact's names updates journal entries", async ({ page }) => {
|
test("changing a contact's names updates journal entries", async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await verifyCreateUser(page, { names: ['John Contact'] });
|
await verifyCreateUser(page, { names: ['John Contact'] });
|
||||||
await verifyCreateUser(page, { names: ['Jack Contact'] });
|
await verifyCreateUser(page, { names: ['Jack Contact'] });
|
||||||
|
|
||||||
await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]');
|
await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]');
|
||||||
await page.getByRole('button', { name: /add entry/i }).click();
|
await page.getByRole('button', { name: /add entry/i }).click();
|
||||||
|
|
||||||
const nav = page.getByRole('navigation');
|
const nav = page.getByRole('navigation');
|
||||||
const journal = page.locator('#journal');
|
const journal = page.locator('#journal');
|
||||||
|
|
||||||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
|
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
|
||||||
|
|
||||||
// add a new name
|
// add a new name
|
||||||
await nav.getByRole("link", { name: 'John Contact' }).click();
|
await nav.getByRole("link", { name: 'John Contact' }).click();
|
||||||
await page.getByRole('link', { name: /edit/i }).click();
|
await page.getByRole('link', { name: /edit/i }).click();
|
||||||
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
|
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
|
||||||
await page.getByRole('button', { name: 'Add' }).nth(1).click();
|
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||||
await page.getByRole('button', { name: /save/i }).click();
|
await page.getByRole('button', { name: /save/i }).click();
|
||||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||||
console.log(await journal.innerHTML());
|
console.log(await journal.innerHTML());
|
||||||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
|
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
|
||||||
|
|
||||||
// delete an existing name
|
// delete an existing name
|
||||||
await nav.getByRole("link", { name: 'John Contact' }).click();
|
await nav.getByRole("link", { name: 'John Contact' }).click();
|
||||||
await page.getByRole('link', { name: /edit/i }).click();
|
await page.getByRole('link', { name: /edit/i }).click();
|
||||||
await page.getByRole('button', { name: '×', disabled: false }).click();
|
await page.getByRole('button', { name: '×', disabled: false }).click();
|
||||||
await page.getByRole('button', { name: /save/i }).click();
|
await page.getByRole('button', { name: /save/i }).click();
|
||||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
|
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
|
||||||
|
|
||||||
// put it back, then...
|
// put it back, then...
|
||||||
await nav.getByRole("link", { name: 'John Contact' }).click();
|
await nav.getByRole("link", { name: 'John Contact' }).click();
|
||||||
await page.getByRole('link', { name: /edit/i }).click();
|
await page.getByRole('link', { name: /edit/i }).click();
|
||||||
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
|
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
|
||||||
await page.getByRole('button', { name: 'Add' }).nth(1).click();
|
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||||
await page.getByRole('button', { name: /save/i }).click();
|
await page.getByRole('button', { name: /save/i }).click();
|
||||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
|
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
|
||||||
|
|
||||||
// ...add a name that makes it no longer n=1
|
// ...add a name that makes it no longer n=1
|
||||||
await nav.getByRole("link", { name: 'Jack Contact' }).click();
|
await nav.getByRole("link", { name: 'Jack Contact' }).click();
|
||||||
await page.getByRole('link', { name: /edit/i }).click();
|
await page.getByRole('link', { name: /edit/i }).click();
|
||||||
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
|
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
|
||||||
await page.getByRole('button', { name: 'Add' }).nth(1).click();
|
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||||
await page.getByRole('button', { name: /save/i }).click();
|
await page.getByRole('button', { name: /save/i }).click();
|
||||||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
|
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
|
||||||
|
|
||||||
// delete a name that makes it now n=1
|
// delete a name that makes it now n=1
|
||||||
await nav.getByRole("link", { name: 'John Contact' }).click();
|
await nav.getByRole("link", { name: 'John Contact' }).click();
|
||||||
await page.getByRole('link', { name: /edit/i }).click();
|
await page.getByRole('link', { name: /edit/i }).click();
|
||||||
await page.getByRole('button', { name: '×', disabled: false }).click();
|
await page.getByRole('button', { name: '×', disabled: false }).click();
|
||||||
await page.getByRole('button', { name: /save/i }).click();
|
await page.getByRole('button', { name: /save/i }).click();
|
||||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
|
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can edit existing journal entries on home page', async ({ page }) => {
|
test('can edit existing journal entries on home page', async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await verifyCreateUser(page, { names: ['John Contact'] });
|
await verifyCreateUser(page, { names: ['John Contact'] });
|
||||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||||
|
|
||||||
await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana");
|
await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana");
|
||||||
await page.getByRole('button', { name: /add entry/i }).click();
|
await page.getByRole('button', { name: /add entry/i }).click();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await page.getByRole('checkbox', { name: /edit/i }).click();
|
await page.getByRole('checkbox', { name: /edit/i }).click();
|
||||||
const textbox = page.locator('form').filter({
|
const textbox = page.locator('form').filter({
|
||||||
has: page.getByRole('button', { name: '✓' })
|
has: page.getByRole('button', { name: '✓' })
|
||||||
}).locator('textarea');
|
}).locator('textarea');
|
||||||
await textbox.fill('met with [[John Contact]]');
|
await textbox.fill('met with [[John Contact]]');
|
||||||
|
|
||||||
await page.getByRole('button', { name: '✓' }).click();
|
await page.getByRole('button', { name: '✓' }).click();
|
||||||
|
|
||||||
await page.getByRole('checkbox', { name: /edit/i }).click();
|
await page.getByRole('checkbox', { name: /edit/i }).click();
|
||||||
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toHaveCount(1);
|
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can have multiple links', async ({ page }) => {
|
test('can have multiple links', async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await verifyCreateUser(page, { names: ['alice'] });
|
await verifyCreateUser(page, { names: ['alice'] });
|
||||||
await verifyCreateUser(page, { names: ['bob'] });
|
await verifyCreateUser(page, { names: ['bob'] });
|
||||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||||
|
|
||||||
await page.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids');
|
await page.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids');
|
||||||
await page.getByRole('button', { name: /add entry/i }).click();
|
await page.getByRole('button', { name: /add entry/i }).click();
|
||||||
|
|
||||||
const journal = page.locator('#journal');
|
const journal = page.locator('#journal');
|
||||||
await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1);
|
await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1);
|
||||||
await expect.soft(journal.getByRole('link', { name: 'bob' })).toHaveCount(1);
|
await expect.soft(journal.getByRole('link', { name: 'bob' })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,31 @@
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
export const login = async (page: Page) => {
|
export const login = async (page: Page) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByLabel("Username").fill("test");
|
await page.getByLabel("Username").fill("test");
|
||||||
await page.getByLabel("Password").fill("test");
|
await page.getByLabel("Password").fill("test");
|
||||||
await page.getByRole("button", { name: /login/i }).click();
|
await page.getByRole("button", { name: /login/i }).click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const todate = () => new Date().toISOString().split('T')[0];
|
export const todate = () => new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
type UserFields = {
|
type UserFields = {
|
||||||
names?: Array<string>,
|
names?: Array<string>,
|
||||||
birthday?: string,
|
birthday?: string,
|
||||||
};
|
};
|
||||||
export const verifyCreateUser = async (page: Page, fields: UserFields) => {
|
export const verifyCreateUser = async (page: Page, fields: UserFields) => {
|
||||||
await page.getByRole('button', { name: /add contact/i }).click();
|
await page.getByRole('button', { name: /add contact/i }).click();
|
||||||
|
|
||||||
const { names, ...simple } = fields;
|
const { names, ...simple } = fields;
|
||||||
for (const name of (names ?? [])) {
|
for (const name of (names ?? [])) {
|
||||||
await page.getByRole('textbox', { name: 'New name' }).fill(name);
|
await page.getByRole('textbox', { name: 'New name' }).fill(name);
|
||||||
await page.getByRole('button', { name: 'Add' }).nth(1).click();
|
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [label, value] of Object.entries(simple)) {
|
for (const [label, value] of Object.entries(simple)) {
|
||||||
await page.getByLabel(label).fill(value);
|
await page.getByLabel(label).fill(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: /save/i }).click();
|
await page.getByRole('button', { name: /save/i }).click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
2
e2e/pnpm-lock.yaml
generated
2
e2e/pnpm-lock.yaml
generated
|
|
@ -9,7 +9,7 @@ importers:
|
||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: '=1.57.0'
|
specifier: ^1.56.1
|
||||||
version: 1.57.0
|
version: 1.57.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.9.1
|
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?;
|
let pool = SqlitePoolOptions::new().connect_with(db_options).await?;
|
||||||
|
|
||||||
sqlx::migrate!("./migrations/each_user/").run(&pool).await?;
|
let migrator = if user.username == "demo" {
|
||||||
if user.username == "demo" {
|
sqlx::migrate!("./migrations/demo.db/")
|
||||||
sqlx::query_file!("./migrations/demo.sql")
|
} else {
|
||||||
.execute(&pool)
|
sqlx::migrate!("./migrations/each_user/")
|
||||||
.await?;
|
|
||||||
};
|
};
|
||||||
|
migrator.run(&pool).await?;
|
||||||
|
|
||||||
Ok(Self { pool })
|
Ok(Self { pool })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/main.rs
31
src/main.rs
|
|
@ -17,19 +17,19 @@ use tower_sessions_sqlx_store::SqliteStore;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
mod models;
|
mod models;
|
||||||
use models::contact::MentionTrie;
|
use models::contact::ContactTrie;
|
||||||
use models::user::{Backend, User};
|
use models::user::{Backend, User};
|
||||||
|
|
||||||
mod db;
|
mod db;
|
||||||
use db::{Database, DbId};
|
use db::{Database, DbId};
|
||||||
|
|
||||||
mod web;
|
mod web;
|
||||||
use web::{auth, contact, group, home, ics, journal, settings};
|
use web::{auth, contact, home, ics, journal, settings};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppStateEntry {
|
struct AppStateEntry {
|
||||||
database: Arc<Database>,
|
database: Arc<Database>,
|
||||||
contact_search: Arc<RwLock<MentionTrie>>,
|
contact_search: Arc<RwLock<ContactTrie>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -50,7 +50,7 @@ impl AppState {
|
||||||
pub async fn init(&mut self, user: &User) -> Result<Option<AppStateEntry>, AppError> {
|
pub async fn init(&mut self, user: &User) -> Result<Option<AppStateEntry>, AppError> {
|
||||||
let database = Database::for_user(&user).await?;
|
let database = Database::for_user(&user).await?;
|
||||||
let mut trie = radix_trie::Trie::new();
|
let mut trie = radix_trie::Trie::new();
|
||||||
let mentionable_names = sqlx::query_as!(
|
let rows = sqlx::query_as!(
|
||||||
NameReference,
|
NameReference,
|
||||||
"select name, contact_id from (
|
"select name, contact_id from (
|
||||||
select contact_id, name, count(name) as ct from names group by name
|
select contact_id, name, count(name) as ct from names group by name
|
||||||
|
|
@ -59,21 +59,8 @@ impl AppState {
|
||||||
.fetch_all(&database.pool)
|
.fetch_all(&database.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for row in mentionable_names {
|
for row in rows {
|
||||||
trie.insert(
|
trie.insert(row.name, DbId::try_from(row.contact_id)?);
|
||||||
row.name,
|
|
||||||
format!("/contact/{}", DbId::try_from(row.contact_id)?),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let groups: Vec<(String, String)> =
|
|
||||||
sqlx::query_as("select distinct name, slug from groups")
|
|
||||||
.fetch_all(&database.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for (group, slug) in groups {
|
|
||||||
// TODO urlencode
|
|
||||||
trie.insert(group, format!("/group/{}", slug));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut map = self.map.write().expect("rwlock poisoned");
|
let mut map = self.map.write().expect("rwlock poisoned");
|
||||||
|
|
@ -91,9 +78,10 @@ impl AppState {
|
||||||
}
|
}
|
||||||
pub fn db(&self, user: &impl AuthUser<Id = DbId>) -> Arc<Database> {
|
pub fn db(&self, user: &impl AuthUser<Id = DbId>) -> Arc<Database> {
|
||||||
let map = self.map.read().expect("rwlock poisoned");
|
let map = self.map.read().expect("rwlock poisoned");
|
||||||
|
|
||||||
map.get(&user.id()).unwrap().database.clone()
|
map.get(&user.id()).unwrap().database.clone()
|
||||||
}
|
}
|
||||||
pub fn contact_search(&self, user: &impl AuthUser<Id = DbId>) -> Arc<RwLock<MentionTrie>> {
|
pub fn contact_search(&self, user: &impl AuthUser<Id = DbId>) -> Arc<RwLock<ContactTrie>> {
|
||||||
let map = self.map.read().expect("rwlock poisoned");
|
let map = self.map.read().expect("rwlock poisoned");
|
||||||
map.get(&user.id()).unwrap().contact_search.clone()
|
map.get(&user.id()).unwrap().contact_search.clone()
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +165,7 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
|
||||||
.with(
|
.with(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
format!(
|
format!(
|
||||||
"{}=debug,tower_http=debug,axum=trace,sqlx=debug",
|
"{}=debug,tower_http=debug,axum=trace",
|
||||||
env!("CARGO_CRATE_NAME")
|
env!("CARGO_CRATE_NAME")
|
||||||
)
|
)
|
||||||
.into()
|
.into()
|
||||||
|
|
@ -189,7 +177,6 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(home::get::home))
|
.route("/", get(home::get::home))
|
||||||
.merge(contact::router())
|
.merge(contact::router())
|
||||||
.merge(group::router())
|
|
||||||
.merge(journal::router())
|
.merge(journal::router())
|
||||||
.merge(settings::router())
|
.merge(settings::router())
|
||||||
.route_layer(login_required!(Backend, login_url = "/login"))
|
.route_layer(login_required!(Backend, login_url = "/login"))
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,7 @@ impl HydratedContact {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub type ContactTrie = radix_trie::Trie<String, DbId>;
|
||||||
/* name/group, url */
|
|
||||||
pub type MentionTrie = radix_trie::Trie<String, String>;
|
|
||||||
|
|
||||||
impl FromRow<'_, SqliteRow> for Contact {
|
impl FromRow<'_, SqliteRow> for Contact {
|
||||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use sqlx::{FromRow, Row};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use super::contact::MentionTrie;
|
use super::contact::ContactTrie;
|
||||||
use crate::AppError;
|
use crate::AppError;
|
||||||
use crate::db::DbId;
|
use crate::db::DbId;
|
||||||
|
|
||||||
|
|
@ -19,24 +19,24 @@ pub struct JournalEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Hash, FromRow)]
|
#[derive(Debug, PartialEq, Eq, Hash, FromRow)]
|
||||||
pub struct Mention {
|
pub struct ContactMention {
|
||||||
pub entry_id: DbId,
|
pub entry_id: DbId,
|
||||||
pub url: String,
|
pub contact_id: DbId,
|
||||||
pub input_text: String,
|
pub input_text: String,
|
||||||
pub byte_range_start: u32,
|
pub byte_range_start: u32,
|
||||||
pub byte_range_end: u32,
|
pub byte_range_end: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JournalEntry {
|
impl JournalEntry {
|
||||||
pub fn extract_mentions(&self, trie: &MentionTrie) -> HashSet<Mention> {
|
pub fn extract_mentions(&self, trie: &ContactTrie) -> HashSet<ContactMention> {
|
||||||
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();
|
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();
|
||||||
name_re
|
name_re
|
||||||
.captures_iter(&self.value)
|
.captures_iter(&self.value)
|
||||||
.map(|caps| {
|
.map(|caps| {
|
||||||
let range = caps.get_match().range();
|
let range = caps.get_match().range();
|
||||||
trie.get(&caps[1]).map(|url| Mention {
|
trie.get(&caps[1]).map(|cid| ContactMention {
|
||||||
entry_id: self.id,
|
entry_id: self.id,
|
||||||
url: url.to_string(),
|
contact_id: cid.to_owned(),
|
||||||
input_text: caps[1].to_string(),
|
input_text: caps[1].to_string(),
|
||||||
byte_range_start: u32::try_from(range.start).unwrap(),
|
byte_range_start: u32::try_from(range.start).unwrap(),
|
||||||
byte_range_end: u32::try_from(range.end).unwrap(),
|
byte_range_end: u32::try_from(range.end).unwrap(),
|
||||||
|
|
@ -49,9 +49,9 @@ impl JournalEntry {
|
||||||
|
|
||||||
pub async fn insert_mentions(
|
pub async fn insert_mentions(
|
||||||
&self,
|
&self,
|
||||||
trie: Arc<RwLock<MentionTrie>>,
|
trie: Arc<RwLock<ContactTrie>>,
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
) -> Result<HashSet<Mention>, AppError> {
|
) -> Result<HashSet<ContactMention>, AppError> {
|
||||||
let mentions = {
|
let mentions = {
|
||||||
let trie = trie.read().unwrap();
|
let trie = trie.read().unwrap();
|
||||||
self.extract_mentions(&trie)
|
self.extract_mentions(&trie)
|
||||||
|
|
@ -59,12 +59,12 @@ impl JournalEntry {
|
||||||
|
|
||||||
for mention in &mentions {
|
for mention in &mentions {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"insert into journal_mentions(
|
"insert into contact_mentions(
|
||||||
entry_id, url, input_text,
|
entry_id, contact_id, input_text,
|
||||||
byte_range_start, byte_range_end
|
byte_range_start, byte_range_end
|
||||||
) values ($1, $2, $3, $4, $5)",
|
) values ($1, $2, $3, $4, $5)",
|
||||||
mention.entry_id,
|
mention.entry_id,
|
||||||
mention.url,
|
mention.contact_id,
|
||||||
mention.input_text,
|
mention.input_text,
|
||||||
mention.byte_range_start,
|
mention.byte_range_start,
|
||||||
mention.byte_range_end
|
mention.byte_range_end
|
||||||
|
|
@ -79,8 +79,8 @@ impl JournalEntry {
|
||||||
pub async fn to_html(&self, pool: &SqlitePool) -> Result<Markup, AppError> {
|
pub async fn to_html(&self, pool: &SqlitePool) -> Result<Markup, AppError> {
|
||||||
// important to sort desc so that changing contents early in the string
|
// important to sort desc so that changing contents early in the string
|
||||||
// doesn't break inserting mentions at byte offsets further in
|
// doesn't break inserting mentions at byte offsets further in
|
||||||
let mentions: Vec<Mention> = sqlx::query_as(
|
let mentions: Vec<ContactMention> = sqlx::query_as(
|
||||||
"select * from journal_mentions
|
"select * from contact_mentions
|
||||||
where entry_id = $1 order by byte_range_start desc",
|
where entry_id = $1 order by byte_range_start desc",
|
||||||
)
|
)
|
||||||
.bind(self.id)
|
.bind(self.id)
|
||||||
|
|
@ -89,10 +89,9 @@ impl JournalEntry {
|
||||||
|
|
||||||
let mut value = self.value.clone();
|
let mut value = self.value.clone();
|
||||||
for mention in mentions {
|
for mention in mentions {
|
||||||
tracing::debug!("url ({})", mention.url);
|
|
||||||
value.replace_range(
|
value.replace_range(
|
||||||
(mention.byte_range_start as usize)..(mention.byte_range_end as usize),
|
(mention.byte_range_start as usize)..(mention.byte_range_end as usize),
|
||||||
&format!("[{}]({})", 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)"
|
button x-bind:disabled="(date === initial_date) && (value === initial_value)"
|
||||||
x-on:click="initial_date = date; initial_value = value"
|
x-on:click="initial_date = date; initial_value = value"
|
||||||
hx-patch=(entry_url)
|
hx-patch=(entry_url)
|
||||||
hx-target="closest .entry"
|
hx-target="previous .entry"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
title="Save" { "✓" }
|
title="Save" { "✓" }
|
||||||
button x-bind:disabled="(date === initial_date) && (value === initial_value)"
|
button x-bind:disabled="(date === initial_date) && (value === initial_value)"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ use chrono::DateTime;
|
||||||
use maud::{Markup, PreEscaped, html};
|
use maud::{Markup, PreEscaped, html};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use slug::slugify;
|
|
||||||
use sqlx::{QueryBuilder, Sqlite};
|
use sqlx::{QueryBuilder, Sqlite};
|
||||||
|
|
||||||
use super::Layout;
|
use super::Layout;
|
||||||
|
|
@ -29,13 +28,6 @@ pub struct Address {
|
||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, Debug)]
|
|
||||||
pub struct Group {
|
|
||||||
pub contact_id: DbId,
|
|
||||||
pub name: String,
|
|
||||||
pub slug: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/contact/new", post(self::post::contact))
|
.route("/contact/new", post(self::post::contact))
|
||||||
|
|
@ -86,14 +78,9 @@ mod get {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let entries: Vec<JournalEntry> = sqlx::query_as(
|
let entries: Vec<JournalEntry> = sqlx::query_as(
|
||||||
"select distinct j.id, j.value, j.date from journal_entries j
|
"select j.id, j.value, j.date from journal_entries j
|
||||||
join journal_mentions cm on j.id = cm.entry_id
|
join contact_mentions cm on j.id = cm.entry_id
|
||||||
where cm.url = '/contact/'||$1 or cm.url in (
|
where cm.contact_id = $1",
|
||||||
select '/group/'||slug from groups
|
|
||||||
where contact_id = $1
|
|
||||||
)
|
|
||||||
order by j.date desc
|
|
||||||
",
|
|
||||||
)
|
)
|
||||||
.bind(contact_id)
|
.bind(contact_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
|
|
@ -107,14 +94,6 @@ mod get {
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let groups: Vec<Group> = sqlx::query_as!(
|
|
||||||
Group,
|
|
||||||
"select * from groups where contact_id = $1",
|
|
||||||
contact_id
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let text_body: Option<String> =
|
let text_body: Option<String> =
|
||||||
sqlx::query!("select text_body from contacts where id = $1", contact_id)
|
sqlx::query!("select text_body from contacts where id = $1", contact_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|
@ -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
|
from names where contact_id = c.id
|
||||||
) as names, (
|
) as names, (
|
||||||
select jes.date from journal_entries jes
|
select jes.date from journal_entries jes
|
||||||
join journal_mentions cms on cms.entry_id = jes.id
|
join contact_mentions cms on cms.entry_id = jes.id
|
||||||
where cms.url = '/contact/'||c.id
|
where cms.contact_id = c.id
|
||||||
order by jes.date desc limit 1
|
order by jes.date desc limit 1
|
||||||
) as last_mention_date from contacts c
|
) as last_mention_date from contacts c
|
||||||
where c.id = $1",
|
where c.id = $1",
|
||||||
|
|
@ -236,17 +204,6 @@ mod get {
|
||||||
.clone()
|
.clone()
|
||||||
.map_or("".to_string(), |m| m.to_rfc3339());
|
.map_or("".to_string(), |m| m.to_rfc3339());
|
||||||
|
|
||||||
let groups: Vec<String> = sqlx::query_as!(
|
|
||||||
Group,
|
|
||||||
"select * from groups where contact_id = $1",
|
|
||||||
contact_id
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.map(|group| group.name)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let text_body: String =
|
let text_body: String =
|
||||||
sqlx::query!("select text_body from contacts where id = $1", contact_id)
|
sqlx::query!("select text_body from contacts where id = $1", contact_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|
@ -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 = ''";
|
input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''";
|
||||||
}
|
}
|
||||||
label { "groups" }
|
|
||||||
#groups x-data=(json!({ "groups": groups, "new_group": "" })) {
|
|
||||||
template x-for="(group, index) in groups" x-bind:key="index" {
|
|
||||||
input name="group" x-model="group" placeholder="group name";
|
|
||||||
}
|
|
||||||
input name="group" x-model="new_group" placeholder="group name";
|
|
||||||
input type="button" value="Add" x-on:click="groups.push(new_group); new_group = ''";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
div #text_body {
|
div #text_body {
|
||||||
div { "Free text (supports markdown)" }
|
div { "Free text (supports markdown)" }
|
||||||
|
|
@ -363,14 +312,13 @@ mod put {
|
||||||
manually_freshened_at: String,
|
manually_freshened_at: String,
|
||||||
address_label: Option<Vec<String>>,
|
address_label: Option<Vec<String>>,
|
||||||
address_value: Option<Vec<String>>,
|
address_value: Option<Vec<String>>,
|
||||||
group: Option<Vec<String>>,
|
|
||||||
text_body: String,
|
text_body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn contact(
|
pub async fn contact(
|
||||||
auth_session: AuthSession,
|
auth_session: AuthSession,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(contact_id): Path<DbId>,
|
Path(contact_id): Path<u32>,
|
||||||
Form(payload): Form<PutContact>,
|
Form(payload): Form<PutContact>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let user = auth_session.user.unwrap();
|
let user = auth_session.user.unwrap();
|
||||||
|
|
@ -409,183 +357,100 @@ mod put {
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// these blocks are not in functions because payload gets progressively
|
|
||||||
// partially moved as we handle each field and i don't want to deal with it
|
|
||||||
|
|
||||||
{
|
{
|
||||||
// update addresses
|
// update addresses
|
||||||
let new_addresses = payload.address_value.clone().map(|values| {
|
sqlx::query!("delete from addresses where contact_id = $1", contact_id)
|
||||||
let labels: Vec<String> = if values.len() == 1 {
|
.execute(pool)
|
||||||
vec![String::new()]
|
.await?;
|
||||||
|
|
||||||
|
if let Some(values) = payload.address_value {
|
||||||
|
let labels = if values.len() == 1 {
|
||||||
|
Some(vec![String::new()])
|
||||||
} else {
|
} else {
|
||||||
payload.address_label.clone().unwrap_or(vec![])
|
payload.address_label
|
||||||
};
|
};
|
||||||
|
if let Some(labels) = labels {
|
||||||
labels
|
let new_addresses = labels
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.zip(values)
|
.zip(values)
|
||||||
.filter(|(_, val)| val.len() > 0)
|
.filter(|(_, val)| val.len() > 0);
|
||||||
.collect::<Vec<(String, String)>>()
|
for (label, value) in new_addresses {
|
||||||
});
|
sqlx::query!(
|
||||||
let new_addresses = new_addresses.unwrap_or(vec![]);
|
"insert into addresses (contact_id, label, value) values ($1, $2, $3)",
|
||||||
|
contact_id,
|
||||||
let old_addresses: Vec<(String, String)> =
|
label,
|
||||||
sqlx::query_as("select label, value from addresses where contact_id = $1")
|
value
|
||||||
.bind(contact_id)
|
)
|
||||||
.fetch_all(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
let old_names: Vec<(String,)> = sqlx::query_as(
|
||||||
// recalculate all contact mentions and name trie if name-list changed
|
"delete from contact_mentions;
|
||||||
let new_names: Vec<String> = payload
|
delete from names where contact_id = $1 returning name;",
|
||||||
.name
|
)
|
||||||
.unwrap_or(vec![])
|
.bind(contact_id)
|
||||||
.into_iter()
|
.fetch_all(pool)
|
||||||
.filter(|n| n.len() > 0)
|
.await?;
|
||||||
.collect();
|
|
||||||
let old_names: Vec<(String,)> =
|
|
||||||
sqlx::query_as("select name from names where contact_id = $1")
|
|
||||||
.bind(contact_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
|
|
||||||
|
|
||||||
if old_names != new_names {
|
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
|
||||||
// delete and regen *all* journal mentions, not just the ones for the
|
"select name, contact_id from (
|
||||||
// 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 (
|
|
||||||
select name, contact_id, count(name) as ct from names where name in (",
|
select name, contact_id, count(name) as ct from names where name in (",
|
||||||
);
|
);
|
||||||
let mut name_list = recalc_counts.separated(", ");
|
let mut name_list = recalc_counts.separated(", ");
|
||||||
for name in &old_names {
|
for (name,) in &old_names {
|
||||||
name_list.push_bind(name);
|
name_list.push_bind(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(names) = payload.name {
|
||||||
|
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() {
|
let mut name_insert: QueryBuilder<Sqlite> =
|
||||||
for name in &new_names {
|
QueryBuilder::new("insert into names (contact_id, sort, name) ");
|
||||||
name_list.push_bind(name.clone());
|
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> =
|
name_list.push_unseparated(") group by name) where ct = 1");
|
||||||
QueryBuilder::new("insert into names (contact_id, sort, name) ");
|
let recalc_names: Vec<(String, DbId)> = recalc_counts
|
||||||
name_insert.push_values(
|
.build_query_as()
|
||||||
new_names.iter().enumerate(),
|
.persistent(false)
|
||||||
|mut builder, (sort, name)| {
|
.fetch_all(pool)
|
||||||
builder
|
.await?;
|
||||||
.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
|
let trie_mutex = state.contact_search(&user);
|
||||||
.build_query_as()
|
let mut trie = trie_mutex.write().unwrap();
|
||||||
.persistent(false)
|
for name in &old_names {
|
||||||
.fetch_all(pool)
|
trie.remove(&name.0);
|
||||||
.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 new_groups: Vec<String> = payload
|
for name in recalc_names {
|
||||||
.group
|
trie.insert(name.0, name.1);
|
||||||
.unwrap_or(vec![])
|
}
|
||||||
.into_iter()
|
}
|
||||||
.filter(|n| n.len() > 0)
|
|
||||||
.collect();
|
|
||||||
let old_groups: Vec<(String,)> =
|
|
||||||
sqlx::query_as("select name from groups where contact_id = $1")
|
|
||||||
.bind(contact_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect();
|
|
||||||
|
|
||||||
if new_groups != old_groups {
|
let journal_entries: Vec<JournalEntry> = sqlx::query_as("select * from journal_entries")
|
||||||
sqlx::query!(
|
.fetch_all(pool)
|
||||||
"delete from journal_mentions; delete from groups where contact_id = $1",
|
.await?;
|
||||||
contact_id
|
|
||||||
)
|
for entry in journal_entries {
|
||||||
.execute(pool)
|
entry
|
||||||
|
.insert_mentions(state.contact_search(&user), pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
QueryBuilder::new("insert into groups (contact_id, name, slug) ")
|
|
||||||
.push_values(&new_groups, |mut b, name| {
|
|
||||||
b.push_bind(contact_id)
|
|
||||||
.push_bind(name)
|
|
||||||
.push_bind(slugify(name));
|
|
||||||
})
|
|
||||||
.build()
|
|
||||||
.persistent(false)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
{
|
|
||||||
let trie_mutex = state.contact_search(&user);
|
|
||||||
let mut trie = trie_mutex.write().unwrap();
|
|
||||||
for name in &old_groups {
|
|
||||||
// TODO i think we care about group name vs contact name counts,
|
|
||||||
// otherwise this will cause a problem (or we want to disallow
|
|
||||||
// setting group names that are contact names or vice versa?)
|
|
||||||
trie.remove(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
for group in &new_groups {
|
|
||||||
trie.insert(group.clone(), format!("/group/{}", slugify(group)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if new_names != old_names || new_groups != old_groups {
|
|
||||||
let journal_entries: Vec<JournalEntry> =
|
|
||||||
sqlx::query_as("select * from journal_entries")
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for entry in journal_entries {
|
|
||||||
entry
|
|
||||||
.insert_mentions(state.contact_search(&user), pool)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
|
|
@ -604,7 +469,7 @@ mod delete {
|
||||||
let pool = &state.db(&user).pool;
|
let pool = &state.db(&user).pool;
|
||||||
|
|
||||||
sqlx::query(
|
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 names where contact_id = $1;
|
||||||
delete from contacts where 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
|
from names where contact_id = c.id
|
||||||
) as names, (
|
) as names, (
|
||||||
select jes.date from journal_entries jes
|
select jes.date from journal_entries jes
|
||||||
join journal_mentions cms on cms.entry_id = jes.id
|
join contact_mentions cms on cms.entry_id = jes.id
|
||||||
where cms.url = '/contact/'||c.id
|
where cms.contact_id = c.id
|
||||||
order by jes.date desc limit 1
|
order by jes.date desc limit 1
|
||||||
) as last_mention_date from contacts c",
|
) as last_mention_date from contacts c",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -98,26 +98,26 @@ mod patch {
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let new_entry: JournalEntry = sqlx::query_as(
|
sqlx::query!(
|
||||||
"update journal_entries set date = $1, value = $2 where id = $3 returning *",
|
"update journal_entries set date = $1, value = $2 where id = $3",
|
||||||
|
payload.date,
|
||||||
|
payload.value,
|
||||||
|
entry_id
|
||||||
)
|
)
|
||||||
.bind(&payload.date)
|
.execute(pool)
|
||||||
.bind(&payload.value)
|
|
||||||
.bind(entry_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if entry.value != new_entry.value {
|
if entry.value != payload.value {
|
||||||
sqlx::query!("delete from journal_mentions where entry_id = $1", entry_id)
|
sqlx::query!("delete from contact_mentions where entry_id = $1", entry_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
new_entry
|
entry
|
||||||
.insert_mentions(state.contact_search(&user), pool)
|
.insert_mentions(state.contact_search(&user), pool)
|
||||||
.await?;
|
.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;
|
let pool = &state.db(&user).pool;
|
||||||
|
|
||||||
sqlx::query(
|
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",
|
delete from journal_entries where id = $2 returning id,date,value",
|
||||||
)
|
)
|
||||||
.bind(entry_id)
|
.bind(entry_id)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ use super::{AppError, AppState};
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod contact;
|
pub mod contact;
|
||||||
pub mod group;
|
|
||||||
pub mod home;
|
pub mod home;
|
||||||
pub mod ics;
|
pub mod ics;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,6 @@ main {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#groups {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: min-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
#text_body {
|
#text_body {
|
||||||
margin-top: 1em;
|
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