Compare commits
No commits in common. "main" and "wip--tests" have entirely different histories.
main
...
wip--tests
35 changed files with 365 additions and 1150 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,7 +3,7 @@ e2e/node_modules
|
|||
e2e/playwright-report
|
||||
e2e/test-results
|
||||
/some_user.db
|
||||
/dbs/*
|
||||
/dbs
|
||||
/hashed_static
|
||||
/users.db
|
||||
/.sqlx
|
||||
|
|
|
|||
42
CHANGELOG.md
42
CHANGELOG.md
|
|
@ -1,42 +0,0 @@
|
|||
## [0.2.0] - 2026-04-08
|
||||
|
||||
### Features
|
||||
|
||||
- Scroll to current contact in sidebar by default
|
||||
- Clicking off sidebar on small windows closes it
|
||||
- Sort contacts sidebar ignoring case
|
||||
- Report version information
|
||||
|
||||
### Refactor
|
||||
|
||||
- Cloak input elements while alpine.js loads
|
||||
|
||||
### Performance
|
||||
|
||||
- *(test)* Test performance improvements
|
||||
- Large cache time on immutable hashed statics
|
||||
|
||||
### Testing
|
||||
|
||||
- Deflake by waiting for Save response
|
||||
- Open sidebar on mobile
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Prepare for tagged releases
|
||||
|
||||
## [0.1.0] - 2026-04-05
|
||||
|
||||
# Features
|
||||
* In-app contacts
|
||||
* For each contact:
|
||||
* Names
|
||||
* Birthday
|
||||
* Last-contact-time mapping
|
||||
* Address as single field (plus code? lat/long? go crazy!)
|
||||
* Free-text-entry field
|
||||
* Desired contact periodicity
|
||||
* Journal with Obsidian-like `[[link]]` syntax
|
||||
* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner")
|
||||
* ical server for birthday reminders
|
||||
* Mark contacts as inactive or prevent stale checks
|
||||
249
Cargo.lock
generated
249
Cargo.lock
generated
|
|
@ -400,39 +400,6 @@ dependencies = [
|
|||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "camino"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo-platform"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo_metadata"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"cargo-platform",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.29"
|
||||
|
|
@ -615,41 +582,6 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "debug-helper"
|
||||
version = "0.3.13"
|
||||
|
|
@ -677,37 +609,6 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||
dependencies = [
|
||||
"derive_builder_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_core"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_macro"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deunicode"
|
||||
version = "1.6.2"
|
||||
|
|
@ -1288,12 +1189,6 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.4.0"
|
||||
|
|
@ -1385,47 +1280,6 @@ version = "1.0.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb-platform"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||
dependencies = [
|
||||
"jiff-tzdb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.81"
|
||||
|
|
@ -1560,7 +1414,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "mascarpone"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -1573,7 +1427,6 @@ dependencies = [
|
|||
"http",
|
||||
"icalendar",
|
||||
"itertools 0.14.0",
|
||||
"jiff",
|
||||
"listenfd",
|
||||
"markdown",
|
||||
"maud",
|
||||
|
|
@ -1589,14 +1442,12 @@ dependencies = [
|
|||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
"tower-sessions-sqlx-store",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"vcard",
|
||||
"vergen-gitcl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1756,9 +1607,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
|
|
@ -1790,15 +1641,6 @@ dependencies = [
|
|||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.37.3"
|
||||
|
|
@ -2005,21 +1847,6 @@ version = "0.3.32"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.3"
|
||||
|
|
@ -2329,16 +2156,6 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
|
|
@ -2874,32 +2691,30 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
version = "0.3.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"serde",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
|
|
@ -2986,9 +2801,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
|
|
@ -3322,46 +3137,6 @@ version = "0.2.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vergen"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_metadata",
|
||||
"derive_builder",
|
||||
"regex",
|
||||
"rustversion",
|
||||
"time",
|
||||
"vergen-lib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vergen-gitcl"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
"time",
|
||||
"vergen",
|
||||
"vergen-lib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vergen-lib"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "mascarpone"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[profile.release]
|
||||
|
|
@ -19,7 +19,6 @@ clap = { version = "4.5.53", features = ["derive"] }
|
|||
http = "1.3.1"
|
||||
icalendar = "0.17.5"
|
||||
itertools = "0.14.0"
|
||||
jiff = { version = "0.2.23", features = ["serde"] }
|
||||
listenfd = "1.0.2"
|
||||
markdown = "1.0.0"
|
||||
maud = { version = "0.27.0", features = ["axum"] }
|
||||
|
|
@ -35,8 +34,7 @@ sqlx = { version = "0.8", features = ["macros", "runtim
|
|||
thiserror = "2.0.17"
|
||||
time = "0.3.44"
|
||||
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] }
|
||||
tower = "0.5.3"
|
||||
tower-http = { version = "0.6.6", features = ["fs", "set-header", "trace"] }
|
||||
tower-http = { version = "0.6.6", features = ["fs", "trace"] }
|
||||
tower-sessions = { version = "0.14.0", features = ["signed"] }
|
||||
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
|
||||
tracing = { version = "0.1.41", features = ["attributes"] }
|
||||
|
|
@ -45,4 +43,3 @@ vcard = "0.4.13"
|
|||
|
||||
[build-dependencies]
|
||||
cache_bust = "0.2.0"
|
||||
vergen-gitcl = { version = "9.1.0", features = ["build", "cargo"] }
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -11,56 +11,17 @@ I think of when I see "CRM".
|
|||
* Last-contact-time mapping
|
||||
* Address as single field (plus code? lat/long? go crazy!)
|
||||
* Free-text-entry field
|
||||
* Desired contact periodicity
|
||||
* Journal with Obsidian-like `[[link]]` syntax
|
||||
* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner")
|
||||
* ical server for birthday reminders
|
||||
|
||||
## Explore
|
||||
|
||||
My instance is at https://crm.rperce.net. Username "demo" and password "demo" let
|
||||
you log into an ephemeral demo user if you want to poke around.
|
||||
|
||||
If you want an account, contact me directly or use the "self-hosting" instructions below.
|
||||
|
||||
## Planned features
|
||||
* Report birthdays and manage add'l fields for contacts stored on a remote CardDAV server
|
||||
* Act as CardDAV server for other clients
|
||||
* For each contact:
|
||||
* Arbitrary add'l yearly dates (e.g. anniversaries) that show on calendar
|
||||
* Relationship mapping
|
||||
* Desired contact periodicity
|
||||
* Additional arbitrary fields (no special handling)
|
||||
* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner")
|
||||
* "Named in journal but has no contact entry" detection
|
||||
* Email birthday reminders over SMTP
|
||||
|
||||
---
|
||||
|
||||
## Development / self-hosting
|
||||
|
||||
1. Clone the repo.
|
||||
2. Build for your system with `./Taskfile _cargo build --release`.
|
||||
3. Deploy the binary from `./target/release/mascarpone` to wherever you want that's in PATH
|
||||
(or use it from here if you want)
|
||||
4. In the working directory that you want the server to save its databases in,
|
||||
1. Create a user for yourself with `mascarpone set-password YOUR_USERNAME`. This will create a `users.db` file.
|
||||
2. Run `mkdir dbs`.
|
||||
3. Copy the `hashed_static` directory from the code repository.
|
||||
5. Run `mascarpone serve [port]` from that working directory. The default port is 3000.
|
||||
If you need to be able to bind to a host other than `0.0.0.0`, contact me directly.
|
||||
|
||||
### Example systemd service file
|
||||
```
|
||||
[Unit]
|
||||
Description=Mascarpone CRM
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/var/local/mascarpone/
|
||||
ExecStart=/usr/bin/mascarpone serve
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
|
|
|||
16
Taskfile
16
Taskfile
|
|
@ -1,11 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
playwright:local() {
|
||||
bash e2e/Taskfile playwright:local "$@"
|
||||
bash e2e/Taskfile playwright:local
|
||||
}
|
||||
|
||||
playwright:ui() {
|
||||
bash e2e/Taskfile playwright:ui "$@"
|
||||
bash e2e/Taskfile playwright:ui
|
||||
}
|
||||
|
||||
refresh_sqlx_db() {
|
||||
|
|
@ -44,16 +44,4 @@ dev() {
|
|||
find src static migrations | entr -ccdr ./Taskfile _cargo run -- serve
|
||||
}
|
||||
|
||||
release() {
|
||||
set -euo pipefail
|
||||
bash e2e/Taskfile playwright:ci
|
||||
_cargo test
|
||||
new_tag=$(git-cliff --unreleased --bumped-version)
|
||||
git tag -m "$new_tag" "$new_tag"
|
||||
cargo set-version "${new_tag#v}"
|
||||
mv CHANGELOG.md CHANGELOG.old
|
||||
cat <(git cliff) <(printf "\n") CHANGELOG.old > CHANGELOG.md
|
||||
rm CHANGELOG.old
|
||||
}
|
||||
|
||||
"$@"
|
||||
|
|
|
|||
11
build.rs
11
build.rs
|
|
@ -1,5 +1,4 @@
|
|||
use cache_bust::CacheBust;
|
||||
use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
|
|
@ -10,14 +9,4 @@ fn main() {
|
|||
.build();
|
||||
|
||||
cache_bust.hash_dir().expect("Cache busting failed");
|
||||
|
||||
let build = BuildBuilder::all_build().expect("build information failed");
|
||||
let gitcl = GitclBuilder::all_git().expect("gitcl information failed");
|
||||
Emitter::default()
|
||||
.add_instructions(&build)
|
||||
.unwrap()
|
||||
.add_instructions(&gitcl)
|
||||
.unwrap()
|
||||
.emit()
|
||||
.unwrap();
|
||||
}
|
||||
|
|
|
|||
94
cliff.toml
94
cliff.toml
|
|
@ -1,94 +0,0 @@
|
|||
# git-cliff ~ configuration file
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
|
||||
[changelog]
|
||||
# A Tera template to be rendered for each release in the changelog.
|
||||
# See https://keats.github.io/tera/docs/#introduction
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
|
||||
{% if commit.breaking %}[**breaking**] {% endif %}\
|
||||
{{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
"""
|
||||
# Remove leading and trailing whitespaces from the changelog's body.
|
||||
trim = true
|
||||
# Render body even when there are no releases to process.
|
||||
render_always = true
|
||||
# An array of regex based postprocessors to modify the changelog.
|
||||
postprocessors = [
|
||||
# Replace the placeholder <REPO> with a URL.
|
||||
#{ pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" },
|
||||
]
|
||||
# render body even when there are no releases to process
|
||||
# render_always = true
|
||||
# output file path
|
||||
# output = "test.md"
|
||||
|
||||
[git]
|
||||
# Parse commits according to the conventional commits specification.
|
||||
# See https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# Exclude commits that do not match the conventional commits specification.
|
||||
filter_unconventional = true
|
||||
# Require all commits to be conventional.
|
||||
# Takes precedence over filter_unconventional.
|
||||
require_conventional = false
|
||||
# Split commits on newlines, treating each line as an individual commit.
|
||||
split_commits = false
|
||||
# An array of regex based parsers to modify commit messages prior to further processing.
|
||||
commit_preprocessors = [
|
||||
# Replace issue numbers with link templates to be updated in `changelog.postprocessors`.
|
||||
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
|
||||
# Check spelling of the commit message using https://github.com/crate-ci/typos.
|
||||
# If the spelling is incorrect, it will be fixed automatically.
|
||||
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
|
||||
]
|
||||
# Prevent commits that are breaking from being excluded by commit parsers.
|
||||
protect_breaking_commits = false
|
||||
# An array of regex based parsers for extracting data from the commit message.
|
||||
# Assigns commits to groups.
|
||||
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "<!-- 0 -->Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^doc", group = "<!-- 3 -->Documentation" },
|
||||
{ message = "^perf", group = "<!-- 4 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
|
||||
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||
{ message = "^chore\\(pr\\)", skip = true },
|
||||
{ message = "^chore\\(pull\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
|
||||
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
|
||||
{ message = ".*", group = "<!-- 10 -->💼 Other" },
|
||||
]
|
||||
# Exclude commits that are not matched by any commit parser.
|
||||
filter_commits = false
|
||||
# Fail on a commit that is not matched by any commit parser.
|
||||
fail_on_unmatched_commit = false
|
||||
# An array of link parsers for extracting external references, and turning them into URLs, using regex.
|
||||
link_parsers = []
|
||||
# Include only the tags that belong to the current branch.
|
||||
use_branch_tags = false
|
||||
# Order releases topologically instead of chronologically.
|
||||
topo_order = false
|
||||
# Order commits topologically instead of chronologically.
|
||||
topo_order_commits = true
|
||||
# Order of commits in each group/release within the changelog.
|
||||
# Allowed values: newest, oldest
|
||||
sort_commits = "oldest"
|
||||
# Process submodules commits
|
||||
recurse_submodules = false
|
||||
10
e2e/Taskfile
10
e2e/Taskfile
|
|
@ -10,12 +10,20 @@ playwright:local() {
|
|||
exec docker run \
|
||||
--interactive --tty --rm --ipc=host --net=host \
|
||||
--volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \
|
||||
--env ASTRO_TELEMETRY_DISABLED=1 \
|
||||
"mcr.microsoft.com/playwright:$(_playwright_version)" \
|
||||
bash -c "cd /e2e && env PROJECT_FILTER=firefox ./Taskfile _test $*"
|
||||
}
|
||||
|
||||
playwright:ui() {
|
||||
playwright:local --ui-host=0.0.0.0
|
||||
xhost +local:docker
|
||||
exec docker run \
|
||||
--interactive --tty --rm --ipc=host --net=host\
|
||||
--env DISPLAY="$DISPLAY" \
|
||||
--volume /tmp/.X11-unix:/tmp/.X11-unix \
|
||||
--volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \
|
||||
"mcr.microsoft.com/playwright:$(_playwright_version)" \
|
||||
/bin/bash -c "cd /e2e && ./Taskfile _test --ui $*"
|
||||
}
|
||||
|
||||
playwright:ci() {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import { login, verifyCreateUser, todate } from './util';
|
|||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await verifyCreateUser(page, { names: ['Test Testerson'] });
|
||||
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
|
||||
});
|
||||
|
||||
test('manual-freshen date is editable', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /edit/i }).click();
|
||||
await expect(page.locator('input[name="manually_freshened_on"]')).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: /freshened/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('last-contact date on display resolves journal mentions and manual-freshen', async ({ page }) => {
|
||||
|
|
@ -18,84 +19,44 @@ test('last-contact date on display resolves journal mentions and manual-freshen'
|
|||
const entryBox = page.getByPlaceholder(/new entry/i);
|
||||
await entryDate.fill("2025-05-05");
|
||||
await entryBox.fill("[[Test Testerson]]");
|
||||
|
||||
let load = page.waitForResponse('/journal_entry');
|
||||
await page.getByRole('button', { name: /add entry/i }).click();
|
||||
await load;
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('#fields')).toContainText("freshened2025-05-05");
|
||||
});
|
||||
|
||||
test.skip("groups wrap nicely", async ({ page }) => {
|
||||
await page.getByRole('link', { name: /edit/i }).click();
|
||||
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
|
||||
|
||||
const groupBox = page.getByPlaceholder(/group name/i);
|
||||
await groupBox.fill('this is a long group name');
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
|
||||
|
||||
// TODO: this drives to the right location but i can't figure out how to assert
|
||||
// that the text is all on one line. Manual inspection looks good at time of writing.
|
||||
});
|
||||
|
||||
test('allow marking as inactive', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /edit/i }).click();
|
||||
test('allow marking as hidden', async ({ page }) => {
|
||||
|
||||
await page.getByLabel('status').selectOption('Inactive');
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
|
||||
await expect(page.locator('#contacts-sidebar').getByText("Test Testerson")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('allow exempting from stale', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#freshness')).toContainText('Test Testersonnever');
|
||||
await page.locator('#freshness').getByRole('link', { name: /testerson/i }).click();
|
||||
await page.getByRole('link', { name: /edit/i }).click();
|
||||
|
||||
await page.getByLabel('status').selectOption('Cannot go stale');
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
await page.waitForURL(/contact\/\d+$/);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#freshness')).not.toContainText('Test Testersonnever');
|
||||
});
|
||||
|
||||
test('stale list considers periodicity', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /edit/i }).click();
|
||||
const last_week = (() => {
|
||||
let last_week = new Date();
|
||||
last_week.setDate(last_week.getDate() - 7);
|
||||
return last_week.toISOString().split("T")[0];
|
||||
})();
|
||||
await page.getByLabel('freshened').fill(last_week);
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
await expect(page.locator('#journal')).toBeVisible();
|
||||
await expect(page.locator('#fields')).toContainText(`freshened${last_week}`);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#freshness')).toContainText(`Test Testerson${last_week}7d`);
|
||||
await page.locator('#freshness').getByRole('link', { name: /testerson/i }).click();
|
||||
await page.getByRole('link', { name: /edit/i }).click();
|
||||
|
||||
await page.getByLabel('minimum stale time').fill('2 weeks');
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
await expect(page.locator('#journal')).toBeVisible();
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#freshness')).not.toContainText(`Test Testerson`);
|
||||
});
|
||||
|
||||
test('page title has contact primary name', async ({ page }) => {
|
||||
// wait for page load to finish
|
||||
await expect(page.locator('#journal')).toBeVisible();
|
||||
expect(await page.title()).toContain("Test Testerson");
|
||||
});
|
||||
|
||||
|
||||
/*
|
||||
test('bullet points in free text display well', async ({ page }) => {
|
||||
|
||||
});
|
||||
|
||||
twst('page title has contact primary name', async ({ page }) => {
|
||||
await expect(page.title()).toContain("Test Testerson");
|
||||
});
|
||||
|
||||
/*
|
||||
home: contact list scrolls in screen, not off screen
|
||||
home: clicking off contact list closes it
|
||||
home: contact list is sorted ignoring case
|
||||
home: contact list should scroll to current contact in center of view
|
||||
journal: bullet points don't display
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -17,9 +17,6 @@ test('has no contacts', async ({ page }) => {
|
|||
test('can add contacts', async ({ page }) => {
|
||||
await verifyCreateUser(page, { names: ['John Contact'] });
|
||||
await verifyCreateUser(page, { names: ['Jack Contact'] });
|
||||
if (await page.locator('#sidebar-show-hide').isVisible()) {
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
}
|
||||
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
|
||||
});
|
||||
|
||||
|
|
@ -44,92 +41,5 @@ test('sidebar is sorted alphabetically', async ({ page }) => {
|
|||
await verifyCreateUser(page, { names: ['Alfa'] });
|
||||
await verifyCreateUser(page, { names: ['Golf'] });
|
||||
|
||||
if (await page.locator('#sidebar-show-hide').isVisible()) {
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
}
|
||||
|
||||
await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/);
|
||||
});
|
||||
|
||||
test('upcoming and recent show at least one birthday a week away', async ({ page }) => {
|
||||
const monthday = d => d.toISOString().split("T")[0].replace(/^\d{4}-/, '');
|
||||
const today = monthday(new Date());
|
||||
const yesterday = monthday((() => {
|
||||
let date = new Date();
|
||||
date.setDate(date.getDate() - 1);
|
||||
return date;
|
||||
})());
|
||||
const tomorrow = monthday((() => {
|
||||
let date = new Date();
|
||||
date.setDate(date.getDate() + 1);
|
||||
return date;
|
||||
})());
|
||||
const aMonthAgo = monthday((() => {
|
||||
let date = new Date();
|
||||
date.setDate(date.getDate() - 28);
|
||||
return date;
|
||||
})());
|
||||
const inAMonth = monthday((() => {
|
||||
let date = new Date();
|
||||
date.setDate(date.getDate() + 28);
|
||||
return date;
|
||||
})());
|
||||
await verifyCreateUser(page, { names: ['Alfa'], birthday: today });
|
||||
await verifyCreateUser(page, { names: ['Beta'], birthday: yesterday });
|
||||
await verifyCreateUser(page, { names: ['Echo'], birthday: today });
|
||||
await verifyCreateUser(page, { names: ['Golf'], birthday: yesterday });
|
||||
await verifyCreateUser(page, { names: ['Lima'], birthday: tomorrow });
|
||||
await verifyCreateUser(page, { names: ['Mike'], birthday: yesterday });
|
||||
await verifyCreateUser(page, { names: ['Xray'], birthday: inAMonth });
|
||||
await verifyCreateUser(page, { names: ['Zulu'], birthday: aMonthAgo });
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('#upcoming').getByRole('link')).toHaveCount(4);
|
||||
await expect(page.locator('#recent').getByRole('link')).toHaveCount(4);
|
||||
});
|
||||
|
||||
|
||||
test('contact list scrolls (independently) to current contact in center of view', async ({ page }) => {
|
||||
for (let count = 0; count < 30; count++) {
|
||||
await verifyCreateUser(page, { names: [`Contact${count < 10 ? '0' + count : count}`] });
|
||||
}
|
||||
|
||||
await page.goto('/contact/28');
|
||||
if (await page.locator('#sidebar-show-hide').isVisible()) {
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
}
|
||||
await expect(page.getByRole('navigation').getByRole('link', { name: /Contact28/ })).toBeVisible();
|
||||
expect(await page.locator('main').evaluate(e => e.scrollTop)).toEqual(0);
|
||||
|
||||
await page.goto('/contact/16');
|
||||
if (await page.locator('#sidebar-show-hide').isVisible()) {
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
}
|
||||
await expect(page.locator('#nav-link-16')).toBeVisible();
|
||||
const linkPos: number = await page.locator('#nav-link-16').evaluate(e => e.getBoundingClientRect().y);
|
||||
|
||||
// roughly centered is fine, not that fussy about headers and whatnot
|
||||
expect(await page.getByRole('navigation').evaluate(e => e.scrollTop)).not.toEqual(0);
|
||||
expect(Math.abs(linkPos - (await page.evaluate('window.innerHeight/2') as number))).toBeLessThan(300);
|
||||
});
|
||||
|
||||
test('clicking off contact list when expanded closes it', async ({ page }) => {
|
||||
await page.setViewportSize({
|
||||
width: 640,
|
||||
height: 1000,
|
||||
});
|
||||
// TODO aria-label
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
await expect(page.getByRole('button', { name: /add contact/i })).toBeVisible();
|
||||
await page.mouse.click(600, 500);
|
||||
await expect(page.getByRole('button', { name: /add contact/i })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('contact list is sorted ignoring case', async ({ page }) => {
|
||||
await verifyCreateUser(page, { names: ['Alfa'] });
|
||||
await verifyCreateUser(page, { names: ['bob'] });
|
||||
await verifyCreateUser(page, { names: ['Charlie'] });
|
||||
|
||||
await expect(page.locator('#contacts-sidebar')).toContainText(/alfa\s*bob\s*charlie/i);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ test('can add journal entries', async ({ page }) => {
|
|||
|
||||
const entryBox = page.getByPlaceholder(/new entry/i);
|
||||
await entryBox.fill('banana banana banana');
|
||||
let load = page.waitForResponse('/journal_entry');
|
||||
await page.getByRole('button', { name: /add entry/i }).click();
|
||||
await load;
|
||||
|
||||
await expect(entryBox).toBeEmpty();
|
||||
await expect(page.getByText('banana banana banana')).toBeVisible();
|
||||
|
|
@ -20,9 +18,7 @@ test('journal entries autolink', async ({ page }) => {
|
|||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||
|
||||
await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]');
|
||||
let load = page.waitForResponse('/journal_entry');
|
||||
await page.getByRole('button', { name: /add entry/i }).click();
|
||||
await load;
|
||||
|
||||
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible();
|
||||
});
|
||||
|
|
@ -33,9 +29,7 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
|
|||
await verifyCreateUser(page, { names: ['Jack Contact'] });
|
||||
|
||||
await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]');
|
||||
let load = page.waitForResponse('/journal_entry');
|
||||
await page.getByRole('button', { name: /add entry/i }).click();
|
||||
await load;
|
||||
|
||||
const nav = page.getByRole('navigation');
|
||||
const journal = page.locator('#journal');
|
||||
|
|
@ -43,21 +37,16 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
|
|||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
|
||||
|
||||
// add a new name
|
||||
if (await page.locator('#sidebar-show-hide').isVisible()) {
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
}
|
||||
await nav.getByRole("link", { name: 'John Contact' }).click();
|
||||
await page.getByRole('link', { name: /edit/i }).click();
|
||||
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
|
||||
await page.getByRole('button', { name: 'Add' }).nth(1).click();
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||
console.log(await journal.innerHTML());
|
||||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
|
||||
|
||||
// delete an existing name
|
||||
if (await page.locator('#sidebar-show-hide').isVisible()) {
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
}
|
||||
await nav.getByRole("link", { name: 'John Contact' }).click();
|
||||
await page.getByRole('link', { name: /edit/i }).click();
|
||||
await page.getByRole('button', { name: '×', disabled: false }).click();
|
||||
|
|
@ -66,9 +55,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
|
|||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
|
||||
|
||||
// put it back, then...
|
||||
if (await page.locator('#sidebar-show-hide').isVisible()) {
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
}
|
||||
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');
|
||||
|
|
@ -78,9 +64,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
|
|||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
|
||||
|
||||
// ...add a name that makes it no longer n=1
|
||||
if (await page.locator('#sidebar-show-hide').isVisible()) {
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
}
|
||||
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');
|
||||
|
|
@ -89,9 +72,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
|
|||
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
|
||||
|
||||
// delete a name that makes it now n=1
|
||||
if (await page.locator('#sidebar-show-hide').isVisible()) {
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
}
|
||||
await nav.getByRole("link", { name: 'John Contact' }).click();
|
||||
await page.getByRole('link', { name: /edit/i }).click();
|
||||
await page.getByRole('button', { name: '×', disabled: false }).click();
|
||||
|
|
@ -106,9 +86,7 @@ test('can edit existing journal entries on home page', async ({ page }) => {
|
|||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||
|
||||
await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana");
|
||||
let load = page.waitForResponse('/journal_entry');
|
||||
await page.getByRole('button', { name: /add entry/i }).click();
|
||||
await load;
|
||||
|
||||
await page.reload();
|
||||
|
||||
|
|
@ -131,9 +109,7 @@ test('can have multiple links', async ({ page }) => {
|
|||
await page.getByRole('link', { name: 'Mascarpone' }).click();
|
||||
|
||||
await page.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids');
|
||||
let load = page.waitForResponse('/journal_entry');
|
||||
await page.getByRole('button', { name: /add entry/i }).click();
|
||||
await load;
|
||||
|
||||
const journal = page.locator('#journal');
|
||||
await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export const login = async (page: Page) => {
|
||||
await page.goto('/login');
|
||||
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.waitForURL('/');
|
||||
};
|
||||
|
||||
export const todate = () => new Date().toISOString().split('T')[0];
|
||||
|
|
@ -16,11 +14,10 @@ type UserFields = {
|
|||
birthday?: string,
|
||||
};
|
||||
export const verifyCreateUser = async (page: Page, fields: UserFields) => {
|
||||
if (await page.locator('#sidebar-show-hide').isVisible()) {
|
||||
await page.locator('#sidebar-show-hide').click();
|
||||
}
|
||||
await page.getByRole('button', { name: /add contact/i }).click();
|
||||
await page.waitForURL(/contact\/\d+\/edit$/);
|
||||
|
||||
// TODO this is stupid but playwright kept filling while alpine was initializing
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const { names, ...simple } = fields;
|
||||
for (const name of (names ?? [])) {
|
||||
|
|
@ -33,6 +30,5 @@ export const verifyCreateUser = async (page: Page, fields: UserFields) => {
|
|||
}
|
||||
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
await page.waitForURL(/contact\/\d+$/);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
import './custom-expects';
|
||||
import 'custom-expects';
|
||||
|
||||
// purposefully not using ??: we want to replace empty empty string with default
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
insert into contacts(id, birthday, manually_freshened_at) values
|
||||
(0, '04-15', '2000-01-01T12:00:00');
|
||||
(0, '--0415', '2000-01-01T12:00:00');
|
||||
insert into names(contact_id, sort, name) values
|
||||
(0, 0, 'Alex Aaronson'),
|
||||
(0, 1, 'Alexi'),
|
||||
|
|
@ -7,8 +7,8 @@ insert into names(contact_id, sort, name) values
|
|||
insert into groups(contact_id, name, slug) values
|
||||
(0, 'ABC', 'abc');
|
||||
|
||||
insert into contacts(id, birthday, active) values
|
||||
(1, 'April?', false);
|
||||
insert into contacts(id, birthday) values
|
||||
(1, 'April?');
|
||||
insert into names(contact_id, sort, name) values
|
||||
(1, 0, 'Bazel Bagend'),
|
||||
(1, 1, 'Bazel');
|
||||
|
|
@ -16,7 +16,7 @@ insert into groups(contact_id, name, slug) values
|
|||
(1, 'ABC', 'abc');
|
||||
|
||||
insert into contacts(id, birthday) values
|
||||
(2, '1995-10-18');
|
||||
(2, '19951018');
|
||||
insert into names(contact_id, sort, name) values
|
||||
(2, 0, 'Charlie Certaindate');
|
||||
insert into groups(contact_id, name, slug) values
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
alter table contacts add column
|
||||
can_stale boolean not null default true;
|
||||
|
||||
alter table contacts add column
|
||||
periodicity text not null default 'P0D';
|
||||
|
||||
alter table contacts add column
|
||||
active boolean not null default true;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
update contacts
|
||||
set birthday = substr(birthday, 3, 2) || '-' || substr(birthday, 5, 2)
|
||||
where birthday GLOB '--[01][0-9][0-3][0-9]';
|
||||
|
||||
update contacts
|
||||
set birthday = substr(birthday, 1, 4) || '-' || substr(birthday, 5, 2) || '-' || substr(birthday, 7, 2)
|
||||
where birthday GLOB '[0-9][0-9][0-9][0-9][01][0-9][0-3][0-9]';
|
||||
|
|
@ -2,4 +2,3 @@
|
|||
"rust-analyzer" = "latest"
|
||||
"jj" = "latest"
|
||||
node = "24"
|
||||
git-cliff = "latest"
|
||||
|
|
|
|||
51
src/main.rs
51
src/main.rs
|
|
@ -12,9 +12,7 @@ use std::sync::{Arc, RwLock};
|
|||
use tokio::net::TcpListener;
|
||||
use tokio::signal;
|
||||
use tokio::task::AbortHandle;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
use tower_http::services::{ServeDir,ServeFile};
|
||||
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
|
||||
use tower_sessions_sqlx_store::SqliteStore;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
|
@ -126,8 +124,6 @@ enum Commands {
|
|||
ephemeral: bool,
|
||||
},
|
||||
|
||||
/// print version information
|
||||
Version,
|
||||
}
|
||||
|
||||
async fn serve(port: &u32) -> Result<(), anyhow::Error> {
|
||||
|
|
@ -184,19 +180,8 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
|
|||
.route_layer(login_required!(Backend, login_url = "/login"))
|
||||
.merge(auth::router())
|
||||
.merge(ics::router())
|
||||
.nest_service(
|
||||
"/static",
|
||||
ServiceBuilder::new()
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
http::header::CACHE_CONTROL,
|
||||
http::header::HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
))
|
||||
.service(ServeDir::new("./hashed_static")),
|
||||
)
|
||||
.nest_service(
|
||||
"/favicon.ico",
|
||||
ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))),
|
||||
)
|
||||
.nest_service("/static", ServeDir::new("./hashed_static"))
|
||||
.nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))))
|
||||
.layer(auth_layer)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
|
@ -246,10 +231,7 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||
println!("No update was made; probably something went wrong.");
|
||||
}
|
||||
}
|
||||
Some(Commands::SetEphemeral {
|
||||
username,
|
||||
ephemeral,
|
||||
}) => {
|
||||
Some(Commands::SetEphemeral { username, ephemeral }) => {
|
||||
let users_db = {
|
||||
let db_options = SqliteConnectOptions::from_str("users.db")?
|
||||
.create_if_missing(true)
|
||||
|
|
@ -260,20 +242,19 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||
db
|
||||
};
|
||||
|
||||
let eph: Option<bool> =
|
||||
sqlx::query_scalar("select ephemeral from users where username = ?")
|
||||
let eph: Option<bool> = sqlx::query_scalar(
|
||||
"select ephemeral from users where username = ?"
|
||||
)
|
||||
.bind(&username)
|
||||
.fetch_optional(&users_db)
|
||||
.await?;
|
||||
if let Some(eph) = eph {
|
||||
if eph == *ephemeral {
|
||||
println!(
|
||||
"User {} is already {}.",
|
||||
username,
|
||||
if eph { "ephemeral" } else { "not ephemeral" }
|
||||
);
|
||||
println!("User {} is already {}.", username, if eph { "ephemeral" } else { "not ephemeral" });
|
||||
} else {
|
||||
let update = sqlx::query("update users set ephemeral=$1 where username = $2")
|
||||
let update = sqlx::query(
|
||||
"update users set ephemeral=$1 where username = $2",
|
||||
)
|
||||
.bind(ephemeral)
|
||||
.bind(&username)
|
||||
.execute(&users_db)
|
||||
|
|
@ -286,19 +267,13 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"User {} does not exist. Create them first with set-password.",
|
||||
username
|
||||
);
|
||||
println!("User {} does not exist. Create them first with set-password.", username);
|
||||
}
|
||||
|
||||
}
|
||||
Some(Commands::Serve { port }) => {
|
||||
serve(port).await?;
|
||||
}
|
||||
Some(Commands::Version) => {
|
||||
println!("mascarpone v{}", env!("CARGO_PKG_VERSION"));
|
||||
println!("from git commit {}", env!("VERGEN_GIT_SHA"));
|
||||
}
|
||||
None => {
|
||||
serve(&3000).await?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone};
|
||||
use chrono::Local;
|
||||
use sqlx::sqlite::SqliteRow;
|
||||
use sqlx::{FromRow, Row};
|
||||
use std::fmt::Display;
|
||||
|
|
@ -20,40 +20,42 @@ pub enum Birthday {
|
|||
|
||||
impl Display for Birthday {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
Birthday::Date(date) => write!(f, "{}", date),
|
||||
Birthday::Text(t) => write!(f, "{}", t.value),
|
||||
}
|
||||
let str = match self {
|
||||
Birthday::Date(date) => date.to_string(),
|
||||
Birthday::Text(t) => t.value.clone(),
|
||||
};
|
||||
write!(f, "{}", str)
|
||||
}
|
||||
}
|
||||
|
||||
impl Birthday {
|
||||
pub fn next_occurrence(&self) -> Option<civil::Date> {
|
||||
pub fn next_occurrence(&self) -> Option<chrono::NaiveDate> {
|
||||
match &self {
|
||||
Birthday::Text(_) => None,
|
||||
Birthday::Date(date) => date.next_month_day_occurrence(),
|
||||
Birthday::Date(date) => Some(date.next_month_day_occurrence()?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn until_next(&self) -> Option<jiff::Span> {
|
||||
pub fn until_next(&self) -> Option<chrono::TimeDelta> {
|
||||
self.next_occurrence()
|
||||
.map(|when| when.since(Zoned::now().date()).ok())?
|
||||
.map(|when| when.signed_duration_since(Local::now().date_naive()))
|
||||
}
|
||||
|
||||
/// None if this is a text birthday or doesn't have a year
|
||||
pub fn age(&self) -> Option<i32> {
|
||||
pub fn age(&self) -> Option<u32> {
|
||||
match &self {
|
||||
Birthday::Text(_) => None,
|
||||
Birthday::Date(date) => {
|
||||
let now = Timestamp::now().to_zoned(TimeZone::UTC);
|
||||
date.to_civil_date().map(|birthdate| {
|
||||
now.since(&birthdate.to_zoned(TimeZone::UTC).unwrap())
|
||||
.unwrap()
|
||||
.total((Unit::Year, &now))
|
||||
.unwrap() as i32
|
||||
})
|
||||
Birthday::Date(date) => date
|
||||
.to_date_naive()
|
||||
.map(|birthdate| Local::now().date_naive().years_since(birthdate))?,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> String {
|
||||
match &self {
|
||||
Birthday::Text(text) => text.value.clone(),
|
||||
Birthday::Date(date) => date.serialize(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use jiff::{Span, Timestamp, civil::Date};
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
use std::str::FromStr;
|
||||
|
||||
|
|
@ -12,20 +12,14 @@ struct RawContact {
|
|||
birthday: Option<String>,
|
||||
manually_freshened_at: Option<String>,
|
||||
lives_with: String,
|
||||
can_stale: bool,
|
||||
active: bool,
|
||||
periodicity: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Contact {
|
||||
pub id: DbId,
|
||||
pub birthday: Option<Birthday>,
|
||||
pub manually_freshened_at: Option<Timestamp>,
|
||||
pub manually_freshened_at: Option<DateTime<Utc>>,
|
||||
pub lives_with: String,
|
||||
pub can_stale: bool,
|
||||
pub active: bool,
|
||||
pub periodicity: Span,
|
||||
}
|
||||
|
||||
impl Into<Contact> for RawContact {
|
||||
|
|
@ -37,11 +31,9 @@ impl Into<Contact> for RawContact {
|
|||
.and_then(|s| Birthday::from_str(s.as_ref()).ok()),
|
||||
manually_freshened_at: self
|
||||
.manually_freshened_at
|
||||
.and_then(|str| str.parse::<Timestamp>().ok()),
|
||||
.and_then(|str| DateTime::parse_from_str(str.as_ref(), "%+").ok())
|
||||
.map(|d| d.to_utc()),
|
||||
lives_with: self.lives_with,
|
||||
can_stale: self.can_stale,
|
||||
active: self.active,
|
||||
periodicity: self.periodicity.parse().unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -51,10 +43,6 @@ struct RawHydratedContact {
|
|||
birthday: Option<String>,
|
||||
manually_freshened_at: Option<String>,
|
||||
lives_with: String,
|
||||
can_stale: bool,
|
||||
active: bool,
|
||||
periodicity: String,
|
||||
|
||||
last_mention_date: Option<String>,
|
||||
names: Option<String>,
|
||||
}
|
||||
|
|
@ -62,7 +50,7 @@ struct RawHydratedContact {
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct HydratedContact {
|
||||
pub contact: Contact,
|
||||
pub last_mention_date: Option<Date>,
|
||||
pub last_mention_date: Option<NaiveDate>,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
|
|
@ -74,9 +62,6 @@ impl Into<HydratedContact> for RawHydratedContact {
|
|||
birthday: self.birthday,
|
||||
manually_freshened_at: self.manually_freshened_at,
|
||||
lives_with: self.lives_with,
|
||||
can_stale: self.can_stale,
|
||||
active: self.active,
|
||||
periodicity: self.periodicity,
|
||||
}),
|
||||
names: self
|
||||
.names
|
||||
|
|
@ -86,7 +71,7 @@ impl Into<HydratedContact> for RawHydratedContact {
|
|||
.collect::<Vec<String>>(),
|
||||
last_mention_date: self
|
||||
.last_mention_date
|
||||
.and_then(|str| str.parse::<Date>().ok()),
|
||||
.and_then(|str| NaiveDate::from_str(str.as_ref()).ok()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,27 +92,11 @@ impl HydratedContact {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> &'static str {
|
||||
if self.can_stale {
|
||||
if self.active { "normal" } else { "inactive" }
|
||||
} else {
|
||||
"permanent"
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load(id: DbId, pool: &SqlitePool) -> Result<Self, AppError> {
|
||||
// copy-paste the query from 'all', then add "where c.id = $2" to the last line
|
||||
let raw = sqlx::query_as!(
|
||||
RawHydratedContact,
|
||||
r#"select
|
||||
id,
|
||||
birthday,
|
||||
lives_with,
|
||||
manually_freshened_at as "manually_freshened_at: String",
|
||||
can_stale,
|
||||
active,
|
||||
periodicity,
|
||||
(
|
||||
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
|
||||
select string_agg(name,x'1c' order by sort)
|
||||
from names where contact_id = c.id
|
||||
) as names, (
|
||||
|
|
@ -154,15 +123,7 @@ impl HydratedContact {
|
|||
pub async fn all(pool: &SqlitePool) -> Result<Vec<Self>, AppError> {
|
||||
let contacts = sqlx::query_as!(
|
||||
RawHydratedContact,
|
||||
r#"select
|
||||
id,
|
||||
birthday,
|
||||
lives_with,
|
||||
manually_freshened_at as "manually_freshened_at: String",
|
||||
can_stale,
|
||||
active,
|
||||
periodicity,
|
||||
(
|
||||
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
|
||||
select string_agg(name,x'1c' order by sort)
|
||||
from names where contact_id = c.id
|
||||
) as names, (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use jiff::civil::Date;
|
||||
use chrono::NaiveDate;
|
||||
use maud::{Markup, html};
|
||||
use serde_json::json;
|
||||
use sqlx::sqlite::{SqlitePool, SqliteRow};
|
||||
|
|
@ -12,7 +12,7 @@ use crate::switchboard::{MentionHost, MentionHostType};
|
|||
pub struct JournalEntry {
|
||||
pub id: DbId,
|
||||
pub value: String,
|
||||
pub date: Date,
|
||||
pub date: NaiveDate,
|
||||
}
|
||||
|
||||
impl<'a> Into<MentionHost<'a>> for &'a JournalEntry {
|
||||
|
|
@ -69,7 +69,7 @@ impl FromRow<'_, SqliteRow> for JournalEntry {
|
|||
let id: DbId = row.try_get("id")?;
|
||||
let value: String = row.try_get("value")?;
|
||||
let date_str: &str = row.try_get("date")?;
|
||||
let date: Date = date_str.parse().unwrap();
|
||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap();
|
||||
Ok(Self { id, value, date })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use jiff::{Timestamp, civil::Date, tz::TimeZone};
|
||||
use chrono::{Datelike, Local, NaiveDate};
|
||||
use regex::Regex;
|
||||
use sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull};
|
||||
use std::fmt::Display;
|
||||
|
|
@ -6,43 +6,51 @@ use std::str::FromStr;
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct YearOptionalDate {
|
||||
pub year: Option<i16>,
|
||||
pub month: i8,
|
||||
pub day: i8,
|
||||
pub year: Option<i32>,
|
||||
pub month: u32,
|
||||
pub day: u32,
|
||||
}
|
||||
|
||||
impl YearOptionalDate {
|
||||
pub fn prev_month_day_occurrence(&self) -> Option<Date> {
|
||||
let now = Timestamp::now().to_zoned(TimeZone::UTC);
|
||||
pub fn prev_month_day_occurrence(&self) -> Option<NaiveDate> {
|
||||
let now = Local::now();
|
||||
let year = now.year();
|
||||
Date::new(year, self.month, self.day).ok().and_then(|date| {
|
||||
if date >= now.date() {
|
||||
Date::new(year - 1, self.month, self.day).ok()
|
||||
} else {
|
||||
Some(date)
|
||||
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
|
||||
if let Some(real_date) = date {
|
||||
if real_date >= now.date_naive() {
|
||||
date = NaiveDate::from_ymd_opt(year - 1, self.month, self.day);
|
||||
}
|
||||
})
|
||||
}
|
||||
date
|
||||
}
|
||||
pub fn next_month_day_occurrence(&self) -> Option<NaiveDate> {
|
||||
let now = Local::now();
|
||||
let year = now.year();
|
||||
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
|
||||
if let Some(real_date) = date {
|
||||
if real_date < now.date_naive() {
|
||||
date = NaiveDate::from_ymd_opt(year + 1, self.month, self.day);
|
||||
}
|
||||
}
|
||||
date
|
||||
}
|
||||
|
||||
pub fn next_month_day_occurrence(&self) -> Option<Date> {
|
||||
let now = Timestamp::now().to_zoned(TimeZone::UTC);
|
||||
let year = now.year();
|
||||
Date::new(year, self.month, self.day).ok().and_then(|date| {
|
||||
if date < now.date() {
|
||||
Date::new(year + 1, self.month, self.day).ok()
|
||||
} else {
|
||||
Some(date)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_civil_date(&self) -> Option<Date> {
|
||||
pub fn to_date_naive(&self) -> Option<NaiveDate> {
|
||||
if let Some(year) = self.year {
|
||||
Date::new(year, self.month, self.day).ok()
|
||||
NaiveDate::from_ymd_opt(year, self.month, self.day)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"{}{:0>2}{:0>2}",
|
||||
self.year.map_or("--".to_string(), |y| format!("{:0>4}", y)),
|
||||
self.month,
|
||||
self.day
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for YearOptionalDate {
|
||||
|
|
@ -57,18 +65,21 @@ impl Display for YearOptionalDate {
|
|||
impl FromStr for YearOptionalDate {
|
||||
type Err = anyhow::Error;
|
||||
fn from_str(str: &str) -> Result<Self, Self::Err> {
|
||||
let date_re = Regex::new(r"^(?:([0-9]{4})-)?([0-9]{2})-([0-9]{2})$").unwrap();
|
||||
let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap();
|
||||
if let Some(caps) = date_re.captures(str) {
|
||||
let year = caps
|
||||
.get(1)
|
||||
.map(|yyyy| i16::from_str(yyyy.as_str()).unwrap());
|
||||
let month = i8::from_str(&caps[2]).unwrap();
|
||||
let day = i8::from_str(&caps[3]).unwrap();
|
||||
let year_str = &caps[1];
|
||||
let month = u32::from_str(&caps[2]).unwrap();
|
||||
let day = u32::from_str(&caps[3]).unwrap();
|
||||
let year = if year_str == "--" {
|
||||
None
|
||||
} else {
|
||||
Some(i32::from_str(year_str).unwrap())
|
||||
};
|
||||
|
||||
return Ok(Self { year, month, day });
|
||||
}
|
||||
Err(anyhow::Error::msg(format!(
|
||||
"parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}-)?[0-9]{{2}}-[0-9]{{2}}/",
|
||||
"parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}|--)[0-9]{{4}}/",
|
||||
str
|
||||
)))
|
||||
}
|
||||
|
|
@ -98,6 +109,6 @@ where
|
|||
&self,
|
||||
buf: &mut <Sqlite as Database>::ArgumentBuffer<'r>,
|
||||
) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
|
||||
<String as Encode<'r, Sqlite>>::encode(self.to_string(), buf)
|
||||
<String as Encode<'r, Sqlite>>::encode(self.serialize(), buf)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ impl MentionHost<'_> {
|
|||
}
|
||||
|
||||
impl Switchboard {
|
||||
pub async fn gen_trie(pool: &SqlitePool) -> Result<radix_trie::Trie<String, String>, AppError> {
|
||||
pub async fn gen_trie(pool: &SqlitePool) -> Result<radix_trie::Trie<String,String>, AppError> {
|
||||
let mut trie = radix_trie::Trie::new();
|
||||
|
||||
let mentionables = sqlx::query_as!(
|
||||
|
|
@ -109,6 +109,14 @@ impl Switchboard {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn remove(self: &mut Self, text: &String) {
|
||||
self.trie.remove(text);
|
||||
}
|
||||
|
||||
pub fn add_mentionable(self: &mut Self, text: String, uri: String) {
|
||||
self.trie.insert(text, uri);
|
||||
}
|
||||
|
||||
pub fn extract_mentions<'a>(&self, host: impl Into<MentionHost<'a>>) -> HashSet<Mention> {
|
||||
let host: MentionHost = host.into();
|
||||
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();
|
||||
|
|
|
|||
|
|
@ -81,28 +81,22 @@ mod get {
|
|||
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
|
||||
title { "Mascarpone CRM" }
|
||||
meta name="viewport" content="width=device-width";
|
||||
@if cfg!(debug_assertions) {
|
||||
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
|
||||
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
|
||||
script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
|
||||
} @else {
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
|
||||
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
|
||||
}
|
||||
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
|
||||
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css")));
|
||||
title { "Mascarpone" }
|
||||
}
|
||||
body hx-ext="response-targets" {
|
||||
h1 { "Mascarpone" }
|
||||
form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '', htmx: false }" "x-on:htmx:load.document"="htmx = true" {
|
||||
form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '' }" {
|
||||
label for="username" { "Username" }
|
||||
input name="username" #username autofocus x-model="user" x-cloak;
|
||||
input name="username" #username autofocus x-model="user";
|
||||
label for="password" { "Password" }
|
||||
input name="password" #password type="password" x-model="pass" x-cloak;
|
||||
input name="password" #password type="password" x-model="pass";
|
||||
|
||||
input type="submit" value="login" x-bind:disabled="!(user.length && pass.length && htmx)" hx-disabled-elt;
|
||||
input type="submit" value="login" x-bind:disabled="!(user.length && pass.length)";
|
||||
#error {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,19 +7,19 @@ use axum::{
|
|||
};
|
||||
use axum_extra::extract::Form;
|
||||
use cache_bust::asset;
|
||||
use jiff::{Timestamp, Unit, tz::TimeZone};
|
||||
use chrono::DateTime;
|
||||
use maud::{Markup, html};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use slug::slugify;
|
||||
use sqlx::QueryBuilder;
|
||||
use sqlx::{QueryBuilder, Sqlite};
|
||||
|
||||
use super::Layout;
|
||||
use super::home::journal_section;
|
||||
use crate::db::DbId;
|
||||
use crate::models::user::AuthSession;
|
||||
use crate::models::{HydratedContact, JournalEntry};
|
||||
use crate::switchboard::{MentionHost, MentionHostType, Switchboard, insert_mentions};
|
||||
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions, Switchboard};
|
||||
use crate::{AppError, AppState};
|
||||
|
||||
pub mod fields;
|
||||
|
|
@ -40,41 +40,27 @@ pub fn router() -> Router<AppState> {
|
|||
.route("/contact/{contact_id}/edit", get(self::get::contact_edit))
|
||||
}
|
||||
|
||||
fn human_delta(span: &jiff::Span) -> String {
|
||||
let todate = Timestamp::now().to_zoned(TimeZone::UTC).date();
|
||||
let span = span
|
||||
.round(
|
||||
jiff::SpanRound::new()
|
||||
.largest(Unit::Year)
|
||||
.smallest(Unit::Day)
|
||||
.relative(todate),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if span.is_zero() {
|
||||
"today".to_string()
|
||||
} else {
|
||||
format!("in {:#}", span)
|
||||
fn human_delta(delta: &chrono::TimeDelta) -> String {
|
||||
if delta.num_days() == 0 {
|
||||
return "today".to_string();
|
||||
}
|
||||
|
||||
let mut result = "in ".to_string();
|
||||
let mut rem = delta.clone();
|
||||
if rem.num_days().abs() >= 7 {
|
||||
let weeks = rem.num_days() / 7;
|
||||
rem -= chrono::TimeDelta::days(weeks * 7);
|
||||
result.push_str(&format!("{}w ", weeks));
|
||||
}
|
||||
if rem.num_days().abs() > 0 {
|
||||
result.push_str(&format!("{}d ", rem.num_days()));
|
||||
}
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
mod get {
|
||||
use super::*;
|
||||
|
||||
fn scroll_to(id: DbId) -> String {
|
||||
format!(
|
||||
"\
|
||||
const top=document\
|
||||
.getElementById('nav-link-{}')\
|
||||
?.getBoundingClientRect()\
|
||||
?.top;\
|
||||
top&&document\
|
||||
.getElementById('contacts-sidebar')\
|
||||
.scrollTo({{top:top-window.innerHeight/2,left:0,behavior:'instant'}});",
|
||||
id
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn contact(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -102,9 +88,7 @@ mod get {
|
|||
.await?;
|
||||
|
||||
let freshened = std::cmp::max(
|
||||
contact
|
||||
.manually_freshened_at
|
||||
.map(|when| when.to_zoned(jiff::tz::TimeZone::UTC).date()),
|
||||
contact.manually_freshened_at.map(|when| when.date_naive()),
|
||||
entries.get(0).map(|entry| entry.date),
|
||||
);
|
||||
|
||||
|
|
@ -139,21 +123,13 @@ mod get {
|
|||
html! {
|
||||
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
|
||||
|
||||
div #fields x-init=(scroll_to(contact_id)) {
|
||||
div id="fields" {
|
||||
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
|
||||
div {
|
||||
@for name in &contact.names {
|
||||
div { (name) }
|
||||
}
|
||||
}
|
||||
@if contact.status() != "normal" {
|
||||
label { "status" }
|
||||
div { (contact.status()) }
|
||||
}
|
||||
@if contact.status() == "normal" && contact.periodicity.is_positive() {
|
||||
label { "periodicity" }
|
||||
div { (format!("{:#}", contact.periodicity)) }
|
||||
}
|
||||
@if let Some(bday) = &contact.birthday {
|
||||
label { "birthday" }
|
||||
div {
|
||||
|
|
@ -234,16 +210,10 @@ mod get {
|
|||
.await?;
|
||||
|
||||
let cid_url = format!("/contact/{}", contact.id);
|
||||
let mfresh_on_str = contact
|
||||
let mfresh_str = contact
|
||||
.manually_freshened_at
|
||||
.clone()
|
||||
.map_or("".to_string(), |m| {
|
||||
m.to_zoned(TimeZone::UTC).date().to_string()
|
||||
});
|
||||
let mfresh_at_str = contact
|
||||
.manually_freshened_at
|
||||
.clone()
|
||||
.map_or("".to_string(), |m| m.to_zoned(TimeZone::UTC).to_string());
|
||||
.map_or("".to_string(), |m| m.to_rfc3339());
|
||||
|
||||
let text_body: String =
|
||||
sqlx::query!("select text_body from contacts where id = $1", contact_id)
|
||||
|
|
@ -263,7 +233,7 @@ mod get {
|
|||
div #error;
|
||||
}
|
||||
|
||||
#fields x-data=(json!({ "status": contact.status() })) x-init=(scroll_to(contact_id)) x-cloak {
|
||||
div #fields {
|
||||
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
|
||||
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
|
||||
template x-for="(name, idx) in names" {
|
||||
|
|
@ -279,40 +249,16 @@ mod get {
|
|||
input type="button" value="Add" x-on:click="names.push(new_name); new_name = ''";
|
||||
}
|
||||
}
|
||||
label for="status" { "status" }
|
||||
label { "birthday" }
|
||||
div {
|
||||
select #status name="status" x-model=("status") {
|
||||
option value="normal" { "Normal" }
|
||||
option value="permanent" { "Cannot go stale" }
|
||||
option value="inactive" { "Inactive" }
|
||||
input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize()));
|
||||
span .hint { code { "(yyyy|--)mmdd" } " or free text" }
|
||||
}
|
||||
}
|
||||
label x-show="status === 'normal'" for="periodicity" { "minimum stale time" }
|
||||
div x-show="status === 'normal'"{
|
||||
input name="periodicity" id="periodicity" value=(format!("{:#}", contact.periodicity));
|
||||
span .hint { code { "[0-9]+ (yr|mo|wk|day|h|m|s)" } "(" a href="https://docs.rs/jiff/latest/jiff/struct.Span.html#parsing-and-printing" { "details" } ")" }
|
||||
}
|
||||
label for="birthday" { "birthday" }
|
||||
div {
|
||||
input name="birthday" id="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| format!("{b}")));
|
||||
span .hint { code { "(yyyy-)?mm-dd" } " or free text" }
|
||||
}
|
||||
label for="manually_freshened_on" { "freshened" }
|
||||
div x-data=(json!({ "date": mfresh_on_str, "stamp": mfresh_at_str })) x-init="today = () => (new Date().toISOString().split('T')[0])" {
|
||||
input
|
||||
type="hidden"
|
||||
name="manually_freshened_at"
|
||||
x-model="stamp";
|
||||
input
|
||||
type="date"
|
||||
name="manually_freshened_on"
|
||||
id="manually_freshened_on"
|
||||
x-model="date"
|
||||
x-bind:max="today()"
|
||||
x-on:input="stamp = new Date(date).toISOString()";
|
||||
|
||||
input type="button" value="Mark fresh now" x-on:click="date = today(); stamp = new Date().toISOString()";
|
||||
span .hint x-text="`max ${today()}`";
|
||||
label { "freshened" }
|
||||
div x-data=(json!({ "date": mfresh_str })) {
|
||||
input type="hidden" name="manually_freshened_at" x-model="date";
|
||||
span x-text="date.length ? date.split('T')[0] : '(never)'" {}
|
||||
input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()";
|
||||
}
|
||||
label { "phone" }
|
||||
#phone_numbers x-data=(json!({ "phones": phone_numbers, "new_label": "", "new_number": "" })) {
|
||||
|
|
@ -378,8 +324,6 @@ mod put {
|
|||
#[derive(Deserialize)]
|
||||
pub struct PutContact {
|
||||
name: Option<Vec<String>>,
|
||||
status: String,
|
||||
periodicity: Option<String>,
|
||||
birthday: String,
|
||||
manually_freshened_at: String,
|
||||
lives_with: String,
|
||||
|
|
@ -407,22 +351,17 @@ mod put {
|
|||
Some(payload.birthday)
|
||||
};
|
||||
|
||||
let manually_freshened_at: Option<String> = if payload.manually_freshened_at.is_empty() {
|
||||
let manually_freshened_at = if payload.manually_freshened_at.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
payload
|
||||
.manually_freshened_at
|
||||
.parse::<Timestamp>()
|
||||
DateTime::parse_from_str(&payload.manually_freshened_at, "%+")
|
||||
.map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
|
||||
.to_string(),
|
||||
.to_utc()
|
||||
.to_rfc3339(),
|
||||
)
|
||||
};
|
||||
|
||||
let active: bool = payload.status != "inactive";
|
||||
let can_stale: bool = payload.status != "permanent";
|
||||
let periodicity: String = payload.periodicity.unwrap_or("P0D".to_string());
|
||||
|
||||
let text_body = if payload.text_body.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
|
@ -435,24 +374,21 @@ mod put {
|
|||
|
||||
sqlx::query!(
|
||||
"update contacts set
|
||||
(
|
||||
birthday, manually_freshened_at, lives_with, text_body,
|
||||
active, can_stale, periodicity
|
||||
) =
|
||||
(?, ?, ?, ?, ?, ?, ?)
|
||||
where id = ?",
|
||||
(birthday, manually_freshened_at, lives_with, text_body) =
|
||||
($1, $2, $3, $4)
|
||||
where id = $5",
|
||||
birthday,
|
||||
manually_freshened_at,
|
||||
payload.lives_with,
|
||||
text_body,
|
||||
active,
|
||||
can_stale,
|
||||
periodicity,
|
||||
contact_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if old_contact.text_body != text_body {
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
|
|
@ -552,18 +488,22 @@ mod put {
|
|||
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
|
||||
|
||||
if old_names != new_names {
|
||||
sqlx::query!("delete from names where contact_id = $1", contact_id)
|
||||
sqlx::query!(
|
||||
"delete from names where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if !new_names.is_empty() {
|
||||
QueryBuilder::new("insert into names (contact_id, sort, name) ")
|
||||
.push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
|
||||
b.push_bind(contact_id)
|
||||
QueryBuilder::new(
|
||||
"insert into names (contact_id, sort, name) "
|
||||
).push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
|
||||
b
|
||||
.push_bind(contact_id)
|
||||
.push_bind(DbId::try_from(sort).unwrap())
|
||||
.push_bind(name);
|
||||
})
|
||||
.build()
|
||||
}).build()
|
||||
.persistent(false)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
|
@ -584,7 +524,10 @@ mod put {
|
|||
let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect();
|
||||
|
||||
if new_groups != old_groups {
|
||||
sqlx::query!("delete from groups where contact_id = $1", contact_id)
|
||||
sqlx::query!(
|
||||
"delete from groups where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
|
|
@ -623,6 +566,7 @@ mod put {
|
|||
.await?;
|
||||
}
|
||||
|
||||
|
||||
if regen_text_body {
|
||||
sqlx::query!(
|
||||
"delete from mentions where entity_id = $1 and entity_type = $2",
|
||||
|
|
|
|||
110
src/web/home.rs
110
src/web/home.rs
|
|
@ -1,7 +1,7 @@
|
|||
use axum::extract::State;
|
||||
use axum::response::IntoResponse;
|
||||
use cache_bust::asset;
|
||||
use jiff::{Timestamp, ToSpan, Unit, Zoned, civil, tz::TimeZone};
|
||||
use chrono::{Local, NaiveDate, TimeDelta};
|
||||
use maud::{Markup, html};
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ use crate::{AppError, AppState};
|
|||
struct ContactFreshness {
|
||||
contact_id: DbId,
|
||||
display: String,
|
||||
fresh_date: civil::Date,
|
||||
fresh_date: NaiveDate,
|
||||
fresh_str: String,
|
||||
elapsed_str: String,
|
||||
}
|
||||
|
|
@ -46,63 +46,36 @@ fn freshness_section(freshens: &Vec<ContactFreshness>) -> Result<Markup, AppErro
|
|||
struct KnownBirthdayContact {
|
||||
contact_id: i64,
|
||||
display: String,
|
||||
prev_birthday: civil::Date,
|
||||
next_birthday: civil::Date,
|
||||
prev_birthday: NaiveDate,
|
||||
next_birthday: NaiveDate,
|
||||
}
|
||||
fn birthdays_section(
|
||||
prev_birthdays: &Vec<KnownBirthdayContact>,
|
||||
upcoming_birthdays: &Vec<KnownBirthdayContact>,
|
||||
) -> Result<Markup, AppError> {
|
||||
let now = Timestamp::now().to_zoned(TimeZone::UTC);
|
||||
let in_a_week = upcoming_birthdays
|
||||
.iter()
|
||||
.position(|b| {
|
||||
now.until(&b.next_birthday.to_zoned(TimeZone::UTC).unwrap())
|
||||
.unwrap()
|
||||
.compare((&1_i32.week(), &now))
|
||||
.unwrap()
|
||||
!= std::cmp::Ordering::Less
|
||||
})
|
||||
.unwrap_or(upcoming_birthdays.len());
|
||||
let upcoming = &upcoming_birthdays
|
||||
[0..std::cmp::min(std::cmp::max(3, in_a_week + 1), upcoming_birthdays.len())];
|
||||
|
||||
let a_week_ago = prev_birthdays
|
||||
.iter()
|
||||
.position(|b| {
|
||||
now.since(&b.prev_birthday.to_zoned(TimeZone::UTC).unwrap())
|
||||
.unwrap()
|
||||
.compare((&1_i32.week(), &now))
|
||||
.unwrap()
|
||||
!= std::cmp::Ordering::Less
|
||||
})
|
||||
.unwrap_or(upcoming_birthdays.len());
|
||||
let recent =
|
||||
&prev_birthdays[0..std::cmp::min(std::cmp::max(3, a_week_ago + 1), prev_birthdays.len())];
|
||||
|
||||
Ok(html! {
|
||||
div id="birthdays" {
|
||||
h2 { "Birthdays" }
|
||||
#birthday-sections {
|
||||
.datelist #upcoming {
|
||||
.datelist {
|
||||
h3 { "upcoming" }
|
||||
@for contact in upcoming {
|
||||
@for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] {
|
||||
a href=(format!("/contact/{}", contact.contact_id)) {
|
||||
(contact.display)
|
||||
}
|
||||
span {
|
||||
(contact.next_birthday.strftime("%m-%d"))
|
||||
(contact.next_birthday.format("%m-%d"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.datelist #recent {
|
||||
.datelist {
|
||||
h3 { "recent" }
|
||||
@for contact in recent {
|
||||
@for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] {
|
||||
a href=(format!("/contact/{}", contact.contact_id)) {
|
||||
(contact.display)
|
||||
}
|
||||
span {
|
||||
(contact.prev_birthday.strftime("%m-%d"))
|
||||
(contact.prev_birthday.format("%m-%d"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -129,8 +102,8 @@ pub async fn journal_section(
|
|||
are now, or leave everything blank to default to 'today'. Entries will be
|
||||
added to the top of the list regardless of date; refresh the page to re-sort."
|
||||
}
|
||||
form hx-post="/journal_entry" hx-target="next .entries" hx-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" x-cloak {
|
||||
input name="date" placeholder=(Zoned::now().date().to_string());
|
||||
form hx-post="/journal_entry" hx-target="next .entries" hx-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" {
|
||||
input name="date" placeholder=(Local::now().date_naive().to_string());
|
||||
textarea name="value" placeholder="New entry..." autofocus {}
|
||||
input type="submit" value="Add Entry";
|
||||
}
|
||||
|
|
@ -161,60 +134,57 @@ pub mod get {
|
|||
let mut freshens: Vec<ContactFreshness> = contacts
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter_map(|contact| {
|
||||
if !contact.can_stale || !contact.active {
|
||||
return None;
|
||||
}
|
||||
|
||||
let zero = jiff::civil::Date::ZERO;
|
||||
.map(|contact| {
|
||||
let zero = NaiveDate::from_epoch_days(0).unwrap();
|
||||
let fresh_date = std::cmp::max(
|
||||
contact
|
||||
.manually_freshened_at
|
||||
.map(|ts| ts.to_zoned(TimeZone::UTC).date())
|
||||
.map(|x| x.date_naive())
|
||||
.unwrap_or(zero),
|
||||
contact.last_mention_date.unwrap_or(zero),
|
||||
);
|
||||
if fresh_date == zero {
|
||||
Some(ContactFreshness {
|
||||
ContactFreshness {
|
||||
contact_id: contact.id,
|
||||
display: contact.display_name(),
|
||||
fresh_date,
|
||||
fresh_str: "never".to_string(),
|
||||
elapsed_str: "".to_string(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
let utc = TimeZone::UTC;
|
||||
let todate = Timestamp::now().to_zoned(utc.clone()).date();
|
||||
let elapsed = todate
|
||||
.since(&fresh_date.to_zoned(utc).unwrap())
|
||||
.unwrap()
|
||||
.round(
|
||||
jiff::SpanRound::new()
|
||||
.largest(Unit::Year)
|
||||
.smallest(Unit::Day)
|
||||
.relative(todate),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if let Some(cmp) = elapsed.compare((contact.periodicity, todate)).ok() {
|
||||
if cmp == std::cmp::Ordering::Less {
|
||||
return None;
|
||||
let mut duration = Local::now().date_naive().signed_duration_since(fresh_date);
|
||||
let mut elapsed: Vec<String> = Vec::new();
|
||||
let y = duration.num_weeks() / 52;
|
||||
let count = |n: i64, noun: &str| {
|
||||
format!("{} {}{}", n, noun, if n > 1 { "s" } else { "" })
|
||||
};
|
||||
if y > 0 {
|
||||
elapsed.push(count(y, "year"));
|
||||
duration -= TimeDelta::weeks(y * 52);
|
||||
}
|
||||
let w = duration.num_weeks();
|
||||
if w > 0 {
|
||||
elapsed.push(count(w, "week"));
|
||||
duration -= TimeDelta::weeks(w);
|
||||
}
|
||||
let d = duration.num_days();
|
||||
if d > 0 {
|
||||
elapsed.push(count(d, "day"));
|
||||
}
|
||||
|
||||
let elapsed_str = if elapsed.is_zero() {
|
||||
let elapsed_str = if elapsed.is_empty() {
|
||||
"today".to_string()
|
||||
} else {
|
||||
format!("{:#}", elapsed)
|
||||
elapsed.join(", ")
|
||||
};
|
||||
|
||||
Some(ContactFreshness {
|
||||
ContactFreshness {
|
||||
contact_id: contact.id,
|
||||
display: contact.display_name(),
|
||||
fresh_date,
|
||||
fresh_str: fresh_date.to_string(),
|
||||
elapsed_str,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -227,8 +197,8 @@ pub mod get {
|
|||
Some(KnownBirthdayContact {
|
||||
contact_id: contact.id,
|
||||
display: contact.display_name(),
|
||||
prev_birthday: date.prev_month_day_occurrence()?,
|
||||
next_birthday: date.next_month_day_occurrence()?,
|
||||
prev_birthday: date.prev_month_day_occurrence().unwrap(),
|
||||
next_birthday: date.next_month_day_occurrence().unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
|
|
|||
|
|
@ -61,9 +61,9 @@ mod get {
|
|||
for contact in &contacts {
|
||||
if let Some(Birthday::Date(yo_date)) = &contact.birthday {
|
||||
if let Some(date) = NaiveDate::from_ymd_opt(
|
||||
yo_date.year.unwrap_or(1900).into(),
|
||||
yo_date.month.try_into().unwrap(),
|
||||
yo_date.day.try_into().unwrap(),
|
||||
yo_date.year.unwrap_or(1900),
|
||||
yo_date.month,
|
||||
yo_date.day,
|
||||
) {
|
||||
calendar.push(
|
||||
Event::new()
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
routing::{delete, patch, post},
|
||||
};
|
||||
use jiff::{Zoned, civil::Date};
|
||||
use chrono::{Datelike, Local, NaiveDate};
|
||||
use maud::Markup;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::JournalEntry;
|
||||
use crate::models::user::AuthSession;
|
||||
use crate::switchboard::{MentionHostType, insert_mentions};
|
||||
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions};
|
||||
use crate::{AppError, AppState, DbId};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
|
|
@ -39,10 +39,10 @@ mod post {
|
|||
let pool = &state.db(&user).pool;
|
||||
let sw_lock = state.switchboard(&user);
|
||||
|
||||
let now = Zoned::now();
|
||||
let now = Local::now().date_naive();
|
||||
|
||||
let date = if payload.date.is_empty() {
|
||||
now.date()
|
||||
now
|
||||
} else {
|
||||
let date_re =
|
||||
Regex::new(r"^(?:(?<year>[0-9]{4})-)?(?:(?<month>[0-9]{2})-)?(?<day>[0-9]{2})$")
|
||||
|
|
@ -54,16 +54,17 @@ mod post {
|
|||
// unwrapping these parses is safe since it's matching [0-9]{2,4}
|
||||
let year = caps
|
||||
.name("year")
|
||||
.map(|m| m.as_str().parse::<i16>().unwrap())
|
||||
.map(|m| m.as_str().parse::<i32>().unwrap())
|
||||
.unwrap_or(now.year());
|
||||
let month = caps
|
||||
.name("month")
|
||||
.map(|m| m.as_str().parse::<i8>().unwrap())
|
||||
.map(|m| m.as_str().parse::<u32>().unwrap())
|
||||
.unwrap_or(now.month());
|
||||
let day = caps.name("day").unwrap().as_str().parse::<i8>().unwrap();
|
||||
let day = caps.name("day").unwrap().as_str().parse::<u32>().unwrap();
|
||||
|
||||
Date::new(year, month, day)
|
||||
.map_err(|_| anyhow::Error::msg("invalid date: failed NaiveDate construction"))?
|
||||
NaiveDate::from_ymd_opt(year, month, day).ok_or(anyhow::Error::msg(
|
||||
"invalid date: failed NaiveDate construction",
|
||||
))?
|
||||
};
|
||||
|
||||
// not a macro query, we want to use JournalEntry's custom FromRow
|
||||
|
|
@ -130,6 +131,7 @@ mod patch {
|
|||
insert_mentions(&mentions, pool).await?;
|
||||
}
|
||||
|
||||
|
||||
Ok(new_entry.to_html(pool).await?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ struct ContactLink {
|
|||
#[derive(Debug)]
|
||||
pub struct Layout {
|
||||
contact_links: Vec<ContactLink>,
|
||||
inactive_contact_links: Vec<ContactLink>,
|
||||
user: User,
|
||||
}
|
||||
|
||||
|
|
@ -49,40 +48,20 @@ impl FromRequestParts<AppState> for Layout {
|
|||
from contacts c
|
||||
left join names n on c.id = n.contact_id
|
||||
where n.sort is null or n.sort = 0
|
||||
and c.active = true
|
||||
order by name collate nocase asc",
|
||||
)
|
||||
.fetch_all(&state.db(&user).pool)
|
||||
.await?;
|
||||
|
||||
let inactive_contact_links = sqlx::query_as!(
|
||||
ContactLink,
|
||||
"select c.id as contact_id,
|
||||
coalesce(n.name, '(unnamed)') as name
|
||||
from contacts c
|
||||
left join names n on c.id = n.contact_id
|
||||
where n.sort is null or n.sort = 0
|
||||
and c.active = false
|
||||
order by name collate nocase asc",
|
||||
order by name asc",
|
||||
)
|
||||
.fetch_all(&state.db(&user).pool)
|
||||
.await?;
|
||||
|
||||
Ok(Layout {
|
||||
contact_links,
|
||||
inactive_contact_links,
|
||||
user,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn render(
|
||||
&self,
|
||||
title: impl AsRef<str>,
|
||||
css: Option<Vec<&str>>,
|
||||
content: Markup,
|
||||
) -> Markup {
|
||||
pub fn render(&self, title: impl AsRef<str>, css: Option<Vec<&str>>, content: Markup) -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html {
|
||||
|
|
@ -94,15 +73,9 @@ impl Layout {
|
|||
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
|
||||
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
|
||||
meta name="viewport" content="width=device-width";
|
||||
@if cfg!(debug_assertions) {
|
||||
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
|
||||
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
|
||||
script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
|
||||
} @else {
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
|
||||
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
|
||||
}
|
||||
@if let Some(hrefs) = css {
|
||||
@for href in hrefs {
|
||||
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
|
||||
|
|
@ -118,23 +91,10 @@ impl Layout {
|
|||
a href="/logout" { "Logout" }
|
||||
}
|
||||
section #content {
|
||||
nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" "x-on:click.self"="sidebar = !sidebar" {
|
||||
nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" {
|
||||
ul {
|
||||
li { button hx-post="/contact/new" { "+ Add Contact" } }
|
||||
@for link in &self.contact_links {
|
||||
li {
|
||||
a id=(format!("nav-link-{}", link.contact_id)) href=(format!("/contact/{}", link.contact_id)) {
|
||||
(link.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if !self.inactive_contact_links.is_empty() {
|
||||
li .inactive {
|
||||
details {
|
||||
summary { "Inactive contacts" }
|
||||
ul {
|
||||
@for link in &self.inactive_contact_links {
|
||||
li {
|
||||
a href=(format!("/contact/{}", link.contact_id)) {
|
||||
(link.name)
|
||||
|
|
@ -143,14 +103,11 @@ impl Layout {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
main {
|
||||
(content)
|
||||
}
|
||||
}
|
||||
template #alpine-loaded x-cloak {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
static/alpinejs.min.js
vendored
5
static/alpinejs.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
(function(){var f;var o="hx-target-";function g(e,r){return e.substring(0,r.length)===r}function s(e,r){if(!e||!r)return null;var t=r.toString();var s=[t,t.substring(0,2)+"*",t.substring(0,2)+"x",t.substring(0,1)+"*",t.substring(0,1)+"x",t.substring(0,1)+"**",t.substring(0,1)+"xx","*","x","***","xxx"];if(g(t,"4")||g(t,"5")){s.push("error")}for(var n=0;n<s.length;n++){var i=o+s[n];var a=f.getClosestAttributeValue(e,i);if(a){if(a==="this"){return f.findThisElement(e,i)}else{return f.querySelectorExt(e,a)}}}return null}function n(e){if(e.detail.isError){if(htmx.config.responseTargetUnsetsError){e.detail.isError=false}}else if(htmx.config.responseTargetSetsError){e.detail.isError=true}}htmx.defineExtension("response-targets",{init:function(e){f=e;if(htmx.config.responseTargetUnsetsError===undefined){htmx.config.responseTargetUnsetsError=true}if(htmx.config.responseTargetSetsError===undefined){htmx.config.responseTargetSetsError=false}if(htmx.config.responseTargetPrefersExisting===undefined){htmx.config.responseTargetPrefersExisting=false}if(htmx.config.responseTargetPrefersRetargetHeader===undefined){htmx.config.responseTargetPrefersRetargetHeader=true}},onEvent:function(e,r){if(e==="htmx:beforeSwap"&&r.detail.xhr&&r.detail.xhr.status!==200){if(r.detail.target){if(htmx.config.responseTargetPrefersExisting){r.detail.shouldSwap=true;n(r);return true}if(htmx.config.responseTargetPrefersRetargetHeader&&r.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)){r.detail.shouldSwap=true;n(r);return true}}if(!r.detail.requestConfig){return true}var t=s(r.detail.requestConfig.elt,r.detail.xhr.status);if(t){n(r);r.detail.shouldSwap=true;r.detail.target=t}return true}}})})();
|
||||
1
static/htmx.min.js
vendored
1
static/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -8,7 +8,7 @@ html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abb
|
|||
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -35,7 +35,6 @@ section#content {
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@media only screen and (max-width: 650px) {
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -45,8 +44,6 @@ section#content {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-right: 1em;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
@media only screen and (max-width: 650px) {
|
||||
position: absolute;
|
||||
float: left;
|
||||
|
|
@ -57,12 +54,11 @@ section#content {
|
|||
height: 100%;
|
||||
|
||||
&.hide {
|
||||
left: -200%;
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > ul {
|
||||
ul {
|
||||
flex: 1;
|
||||
width: fit-content;
|
||||
background-color: var(--main-bg-color);
|
||||
|
|
@ -83,17 +79,12 @@ section#content {
|
|||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
li.inactive {
|
||||
font-size: small;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
|
@ -107,5 +98,3 @@ a, a:visited {
|
|||
color: var(--link-color);
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue