Compare commits
12 Commits
a520251dab
...
fb936a64b0
| Author | SHA1 | Date | |
|---|---|---|---|
| fb936a64b0 | |||
| 2bfd0c7984 | |||
| bfaea4a4b0 | |||
| 2b702d88e4 | |||
| e89cf48b34 | |||
| aa31e1bdcd | |||
| ff39680106 | |||
| 35b82fc5b3 | |||
| c4e9076b11 | |||
| 7bd27d9b9c | |||
| 13031347cc | |||
| d13df586dd |
@@ -1,5 +1,6 @@
|
|||||||
/target
|
/target
|
||||||
/app
|
/app/node_modules
|
||||||
|
/app/build
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.git/
|
.git/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
42
.gitea/workflows/ci.yml
Normal file
42
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
name: Check / Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: clippy
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cargo test
|
||||||
80
.github/workflows/ci.yml
vendored
Normal file
80
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
name: Check / Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: clippy
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cargo test
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Build & Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: ci
|
||||||
|
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha,prefix=
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,7 @@
|
|||||||
/target
|
/target
|
||||||
.env
|
.env
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
415
Cargo.lock
generated
415
Cargo.lock
generated
@@ -17,6 +17,15 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -24,26 +33,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api-types"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
|
||||||
"axum",
|
|
||||||
"common",
|
|
||||||
"domain",
|
|
||||||
"persistence",
|
|
||||||
"rand 0.10.0",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"utoipa",
|
||||||
"thiserror 2.0.18",
|
]
|
||||||
"tokio",
|
|
||||||
"tower-http",
|
[[package]]
|
||||||
|
name = "application"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"domain",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
|
||||||
"ug-parser",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -70,6 +88,20 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"domain",
|
||||||
|
"jsonwebtoken",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"serde",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -183,6 +215,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -241,14 +282,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chrono"
|
||||||
version = "0.10.0"
|
version = "0.4.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"iana-time-zone",
|
||||||
"cpufeatures 0.3.0",
|
"js-sys",
|
||||||
"rand_core 0.10.0",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -270,23 +314,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "common"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
|
||||||
"domain",
|
|
||||||
"rand 0.10.0",
|
|
||||||
"reqwest",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -337,15 +364,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cpufeatures"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc"
|
name = "crc"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -420,6 +438,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.20"
|
version = "0.99.20"
|
||||||
@@ -458,12 +482,11 @@ dependencies = [
|
|||||||
name = "domain"
|
name = "domain"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"rand 0.10.0",
|
"chrono",
|
||||||
|
"email_address",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -509,6 +532,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email_address"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -745,7 +777,6 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"rand_core 0.10.0",
|
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
@@ -960,6 +991,30 @@ dependencies = [
|
|||||||
"windows-registry",
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -1081,6 +1136,10 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "infra-wiring"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -1169,6 +1228,21 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "9.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"js-sys",
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"simple_asn1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1350,6 +1424,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -1366,6 +1450,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@@ -1437,6 +1527,27 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -1452,23 +1563,6 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "persistence"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
|
||||||
"common",
|
|
||||||
"domain",
|
|
||||||
"rand 0.10.0",
|
|
||||||
"serde_json",
|
|
||||||
"sqlx",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -1569,6 +1663,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -1584,6 +1684,29 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "presentation"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"api-types",
|
||||||
|
"application",
|
||||||
|
"async-trait",
|
||||||
|
"auth",
|
||||||
|
"axum",
|
||||||
|
"domain",
|
||||||
|
"infra-wiring",
|
||||||
|
"serde",
|
||||||
|
"sqlite",
|
||||||
|
"tokio",
|
||||||
|
"tower-http",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"ug-parser",
|
||||||
|
"utoipa",
|
||||||
|
"utoipa-scalar",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -1701,17 +1824,6 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
|
|
||||||
dependencies = [
|
|
||||||
"chacha20",
|
|
||||||
"getrandom 0.4.2",
|
|
||||||
"rand_core 0.10.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1750,12 +1862,6 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -1774,6 +1880,18 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -2126,7 +2244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2137,7 +2255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2176,6 +2294,18 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simple_asn1"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -2226,6 +2356,18 @@ dependencies = [
|
|||||||
"der",
|
"der",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlite"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"domain",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlx"
|
name = "sqlx"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -2580,6 +2722,36 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.53"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -2801,12 +2973,10 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
|||||||
name = "ug-parser"
|
name = "ug-parser"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"domain",
|
"domain",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"scraper",
|
"scraper",
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2885,6 +3055,42 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utoipa"
|
||||||
|
version = "5.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"utoipa-gen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utoipa-gen"
|
||||||
|
version = "5.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"regex",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utoipa-scalar"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"utoipa",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.0"
|
version = "1.23.0"
|
||||||
@@ -3119,6 +3325,41 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
30
Cargo.toml
30
Cargo.toml
@@ -1,27 +1,39 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"crates/api",
|
"crates/adapters/auth",
|
||||||
"crates/common",
|
"crates/adapters/sqlite",
|
||||||
|
"crates/adapters/ug-parser",
|
||||||
|
"crates/api-types",
|
||||||
|
"crates/application",
|
||||||
"crates/domain",
|
"crates/domain",
|
||||||
"crates/infrastructure/persistence",
|
"crates/infra-wiring",
|
||||||
"crates/infrastructure/ug-parser",
|
"crates/presentation",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0.102"
|
tokio = { version = "1.51.0", features = ["full"] }
|
||||||
reqwest = "0.13.2"
|
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
anyhow = "1.0.102"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.51.0", features = ["full"] }
|
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
async-trait = "0.1.89"
|
||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||||
rand = "0.10.0"
|
rand = "0.10.0"
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] }
|
reqwest = "0.13.2"
|
||||||
async-trait = "0.1.89"
|
|
||||||
scraper = "0.23"
|
scraper = "0.23"
|
||||||
|
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] }
|
||||||
|
axum = { version = "0.8.8", features = ["macros"] }
|
||||||
|
|
||||||
|
domain = { path = "crates/domain" }
|
||||||
|
application = { path = "crates/application" }
|
||||||
|
api-types = { path = "crates/api-types" }
|
||||||
|
infra-wiring = { path = "crates/infra-wiring" }
|
||||||
|
auth = { path = "crates/adapters/auth" }
|
||||||
|
sqlite = { path = "crates/adapters/sqlite" }
|
||||||
|
ug-parser = { path = "crates/adapters/ug-parser" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -1,30 +1,36 @@
|
|||||||
FROM rust:1.92 AS builder
|
FROM node:22-slim AS frontend
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY app/package.json app/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY app/ .
|
||||||
|
ENV VITE_API_URL=/api
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM rust:1.97 AS backend
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN cargo build --release -p presentation
|
||||||
# Build the release binary
|
|
||||||
RUN cargo build --release -p api
|
|
||||||
|
|
||||||
FROM debian:trixie-slim
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install OpenSSL, CA certs
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libssl3 \
|
libssl3 \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
libsqlite3-0 \
|
libsqlite3-0 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/api .
|
COPY --from=backend /app/target/release/presentation .
|
||||||
|
COPY --from=frontend /app/frontend/build/client ./spa
|
||||||
|
|
||||||
|
|
||||||
# Create data directory for SQLite
|
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db
|
ENV DATABASE_URL=sqlite:///app/data/pocket-chords.db
|
||||||
|
ENV SPA_DIR=/app/spa
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["./api"]
|
CMD ["./presentation"]
|
||||||
|
|||||||
22
LICENSE
22
LICENSE
@@ -1 +1,21 @@
|
|||||||
MIT
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Gabriel Kaszewski
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|||||||
44
Makefile
Normal file
44
Makefile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
|
# Run the full local check suite — same order as CI would.
|
||||||
|
check: fmt-check clippy test
|
||||||
|
@echo "✅ All checks passed"
|
||||||
|
|
||||||
|
# Apply rustfmt to all files.
|
||||||
|
fmt:
|
||||||
|
cargo fmt
|
||||||
|
|
||||||
|
# Check formatting without modifying files (CI-safe).
|
||||||
|
fmt-check:
|
||||||
|
cargo fmt --check
|
||||||
|
|
||||||
|
# Run Clippy and treat warnings as errors.
|
||||||
|
clippy:
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
# Run the test suite.
|
||||||
|
test:
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Apply fmt + clippy auto-fixes in one shot.
|
||||||
|
fix:
|
||||||
|
cargo fmt
|
||||||
|
cargo clippy --fix --allow-dirty --allow-staged
|
||||||
|
|
||||||
|
# Build the frontend SPA.
|
||||||
|
build-app:
|
||||||
|
cd app && npm run build
|
||||||
|
|
||||||
|
# Run the backend (builds frontend first if needed).
|
||||||
|
dev: build-app
|
||||||
|
JWT_SECRET=dev-secret ALLOW_REGISTRATION=true cargo run -p presentation
|
||||||
|
|
||||||
|
# Run backend only (skip frontend build, assumes build-app was run).
|
||||||
|
dev-api:
|
||||||
|
JWT_SECRET=dev-secret ALLOW_REGISTRATION=true cargo run -p presentation
|
||||||
|
|
||||||
|
# Build and push Docker image to private registry.
|
||||||
|
deploy:
|
||||||
|
./deploy.sh
|
||||||
|
|
||||||
|
.PHONY: check fmt fmt-check clippy test fix build-app dev dev-api deploy
|
||||||
78
README.md
78
README.md
@@ -1,3 +1,79 @@
|
|||||||
# PocketChords
|
# PocketChords
|
||||||
|
|
||||||
A rip-off of [TabsUltimate](https://www.tabultimateguitar.com/) with a focus on mobile users, without any ads or subscription. It is open source and free to use.
|
A rip-off of [Ultimate Guitar](https://www.ultimate-guitar.com/) with a focus on mobile users, without any ads or subscription. Self-hosted, open source and free to use.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Import chord sheets from Ultimate Guitar URLs or HTML files (bulk import supported)
|
||||||
|
- Transpose chords up/down with one tap
|
||||||
|
- Piano keyboard and guitar fretboard chord diagrams
|
||||||
|
- Capo support with sounding-key display
|
||||||
|
- Adjustable font size (S/M/L) for readability while playing
|
||||||
|
- Search and sort your library
|
||||||
|
- Dark/light theme
|
||||||
|
- PWA — add to home screen for native feel
|
||||||
|
- JWT auth with registration/login
|
||||||
|
- OpenAPI docs at `/docs`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Hexagonal / ports-and-adapters with CQRS in the application layer. See `architecture.mmd` for the full diagram.
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
domain/ # entities, value objects, ports, domain services
|
||||||
|
application/ # use cases: songs/, tabs/, auth/ (commands, queries, deps)
|
||||||
|
presentation/ # axum HTTP server, routes, extractors, OpenAPI
|
||||||
|
api-types/ # request/response DTOs
|
||||||
|
infra-wiring/ # shared config (AppConfig)
|
||||||
|
adapters/
|
||||||
|
sqlite/ # SQLite persistence (songs, users, refresh sessions)
|
||||||
|
ug-parser/ # Ultimate Guitar HTML parser
|
||||||
|
auth/ # JWT + Argon2 password hashing
|
||||||
|
app/ # React Router SPA (Tailwind, shadcn/ui)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# prerequisites: rust, node
|
||||||
|
|
||||||
|
# run locally (builds frontend, starts backend on :8000)
|
||||||
|
make dev
|
||||||
|
|
||||||
|
# or backend only (if frontend already built)
|
||||||
|
make dev-api
|
||||||
|
|
||||||
|
# run checks (fmt, clippy, tests)
|
||||||
|
make check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Single Docker image serves both API and SPA:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# build and push to private registry
|
||||||
|
make deploy
|
||||||
|
|
||||||
|
# or with a specific tag
|
||||||
|
./deploy.sh --tag v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ---------------------- | ----------------------------- | ------------------------------ |
|
||||||
|
| `DATABASE_URL` | `sqlite://./pocket-chords.db` | SQLite connection string |
|
||||||
|
| `HOST` | `0.0.0.0` | Bind address |
|
||||||
|
| `PORT` | `8000` | Bind port |
|
||||||
|
| `JWT_SECRET` | _(required)_ | Secret for signing JWTs |
|
||||||
|
| `JWT_TTL_SECONDS` | `900` | Access token TTL (15 min) |
|
||||||
|
| `REFRESH_TTL_SECONDS` | `2592000` | Refresh token TTL (30 days) |
|
||||||
|
| `ALLOW_REGISTRATION` | `false` | Enable user registration |
|
||||||
|
| `CORS_ALLOWED_ORIGINS` | `*` | Comma-separated origins or `*` |
|
||||||
|
| `SPA_DIR` | `./app/build/client` | Path to SPA static files |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT (see [LICENSE](LICENSE) for details).
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
VITE_API_URL=http://localhost:8000
|
VITE_API_URL=/api
|
||||||
|
|||||||
@@ -1,87 +1,32 @@
|
|||||||
# Welcome to React Router!
|
# PocketChords — Frontend
|
||||||
|
|
||||||
A modern, production-ready template for building full-stack React applications using React Router.
|
React Router v7 SPA with Tailwind CSS and shadcn/ui. Mobile-first design for viewing chord charts while playing.
|
||||||
|
|
||||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
## Setup
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 🚀 Server-side rendering
|
|
||||||
- ⚡️ Hot Module Replacement (HMR)
|
|
||||||
- 📦 Asset bundling and optimization
|
|
||||||
- 🔄 Data loading and mutations
|
|
||||||
- 🔒 TypeScript by default
|
|
||||||
- 🎉 TailwindCSS for styling
|
|
||||||
- 📖 [React Router docs](https://reactrouter.com/)
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
Install the dependencies:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
## Development
|
||||||
|
|
||||||
Start the development server with HMR:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Your application will be available at `http://localhost:5173`.
|
Opens at `http://localhost:5173`. Set `VITE_API_URL` in `.env` to point to the backend (defaults to `/api`).
|
||||||
|
|
||||||
## Building for Production
|
## Build
|
||||||
|
|
||||||
Create a production build:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment
|
Outputs static files to `build/client/`. The Rust backend serves these in production.
|
||||||
|
|
||||||
### Docker Deployment
|
## Stack
|
||||||
|
|
||||||
To build and run using Docker:
|
- React 19 + React Router 7 (SPA mode)
|
||||||
|
- Tailwind CSS 4 + shadcn/ui
|
||||||
```bash
|
- Tonal (music theory / chord voicings)
|
||||||
docker build -t my-app .
|
- Vite
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -p 3000:3000 my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
The containerized application can be deployed to any platform that supports Docker, including:
|
|
||||||
|
|
||||||
- AWS ECS
|
|
||||||
- Google Cloud Run
|
|
||||||
- Azure Container Apps
|
|
||||||
- Digital Ocean App Platform
|
|
||||||
- Fly.io
|
|
||||||
- Railway
|
|
||||||
|
|
||||||
### DIY Deployment
|
|
||||||
|
|
||||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
|
||||||
|
|
||||||
Make sure to deploy the output of `npm run build`
|
|
||||||
|
|
||||||
```
|
|
||||||
├── package.json
|
|
||||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
|
||||||
├── build/
|
|
||||||
│ ├── client/ # Static assets
|
|
||||||
│ └── server/ # Server-side code
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Built with ❤️ using React Router.
|
|
||||||
|
|||||||
@@ -1,45 +1,104 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { NavLink } from "react-router";
|
import { NavLink } from "react-router";
|
||||||
import { Music, Sun, Moon } from "lucide-react";
|
import { LogOut, Music, Sun, Moon, User } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu";
|
||||||
|
import { useAuth } from "~/lib/auth";
|
||||||
|
import { LoginSheet } from "~/components/login-sheet";
|
||||||
|
import { RegisterSheet } from "~/components/register-sheet";
|
||||||
|
|
||||||
export function BottomNav() {
|
export function BottomNav() {
|
||||||
const { resolvedTheme, setTheme } = useTheme();
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
const [registerOpen, setRegisterOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="border-t bg-background shrink-0">
|
<>
|
||||||
<div className="max-w-lg mx-auto flex items-center">
|
<nav className="border-t bg-background shrink-0">
|
||||||
<NavLink
|
<div className="max-w-lg mx-auto flex items-center">
|
||||||
to="/"
|
<NavLink
|
||||||
end
|
to="/"
|
||||||
className={({ isActive }) =>
|
end
|
||||||
cn(
|
className={({ isActive }) =>
|
||||||
"flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
|
cn(
|
||||||
isActive
|
"flex flex-col items-center gap-0.5 flex-1 py-2 text-xs transition-colors",
|
||||||
? "text-primary"
|
isActive
|
||||||
: "text-muted-foreground hover:text-foreground"
|
? "text-primary"
|
||||||
)
|
: "text-muted-foreground hover:text-foreground",
|
||||||
}
|
)
|
||||||
>
|
}
|
||||||
<Music className="w-5 h-5" />
|
>
|
||||||
<span>Library</span>
|
<Music className="w-5 h-5" />
|
||||||
</NavLink>
|
<span>Library</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center gap-1 mr-2">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="mr-2 text-muted-foreground hover:text-foreground"
|
size="icon"
|
||||||
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
className="text-muted-foreground hover:text-foreground"
|
||||||
aria-label="Toggle theme"
|
onClick={() =>
|
||||||
>
|
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||||
{resolvedTheme === "dark" ? (
|
}
|
||||||
<Sun className="w-5 h-5" />
|
aria-label="Toggle theme"
|
||||||
) : (
|
>
|
||||||
<Moon className="w-5 h-5" />
|
{resolvedTheme === "dark" ? (
|
||||||
)}
|
<Sun className="w-5 h-5" />
|
||||||
</Button>
|
) : (
|
||||||
</div>
|
<Moon className="w-5 h-5" />
|
||||||
</nav>
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => logout()}>
|
||||||
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
Sign out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setLoginOpen(true)}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<LoginSheet
|
||||||
|
open={loginOpen}
|
||||||
|
onOpenChange={setLoginOpen}
|
||||||
|
onSwitchToRegister={() => setRegisterOpen(true)}
|
||||||
|
/>
|
||||||
|
<RegisterSheet
|
||||||
|
open={registerOpen}
|
||||||
|
onOpenChange={setRegisterOpen}
|
||||||
|
onSwitchToLogin={() => setLoginOpen(true)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
91
app/app/components/login-sheet.tsx
Normal file
91
app/app/components/login-sheet.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "~/components/ui/sheet";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { useAuth } from "~/lib/auth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSwitchToRegister: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginSheet({ open, onOpenChange, onSwitchToRegister }: Props) {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
onOpenChange(false);
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
toast.success("Logged in");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Login failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="bottom" className="rounded-t-xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Sign in</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4 pb-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="login-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="login-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="********"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-sm text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSwitchToRegister();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Don't have an account? Register
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
app/app/components/register-sheet.tsx
Normal file
106
app/app/components/register-sheet.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "~/components/ui/sheet";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { useAuth } from "~/lib/auth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSwitchToLogin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterSheet({ open, onOpenChange, onSwitchToLogin }: Props) {
|
||||||
|
const { register } = useAuth();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await register(email, username, password);
|
||||||
|
onOpenChange(false);
|
||||||
|
setEmail("");
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
toast.success("Account created");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Registration failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="bottom" className="rounded-t-xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Create account</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4 pb-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="register-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="register-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="register-username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="register-username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
placeholder="johndoe"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="register-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="register-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="min. 8 characters"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
|
{loading ? "Creating account..." : "Register"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-sm text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSwitchToLogin();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Already have an account? Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,87 @@
|
|||||||
import type { Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";
|
import type { LoginResponse, RefreshResponse, Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
|
const API_BASE = import.meta.env.VITE_API_URL ?? "/api";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "pocket_chords_token";
|
||||||
|
const REFRESH_KEY = "pocket_chords_refresh_token";
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return localStorage.getItem(REFRESH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTokens(token: string, refreshToken: string) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
localStorage.setItem(REFRESH_KEY, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTokens() {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const token = getToken();
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
const res = await fetch(url, { ...init, headers });
|
||||||
|
if (res.status === 401) clearTokens();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
|
export async function apiLogin(email: string, password: string): Promise<LoginResponse> {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((data as { error?: string }).error ?? `Login failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRegister(email: string, username: string, password: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((data as { error?: string }).error ?? `Registration failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRefresh(refreshToken: string): Promise<RefreshResponse> {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
clearTokens();
|
||||||
|
throw new Error("Session expired");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiLogout(refreshToken: string): Promise<void> {
|
||||||
|
await fetch(`${API_BASE}/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
}).catch(() => {});
|
||||||
|
clearTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Songs (read — public) ---
|
||||||
|
|
||||||
export async function listSongs(q = "", sort = "date", order = "desc"): Promise<SongSummary[]> {
|
export async function listSongs(q = "", sort = "date", order = "desc"): Promise<SongSummary[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -23,11 +104,10 @@ export async function getSong(id: string, applyCapo = false): Promise<Song | nul
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSong(body: {
|
// --- Songs (mutations — auth required) ---
|
||||||
source?: string;
|
|
||||||
html?: string;
|
export async function createSong(body: { source?: string; html?: string }): Promise<StoredSong> {
|
||||||
}): Promise<StoredSong> {
|
const res = await authFetch(`${API_BASE}/songs`, {
|
||||||
const res = await fetch(`${API_BASE}/songs`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -40,12 +120,12 @@ export async function createSong(body: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSong(id: string): Promise<void> {
|
export async function deleteSong(id: string): Promise<void> {
|
||||||
const res = await fetch(`${API_BASE}/songs/${id}`, { method: "DELETE" });
|
const res = await authFetch(`${API_BASE}/songs/${id}`, { method: "DELETE" });
|
||||||
if (!res.ok) throw new Error(`Failed to delete song: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Failed to delete song: HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSong(id: string, patch: UpdateSongRequest): Promise<SongSummary> {
|
export async function updateSong(id: string, patch: UpdateSongRequest): Promise<SongSummary> {
|
||||||
const res = await fetch(`${API_BASE}/songs/${id}`, {
|
const res = await authFetch(`${API_BASE}/songs/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
|
|||||||
76
app/app/lib/auth.tsx
Normal file
76
app/app/lib/auth.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { apiLogin, apiLogout, apiRefresh, apiRegister, clearTokens, getRefreshToken, getToken, setTokens } from "./api";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
userId: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getToken();
|
||||||
|
const refresh = getRefreshToken();
|
||||||
|
if (!token || !refresh) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiRefresh(refresh)
|
||||||
|
.then((res) => {
|
||||||
|
setTokens(res.token, res.refresh_token);
|
||||||
|
const payload = JSON.parse(atob(res.token.split(".")[1]));
|
||||||
|
setUserId(payload.sub);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
clearTokens();
|
||||||
|
setUserId(null);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const res = await apiLogin(email, password);
|
||||||
|
setTokens(res.token, res.refresh_token);
|
||||||
|
setUserId(res.user_id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const register = useCallback(async (email: string, username: string, password: string) => {
|
||||||
|
await apiRegister(email, username, password);
|
||||||
|
await login(email, password);
|
||||||
|
}, [login]);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
const refresh = getRefreshToken();
|
||||||
|
if (refresh) await apiLogout(refresh);
|
||||||
|
setUserId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthState>(
|
||||||
|
() => ({
|
||||||
|
userId,
|
||||||
|
isAuthenticated: !!userId,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[userId, isLoading, login, register, logout],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext value={value}>{children}</AuthContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -28,11 +28,9 @@ export interface Song {
|
|||||||
sections: Section[];
|
sections: Section[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trimmed version used in the library grid
|
|
||||||
export interface SongSummary {
|
export interface SongSummary {
|
||||||
id: string;
|
id: string;
|
||||||
meta: SongMeta;
|
meta: SongMeta;
|
||||||
// First 5 unique chord names from the song, in order of appearance
|
|
||||||
preview_chords: string[];
|
preview_chords: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,3 +44,16 @@ export interface UpdateSongRequest {
|
|||||||
artist?: string;
|
artist?: string;
|
||||||
original_key?: string;
|
original_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
user_id: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshResponse {
|
||||||
|
token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ThemeProvider } from "next-themes";
|
|||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
import { AuthProvider } from "./lib/auth";
|
||||||
import { TooltipProvider } from "./components/ui/tooltip";
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
@@ -43,11 +44,13 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TooltipProvider>
|
<AuthProvider>
|
||||||
{children}
|
<TooltipProvider>
|
||||||
<ScrollRestoration />
|
{children}
|
||||||
<Scripts />
|
<ScrollRestoration />
|
||||||
</TooltipProvider>
|
<Scripts />
|
||||||
|
</TooltipProvider>
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,74 +1,87 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams, useRevalidator } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import type { Route } from "./+types/home";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Plus } from "lucide-react";
|
import { Loader2, Plus } from "lucide-react";
|
||||||
import { SongCard } from "~/components/song-card";
|
import { SongCard } from "~/components/song-card";
|
||||||
import { AddSongSheet } from "~/components/add-song-sheet";
|
import { AddSongSheet } from "~/components/add-song-sheet";
|
||||||
import { listSongs } from "~/lib/api";
|
import { listSongs } from "~/lib/api";
|
||||||
|
import { useAuth } from "~/lib/auth";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import type { SongSummary } from "~/lib/types";
|
import type { SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta() {
|
||||||
return [
|
return [
|
||||||
{ title: "PocketChords" },
|
{ title: "PocketChords" },
|
||||||
{ name: "description", content: "Your personal chord chart library" },
|
{ name: "description", content: "Your personal chord chart library" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export default function Home() {
|
||||||
const url = new URL(request.url);
|
const { isAuthenticated } = useAuth();
|
||||||
const q = url.searchParams.get("q") ?? "";
|
|
||||||
const sort = url.searchParams.get("sort") ?? "date";
|
|
||||||
const order = url.searchParams.get("order") ?? "desc";
|
|
||||||
try {
|
|
||||||
const songs = await listSongs(q, sort, order);
|
|
||||||
return { songs, q, sort, order, error: false };
|
|
||||||
} catch {
|
|
||||||
return { songs: [], q, sort, order, error: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
|
||||||
const { songs, q: initialQ, sort: initialSort, order: initialOrder, error } = loaderData;
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const [songs, setSongs] = useState<SongSummary[]>([]);
|
||||||
const [localSongs, setLocalSongs] = useState<SongSummary[]>([]);
|
const [localSongs, setLocalSongs] = useState<SongSummary[]>([]);
|
||||||
const revalidator = useRevalidator();
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState(initialQ);
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const sort = searchParams.get("sort") ?? "date";
|
||||||
|
const order = searchParams.get("order") ?? "desc";
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState(q);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleSearch = useCallback((value: string) => {
|
useEffect(() => {
|
||||||
setInputValue(value);
|
setLoading(true);
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
setError(false);
|
||||||
debounceRef.current = setTimeout(() => {
|
listSongs(q, sort, order)
|
||||||
const next: Record<string, string> = {};
|
.then((data) => {
|
||||||
if (value.trim()) next.q = value.trim();
|
setSongs(data);
|
||||||
if (initialSort !== "date") next.sort = initialSort;
|
setLocalSongs([]);
|
||||||
if (initialOrder !== "desc") next.order = initialOrder;
|
})
|
||||||
setSearchParams(next, { replace: true });
|
.catch(() => setError(true))
|
||||||
}, 300);
|
.finally(() => setLoading(false));
|
||||||
}, [setSearchParams, initialSort, initialOrder]);
|
}, [q, sort, order]);
|
||||||
|
|
||||||
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []);
|
const handleSearch = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setInputValue(value);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
if (value.trim()) next.q = value.trim();
|
||||||
|
if (sort !== "date") next.sort = sort;
|
||||||
|
if (order !== "desc") next.order = order;
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
[setSearchParams, sort, order],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const allSongs = [...songs, ...localSongs];
|
const allSongs = [...songs, ...localSongs];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full max-w-lg mx-auto">
|
<div className="flex flex-col h-full max-w-lg mx-auto">
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||||
<h1 className="text-lg font-bold">PocketChords</h1>
|
<h1 className="text-lg font-bold">PocketChords</h1>
|
||||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
{isAuthenticated && (
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||||
Add
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
</Button>
|
Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="px-4 pb-3">
|
<div className="px-4 pb-3">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search songs..."
|
placeholder="Search songs..."
|
||||||
@@ -79,11 +92,24 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 px-4 pb-2">
|
<div className="flex gap-1 px-4 pb-2">
|
||||||
{([["date", "Date"], ["title", "Title"], ["artist", "Artist"]] as const).map(([val, label]) => (
|
{(
|
||||||
|
[
|
||||||
|
["date", "Date"],
|
||||||
|
["title", "Title"],
|
||||||
|
["artist", "Artist"],
|
||||||
|
] as const
|
||||||
|
).map(([val, label]) => (
|
||||||
<button
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newOrder = initialSort === val ? (initialOrder === "asc" ? "desc" : "asc") : (val === "date" ? "desc" : "asc");
|
const newOrder =
|
||||||
|
sort === val
|
||||||
|
? order === "asc"
|
||||||
|
? "desc"
|
||||||
|
: "asc"
|
||||||
|
: val === "date"
|
||||||
|
? "desc"
|
||||||
|
: "asc";
|
||||||
const next: Record<string, string> = {};
|
const next: Record<string, string> = {};
|
||||||
if (inputValue.trim()) next.q = inputValue.trim();
|
if (inputValue.trim()) next.q = inputValue.trim();
|
||||||
next.sort = val;
|
next.sort = val;
|
||||||
@@ -92,12 +118,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs px-2 py-1 rounded-full border transition-colors",
|
"text-xs px-2 py-1 rounded-full border transition-colors",
|
||||||
initialSort === val
|
sort === val
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
: "text-muted-foreground border-border"
|
: "text-muted-foreground border-border",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}{initialSort === val ? (initialOrder === "asc" ? " ↑" : " ↓") : ""}
|
{label}
|
||||||
|
{sort === val ? (order === "asc" ? " ↑" : " ↓") : ""}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -110,33 +137,50 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => revalidator.revalidate()}
|
onClick={() => {
|
||||||
|
setError(false);
|
||||||
|
setLoading(true);
|
||||||
|
listSongs(q, sort, order)
|
||||||
|
.then(setSongs)
|
||||||
|
.catch(() => setError(true))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
{!error && allSongs.length === 0 && (
|
{loading && !error && (
|
||||||
|
<div className="flex justify-center pt-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && allSongs.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground text-center pt-8 pb-4">
|
<p className="text-sm text-muted-foreground text-center pt-8 pb-4">
|
||||||
{initialQ ? "No songs match your search." : "No songs yet. Tap Add to get started."}
|
{q
|
||||||
|
? "No songs match your search."
|
||||||
|
: "No songs yet. Tap Add to get started."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{!loading && (
|
||||||
{allSongs.map((song) => (
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<SongCard key={song.id} song={song} />
|
{allSongs.map((song) => (
|
||||||
))}
|
<SongCard key={song.id} song={song} />
|
||||||
<Card
|
))}
|
||||||
className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors"
|
{isAuthenticated && (
|
||||||
onClick={() => setSheetOpen(true)}
|
<Card
|
||||||
>
|
className="h-full border-dashed cursor-pointer hover:bg-accent transition-colors"
|
||||||
<CardContent className="p-3 flex items-center justify-center h-full min-h-[80px]">
|
onClick={() => setSheetOpen(true)}
|
||||||
<Plus className="w-6 h-6 text-muted-foreground" />
|
>
|
||||||
</CardContent>
|
<CardContent className="p-3 flex items-center justify-center h-full min-h-[80px]">
|
||||||
</Card>
|
<Plus className="w-6 h-6 text-muted-foreground" />
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddSongSheet
|
<AddSongSheet
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { data, Link } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
import type { Route } from "./+types/songs.$id";
|
import { Loader2 } from "lucide-react";
|
||||||
import { TransposeBar } from "~/components/transpose-bar";
|
import { TransposeBar } from "~/components/transpose-bar";
|
||||||
import { ChordChart } from "~/components/chord-chart";
|
import { ChordChart } from "~/components/chord-chart";
|
||||||
import { ChordGrid } from "~/components/chord-diagram/chord-grid";
|
import { ChordGrid } from "~/components/chord-diagram/chord-grid";
|
||||||
@@ -11,52 +11,42 @@ import { DeleteSongDialog } from "~/components/delete-song-dialog";
|
|||||||
import { transposeSong } from "~/lib/transpose";
|
import { transposeSong } from "~/lib/transpose";
|
||||||
import { extractUniqueChords } from "~/lib/song-utils";
|
import { extractUniqueChords } from "~/lib/song-utils";
|
||||||
import { getSong } from "~/lib/api";
|
import { getSong } from "~/lib/api";
|
||||||
|
import { useAuth } from "~/lib/auth";
|
||||||
import type { Song, SongSummary } from "~/lib/types";
|
import type { Song, SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs) {
|
type FontSize = "sm" | "base" | "lg";
|
||||||
if (!data?.song) return [{ title: "PocketChords" }];
|
|
||||||
return [
|
|
||||||
{ title: `${data.song.meta.title} — PocketChords` },
|
|
||||||
{ name: "description", content: data.song.meta.artist },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
|
||||||
const id = params.id ?? "";
|
|
||||||
try {
|
|
||||||
const song = await getSong(id);
|
|
||||||
if (!song) throw data("Song not found", { status: 404 });
|
|
||||||
return { song, id };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (err && typeof err === "object" && "status" in err && (err as { status: number }).status === 404) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return { song: null as Song | null, id };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type FontSize = 'sm' | 'base' | 'lg';
|
|
||||||
|
|
||||||
function initFontSize(): FontSize {
|
function initFontSize(): FontSize {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem('fontSize');
|
const v = localStorage.getItem("fontSize");
|
||||||
if (v === 'sm' || v === 'base' || v === 'lg') return v;
|
if (v === "sm" || v === "base" || v === "lg") return v;
|
||||||
} catch { /* noop */ }
|
} catch {
|
||||||
return 'sm';
|
/* noop */
|
||||||
|
}
|
||||||
|
return "sm";
|
||||||
}
|
}
|
||||||
|
|
||||||
function initInstrument(): Instrument {
|
function initInstrument(): Instrument {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem('chordDiagramInstrument');
|
const v = localStorage.getItem("chordDiagramInstrument");
|
||||||
if (v === 'piano' || v === 'guitar') return v;
|
if (v === "piano" || v === "guitar") return v;
|
||||||
} catch { /* noop */ }
|
} catch {
|
||||||
return 'piano';
|
/* noop */
|
||||||
|
}
|
||||||
|
return "piano";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
export function meta() {
|
||||||
const { song: initialSong, id } = loaderData;
|
return [{ title: "PocketChords" }];
|
||||||
const [baseSong, setBaseSong] = useState<Song | null>(initialSong ?? null);
|
}
|
||||||
const [displayedSong, setDisplayedSong] = useState<Song | null>(initialSong ?? null);
|
|
||||||
|
export default function SongDetail() {
|
||||||
|
const { id = "" } = useParams();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const [baseSong, setBaseSong] = useState<Song | null>(null);
|
||||||
|
const [displayedSong, setDisplayedSong] = useState<Song | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [applyCapo, setApplyCapo] = useState(false);
|
const [applyCapo, setApplyCapo] = useState(false);
|
||||||
|
|
||||||
const initOffset = (() => {
|
const initOffset = (() => {
|
||||||
@@ -66,7 +56,9 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
const n = parseInt(v, 10);
|
const n = parseInt(v, 10);
|
||||||
if (!isNaN(n)) return n;
|
if (!isNaN(n)) return n;
|
||||||
}
|
}
|
||||||
} catch { /* noop */ }
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -78,9 +70,21 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
|
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getSong(id)
|
||||||
|
.then((s) => {
|
||||||
|
setBaseSong(s);
|
||||||
|
setDisplayedSong(s);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (applyCapo && baseSong?.meta.capo) {
|
if (applyCapo && baseSong?.meta.capo) {
|
||||||
getSong(id, true).then((s) => { if (s) setDisplayedSong(s); });
|
getSong(id, true).then((s) => {
|
||||||
|
if (s) setDisplayedSong(s);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setDisplayedSong(baseSong);
|
setDisplayedSong(baseSong);
|
||||||
}
|
}
|
||||||
@@ -88,26 +92,51 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
|
|
||||||
function handleOffsetChange(newOffset: number) {
|
function handleOffsetChange(newOffset: number) {
|
||||||
setOffset(newOffset);
|
setOffset(newOffset);
|
||||||
try { localStorage.setItem(`transpose:${id}`, String(newOffset)); } catch { /* noop */ }
|
try {
|
||||||
|
localStorage.setItem(`transpose:${id}`, String(newOffset));
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFontSizeChange(size: FontSize) {
|
function handleFontSizeChange(size: FontSize) {
|
||||||
setFontSize(size);
|
setFontSize(size);
|
||||||
try { localStorage.setItem('fontSize', size); } catch { /* noop */ }
|
try {
|
||||||
|
localStorage.setItem("fontSize", size);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInstrumentChange(i: Instrument) {
|
function handleInstrumentChange(i: Instrument) {
|
||||||
setInstrument(i);
|
setInstrument(i);
|
||||||
try { localStorage.setItem('chordDiagramInstrument', i); } catch { /* noop */ }
|
try {
|
||||||
|
localStorage.setItem("chordDiagramInstrument", i);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = useCallback(() => setActiveChord(null), []);
|
const handleScroll = useCallback(() => setActiveChord(null), []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!baseSong || !displayedSong) {
|
if (!baseSong || !displayedSong) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||||
<p className="text-muted-foreground text-sm">Song not found or unavailable.</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
<Link to="/" className="text-sm text-primary underline-offset-4 hover:underline">
|
Song not found or unavailable.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
← Back to library
|
← Back to library
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,8 +148,10 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
const handleChordClick = (chord: string) => setActiveChord(chord);
|
const handleChordClick = (chord: string) => setActiveChord(chord);
|
||||||
|
|
||||||
function handleUpdated(summary: SongSummary) {
|
function handleUpdated(summary: SongSummary) {
|
||||||
setBaseSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
|
setBaseSong((prev) => (prev ? { ...prev, meta: summary.meta } : prev));
|
||||||
setDisplayedSong((prev) => prev ? { ...prev, meta: summary.meta } : prev);
|
setDisplayedSong((prev) =>
|
||||||
|
prev ? { ...prev, meta: summary.meta } : prev,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,8 +160,8 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
meta={baseSong.meta}
|
meta={baseSong.meta}
|
||||||
offset={offset}
|
offset={offset}
|
||||||
onOffsetChange={handleOffsetChange}
|
onOffsetChange={handleOffsetChange}
|
||||||
onEdit={() => setEditOpen(true)}
|
onEdit={isAuthenticated ? () => setEditOpen(true) : undefined}
|
||||||
onDelete={() => setDeleteOpen(true)}
|
onDelete={isAuthenticated ? () => setDeleteOpen(true) : undefined}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
onFontSizeChange={handleFontSizeChange}
|
onFontSizeChange={handleFontSizeChange}
|
||||||
capo={baseSong.meta.capo ?? undefined}
|
capo={baseSong.meta.capo ?? undefined}
|
||||||
@@ -138,9 +169,7 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
onToggleCapo={() => setApplyCapo((v) => !v)}
|
onToggleCapo={() => setApplyCapo((v) => !v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Body: single column on mobile, two columns on desktop */}
|
|
||||||
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
|
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
|
||||||
{/* Left / main column */}
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto"
|
className="flex-1 overflow-y-auto"
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@@ -153,7 +182,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
onChordClick={handleChordClick}
|
onChordClick={handleChordClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mobile bottom chord grid (hidden on desktop) */}
|
|
||||||
<div className="lg:hidden border-t border-border">
|
<div className="lg:hidden border-t border-border">
|
||||||
<ChordGrid
|
<ChordGrid
|
||||||
chords={uniqueChords}
|
chords={uniqueChords}
|
||||||
@@ -164,7 +192,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop side column (hidden on mobile) */}
|
|
||||||
<div className="hidden lg:block w-72 overflow-y-auto border-l border-border shrink-0">
|
<div className="hidden lg:block w-72 overflow-y-auto border-l border-border shrink-0">
|
||||||
<ChordGrid
|
<ChordGrid
|
||||||
chords={uniqueChords}
|
chords={uniqueChords}
|
||||||
@@ -174,7 +201,6 @@ export default function SongDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile inline popup — fixed bottom, dismissed on scroll */}
|
|
||||||
{activeChord && (
|
{activeChord && (
|
||||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg p-3 flex items-center gap-3">
|
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg p-3 flex items-center gap-3">
|
||||||
<ChordDiagram chord={activeChord} instrument={instrument} />
|
<ChordDiagram chord={activeChord} instrument={instrument} />
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import type { Config } from "@react-router/dev/config";
|
|||||||
export default {
|
export default {
|
||||||
// Config options...
|
// Config options...
|
||||||
// Server-side render by default, to enable SPA mode set this to `false`
|
// Server-side render by default, to enable SPA mode set this to `false`
|
||||||
ssr: true,
|
ssr: false,
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
64
architecture.mmd
Normal file
64
architecture.mmd
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
graph TD
|
||||||
|
subgraph Presentation
|
||||||
|
MAIN[main.rs]
|
||||||
|
ROUTES[routes/]
|
||||||
|
EXTRACT[extractors]
|
||||||
|
OPENAPI[OpenAPI / Scalar]
|
||||||
|
SPA[SPA static files]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Application
|
||||||
|
SONGS_UC[songs/]
|
||||||
|
TABS_UC[tabs/]
|
||||||
|
AUTH_UC[auth/]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Domain
|
||||||
|
MODELS[models/]
|
||||||
|
VO[value_objects/]
|
||||||
|
PORTS[ports/]
|
||||||
|
SERVICES[services/]
|
||||||
|
ERRORS[errors/]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Adapters
|
||||||
|
SQLITE[(SQLite)]
|
||||||
|
UG[UG Parser]
|
||||||
|
JWT[JWT + Argon2]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Shared Crates"
|
||||||
|
API_TYPES[api-types]
|
||||||
|
INFRA[infra-wiring]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Frontend
|
||||||
|
REACT[React SPA]
|
||||||
|
end
|
||||||
|
|
||||||
|
MAIN --> ROUTES
|
||||||
|
MAIN --> OPENAPI
|
||||||
|
ROUTES --> EXTRACT
|
||||||
|
ROUTES --> SONGS_UC
|
||||||
|
ROUTES --> TABS_UC
|
||||||
|
ROUTES --> AUTH_UC
|
||||||
|
ROUTES --> API_TYPES
|
||||||
|
|
||||||
|
SONGS_UC --> PORTS
|
||||||
|
TABS_UC --> PORTS
|
||||||
|
AUTH_UC --> PORTS
|
||||||
|
|
||||||
|
PORTS --> MODELS
|
||||||
|
PORTS --> VO
|
||||||
|
PORTS --> ERRORS
|
||||||
|
SERVICES --> MODELS
|
||||||
|
SERVICES --> VO
|
||||||
|
|
||||||
|
SQLITE -.->|implements| PORTS
|
||||||
|
UG -.->|implements| PORTS
|
||||||
|
JWT -.->|implements| PORTS
|
||||||
|
|
||||||
|
MAIN --> INFRA
|
||||||
|
MAIN --> SPA
|
||||||
|
REACT -->|/api/*| ROUTES
|
||||||
|
SPA -->|serves| REACT
|
||||||
14
crates/adapters/auth/Cargo.toml
Normal file
14
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
serde = { workspace = true }
|
||||||
100
crates/adapters/auth/src/lib.rs
Normal file
100
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use argon2::password_hash::SaltString;
|
||||||
|
use argon2::{Argon2, PasswordHash, PasswordHasher as ArgonHasher, PasswordVerifier};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use rand_core::OsRng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::GeneratedToken;
|
||||||
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
|
pub struct JwtAuthService {
|
||||||
|
encoding_key: EncodingKey,
|
||||||
|
decoding_key: DecodingKey,
|
||||||
|
ttl_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtAuthService {
|
||||||
|
pub fn new(secret: &str, ttl_seconds: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
|
||||||
|
ttl_seconds: ttl_seconds as i64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl domain::ports::AuthService for JwtAuthService {
|
||||||
|
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||||
|
let expires_at = Utc::now() + Duration::seconds(self.ttl_seconds);
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.value().to_string(),
|
||||||
|
exp: expires_at.timestamp() as usize,
|
||||||
|
};
|
||||||
|
let token = jsonwebtoken::encode(&Header::default(), &claims, &self.encoding_key)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(GeneratedToken { token, expires_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||||
|
let data =
|
||||||
|
jsonwebtoken::decode::<Claims>(token, &self.decoding_key, &Validation::default())
|
||||||
|
.map_err(|_| DomainError::Unauthorized("invalid or expired token".into()))?;
|
||||||
|
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
|
||||||
|
.map_err(|_| DomainError::Unauthorized("invalid token subject".into()))?;
|
||||||
|
Ok(UserId::from_uuid(uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Argon2PasswordHasher;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl domain::ports::PasswordHasher for Argon2PasswordHasher {
|
||||||
|
async fn hash(
|
||||||
|
&self,
|
||||||
|
plain_password: &str,
|
||||||
|
) -> Result<domain::value_objects::PasswordHash, DomainError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(plain_password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||||
|
.to_string();
|
||||||
|
domain::value_objects::PasswordHash::new(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(
|
||||||
|
&self,
|
||||||
|
plain_password: &str,
|
||||||
|
hash: &domain::value_objects::PasswordHash,
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
|
let parsed = PasswordHash::new(hash.value())
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(plain_password.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(
|
||||||
|
secret: &str,
|
||||||
|
ttl_seconds: u64,
|
||||||
|
) -> (
|
||||||
|
Arc<dyn domain::ports::AuthService>,
|
||||||
|
Arc<dyn domain::ports::PasswordHasher>,
|
||||||
|
) {
|
||||||
|
(
|
||||||
|
Arc::new(JwtAuthService::new(secret, ttl_seconds)),
|
||||||
|
Arc::new(Argon2PasswordHasher),
|
||||||
|
)
|
||||||
|
}
|
||||||
12
crates/adapters/sqlite/Cargo.toml
Normal file
12
crates/adapters/sqlite/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "sqlite"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
domain = { workspace = true }
|
||||||
7
crates/adapters/sqlite/migrations/002_users.sql
Normal file
7
crates/adapters/sqlite/migrations/002_users.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
11
crates/adapters/sqlite/migrations/003_refresh_sessions.sql
Normal file
11
crates/adapters/sqlite/migrations/003_refresh_sessions.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at);
|
||||||
7
crates/adapters/sqlite/src/lib.rs
Normal file
7
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod refresh_sessions;
|
||||||
|
pub mod repository;
|
||||||
|
mod row;
|
||||||
|
mod search;
|
||||||
|
mod users;
|
||||||
|
|
||||||
|
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};
|
||||||
96
crates/adapters/sqlite/src/refresh_sessions.rs
Normal file
96
crates/adapters/sqlite/src/refresh_sessions.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::DateTime;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::RefreshSession;
|
||||||
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
|
use crate::repository::SqliteSongRepository;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct RefreshSessionRow {
|
||||||
|
id: String,
|
||||||
|
user_id: String,
|
||||||
|
token: String,
|
||||||
|
expires_at: String,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_session(row: RefreshSessionRow) -> Result<RefreshSession, DomainError> {
|
||||||
|
let id = uuid::Uuid::parse_str(&row.id)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let user_id = uuid::Uuid::parse_str(&row.user_id)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let expires_at = DateTime::parse_from_rfc3339(&row.expires_at)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||||
|
.to_utc();
|
||||||
|
let created_at = DateTime::parse_from_rfc3339(&row.created_at)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||||
|
.to_utc();
|
||||||
|
|
||||||
|
Ok(RefreshSession {
|
||||||
|
id,
|
||||||
|
user_id: UserId::from_uuid(user_id),
|
||||||
|
token: row.token,
|
||||||
|
expires_at,
|
||||||
|
created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl domain::ports::RefreshSessionRepository for SqliteSongRepository {
|
||||||
|
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(session.id.to_string())
|
||||||
|
.bind(session.user_id.value().to_string())
|
||||||
|
.bind(&session.token)
|
||||||
|
.bind(session.expires_at.to_rfc3339())
|
||||||
|
.bind(session.created_at.to_rfc3339())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, RefreshSessionRow>(
|
||||||
|
"SELECT id, user_id, token, expires_at, created_at FROM refresh_sessions WHERE token = ?",
|
||||||
|
)
|
||||||
|
.bind(token)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
row.map(row_to_session).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM refresh_sessions WHERE token = ?")
|
||||||
|
.bind(token)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
|
||||||
|
.bind(user_id.value().to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?")
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong,
|
DomainError, Song, SongRepositoryPort, SongSummary, SortField, SortOrder, StoredSong,
|
||||||
SortField, SortOrder, song_preview_chords,
|
song_preview_chords,
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
use std::str::FromStr;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::row::{SongRow, sort_clause, row_to_summary};
|
use crate::row::{SongRow, row_to_summary, sort_clause};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SqliteSongRepository {
|
pub struct SqliteSongRepository {
|
||||||
@@ -15,7 +16,9 @@ pub struct SqliteSongRepository {
|
|||||||
|
|
||||||
impl SqliteSongRepository {
|
impl SqliteSongRepository {
|
||||||
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||||
let pool = SqlitePool::connect(database_url).await?;
|
let opts =
|
||||||
|
sqlx::sqlite::SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
||||||
|
let pool = SqlitePool::connect_with(opts).await?;
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
Ok(Self { pool })
|
Ok(Self { pool })
|
||||||
}
|
}
|
||||||
@@ -23,14 +26,14 @@ impl SqliteSongRepository {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SongRepositoryPort for SqliteSongRepository {
|
impl SongRepositoryPort for SqliteSongRepository {
|
||||||
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
|
async fn save(&self, song: &Song) -> Result<StoredSong, DomainError> {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let body = serde_json::to_string(song)
|
let body = serde_json::to_string(song)
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
let preview = song_preview_chords(song);
|
let preview = song_preview_chords(song);
|
||||||
let preview_json = serde_json::to_string(&preview)
|
let preview_json = serde_json::to_string(&preview)
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
let original_key = song.meta.original_key.as_deref();
|
let original_key = song.meta.original_key.as_deref();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -44,12 +47,19 @@ impl SongRepositoryPort for SqliteSongRepository {
|
|||||||
.bind(&body)
|
.bind(&body)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
Ok(StoredSong { id, song: song.clone() })
|
Ok(StoredSong {
|
||||||
|
id,
|
||||||
|
song: song.clone(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
async fn list(
|
||||||
|
&self,
|
||||||
|
sort: SortField,
|
||||||
|
order: SortOrder,
|
||||||
|
) -> Result<Vec<SongSummary>, DomainError> {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs {}",
|
"SELECT id, title, artist, original_key, preview_chords, body FROM songs {}",
|
||||||
sort_clause(sort, order)
|
sort_clause(sort, order)
|
||||||
@@ -57,41 +67,41 @@ impl SongRepositoryPort for SqliteSongRepository {
|
|||||||
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
let rows = sqlx::query_as::<_, SongRow>(&sql)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
rows.into_iter().map(row_to_summary).collect()
|
rows.into_iter().map(row_to_summary).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
async fn get(&self, id: Uuid) -> Result<Option<Song>, DomainError> {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let row = sqlx::query_as::<_, SongRow>(
|
let row = sqlx::query_as::<_, SongRow>(
|
||||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?"
|
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
Some(r) => {
|
Some(r) => {
|
||||||
let song: Song = serde_json::from_str(&r.body)
|
let song: Song = serde_json::from_str(&r.body)
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
Ok(Some(song))
|
Ok(Some(song))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let result = sqlx::query("DELETE FROM songs WHERE id = ?")
|
let result = sqlx::query("DELETE FROM songs WHERE id = ?")
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
Err(RepositoryError::NotFound)
|
Err(DomainError::NotFound)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -103,32 +113,38 @@ impl SongRepositoryPort for SqliteSongRepository {
|
|||||||
title: Option<&str>,
|
title: Option<&str>,
|
||||||
artist: Option<&str>,
|
artist: Option<&str>,
|
||||||
original_key: Option<&str>,
|
original_key: Option<&str>,
|
||||||
) -> Result<SongSummary, RepositoryError> {
|
) -> Result<SongSummary, DomainError> {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
|
|
||||||
let row = sqlx::query_as::<_, SongRow>(
|
let row = sqlx::query_as::<_, SongRow>(
|
||||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?"
|
"SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||||
.ok_or(RepositoryError::NotFound)?;
|
.ok_or(DomainError::NotFound)?;
|
||||||
|
|
||||||
let mut song: Song = serde_json::from_str(&row.body)
|
let mut song: Song = serde_json::from_str(&row.body)
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
if let Some(t) = title { song.meta.title = t.to_string(); }
|
if let Some(t) = title {
|
||||||
if let Some(a) = artist { song.meta.artist = a.to_string(); }
|
song.meta.title = t.to_string();
|
||||||
if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); }
|
}
|
||||||
|
if let Some(a) = artist {
|
||||||
|
song.meta.artist = a.to_string();
|
||||||
|
}
|
||||||
|
if let Some(k) = original_key {
|
||||||
|
song.meta.original_key = Some(k.to_string());
|
||||||
|
}
|
||||||
let new_body = serde_json::to_string(&song)
|
let new_body = serde_json::to_string(&song)
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
let new_title = title.unwrap_or(&row.title);
|
let new_title = title.unwrap_or(&row.title);
|
||||||
let new_artist = artist.unwrap_or(&row.artist);
|
let new_artist = artist.unwrap_or(&row.artist);
|
||||||
let new_key: Option<&str> = original_key.or(row.original_key.as_deref());
|
let new_key: Option<&str> = original_key.or(row.original_key.as_deref());
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?"
|
"UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(new_title)
|
.bind(new_title)
|
||||||
.bind(new_artist)
|
.bind(new_artist)
|
||||||
@@ -137,10 +153,10 @@ impl SongRepositoryPort for SqliteSongRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
|
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
Ok(SongSummary {
|
Ok(SongSummary {
|
||||||
id,
|
id,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use domain::{RepositoryError, SongMeta, SongSummary, SortField, SortOrder};
|
use domain::{DomainError, SongMeta, SongSummary, SortField, SortOrder};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -13,20 +13,20 @@ pub(crate) struct SongRow {
|
|||||||
|
|
||||||
pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
|
pub(crate) fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
|
||||||
match (field, order) {
|
match (field, order) {
|
||||||
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
|
(SortField::Title, SortOrder::Asc) => "ORDER BY title ASC",
|
||||||
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
|
(SortField::Title, SortOrder::Desc) => "ORDER BY title DESC",
|
||||||
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
|
(SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC",
|
||||||
(SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC",
|
(SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC",
|
||||||
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
|
(SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC",
|
||||||
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
|
(SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, RepositoryError> {
|
pub(crate) fn row_to_summary(row: SongRow) -> Result<SongSummary, DomainError> {
|
||||||
let id = Uuid::parse_str(&row.id)
|
let id =
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
Uuid::parse_str(&row.id).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
|
let preview_chords: Vec<String> = serde_json::from_str(&row.preview_chords)
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
Ok(SongSummary {
|
Ok(SongSummary {
|
||||||
id,
|
id,
|
||||||
meta: SongMeta {
|
meta: SongMeta {
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{RepositoryError, SongSearchPort, SongSummary, SortField, SortOrder};
|
use domain::{DomainError, SongSearchPort, SongSummary, SortField, SortOrder};
|
||||||
|
|
||||||
use crate::repository::SqliteSongRepository;
|
use crate::repository::SqliteSongRepository;
|
||||||
use crate::row::{SongRow, sort_clause, row_to_summary};
|
use crate::row::{SongRow, row_to_summary, sort_clause};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SongSearchPort for SqliteSongRepository {
|
impl SongSearchPort for SqliteSongRepository {
|
||||||
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
async fn search(
|
||||||
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
&self,
|
||||||
|
query: &str,
|
||||||
|
sort: SortField,
|
||||||
|
order: SortOrder,
|
||||||
|
) -> Result<Vec<SongSummary>, DomainError> {
|
||||||
|
let escaped = query
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('%', "\\%")
|
||||||
|
.replace('_', "\\_");
|
||||||
let pattern = format!("%{}%", escaped);
|
let pattern = format!("%{}%", escaped);
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \
|
"SELECT id, title, artist, original_key, preview_chords, body FROM songs \
|
||||||
@@ -19,7 +27,7 @@ impl SongSearchPort for SqliteSongRepository {
|
|||||||
.bind(&pattern)
|
.bind(&pattern)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
rows.into_iter().map(row_to_summary).collect()
|
rows.into_iter().map(row_to_summary).collect()
|
||||||
}
|
}
|
||||||
81
crates/adapters/sqlite/src/users.rs
Normal file
81
crates/adapters/sqlite/src/users.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::User;
|
||||||
|
use domain::value_objects::{Email, PasswordHash, UserId, Username};
|
||||||
|
|
||||||
|
use crate::repository::SqliteSongRepository;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct UserRow {
|
||||||
|
id: String,
|
||||||
|
email: String,
|
||||||
|
username: String,
|
||||||
|
password_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_user(row: UserRow) -> Result<User, DomainError> {
|
||||||
|
let id = uuid::Uuid::parse_str(&row.id)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(User::from_persistence(
|
||||||
|
UserId::from_uuid(id),
|
||||||
|
Email::new(&row.email)?,
|
||||||
|
Username::new(&row.username)?,
|
||||||
|
PasswordHash::new(row.password_hash)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl domain::ports::UserRepository for SqliteSongRepository {
|
||||||
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>(
|
||||||
|
"SELECT id, email, username, password_hash FROM users WHERE email = ?",
|
||||||
|
)
|
||||||
|
.bind(email.value())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
row.map(row_to_user).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>(
|
||||||
|
"SELECT id, email, username, password_hash FROM users WHERE username = ?",
|
||||||
|
)
|
||||||
|
.bind(username.value())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
row.map(row_to_user).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>(
|
||||||
|
"SELECT id, email, username, password_hash FROM users WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id.value().to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
row.map(row_to_user).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(user.id().value().to_string())
|
||||||
|
.bind(user.email().value())
|
||||||
|
.bind(user.username().value())
|
||||||
|
.bind(user.password_hash().value())
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { path = "../../domain" }
|
domain = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
scraper = { workspace = true }
|
scraper = { workspace = true }
|
||||||
52
crates/adapters/ug-parser/src/fetcher.rs
Normal file
52
crates/adapters/ug-parser/src/fetcher.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{FetchError, TabFetcherPort, TabSource};
|
||||||
|
|
||||||
|
pub struct UgTabFetcher {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UgTabFetcher {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UgTabFetcher {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TabFetcherPort for UgTabFetcher {
|
||||||
|
async fn fetch(&self, source: TabSource) -> Result<String, FetchError> {
|
||||||
|
match source {
|
||||||
|
TabSource::File(path) => Ok(tokio::fs::read_to_string(&path).await?),
|
||||||
|
TabSource::Url(url) => {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| FetchError::Network(e.to_string()))?;
|
||||||
|
let content_type = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
if !content_type.contains("text/html") {
|
||||||
|
return Err(FetchError::InvalidContentType);
|
||||||
|
}
|
||||||
|
resp.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| FetchError::Network(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/fetcher.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -127,6 +127,14 @@ impl UgHtmlParser {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plain-text chord line (UG sometimes drops spans for later sections)
|
||||||
|
if let Some(parsed) = Self::try_parse_plain_chord_line(trimmed)
|
||||||
|
&& !parsed.is_empty()
|
||||||
|
{
|
||||||
|
pending_chords = parsed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Lyric line
|
// Lyric line
|
||||||
if let Some(sec) = current_section.as_mut() {
|
if let Some(sec) = current_section.as_mut() {
|
||||||
sec.lines.push(LyricLine {
|
sec.lines.push(LyricLine {
|
||||||
@@ -159,6 +167,30 @@ impl UgHtmlParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect a plain-text chord line: every non-whitespace token must be a valid chord.
|
||||||
|
/// Returns None if the line contains non-chord words (i.e. it's a lyric line).
|
||||||
|
pub(crate) fn try_parse_plain_chord_line(line: &str) -> Option<Vec<ChordPosition>> {
|
||||||
|
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut chords = Vec::new();
|
||||||
|
let mut pos = 0;
|
||||||
|
|
||||||
|
for token in &tokens {
|
||||||
|
let token_start = line[pos..].find(token).map(|i| pos + i)?;
|
||||||
|
pos = token_start + token.len();
|
||||||
|
|
||||||
|
let chord = Chord::parse(token)?;
|
||||||
|
chords.push(ChordPosition {
|
||||||
|
offset: token_start,
|
||||||
|
chord,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(chords)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a chord line (raw HTML) into chord positions.
|
/// Parse a chord line (raw HTML) into chord positions.
|
||||||
/// Walks text nodes and span[data-name] elements in order to compute offsets.
|
/// Walks text nodes and span[data-name] elements in order to compute offsets.
|
||||||
fn parse_chord_line(line_html: &str) -> Vec<ChordPosition> {
|
fn parse_chord_line(line_html: &str) -> Vec<ChordPosition> {
|
||||||
@@ -199,65 +231,5 @@ impl TabParserPort for UgHtmlParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "tests/parser.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use domain::TabParserPort;
|
|
||||||
|
|
||||||
fn sample_html(name: &str) -> String {
|
|
||||||
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.parent()
|
|
||||||
.unwrap()
|
|
||||||
.parent()
|
|
||||||
.unwrap()
|
|
||||||
.parent()
|
|
||||||
.unwrap()
|
|
||||||
.join(format!("samples/{}", name));
|
|
||||||
std::fs::read_to_string(path).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_artist_and_title() {
|
|
||||||
let parser = UgHtmlParser;
|
|
||||||
let html = sample_html("A DROP IN THE OCEAN.html");
|
|
||||||
let song = parser.parse(&html).unwrap();
|
|
||||||
assert_eq!(song.meta.artist, "Ron Pope");
|
|
||||||
assert_eq!(song.meta.title, "A Drop In The Ocean");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn capo_is_none_when_no_capo() {
|
|
||||||
let parser = UgHtmlParser;
|
|
||||||
let html = sample_html("A DROP IN THE OCEAN.html");
|
|
||||||
let song = parser.parse(&html).unwrap();
|
|
||||||
assert_eq!(song.meta.capo, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_sections() {
|
|
||||||
let parser = UgHtmlParser;
|
|
||||||
let html = sample_html("A DROP IN THE OCEAN.html");
|
|
||||||
let song = parser.parse(&html).unwrap();
|
|
||||||
assert!(
|
|
||||||
song.sections.len() >= 3,
|
|
||||||
"expected >=3 sections, got {}",
|
|
||||||
song.sections.len()
|
|
||||||
);
|
|
||||||
assert_eq!(song.sections[0].kind, domain::SectionKind::Chorus);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_chord_positions() {
|
|
||||||
let parser = UgHtmlParser;
|
|
||||||
let html = sample_html("A DROP IN THE OCEAN.html");
|
|
||||||
let song = parser.parse(&html).unwrap();
|
|
||||||
// First section, first line: "A drop in the ocean,"
|
|
||||||
// Chord "Em" should be at offset 0 (or small offset from leading whitespace)
|
|
||||||
let first_line = &song.sections[0].lines[0];
|
|
||||||
assert_eq!(first_line.text, "A drop in the ocean,");
|
|
||||||
assert!(
|
|
||||||
first_line.chords[0].chord.name(true) == "Em",
|
|
||||||
"expected Em chord, got {}",
|
|
||||||
first_line.chords[0].chord.name(true)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
crates/adapters/ug-parser/src/tests/fetcher.rs
Normal file
19
crates/adapters/ug-parser/src/tests/fetcher.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use super::*;
|
||||||
|
use domain::TabSource;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_local_file() {
|
||||||
|
let fetcher = UgTabFetcher::new();
|
||||||
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join("samples/drop_in_the_ocean.html");
|
||||||
|
let html = fetcher.fetch(TabSource::File(path)).await.unwrap();
|
||||||
|
assert!(html.contains("[Chorus]"));
|
||||||
|
assert!(html.contains("data-name=\"Em\""));
|
||||||
|
}
|
||||||
77
crates/adapters/ug-parser/src/tests/parser.rs
Normal file
77
crates/adapters/ug-parser/src/tests/parser.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
use super::*;
|
||||||
|
use domain::TabParserPort;
|
||||||
|
|
||||||
|
fn sample_html(name: &str) -> String {
|
||||||
|
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join(format!("samples/{}", name));
|
||||||
|
std::fs::read_to_string(path).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_artist_and_title() {
|
||||||
|
let parser = UgHtmlParser;
|
||||||
|
let html = sample_html("drop_in_the_ocean.html");
|
||||||
|
let song = parser.parse(&html).unwrap();
|
||||||
|
assert_eq!(song.meta.artist, "Ron Pope");
|
||||||
|
assert_eq!(song.meta.title, "A Drop In The Ocean");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capo_is_none_when_no_capo() {
|
||||||
|
let parser = UgHtmlParser;
|
||||||
|
let html = sample_html("drop_in_the_ocean.html");
|
||||||
|
let song = parser.parse(&html).unwrap();
|
||||||
|
assert_eq!(song.meta.capo, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_sections() {
|
||||||
|
let parser = UgHtmlParser;
|
||||||
|
let html = sample_html("drop_in_the_ocean.html");
|
||||||
|
let song = parser.parse(&html).unwrap();
|
||||||
|
assert!(
|
||||||
|
song.sections.len() >= 3,
|
||||||
|
"expected >=3 sections, got {}",
|
||||||
|
song.sections.len()
|
||||||
|
);
|
||||||
|
assert_eq!(song.sections[0].kind, domain::SectionKind::Chorus);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_chord_positions() {
|
||||||
|
let parser = UgHtmlParser;
|
||||||
|
let html = sample_html("drop_in_the_ocean.html");
|
||||||
|
let song = parser.parse(&html).unwrap();
|
||||||
|
let first_line = &song.sections[0].lines[0];
|
||||||
|
assert_eq!(first_line.text, "A drop in the ocean,");
|
||||||
|
assert!(
|
||||||
|
first_line.chords[0].chord.name(true) == "Em",
|
||||||
|
"expected Em chord, got {}",
|
||||||
|
first_line.chords[0].chord.name(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_plain_text_chord_lines() {
|
||||||
|
let parser = UgHtmlParser;
|
||||||
|
let html = sample_html("drop_in_the_ocean.html");
|
||||||
|
let song = parser.parse(&html).unwrap();
|
||||||
|
let last_section = song.sections.last().unwrap();
|
||||||
|
let has_chords = last_section.lines.iter().any(|l| !l.chords.is_empty());
|
||||||
|
assert!(
|
||||||
|
has_chords,
|
||||||
|
"last section should have chords parsed from plain text"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_chord_detection_does_not_eat_lyrics() {
|
||||||
|
assert!(UgHtmlParser::try_parse_plain_chord_line("A drop in the ocean").is_none());
|
||||||
|
assert!(UgHtmlParser::try_parse_plain_chord_line("Am G D").is_some());
|
||||||
|
}
|
||||||
8
crates/api-types/Cargo.toml
Normal file
8
crates/api-types/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "api-types"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
utoipa = { version = "5", features = ["axum_extras"] }
|
||||||
70
crates/api-types/src/lib.rs
Normal file
70
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct ParseRequest {
|
||||||
|
pub source: Option<String>,
|
||||||
|
pub html: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||||
|
pub struct ListQuery {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub order: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateSongRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub original_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||||
|
pub struct GetSongQuery {
|
||||||
|
pub apply_capo: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct RefreshRequest {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct RefreshResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct LogoutRequest {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "api"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
axum = { version = "0.8.8", features = ["macros"] }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tower-http = { version = "0.6.8", features = [
|
|
||||||
"cors",
|
|
||||||
"fs",
|
|
||||||
"trace",
|
|
||||||
"tracing",
|
|
||||||
] }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
tracing-subscriber = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
rand = { workspace = true }
|
|
||||||
|
|
||||||
persistence = { path = "../infrastructure/persistence" }
|
|
||||||
common = { path = "../common" }
|
|
||||||
domain = { path = "../domain" }
|
|
||||||
ug-parser = { path = "../infrastructure/ug-parser" }
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use std::env;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Config {
|
|
||||||
pub database_url: String,
|
|
||||||
pub host: String,
|
|
||||||
pub port: u16,
|
|
||||||
/// Parsed CORS origin policy
|
|
||||||
pub cors_origins: CorsOrigins,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum CorsOrigins {
|
|
||||||
/// Allow any origin (`CORS_ALLOWED_ORIGINS=*`)
|
|
||||||
Any,
|
|
||||||
/// Allow specific origins (`CORS_ALLOWED_ORIGINS=https://a.com,https://b.com`)
|
|
||||||
List(Vec<String>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
pub fn from_env() -> Self {
|
|
||||||
let database_url = env::var("DATABASE_URL")
|
|
||||||
.unwrap_or_else(|_| "sqlite://./pocket-chords.db".into());
|
|
||||||
|
|
||||||
let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into());
|
|
||||||
|
|
||||||
let port = env::var("PORT")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse::<u16>().ok())
|
|
||||||
.unwrap_or(8000);
|
|
||||||
|
|
||||||
let cors_origins = match env::var("CORS_ALLOWED_ORIGINS")
|
|
||||||
.unwrap_or_else(|_| "*".into())
|
|
||||||
.trim()
|
|
||||||
.to_string()
|
|
||||||
{
|
|
||||||
s if s == "*" => CorsOrigins::Any,
|
|
||||||
s => CorsOrigins::List(
|
|
||||||
s.split(',')
|
|
||||||
.map(|o| o.trim().to_string())
|
|
||||||
.filter(|o| !o.is_empty())
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self { database_url, host, port, cors_origins }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bind_addr(&self) -> String {
|
|
||||||
format!("{}:{}", self.host, self.port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
mod config;
|
|
||||||
mod routes;
|
|
||||||
|
|
||||||
use axum::{Router, http::HeaderValue, routing::{get, post}};
|
|
||||||
use common::{SongSearchService, SongService};
|
|
||||||
use config::{Config, CorsOrigins};
|
|
||||||
use persistence::SqliteRepositoryFactory;
|
|
||||||
use routes::songs::{create_song, delete_song, get_song, list_songs, update_song};
|
|
||||||
use routes::tabs::{AppState, parse_tab};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
|
||||||
use ug_parser::{UgHtmlParser, UgTabFetcher};
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
tracing_subscriber::fmt::init();
|
|
||||||
|
|
||||||
let config = Config::from_env();
|
|
||||||
tracing::info!(?config, "starting with config");
|
|
||||||
|
|
||||||
let repo = SqliteRepositoryFactory::create(&config.database_url)
|
|
||||||
.await
|
|
||||||
.expect("failed to connect to database");
|
|
||||||
let songs = SongService::new(Box::new(repo.clone()));
|
|
||||||
let search = SongSearchService::new(Box::new(repo));
|
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
|
||||||
fetcher: Box::new(UgTabFetcher::new()),
|
|
||||||
parser: Box::new(UgHtmlParser),
|
|
||||||
songs,
|
|
||||||
search,
|
|
||||||
});
|
|
||||||
|
|
||||||
let cors = match config.cors_origins {
|
|
||||||
CorsOrigins::Any => CorsLayer::new()
|
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any),
|
|
||||||
CorsOrigins::List(ref origins) => {
|
|
||||||
let parsed: Vec<HeaderValue> = origins
|
|
||||||
.iter()
|
|
||||||
.map(|o| o.parse().unwrap_or_else(|_| panic!("invalid CORS origin: {o}")))
|
|
||||||
.collect();
|
|
||||||
CorsLayer::new()
|
|
||||||
.allow_origin(parsed)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/tabs/parse", post(parse_tab))
|
|
||||||
.route("/songs", post(create_song).get(list_songs))
|
|
||||||
.route("/songs/{id}", get(get_song).delete(delete_song).patch(update_song))
|
|
||||||
.layer(cors)
|
|
||||||
.with_state(state);
|
|
||||||
|
|
||||||
let addr = config.bind_addr();
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await
|
|
||||||
.unwrap_or_else(|e| panic!("failed to bind {addr}: {e}"));
|
|
||||||
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::{Path, Query, State},
|
|
||||||
http::StatusCode,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use domain::{ChordTransposer, RepositoryError, SortField, SortOrder};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ListQuery {
|
|
||||||
pub q: Option<String>,
|
|
||||||
pub sort: Option<String>,
|
|
||||||
pub order: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html};
|
|
||||||
|
|
||||||
pub async fn create_song(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Json(body): Json<ParseRequest>,
|
|
||||||
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
|
|
||||||
let html = resolve_html(&state, body).await.map_err(|e| {
|
|
||||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let song = state.parser.parse(&html).map_err(|e| {
|
|
||||||
(StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() }))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let stored = state.songs.save(&song).await.map_err(|e| {
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(stored))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_songs(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Query(params): Query<ListQuery>,
|
|
||||||
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
|
|
||||||
let sort = match params.sort.as_deref() {
|
|
||||||
Some("title") => SortField::Title,
|
|
||||||
Some("artist") => SortField::Artist,
|
|
||||||
_ => SortField::Date,
|
|
||||||
};
|
|
||||||
let order = match params.order.as_deref() {
|
|
||||||
Some("asc") => SortOrder::Asc,
|
|
||||||
_ => SortOrder::Desc,
|
|
||||||
};
|
|
||||||
let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
|
|
||||||
state.search.search(&q, sort, order).await
|
|
||||||
} else {
|
|
||||||
state.songs.list(sort, order).await
|
|
||||||
};
|
|
||||||
result
|
|
||||||
.map(Json)
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct UpdateSongRequest {
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub artist: Option<String>,
|
|
||||||
pub original_key: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_song(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
Json(body): Json<UpdateSongRequest>,
|
|
||||||
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
|
|
||||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
|
||||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
state.songs
|
|
||||||
.update_meta(
|
|
||||||
uuid,
|
|
||||||
body.title.as_deref(),
|
|
||||||
body.artist.as_deref(),
|
|
||||||
body.original_key.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map(Json)
|
|
||||||
.map_err(|e| match e {
|
|
||||||
domain::RepositoryError::NotFound =>
|
|
||||||
(StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })),
|
|
||||||
e => (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct GetSongQuery {
|
|
||||||
pub apply_capo: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_song(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
Query(params): Query<GetSongQuery>,
|
|
||||||
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
|
||||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
|
||||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let song = match state.songs.get(uuid).await {
|
|
||||||
Ok(Some(s)) => s,
|
|
||||||
Ok(None) => return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))),
|
|
||||||
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
|
|
||||||
};
|
|
||||||
|
|
||||||
let song = if params.apply_capo.unwrap_or(false) {
|
|
||||||
if let Some(capo) = song.meta.capo {
|
|
||||||
ChordTransposer.transpose_song(&song, capo as i8)
|
|
||||||
} else {
|
|
||||||
song
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
song
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(song))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_song(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
|
||||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
|
||||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
match state.songs.delete(uuid).await {
|
|
||||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
|
||||||
Err(RepositoryError::NotFound) => {
|
|
||||||
Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })))
|
|
||||||
}
|
|
||||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
use axum::{extract::State, http::StatusCode, Json};
|
|
||||||
use domain::{TabFetcherPort, TabParserPort, TabSource};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::{path::PathBuf, sync::Arc};
|
|
||||||
|
|
||||||
pub struct AppState {
|
|
||||||
pub fetcher: Box<dyn TabFetcherPort>,
|
|
||||||
pub parser: Box<dyn TabParserPort>,
|
|
||||||
pub songs: common::SongService,
|
|
||||||
pub search: common::SongSearchService,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ParseRequest {
|
|
||||||
pub source: Option<String>,
|
|
||||||
pub html: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct ErrorResponse {
|
|
||||||
pub error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn resolve_html(state: &AppState, body: ParseRequest) -> Result<String, String> {
|
|
||||||
if let Some(raw_html) = body.html {
|
|
||||||
Ok(raw_html)
|
|
||||||
} else if let Some(source) = body.source {
|
|
||||||
let tab_source = if source.starts_with("file://") {
|
|
||||||
let path = source.trim_start_matches("file://");
|
|
||||||
TabSource::File(PathBuf::from(path))
|
|
||||||
} else {
|
|
||||||
TabSource::Url(source)
|
|
||||||
};
|
|
||||||
state.fetcher.fetch(tab_source).await.map_err(|e| e.to_string())
|
|
||||||
} else {
|
|
||||||
Err("Provide either 'source' or 'html'".into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn parse_tab(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Json(body): Json<ParseRequest>,
|
|
||||||
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
|
||||||
let html = resolve_html(&state, body).await.map_err(|e| {
|
|
||||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let song = state.parser.parse(&html).map_err(|e| {
|
|
||||||
(StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() }))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(song))
|
|
||||||
}
|
|
||||||
10
crates/application/Cargo.toml
Normal file
10
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "application"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
uuid = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
domain = { workspace = true }
|
||||||
18
crates/application/src/auth/commands.rs
Normal file
18
crates/application/src/auth/commands.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pub struct RegisterCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RefreshCommand {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LogoutCommand {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
27
crates/application/src/auth/deps.rs
Normal file
27
crates/application/src/auth/deps.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{AuthService, PasswordHasher, RefreshSessionRepository, UserRepository};
|
||||||
|
|
||||||
|
pub struct RegisterDeps {
|
||||||
|
pub user_repo: Arc<dyn UserRepository>,
|
||||||
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub allow_registration: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginDeps {
|
||||||
|
pub user_repo: Arc<dyn UserRepository>,
|
||||||
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub auth_service: Arc<dyn AuthService>,
|
||||||
|
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
|
||||||
|
pub refresh_ttl_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RefreshDeps {
|
||||||
|
pub auth_service: Arc<dyn AuthService>,
|
||||||
|
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
|
||||||
|
pub refresh_ttl_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LogoutDeps {
|
||||||
|
pub refresh_repo: Arc<dyn RefreshSessionRepository>,
|
||||||
|
}
|
||||||
55
crates/application/src/auth/login.rs
Normal file
55
crates/application/src/auth/login.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::RefreshSession;
|
||||||
|
use domain::value_objects::{Email, UserId};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::commands::LoginCommand;
|
||||||
|
use super::deps::LoginDeps;
|
||||||
|
|
||||||
|
pub struct LoginResult {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(deps: &LoginDeps, cmd: LoginCommand) -> Result<LoginResult, DomainError> {
|
||||||
|
let email = Email::new(&cmd.email)?;
|
||||||
|
|
||||||
|
let user = deps.user_repo.find_by_email(&email).await?.ok_or_else(|| {
|
||||||
|
tracing::warn!(email = cmd.email, "login attempt with unknown email");
|
||||||
|
DomainError::Unauthorized("invalid credentials".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let valid = deps
|
||||||
|
.password_hasher
|
||||||
|
.verify(&cmd.password, user.password_hash())
|
||||||
|
.await?;
|
||||||
|
if !valid {
|
||||||
|
tracing::warn!(email = cmd.email, "login attempt with wrong password");
|
||||||
|
return Err(DomainError::Unauthorized("invalid credentials".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let generated = deps.auth_service.generate_token(user.id()).await?;
|
||||||
|
|
||||||
|
let refresh_token = Uuid::new_v4().to_string();
|
||||||
|
let refresh_expires = Utc::now() + Duration::seconds(deps.refresh_ttl_seconds as i64);
|
||||||
|
let session = RefreshSession {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: *user.id(),
|
||||||
|
token: refresh_token.clone(),
|
||||||
|
expires_at: refresh_expires,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
deps.refresh_repo.create(&session).await?;
|
||||||
|
|
||||||
|
tracing::info!(user_id = %user.id().value(), "user logged in");
|
||||||
|
|
||||||
|
Ok(LoginResult {
|
||||||
|
access_token: generated.token,
|
||||||
|
refresh_token,
|
||||||
|
user_id: *user.id(),
|
||||||
|
expires_at: generated.expires_at.to_rfc3339(),
|
||||||
|
})
|
||||||
|
}
|
||||||
9
crates/application/src/auth/logout.rs
Normal file
9
crates/application/src/auth/logout.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
use super::commands::LogoutCommand;
|
||||||
|
use super::deps::LogoutDeps;
|
||||||
|
|
||||||
|
pub async fn execute(deps: &LogoutDeps, cmd: LogoutCommand) -> Result<(), DomainError> {
|
||||||
|
tracing::debug!("user logged out");
|
||||||
|
deps.refresh_repo.revoke(&cmd.refresh_token).await
|
||||||
|
}
|
||||||
6
crates/application/src/auth/mod.rs
Normal file
6
crates/application/src/auth/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
|
pub mod refresh;
|
||||||
|
pub mod register;
|
||||||
52
crates/application/src/auth/refresh.rs
Normal file
52
crates/application/src/auth/refresh.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::RefreshSession;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::commands::RefreshCommand;
|
||||||
|
use super::deps::RefreshDeps;
|
||||||
|
|
||||||
|
pub struct RefreshResult {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &RefreshDeps,
|
||||||
|
cmd: RefreshCommand,
|
||||||
|
) -> Result<RefreshResult, DomainError> {
|
||||||
|
let session = deps
|
||||||
|
.refresh_repo
|
||||||
|
.get_by_token(&cmd.refresh_token)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::Unauthorized("invalid refresh token".into()))?;
|
||||||
|
|
||||||
|
if session.expires_at < Utc::now() {
|
||||||
|
deps.refresh_repo.revoke(&cmd.refresh_token).await?;
|
||||||
|
return Err(DomainError::Unauthorized("refresh token expired".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.refresh_repo.revoke(&cmd.refresh_token).await?;
|
||||||
|
|
||||||
|
let generated = deps.auth_service.generate_token(&session.user_id).await?;
|
||||||
|
|
||||||
|
let new_refresh_token = Uuid::new_v4().to_string();
|
||||||
|
let refresh_expires = Utc::now() + Duration::seconds(deps.refresh_ttl_seconds as i64);
|
||||||
|
let new_session = RefreshSession {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: session.user_id,
|
||||||
|
token: new_refresh_token.clone(),
|
||||||
|
expires_at: refresh_expires,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
deps.refresh_repo.create(&new_session).await?;
|
||||||
|
|
||||||
|
tracing::debug!(user_id = %session.user_id.value(), "token refreshed");
|
||||||
|
|
||||||
|
Ok(RefreshResult {
|
||||||
|
access_token: generated.token,
|
||||||
|
refresh_token: new_refresh_token,
|
||||||
|
expires_at: generated.expires_at.to_rfc3339(),
|
||||||
|
})
|
||||||
|
}
|
||||||
38
crates/application/src/auth/register.rs
Normal file
38
crates/application/src/auth/register.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::User;
|
||||||
|
use domain::value_objects::{Email, Password, Username};
|
||||||
|
|
||||||
|
use super::commands::RegisterCommand;
|
||||||
|
use super::deps::RegisterDeps;
|
||||||
|
|
||||||
|
pub async fn execute(deps: &RegisterDeps, cmd: RegisterCommand) -> Result<(), DomainError> {
|
||||||
|
if !deps.allow_registration {
|
||||||
|
tracing::warn!("registration attempt while disabled");
|
||||||
|
return Err(DomainError::Unauthorized("registration is disabled".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = Password::new(&cmd.password)?;
|
||||||
|
let email = Email::new(&cmd.email)?;
|
||||||
|
let username = Username::new(&cmd.username)?;
|
||||||
|
|
||||||
|
if deps.user_repo.find_by_email(&email).await?.is_some() {
|
||||||
|
return Err(DomainError::ValidationError(
|
||||||
|
"email already registered".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if deps.user_repo.find_by_username(&username).await?.is_some() {
|
||||||
|
return Err(DomainError::ValidationError(
|
||||||
|
"username already taken".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = deps.password_hasher.hash(password.value()).await?;
|
||||||
|
let user = User::new(email, username, hash);
|
||||||
|
|
||||||
|
tracing::info!(user_id = %user.id().value(), "new user registered");
|
||||||
|
|
||||||
|
deps.user_repo.save(&user).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
pub mod auth;
|
||||||
pub mod songs;
|
pub mod songs;
|
||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
17
crates/application/src/songs/commands.rs
Normal file
17
crates/application/src/songs/commands.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use domain::models::Song;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct SaveSongCommand {
|
||||||
|
pub song: Song,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DeleteSongCommand {
|
||||||
|
pub id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UpdateSongMetaCommand {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub original_key: Option<String>,
|
||||||
|
}
|
||||||
10
crates/application/src/songs/delete_song.rs
Normal file
10
crates/application/src/songs/delete_song.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
use super::commands::DeleteSongCommand;
|
||||||
|
use super::deps::SongCommandDeps;
|
||||||
|
|
||||||
|
pub async fn execute(deps: &SongCommandDeps, cmd: DeleteSongCommand) -> Result<(), DomainError> {
|
||||||
|
deps.repo.delete(cmd.id).await?;
|
||||||
|
tracing::debug!(id = %cmd.id, "song deleted");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
11
crates/application/src/songs/deps.rs
Normal file
11
crates/application/src/songs/deps.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use domain::ports::{SongRepositoryPort, SongSearchPort};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct SongCommandDeps {
|
||||||
|
pub repo: Arc<dyn SongRepositoryPort>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SongQueryDeps {
|
||||||
|
pub repo: Arc<dyn SongRepositoryPort>,
|
||||||
|
pub search: Arc<dyn SongSearchPort>,
|
||||||
|
}
|
||||||
12
crates/application/src/songs/get_song.rs
Normal file
12
crates/application/src/songs/get_song.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::Song;
|
||||||
|
|
||||||
|
use super::deps::SongQueryDeps;
|
||||||
|
use super::queries::GetSongQuery;
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &SongQueryDeps,
|
||||||
|
query: GetSongQuery,
|
||||||
|
) -> Result<Option<Song>, DomainError> {
|
||||||
|
deps.repo.get(query.id).await
|
||||||
|
}
|
||||||
12
crates/application/src/songs/list_songs.rs
Normal file
12
crates/application/src/songs/list_songs.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::SongSummary;
|
||||||
|
|
||||||
|
use super::deps::SongQueryDeps;
|
||||||
|
use super::queries::ListSongsQuery;
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &SongQueryDeps,
|
||||||
|
query: ListSongsQuery,
|
||||||
|
) -> Result<Vec<SongSummary>, DomainError> {
|
||||||
|
deps.repo.list(query.sort, query.order).await
|
||||||
|
}
|
||||||
9
crates/application/src/songs/mod.rs
Normal file
9
crates/application/src/songs/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod delete_song;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod get_song;
|
||||||
|
pub mod list_songs;
|
||||||
|
pub mod queries;
|
||||||
|
pub mod save_song;
|
||||||
|
pub mod search_songs;
|
||||||
|
pub mod update_meta;
|
||||||
17
crates/application/src/songs/queries.rs
Normal file
17
crates/application/src/songs/queries.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use domain::value_objects::{SortField, SortOrder};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct ListSongsQuery {
|
||||||
|
pub sort: SortField,
|
||||||
|
pub order: SortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetSongQuery {
|
||||||
|
pub id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SearchSongsQuery {
|
||||||
|
pub query: String,
|
||||||
|
pub sort: SortField,
|
||||||
|
pub order: SortOrder,
|
||||||
|
}
|
||||||
15
crates/application/src/songs/save_song.rs
Normal file
15
crates/application/src/songs/save_song.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::StoredSong;
|
||||||
|
|
||||||
|
use super::commands::SaveSongCommand;
|
||||||
|
use super::deps::SongCommandDeps;
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &SongCommandDeps,
|
||||||
|
cmd: SaveSongCommand,
|
||||||
|
) -> Result<StoredSong, DomainError> {
|
||||||
|
let title = cmd.song.meta.title.clone();
|
||||||
|
let result = deps.repo.save(&cmd.song).await?;
|
||||||
|
tracing::debug!(id = %result.id, title, "song saved");
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
14
crates/application/src/songs/search_songs.rs
Normal file
14
crates/application/src/songs/search_songs.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::SongSummary;
|
||||||
|
|
||||||
|
use super::deps::SongQueryDeps;
|
||||||
|
use super::queries::SearchSongsQuery;
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &SongQueryDeps,
|
||||||
|
query: SearchSongsQuery,
|
||||||
|
) -> Result<Vec<SongSummary>, DomainError> {
|
||||||
|
deps.search
|
||||||
|
.search(&query.query, query.sort, query.order)
|
||||||
|
.await
|
||||||
|
}
|
||||||
22
crates/application/src/songs/update_meta.rs
Normal file
22
crates/application/src/songs/update_meta.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::SongSummary;
|
||||||
|
|
||||||
|
use super::commands::UpdateSongMetaCommand;
|
||||||
|
use super::deps::SongCommandDeps;
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &SongCommandDeps,
|
||||||
|
cmd: UpdateSongMetaCommand,
|
||||||
|
) -> Result<SongSummary, DomainError> {
|
||||||
|
let result = deps
|
||||||
|
.repo
|
||||||
|
.update_meta(
|
||||||
|
cmd.id,
|
||||||
|
cmd.title.as_deref(),
|
||||||
|
cmd.artist.as_deref(),
|
||||||
|
cmd.original_key.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tracing::debug!(id = %cmd.id, "song meta updated");
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
4
crates/application/src/tabs/commands.rs
Normal file
4
crates/application/src/tabs/commands.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub struct ParseTabCommand {
|
||||||
|
pub source: Option<String>,
|
||||||
|
pub html: Option<String>,
|
||||||
|
}
|
||||||
7
crates/application/src/tabs/deps.rs
Normal file
7
crates/application/src/tabs/deps.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use domain::ports::{TabFetcherPort, TabParserPort};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct ParseTabDeps {
|
||||||
|
pub fetcher: Arc<dyn TabFetcherPort>,
|
||||||
|
pub parser: Arc<dyn TabParserPort>,
|
||||||
|
}
|
||||||
3
crates/application/src/tabs/mod.rs
Normal file
3
crates/application/src/tabs/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod parse_tab;
|
||||||
45
crates/application/src/tabs/parse_tab.rs
Normal file
45
crates/application/src/tabs/parse_tab.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::Song;
|
||||||
|
use domain::ports::TabSource;
|
||||||
|
|
||||||
|
use super::commands::ParseTabCommand;
|
||||||
|
use super::deps::ParseTabDeps;
|
||||||
|
|
||||||
|
pub async fn execute(deps: &ParseTabDeps, cmd: ParseTabCommand) -> Result<Song, DomainError> {
|
||||||
|
let html = if let Some(raw_html) = cmd.html {
|
||||||
|
tracing::debug!("parsing from raw HTML input");
|
||||||
|
Ok(raw_html)
|
||||||
|
} else if let Some(source) = cmd.source {
|
||||||
|
let tab_source = if source.starts_with("file://") {
|
||||||
|
let path = source.trim_start_matches("file://");
|
||||||
|
TabSource::File(PathBuf::from(path))
|
||||||
|
} else {
|
||||||
|
tracing::debug!(url = source, "fetching tab from URL");
|
||||||
|
TabSource::Url(source)
|
||||||
|
};
|
||||||
|
deps.fetcher
|
||||||
|
.fetch(tab_source)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
} else {
|
||||||
|
Err(DomainError::ValidationError(
|
||||||
|
"Provide either 'source' or 'html'".into(),
|
||||||
|
))
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let song = deps
|
||||||
|
.parser
|
||||||
|
.parse(&html)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
title = song.meta.title,
|
||||||
|
artist = song.meta.artist,
|
||||||
|
sections = song.sections.len(),
|
||||||
|
"tab parsed"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(song)
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "common"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
reqwest = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
rand = { workspace = true }
|
|
||||||
async-trait = { workspace = true }
|
|
||||||
domain = { path = "../domain" }
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, SortField, SortOrder};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub struct SongService {
|
|
||||||
repo: Box<dyn SongRepositoryPort>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SongService {
|
|
||||||
pub fn new(repo: Box<dyn SongRepositoryPort>) -> Self {
|
|
||||||
Self { repo }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
|
|
||||||
self.repo.save(song).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError> {
|
|
||||||
self.repo.list(sort, order).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError> {
|
|
||||||
self.repo.get(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> {
|
|
||||||
self.repo.delete(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_meta(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
title: Option<&str>,
|
|
||||||
artist: Option<&str>,
|
|
||||||
original_key: Option<&str>,
|
|
||||||
) -> Result<domain::SongSummary, domain::RepositoryError> {
|
|
||||||
self.repo.update_meta(id, title, artist, original_key).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SongSearchService {
|
|
||||||
search: Box<dyn SongSearchPort>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SongSearchService {
|
|
||||||
pub fn new(search: Box<dyn SongSearchPort>) -> Self {
|
|
||||||
Self { search }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
|
|
||||||
self.search.search(query, sort, order).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,9 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
rand = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
email_address = "0.2"
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::Note;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(into = "String", try_from = "String")]
|
|
||||||
pub struct Chord {
|
|
||||||
pub root: Note,
|
|
||||||
pub descriptor: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Chord {
|
|
||||||
pub fn parse(s: &str) -> Option<Self> {
|
|
||||||
let (root, consumed) = Note::parse_prefix(s)?;
|
|
||||||
let descriptor = if consumed < s.len() {
|
|
||||||
Some(s[consumed..].to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
Some(Chord { root, descriptor })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Display chord name. use_sharps=true → "F#m", false → "Gbm".
|
|
||||||
pub fn name(&self, use_sharps: bool) -> String {
|
|
||||||
let root_str = if use_sharps {
|
|
||||||
self.root.to_sharp_str()
|
|
||||||
} else {
|
|
||||||
self.root.to_flat_str()
|
|
||||||
};
|
|
||||||
match &self.descriptor {
|
|
||||||
Some(d) => format!("{}{}", root_str, d),
|
|
||||||
None => root_str.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Chord> for String {
|
|
||||||
fn from(c: Chord) -> String {
|
|
||||||
c.name(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<String> for Chord {
|
|
||||||
type Error = String;
|
|
||||||
fn try_from(s: String) -> Result<Self, Self::Error> {
|
|
||||||
Chord::parse(&s).ok_or_else(|| format!("invalid chord: {}", s))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_simple() {
|
|
||||||
let c = Chord::parse("Em").unwrap();
|
|
||||||
assert_eq!(c.root, crate::Note::E);
|
|
||||||
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_no_descriptor() {
|
|
||||||
let c = Chord::parse("G").unwrap();
|
|
||||||
assert_eq!(c.root, crate::Note::G);
|
|
||||||
assert!(c.descriptor.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_flat_root() {
|
|
||||||
let c = Chord::parse("Bb").unwrap();
|
|
||||||
assert_eq!(c.root, crate::Note::ASharpBFlat);
|
|
||||||
assert!(c.descriptor.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn name_sharp() {
|
|
||||||
let c = Chord { root: crate::Note::FSharpGFlat, descriptor: Some("m".into()) };
|
|
||||||
assert_eq!(c.name(true), "F#m");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn name_flat() {
|
|
||||||
let c = Chord { root: crate::Note::ASharpBFlat, descriptor: None };
|
|
||||||
assert_eq!(c.name(false), "Bb");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_flat_with_descriptor() {
|
|
||||||
let c = Chord::parse("Bbm").unwrap();
|
|
||||||
assert_eq!(c.root, crate::Note::ASharpBFlat);
|
|
||||||
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
crates/domain/src/errors/mod.rs
Normal file
19
crates/domain/src/errors/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DomainError {
|
||||||
|
#[error("Entity not found")]
|
||||||
|
NotFound,
|
||||||
|
|
||||||
|
#[error("Business rule violation: {0}")]
|
||||||
|
ValidationError(String),
|
||||||
|
|
||||||
|
#[error("Infrastructure failure: {0}")]
|
||||||
|
InfrastructureError(String),
|
||||||
|
|
||||||
|
#[error("Unauthorized: {0}")]
|
||||||
|
Unauthorized(String),
|
||||||
|
|
||||||
|
#[error("Forbidden: {0}")]
|
||||||
|
Forbidden(String),
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
pub mod note;
|
pub mod errors;
|
||||||
pub mod chord;
|
pub mod models;
|
||||||
pub mod song;
|
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod transposer;
|
pub mod services;
|
||||||
|
pub mod value_objects;
|
||||||
|
|
||||||
pub use note::Note;
|
pub use errors::DomainError;
|
||||||
pub use chord::Chord;
|
pub use models::{
|
||||||
pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song};
|
ChordPosition, GeneratedToken, LyricLine, RefreshSession, Section, SectionKind, Song, SongMeta,
|
||||||
pub use song::{song_preview_chords, StoredSong, SongSummary};
|
SongSummary, StoredSong, User, song_preview_chords,
|
||||||
pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource};
|
};
|
||||||
pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort, SortField, SortOrder};
|
pub use ports::{
|
||||||
pub use transposer::{ChordTransposer, TransposeError};
|
AuthService, FetchError, ParseError, PasswordHasher, RefreshSessionRepository,
|
||||||
|
SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource, UserRepository,
|
||||||
|
};
|
||||||
|
pub use services::{ChordTransposer, TransposeError};
|
||||||
|
pub use value_objects::{
|
||||||
|
Chord, Email, Note, Password, PasswordHash, SortField, SortOrder, UserId, Username,
|
||||||
|
};
|
||||||
|
|||||||
7
crates/domain/src/models/mod.rs
Normal file
7
crates/domain/src/models/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod refresh_session;
|
||||||
|
pub mod song;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
|
pub use refresh_session::*;
|
||||||
|
pub use song::*;
|
||||||
|
pub use user::*;
|
||||||
17
crates/domain/src/models/refresh_session.rs
Normal file
17
crates/domain/src/models/refresh_session.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::value_objects::UserId;
|
||||||
|
|
||||||
|
pub struct GeneratedToken {
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RefreshSession {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::Chord;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::value_objects::Chord;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ChordPosition {
|
pub struct ChordPosition {
|
||||||
@@ -16,8 +18,14 @@ pub struct LyricLine {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SectionKind {
|
pub enum SectionKind {
|
||||||
Verse, Chorus, Bridge, PreChorus,
|
Verse,
|
||||||
Intro, Outro, Break, Tab,
|
Chorus,
|
||||||
|
Bridge,
|
||||||
|
PreChorus,
|
||||||
|
Intro,
|
||||||
|
Outro,
|
||||||
|
Break,
|
||||||
|
Tab,
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +68,6 @@ pub struct Song {
|
|||||||
pub sections: Vec<Section>,
|
pub sections: Vec<Section>,
|
||||||
}
|
}
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StoredSong {
|
pub struct StoredSong {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -95,28 +101,5 @@ pub fn song_preview_chords(song: &Song) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/song.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use crate::{Chord, Note};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lyric_line_chord_positions() {
|
|
||||||
let line = LyricLine {
|
|
||||||
text: "A drop in the ocean".into(),
|
|
||||||
chords: vec![
|
|
||||||
ChordPosition { offset: 0, chord: Chord { root: Note::E, descriptor: Some("m".into()) } },
|
|
||||||
ChordPosition { offset: 8, chord: Chord { root: Note::C, descriptor: None } },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
assert_eq!(line.chords[0].offset, 0);
|
|
||||||
assert_eq!(line.chords[1].offset, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn section_kind_from_label() {
|
|
||||||
assert_eq!(SectionKind::from_label("Chorus"), SectionKind::Chorus);
|
|
||||||
assert_eq!(SectionKind::from_label("Pre-Chorus"), SectionKind::PreChorus);
|
|
||||||
assert_eq!(SectionKind::from_label("Tab"), SectionKind::Tab);
|
|
||||||
assert_eq!(SectionKind::from_label("Riff"), SectionKind::Other("Riff".into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
50
crates/domain/src/models/user.rs
Normal file
50
crates/domain/src/models/user.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use crate::value_objects::{Email, PasswordHash, UserId, Username};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct User {
|
||||||
|
id: UserId,
|
||||||
|
email: Email,
|
||||||
|
username: Username,
|
||||||
|
password_hash: PasswordHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub fn new(email: Email, username: Username, password_hash: PasswordHash) -> Self {
|
||||||
|
Self {
|
||||||
|
id: UserId::generate(),
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: UserId,
|
||||||
|
email: Email,
|
||||||
|
username: Username,
|
||||||
|
password_hash: PasswordHash,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &UserId {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn email(&self) -> &Email {
|
||||||
|
&self.email
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn username(&self) -> &Username {
|
||||||
|
&self.username
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn password_hash(&self) -> &PasswordHash {
|
||||||
|
&self.password_hash
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Note {
|
|
||||||
A, ASharpBFlat, B, C, CSharpDFlat, D,
|
|
||||||
DSharpEFlat, E, F, FSharpGFlat, G, GSharpAFlat,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Note {
|
|
||||||
pub fn semitone(&self) -> u8 {
|
|
||||||
match self {
|
|
||||||
Note::C => 0, Note::CSharpDFlat => 1, Note::D => 2,
|
|
||||||
Note::DSharpEFlat => 3, Note::E => 4, Note::F => 5,
|
|
||||||
Note::FSharpGFlat => 6, Note::G => 7, Note::GSharpAFlat => 8,
|
|
||||||
Note::A => 9, Note::ASharpBFlat => 10, Note::B => 11,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_semitone(s: u8) -> Note {
|
|
||||||
match s % 12 {
|
|
||||||
0 => Note::C, 1 => Note::CSharpDFlat, 2 => Note::D,
|
|
||||||
3 => Note::DSharpEFlat, 4 => Note::E, 5 => Note::F,
|
|
||||||
6 => Note::FSharpGFlat, 7 => Note::G, 8 => Note::GSharpAFlat,
|
|
||||||
9 => Note::A, 10 => Note::ASharpBFlat, 11 => Note::B,
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_sharp_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Note::C => "C", Note::CSharpDFlat => "C#", Note::D => "D",
|
|
||||||
Note::DSharpEFlat => "D#", Note::E => "E", Note::F => "F",
|
|
||||||
Note::FSharpGFlat => "F#", Note::G => "G", Note::GSharpAFlat => "G#",
|
|
||||||
Note::A => "A", Note::ASharpBFlat => "A#", Note::B => "B",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_flat_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Note::C => "C", Note::CSharpDFlat => "Db", Note::D => "D",
|
|
||||||
Note::DSharpEFlat => "Eb", Note::E => "E", Note::F => "F",
|
|
||||||
Note::FSharpGFlat => "Gb", Note::G => "G", Note::GSharpAFlat => "Ab",
|
|
||||||
Note::A => "A", Note::ASharpBFlat => "Bb", Note::B => "B",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse just the note portion from the start of a string.
|
|
||||||
/// Returns (Note, chars_consumed) or None.
|
|
||||||
pub fn parse_prefix(s: &str) -> Option<(Note, usize)> {
|
|
||||||
let mut chars = s.chars();
|
|
||||||
let root = match chars.next()? {
|
|
||||||
'A' => Note::A, 'B' => Note::B, 'C' => Note::C, 'D' => Note::D,
|
|
||||||
'E' => Note::E, 'F' => Note::F, 'G' => Note::G, _ => return None,
|
|
||||||
};
|
|
||||||
match chars.next() {
|
|
||||||
Some('#') => Some((Self::sharp_of(root), 2)),
|
|
||||||
Some('b') if s.len() > 1 => {
|
|
||||||
let flatted = Self::flat_of(root)?;
|
|
||||||
Some((flatted, 2))
|
|
||||||
}
|
|
||||||
_ => Some((root, 1)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(s: &str) -> Option<Note> {
|
|
||||||
let (note, consumed) = Self::parse_prefix(s)?;
|
|
||||||
if consumed == s.len() { Some(note) } else { None }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sharp_of(root: Note) -> Note {
|
|
||||||
Note::from_semitone((root.semitone() + 1) % 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flat_of(root: Note) -> Option<Note> {
|
|
||||||
Some(Note::from_semitone((root.semitone() + 11) % 12))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn semitone_roundtrip() {
|
|
||||||
assert_eq!(Note::from_semitone(Note::A.semitone()), Note::A);
|
|
||||||
assert_eq!(Note::from_semitone(Note::FSharpGFlat.semitone()), Note::FSharpGFlat);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_cb_enharmonic() {
|
|
||||||
assert_eq!(Note::parse("Cb"), Some(Note::B));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sharp_display() {
|
|
||||||
assert_eq!(Note::ASharpBFlat.to_sharp_str(), "A#");
|
|
||||||
assert_eq!(Note::FSharpGFlat.to_sharp_str(), "F#");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn flat_display() {
|
|
||||||
assert_eq!(Note::ASharpBFlat.to_flat_str(), "Bb");
|
|
||||||
assert_eq!(Note::CSharpDFlat.to_flat_str(), "Db");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_note() {
|
|
||||||
assert_eq!(Note::parse("F#"), Some(Note::FSharpGFlat));
|
|
||||||
assert_eq!(Note::parse("Gb"), Some(Note::FSharpGFlat));
|
|
||||||
assert_eq!(Note::parse("A"), Some(Note::A));
|
|
||||||
assert_eq!(Note::parse("X"), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use thiserror::Error;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use crate::song::Song;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum TabSource {
|
|
||||||
File(PathBuf),
|
|
||||||
Url(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum FetchError {
|
|
||||||
#[error("IO error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
#[error("Network error: {0}")]
|
|
||||||
Network(String),
|
|
||||||
#[error("Response is not HTML")]
|
|
||||||
InvalidContentType,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum ParseError {
|
|
||||||
#[error("Tab content not found in HTML")]
|
|
||||||
MissingContent,
|
|
||||||
#[error("Malformed HTML: {0}")]
|
|
||||||
MalformedHtml(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait TabFetcherPort: Send + Sync {
|
|
||||||
async fn fetch(&self, source: TabSource) -> Result<String, FetchError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait TabParserPort: Send + Sync {
|
|
||||||
fn parse(&self, html: &str) -> Result<Song, ParseError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
use crate::song::{StoredSong, SongSummary};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum SortField {
|
|
||||||
#[default]
|
|
||||||
Date,
|
|
||||||
Title,
|
|
||||||
Artist,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum SortOrder {
|
|
||||||
#[default]
|
|
||||||
Desc,
|
|
||||||
Asc,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum RepositoryError {
|
|
||||||
#[error("Song not found")]
|
|
||||||
NotFound,
|
|
||||||
#[error("Database error: {0}")]
|
|
||||||
Internal(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait SongRepositoryPort: Send + Sync {
|
|
||||||
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError>;
|
|
||||||
async fn list(&self, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
|
|
||||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError>;
|
|
||||||
async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>;
|
|
||||||
async fn update_meta(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
title: Option<&str>,
|
|
||||||
artist: Option<&str>,
|
|
||||||
original_key: Option<&str>,
|
|
||||||
) -> Result<SongSummary, RepositoryError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait SongSearchPort: Send + Sync {
|
|
||||||
async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result<Vec<SongSummary>, RepositoryError>;
|
|
||||||
}
|
|
||||||
34
crates/domain/src/ports/auth.rs
Normal file
34
crates/domain/src/ports/auth.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::errors::DomainError;
|
||||||
|
use crate::models::{GeneratedToken, RefreshSession, User};
|
||||||
|
use crate::value_objects::{Email, PasswordHash, UserId, Username};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AuthService: Send + Sync {
|
||||||
|
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
|
||||||
|
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PasswordHasher: Send + Sync {
|
||||||
|
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError>;
|
||||||
|
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserRepository: Send + Sync {
|
||||||
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||||
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
|
||||||
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
||||||
|
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RefreshSessionRepository: Send + Sync {
|
||||||
|
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>;
|
||||||
|
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError>;
|
||||||
|
async fn revoke(&self, token: &str) -> Result<(), DomainError>;
|
||||||
|
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||||
|
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||||
|
}
|
||||||
7
crates/domain/src/ports/mod.rs
Normal file
7
crates/domain/src/ports/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod repository;
|
||||||
|
pub mod tab_source;
|
||||||
|
|
||||||
|
pub use auth::*;
|
||||||
|
pub use repository::*;
|
||||||
|
pub use tab_source::*;
|
||||||
35
crates/domain/src/ports/repository.rs
Normal file
35
crates/domain/src/ports/repository.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::errors::DomainError;
|
||||||
|
use crate::models::{Song, SongSummary, StoredSong};
|
||||||
|
use crate::value_objects::{SortField, SortOrder};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SongRepositoryPort: Send + Sync {
|
||||||
|
async fn save(&self, song: &Song) -> Result<StoredSong, DomainError>;
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
sort: SortField,
|
||||||
|
order: SortOrder,
|
||||||
|
) -> Result<Vec<SongSummary>, DomainError>;
|
||||||
|
async fn get(&self, id: Uuid) -> Result<Option<Song>, DomainError>;
|
||||||
|
async fn delete(&self, id: Uuid) -> Result<(), DomainError>;
|
||||||
|
async fn update_meta(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
title: Option<&str>,
|
||||||
|
artist: Option<&str>,
|
||||||
|
original_key: Option<&str>,
|
||||||
|
) -> Result<SongSummary, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SongSearchPort: Send + Sync {
|
||||||
|
async fn search(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
sort: SortField,
|
||||||
|
order: SortOrder,
|
||||||
|
) -> Result<Vec<SongSummary>, DomainError>;
|
||||||
|
}
|
||||||
37
crates/domain/src/ports/tab_source.rs
Normal file
37
crates/domain/src/ports/tab_source.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use crate::models::Song;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TabSource {
|
||||||
|
File(PathBuf),
|
||||||
|
Url(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum FetchError {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("Network error: {0}")]
|
||||||
|
Network(String),
|
||||||
|
#[error("Response is not HTML")]
|
||||||
|
InvalidContentType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ParseError {
|
||||||
|
#[error("Tab content not found in HTML")]
|
||||||
|
MissingContent,
|
||||||
|
#[error("Malformed HTML: {0}")]
|
||||||
|
MalformedHtml(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TabFetcherPort: Send + Sync {
|
||||||
|
async fn fetch(&self, source: TabSource) -> Result<String, FetchError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait TabParserPort: Send + Sync {
|
||||||
|
fn parse(&self, html: &str) -> Result<Song, ParseError>;
|
||||||
|
}
|
||||||
3
crates/domain/src/services/mod.rs
Normal file
3
crates/domain/src/services/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod transposer;
|
||||||
|
|
||||||
|
pub use transposer::*;
|
||||||
86
crates/domain/src/services/transposer.rs
Normal file
86
crates/domain/src/services/transposer.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use crate::models::{ChordPosition, LyricLine, Section, Song};
|
||||||
|
use crate::value_objects::{Chord, Note};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub struct ChordTransposer;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum TransposeError {
|
||||||
|
#[error("Song has no original_key set")]
|
||||||
|
MissingOriginalKey,
|
||||||
|
#[error("Unrecognized key: {0}")]
|
||||||
|
UnrecognizedKey(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChordTransposer {
|
||||||
|
pub fn transpose_chord(&self, chord: &Chord, semitones: i8) -> Chord {
|
||||||
|
let new_semitone = (chord.root.semitone() as i16 + semitones as i16).rem_euclid(12) as u8;
|
||||||
|
Chord {
|
||||||
|
root: Note::from_semitone(new_semitone),
|
||||||
|
descriptor: chord.descriptor.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transpose_song(&self, song: &Song, semitones: i8) -> Song {
|
||||||
|
Song {
|
||||||
|
meta: song.meta.clone(),
|
||||||
|
sections: song
|
||||||
|
.sections
|
||||||
|
.iter()
|
||||||
|
.map(|s| self.transpose_section(s, semitones))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transpose_to_key(&self, song: &Song, target_key: &str) -> Result<Song, TransposeError> {
|
||||||
|
let original = song
|
||||||
|
.meta
|
||||||
|
.original_key
|
||||||
|
.as_deref()
|
||||||
|
.ok_or(TransposeError::MissingOriginalKey)?;
|
||||||
|
let from = Note::parse(Self::root_of(original))
|
||||||
|
.ok_or_else(|| TransposeError::UnrecognizedKey(original.to_string()))?;
|
||||||
|
let to = Note::parse(Self::root_of(target_key))
|
||||||
|
.ok_or_else(|| TransposeError::UnrecognizedKey(target_key.to_string()))?;
|
||||||
|
let semitones = (to.semitone() as i16 - from.semitone() as i16).rem_euclid(12) as i8;
|
||||||
|
Ok(self.transpose_song(song, semitones))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_of(key: &str) -> &str {
|
||||||
|
if key.len() >= 2 && (key.as_bytes()[1] == b'#' || key.as_bytes()[1] == b'b') {
|
||||||
|
&key[..2]
|
||||||
|
} else {
|
||||||
|
&key[..1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transpose_section(&self, section: &Section, semitones: i8) -> Section {
|
||||||
|
Section {
|
||||||
|
kind: section.kind.clone(),
|
||||||
|
label: section.label.clone(),
|
||||||
|
lines: section
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| self.transpose_line(l, semitones))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transpose_line(&self, line: &LyricLine, semitones: i8) -> LyricLine {
|
||||||
|
LyricLine {
|
||||||
|
text: line.text.clone(),
|
||||||
|
chords: line
|
||||||
|
.chords
|
||||||
|
.iter()
|
||||||
|
.map(|cp| ChordPosition {
|
||||||
|
offset: cp.offset,
|
||||||
|
chord: self.transpose_chord(&cp.chord, semitones),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/transposer.rs"]
|
||||||
|
mod tests;
|
||||||
47
crates/domain/src/tests/chord.rs
Normal file
47
crates/domain/src/tests/chord.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_simple() {
|
||||||
|
let c = Chord::parse("Em").unwrap();
|
||||||
|
assert_eq!(c.root, crate::value_objects::Note::E);
|
||||||
|
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_no_descriptor() {
|
||||||
|
let c = Chord::parse("G").unwrap();
|
||||||
|
assert_eq!(c.root, crate::value_objects::Note::G);
|
||||||
|
assert!(c.descriptor.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_flat_root() {
|
||||||
|
let c = Chord::parse("Bb").unwrap();
|
||||||
|
assert_eq!(c.root, crate::value_objects::Note::ASharpBFlat);
|
||||||
|
assert!(c.descriptor.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_sharp() {
|
||||||
|
let c = Chord {
|
||||||
|
root: crate::value_objects::Note::FSharpGFlat,
|
||||||
|
descriptor: Some("m".into()),
|
||||||
|
};
|
||||||
|
assert_eq!(c.name(true), "F#m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_flat() {
|
||||||
|
let c = Chord {
|
||||||
|
root: crate::value_objects::Note::ASharpBFlat,
|
||||||
|
descriptor: None,
|
||||||
|
};
|
||||||
|
assert_eq!(c.name(false), "Bb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_flat_with_descriptor() {
|
||||||
|
let c = Chord::parse("Bbm").unwrap();
|
||||||
|
assert_eq!(c.root, crate::value_objects::Note::ASharpBFlat);
|
||||||
|
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
||||||
|
}
|
||||||
35
crates/domain/src/tests/note.rs
Normal file
35
crates/domain/src/tests/note.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semitone_roundtrip() {
|
||||||
|
assert_eq!(Note::from_semitone(Note::A.semitone()), Note::A);
|
||||||
|
assert_eq!(
|
||||||
|
Note::from_semitone(Note::FSharpGFlat.semitone()),
|
||||||
|
Note::FSharpGFlat
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cb_enharmonic() {
|
||||||
|
assert_eq!(Note::parse("Cb"), Some(Note::B));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sharp_display() {
|
||||||
|
assert_eq!(Note::ASharpBFlat.to_sharp_str(), "A#");
|
||||||
|
assert_eq!(Note::FSharpGFlat.to_sharp_str(), "F#");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flat_display() {
|
||||||
|
assert_eq!(Note::ASharpBFlat.to_flat_str(), "Bb");
|
||||||
|
assert_eq!(Note::CSharpDFlat.to_flat_str(), "Db");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_note() {
|
||||||
|
assert_eq!(Note::parse("F#"), Some(Note::FSharpGFlat));
|
||||||
|
assert_eq!(Note::parse("Gb"), Some(Note::FSharpGFlat));
|
||||||
|
assert_eq!(Note::parse("A"), Some(Note::A));
|
||||||
|
assert_eq!(Note::parse("X"), None);
|
||||||
|
}
|
||||||
41
crates/domain/src/tests/song.rs
Normal file
41
crates/domain/src/tests/song.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::value_objects::{Chord, Note};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lyric_line_chord_positions() {
|
||||||
|
let line = LyricLine {
|
||||||
|
text: "A drop in the ocean".into(),
|
||||||
|
chords: vec![
|
||||||
|
ChordPosition {
|
||||||
|
offset: 0,
|
||||||
|
chord: Chord {
|
||||||
|
root: Note::E,
|
||||||
|
descriptor: Some("m".into()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ChordPosition {
|
||||||
|
offset: 8,
|
||||||
|
chord: Chord {
|
||||||
|
root: Note::C,
|
||||||
|
descriptor: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_eq!(line.chords[0].offset, 0);
|
||||||
|
assert_eq!(line.chords[1].offset, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn section_kind_from_label() {
|
||||||
|
assert_eq!(SectionKind::from_label("Chorus"), SectionKind::Chorus);
|
||||||
|
assert_eq!(
|
||||||
|
SectionKind::from_label("Pre-Chorus"),
|
||||||
|
SectionKind::PreChorus
|
||||||
|
);
|
||||||
|
assert_eq!(SectionKind::from_label("Tab"), SectionKind::Tab);
|
||||||
|
assert_eq!(
|
||||||
|
SectionKind::from_label("Riff"),
|
||||||
|
SectionKind::Other("Riff".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
68
crates/domain/src/tests/transposer.rs
Normal file
68
crates/domain/src/tests/transposer.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::value_objects::{Chord, Note};
|
||||||
|
|
||||||
|
fn chord(s: &str) -> Chord {
|
||||||
|
Chord::parse(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_two_semitones() {
|
||||||
|
let t = ChordTransposer;
|
||||||
|
let result = t.transpose_chord(&chord("Em"), 2);
|
||||||
|
assert_eq!(result.name(true), "F#m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn down_two_semitones() {
|
||||||
|
let t = ChordTransposer;
|
||||||
|
let result = t.transpose_chord(&chord("Em"), -2);
|
||||||
|
assert_eq!(result.name(false), "Dm");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_prefers_sharps() {
|
||||||
|
let t = ChordTransposer;
|
||||||
|
let result = t.transpose_chord(&chord("G"), 1);
|
||||||
|
assert_eq!(result.name(true), "G#");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn down_prefers_flats() {
|
||||||
|
let t = ChordTransposer;
|
||||||
|
let result = t.transpose_chord(&chord("G"), -1);
|
||||||
|
assert_eq!(result.name(false), "Gb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_unchanged() {
|
||||||
|
let t = ChordTransposer;
|
||||||
|
let result = t.transpose_chord(&chord("Am7"), 0);
|
||||||
|
assert_eq!(result.descriptor.as_deref(), Some("m7"));
|
||||||
|
assert_eq!(result.root, Note::A);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wraps_octave() {
|
||||||
|
let t = ChordTransposer;
|
||||||
|
let result = t.transpose_chord(&chord("B"), 1);
|
||||||
|
assert_eq!(result.name(true), "C");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transpose_to_key() {
|
||||||
|
let t = ChordTransposer;
|
||||||
|
let meta = crate::models::SongMeta {
|
||||||
|
title: "Test".into(),
|
||||||
|
artist: "Test".into(),
|
||||||
|
capo: None,
|
||||||
|
original_key: Some("G".into()),
|
||||||
|
tuning: None,
|
||||||
|
tempo: None,
|
||||||
|
};
|
||||||
|
let song = crate::models::Song {
|
||||||
|
meta,
|
||||||
|
sections: vec![],
|
||||||
|
};
|
||||||
|
let result = t.transpose_to_key(&song, "A").unwrap();
|
||||||
|
assert_eq!(result.sections.len(), 0);
|
||||||
|
}
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
use thiserror::Error;
|
|
||||||
use crate::{Chord, Note, Song, Section, LyricLine, ChordPosition};
|
|
||||||
|
|
||||||
pub struct ChordTransposer;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum TransposeError {
|
|
||||||
#[error("Song has no original_key set")]
|
|
||||||
MissingOriginalKey,
|
|
||||||
#[error("Unrecognized key: {0}")]
|
|
||||||
UnrecognizedKey(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChordTransposer {
|
|
||||||
pub fn transpose_chord(&self, chord: &Chord, semitones: i8) -> Chord {
|
|
||||||
let new_semitone = (chord.root.semitone() as i16 + semitones as i16).rem_euclid(12) as u8;
|
|
||||||
Chord {
|
|
||||||
root: Note::from_semitone(new_semitone),
|
|
||||||
descriptor: chord.descriptor.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn transpose_song(&self, song: &Song, semitones: i8) -> Song {
|
|
||||||
Song {
|
|
||||||
meta: song.meta.clone(),
|
|
||||||
sections: song.sections.iter().map(|s| self.transpose_section(s, semitones)).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn transpose_to_key(&self, song: &Song, target_key: &str) -> Result<Song, TransposeError> {
|
|
||||||
let original = song.meta.original_key.as_deref()
|
|
||||||
.ok_or(TransposeError::MissingOriginalKey)?;
|
|
||||||
let from = Note::parse(Self::root_of(original))
|
|
||||||
.ok_or_else(|| TransposeError::UnrecognizedKey(original.to_string()))?;
|
|
||||||
let to = Note::parse(Self::root_of(target_key))
|
|
||||||
.ok_or_else(|| TransposeError::UnrecognizedKey(target_key.to_string()))?;
|
|
||||||
let semitones = (to.semitone() as i16 - from.semitone() as i16).rem_euclid(12) as i8;
|
|
||||||
Ok(self.transpose_song(song, semitones))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn root_of(key: &str) -> &str {
|
|
||||||
if key.len() >= 2 && (key.as_bytes()[1] == b'#' || key.as_bytes()[1] == b'b') {
|
|
||||||
&key[..2]
|
|
||||||
} else {
|
|
||||||
&key[..1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transpose_section(&self, section: &Section, semitones: i8) -> Section {
|
|
||||||
Section {
|
|
||||||
kind: section.kind.clone(),
|
|
||||||
label: section.label.clone(),
|
|
||||||
lines: section.lines.iter().map(|l| self.transpose_line(l, semitones)).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transpose_line(&self, line: &LyricLine, semitones: i8) -> LyricLine {
|
|
||||||
LyricLine {
|
|
||||||
text: line.text.clone(),
|
|
||||||
chords: line.chords.iter().map(|cp| ChordPosition {
|
|
||||||
offset: cp.offset,
|
|
||||||
chord: self.transpose_chord(&cp.chord, semitones),
|
|
||||||
}).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{Chord, Note};
|
|
||||||
|
|
||||||
fn chord(s: &str) -> Chord { Chord::parse(s).unwrap() }
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn up_two_semitones() {
|
|
||||||
let t = ChordTransposer;
|
|
||||||
let result = t.transpose_chord(&chord("Em"), 2);
|
|
||||||
assert_eq!(result.name(true), "F#m");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn down_two_semitones() {
|
|
||||||
let t = ChordTransposer;
|
|
||||||
let result = t.transpose_chord(&chord("Em"), -2);
|
|
||||||
assert_eq!(result.name(false), "Dm");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn up_prefers_sharps() {
|
|
||||||
let t = ChordTransposer;
|
|
||||||
let result = t.transpose_chord(&chord("G"), 1);
|
|
||||||
assert_eq!(result.name(true), "G#");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn down_prefers_flats() {
|
|
||||||
let t = ChordTransposer;
|
|
||||||
let result = t.transpose_chord(&chord("G"), -1);
|
|
||||||
assert_eq!(result.name(false), "Gb");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zero_unchanged() {
|
|
||||||
let t = ChordTransposer;
|
|
||||||
let result = t.transpose_chord(&chord("Am7"), 0);
|
|
||||||
assert_eq!(result.descriptor.as_deref(), Some("m7"));
|
|
||||||
assert_eq!(result.root, Note::A);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wraps_octave() {
|
|
||||||
let t = ChordTransposer;
|
|
||||||
let result = t.transpose_chord(&chord("B"), 1);
|
|
||||||
assert_eq!(result.name(true), "C");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transpose_to_key() {
|
|
||||||
let t = ChordTransposer;
|
|
||||||
let meta = crate::SongMeta {
|
|
||||||
title: "Test".into(),
|
|
||||||
artist: "Test".into(),
|
|
||||||
capo: None,
|
|
||||||
original_key: Some("G".into()),
|
|
||||||
tuning: None,
|
|
||||||
tempo: None,
|
|
||||||
};
|
|
||||||
let song = crate::Song { meta, sections: vec![] };
|
|
||||||
let result = t.transpose_to_key(&song, "A").unwrap();
|
|
||||||
assert_eq!(result.sections.len(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
50
crates/domain/src/value_objects/chord.rs
Normal file
50
crates/domain/src/value_objects/chord.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use super::Note;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(into = "String", try_from = "String")]
|
||||||
|
pub struct Chord {
|
||||||
|
pub root: Note,
|
||||||
|
pub descriptor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chord {
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
let (root, consumed) = Note::parse_prefix(s)?;
|
||||||
|
let descriptor = if consumed < s.len() {
|
||||||
|
Some(s[consumed..].to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Some(Chord { root, descriptor })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self, use_sharps: bool) -> String {
|
||||||
|
let root_str = if use_sharps {
|
||||||
|
self.root.to_sharp_str()
|
||||||
|
} else {
|
||||||
|
self.root.to_flat_str()
|
||||||
|
};
|
||||||
|
match &self.descriptor {
|
||||||
|
Some(d) => format!("{}{}", root_str, d),
|
||||||
|
None => root_str.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Chord> for String {
|
||||||
|
fn from(c: Chord) -> String {
|
||||||
|
c.name(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for Chord {
|
||||||
|
type Error = String;
|
||||||
|
fn try_from(s: String) -> Result<Self, Self::Error> {
|
||||||
|
Chord::parse(&s).ok_or_else(|| format!("invalid chord: {}", s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/chord.rs"]
|
||||||
|
mod tests;
|
||||||
18
crates/domain/src/value_objects/ids.rs
Normal file
18
crates/domain/src/value_objects/ids.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct UserId(Uuid);
|
||||||
|
|
||||||
|
impl UserId {
|
||||||
|
pub fn generate() -> Self {
|
||||||
|
Self(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_uuid(uuid: Uuid) -> Self {
|
||||||
|
Self(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> Uuid {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/domain/src/value_objects/mod.rs
Normal file
11
crates/domain/src/value_objects/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
mod chord;
|
||||||
|
mod ids;
|
||||||
|
mod note;
|
||||||
|
mod sorting;
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
pub use chord::*;
|
||||||
|
pub use ids::*;
|
||||||
|
pub use note::*;
|
||||||
|
pub use sorting::*;
|
||||||
|
pub use user::*;
|
||||||
129
crates/domain/src/value_objects/note.rs
Normal file
129
crates/domain/src/value_objects/note.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Note {
|
||||||
|
A,
|
||||||
|
ASharpBFlat,
|
||||||
|
B,
|
||||||
|
C,
|
||||||
|
CSharpDFlat,
|
||||||
|
D,
|
||||||
|
DSharpEFlat,
|
||||||
|
E,
|
||||||
|
F,
|
||||||
|
FSharpGFlat,
|
||||||
|
G,
|
||||||
|
GSharpAFlat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Note {
|
||||||
|
pub fn semitone(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Note::C => 0,
|
||||||
|
Note::CSharpDFlat => 1,
|
||||||
|
Note::D => 2,
|
||||||
|
Note::DSharpEFlat => 3,
|
||||||
|
Note::E => 4,
|
||||||
|
Note::F => 5,
|
||||||
|
Note::FSharpGFlat => 6,
|
||||||
|
Note::G => 7,
|
||||||
|
Note::GSharpAFlat => 8,
|
||||||
|
Note::A => 9,
|
||||||
|
Note::ASharpBFlat => 10,
|
||||||
|
Note::B => 11,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_semitone(s: u8) -> Note {
|
||||||
|
match s % 12 {
|
||||||
|
0 => Note::C,
|
||||||
|
1 => Note::CSharpDFlat,
|
||||||
|
2 => Note::D,
|
||||||
|
3 => Note::DSharpEFlat,
|
||||||
|
4 => Note::E,
|
||||||
|
5 => Note::F,
|
||||||
|
6 => Note::FSharpGFlat,
|
||||||
|
7 => Note::G,
|
||||||
|
8 => Note::GSharpAFlat,
|
||||||
|
9 => Note::A,
|
||||||
|
10 => Note::ASharpBFlat,
|
||||||
|
11 => Note::B,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_sharp_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Note::C => "C",
|
||||||
|
Note::CSharpDFlat => "C#",
|
||||||
|
Note::D => "D",
|
||||||
|
Note::DSharpEFlat => "D#",
|
||||||
|
Note::E => "E",
|
||||||
|
Note::F => "F",
|
||||||
|
Note::FSharpGFlat => "F#",
|
||||||
|
Note::G => "G",
|
||||||
|
Note::GSharpAFlat => "G#",
|
||||||
|
Note::A => "A",
|
||||||
|
Note::ASharpBFlat => "A#",
|
||||||
|
Note::B => "B",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_flat_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Note::C => "C",
|
||||||
|
Note::CSharpDFlat => "Db",
|
||||||
|
Note::D => "D",
|
||||||
|
Note::DSharpEFlat => "Eb",
|
||||||
|
Note::E => "E",
|
||||||
|
Note::F => "F",
|
||||||
|
Note::FSharpGFlat => "Gb",
|
||||||
|
Note::G => "G",
|
||||||
|
Note::GSharpAFlat => "Ab",
|
||||||
|
Note::A => "A",
|
||||||
|
Note::ASharpBFlat => "Bb",
|
||||||
|
Note::B => "B",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_prefix(s: &str) -> Option<(Note, usize)> {
|
||||||
|
let mut chars = s.chars();
|
||||||
|
let root = match chars.next()? {
|
||||||
|
'A' => Note::A,
|
||||||
|
'B' => Note::B,
|
||||||
|
'C' => Note::C,
|
||||||
|
'D' => Note::D,
|
||||||
|
'E' => Note::E,
|
||||||
|
'F' => Note::F,
|
||||||
|
'G' => Note::G,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
match chars.next() {
|
||||||
|
Some('#') => Some((Self::sharp_of(root), 2)),
|
||||||
|
Some('b') if s.len() > 1 => {
|
||||||
|
let flatted = Self::flat_of(root)?;
|
||||||
|
Some((flatted, 2))
|
||||||
|
}
|
||||||
|
_ => Some((root, 1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> Option<Note> {
|
||||||
|
let (note, consumed) = Self::parse_prefix(s)?;
|
||||||
|
if consumed == s.len() {
|
||||||
|
Some(note)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sharp_of(root: Note) -> Note {
|
||||||
|
Note::from_semitone((root.semitone() + 1) % 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flat_of(root: Note) -> Option<Note> {
|
||||||
|
Some(Note::from_semitone((root.semitone() + 11) % 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/note.rs"]
|
||||||
|
mod tests;
|
||||||
14
crates/domain/src/value_objects/sorting.rs
Normal file
14
crates/domain/src/value_objects/sorting.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum SortField {
|
||||||
|
#[default]
|
||||||
|
Date,
|
||||||
|
Title,
|
||||||
|
Artist,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum SortOrder {
|
||||||
|
#[default]
|
||||||
|
Desc,
|
||||||
|
Asc,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user