From bd3f2647354fbe9994f8cb80bb3b00e52d5758fa Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Fri, 10 Jul 2026 11:29:37 +0200 Subject: [PATCH] feat(catalog): browsable catalog v1 (known-users fan-out, one-tap adoption; incl. review cleanup pass) --- server/src/at_record_server.gleam | 3 +- server/src/at_record_server/browse.gleam | 214 ++++++++++++++++++ .../at_record_server/catalog_entities.gleam | 8 +- .../src/at_record_server/catalog_import.gleam | 13 +- server/src/at_record_server/clock.gleam | 9 + server/src/at_record_server/config_env.gleam | 32 ++- server/src/at_record_server/context.gleam | 2 + server/src/at_record_server/covers.gleam | 53 +++++ server/src/at_record_server/crate.gleam | 11 +- .../src/at_record_server/discogs_client.gleam | 41 +--- .../src/at_record_server/discogs_oauth.gleam | 13 +- server/src/at_record_server/external_id.gleam | 20 ++ .../src/at_record_server/handlers/amend.gleam | 19 +- .../at_record_server/handlers/browse.gleam | 203 +++++++++++++++++ .../at_record_server/handlers/discogs.gleam | 61 ++--- .../src/at_record_server/handlers/oauth.gleam | 7 + .../src/at_record_server/handlers/shelf.gleam | 5 +- server/src/at_record_server/known_users.gleam | 14 ++ .../at_record_server/known_users_memory.gleam | 43 ++++ .../known_users_postgres.gleam | 66 ++++++ .../oauth/sessions_memory.gleam | 9 +- .../oauth/sessions_postgres.gleam | 11 +- server/src/at_record_server/oauth/store.gleam | 9 +- server/src/at_record_server/promotion.gleam | 10 +- server/src/at_record_server/router.gleam | 3 + server/src/at_record_server/wiring.gleam | 2 + server/test/browse_test.gleam | 138 +++++++++++ web/priv/static/app.css | 12 + web/src/at_record_web.gleam | 19 +- web/src/at_record_web/effects.gleam | 98 +++++++- web/src/at_record_web/model.gleam | 27 +++ web/src/at_record_web/msg.gleam | 7 +- web/src/at_record_web/pages/browse.gleam | 95 ++++++++ web/src/at_record_web/route.gleam | 4 +- web/src/at_record_web/ui/components.gleam | 2 +- web/src/at_record_web/update.gleam | 106 +++++++-- web/src/at_record_web/view.gleam | 6 +- web/test/at_record_web_test.gleam | 94 +++++++- 38 files changed, 1306 insertions(+), 183 deletions(-) create mode 100644 server/src/at_record_server/browse.gleam create mode 100644 server/src/at_record_server/clock.gleam create mode 100644 server/src/at_record_server/external_id.gleam create mode 100644 server/src/at_record_server/handlers/browse.gleam create mode 100644 server/src/at_record_server/known_users.gleam create mode 100644 server/src/at_record_server/known_users_memory.gleam create mode 100644 server/src/at_record_server/known_users_postgres.gleam create mode 100644 server/test/browse_test.gleam create mode 100644 web/src/at_record_web/pages/browse.gleam diff --git a/server/src/at_record_server.gleam b/server/src/at_record_server.gleam index c1ff150..f4ea75b 100644 --- a/server/src/at_record_server.gleam +++ b/server/src/at_record_server.gleam @@ -19,7 +19,7 @@ pub fn main() -> Nil { let base_url = config_env.base_url(port) let client = atproto_client.client() let assert Ok(store) = store.start() - let #(sessions, discogs_creds) = config_env.stores() + let #(sessions, discogs_creds, known_users) = config_env.stores() let oauth = config.new( client:, @@ -38,6 +38,7 @@ pub fn main() -> Nil { constellation_host: config_env.constellation(), discogs_auth: config_env.discogs_auth(), discogs_creds:, + known_users:, oauth:, ) diff --git a/server/src/at_record_server/browse.gleam b/server/src/at_record_server/browse.gleam new file mode 100644 index 0000000..c076496 --- /dev/null +++ b/server/src/at_record_server/browse.gleam @@ -0,0 +1,214 @@ +//// The browse domain: a live `listRecords` fan-out over every repo the server +//// has seen log in, merged and deduped by Discogs id, annotated with the +//// viewer's owned/wanted state. The HTTP glue lives in `handlers/browse`. + +import at_record/gen/catalog/release as catalog_release +import at_record/gen/shelf/entry.{type ShelfEntry} +import at_record/storage.{type StoredItem} +import at_record_server/crate +import at_record_server/external_id +import at_record_server/known_users.{type KnownUser} +import atproto/blob.{type Blob} +import atproto/xrpc.{type Client} +import gleam/dict +import gleam/dynamic/decode +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/set.{type Set} +import gleam/string +import gleam/uri as gleam_uri + +// The per-user cap on the fan-out listRecords; the merged set is capped at 100. +const per_user_limit = "50" + +/// A decoded browse candidate: a foreign release plus the publisher it came +/// from, before dedup/ownership/encoding. +pub type BrowseRow { + BrowseRow( + uri: String, + cid: String, + title: String, + artist_display: Option(String), + genres: List(String), + styles: List(String), + released: Option(String), + country: Option(String), + cover: Option(Blob), + thumb_url: Option(String), + discogs_id: Option(String), + created_at: String, + publisher_did: String, + publisher_handle: String, + publisher_pds: String, + ) +} + +/// The viewer's crate reduced to what browse matching needs: which release uris +/// and Discogs ids they own vs want. +pub type Ownership { + Ownership( + owned_uris: Set(String), + wanted_uris: Set(String), + owned_ids: Set(String), + wanted_ids: Set(String), + ) +} + +pub fn empty_ownership() -> Ownership { + Ownership(set.new(), set.new(), set.new(), set.new()) +} + +/// Keep the first occurrence of each Discogs id; rows without one are all kept. +pub fn dedup(rows: List(BrowseRow)) -> List(BrowseRow) { + let #(_seen, kept) = + list.fold(rows, #(set.new(), []), fn(acc, row) { + let #(seen, kept) = acc + case row.discogs_id { + None -> #(seen, [row, ..kept]) + Some(id) -> + case set.contains(seen, id) { + True -> acc + False -> #(set.insert(seen, id), [row, ..kept]) + } + } + }) + list.reverse(kept) +} + +/// The viewer's owned/wanted state, keyed by both release uri and Discogs id so +/// a browse row matches on either. +pub fn ownership(stored: List(StoredItem(ShelfEntry))) -> Ownership { + let ids_by_entry = discogs_ids_by_entry(stored) + let entries = crate.fold(stored) + let of = fn(status) { list.filter(entries, fn(e) { e.status == status }) } + let uris = fn(es: List(crate.CrateEntry)) { + es + |> list.filter_map(fn(e) { + e.release |> option.map(fn(r) { r.uri }) |> option.to_result(Nil) + }) + |> set.from_list + } + let ids = fn(es: List(crate.CrateEntry)) { + es + |> list.flat_map(fn(e) { + dict.get(ids_by_entry, e.entry_id) |> result.unwrap([]) + }) + |> set.from_list + } + let owned = of(crate.Owned) + let wanted = of(crate.Wanted) + Ownership( + owned_uris: uris(owned), + wanted_uris: uris(wanted), + owned_ids: ids(owned), + wanted_ids: ids(wanted), + ) +} + +/// The #(owned, wanted) flags for a row against the viewer's crate. +pub fn flags(own: Ownership, row: BrowseRow) -> #(Bool, Bool) { + let owned = + set.contains(own.owned_uris, row.uri) + || matches_id(own.owned_ids, row.discogs_id) + let wanted = + set.contains(own.wanted_uris, row.uri) + || matches_id(own.wanted_ids, row.discogs_id) + #(owned, wanted) +} + +/// The 4-digit year at the head of a `released` string (e.g. "1991-03-27"). +pub fn year_from_released(released: Option(String)) -> Option(Int) { + released + |> option.then(fn(r) { + int.parse(string.slice(r, 0, 4)) |> option.from_result + }) +} + +fn matches_id(ids: Set(String), discogs_id: Option(String)) -> Bool { + case discogs_id { + Some(id) -> set.contains(ids, id) + None -> False + } +} + +fn discogs_ids_by_entry( + stored: List(StoredItem(ShelfEntry)), +) -> dict.Dict(String, List(String)) { + stored + |> list.filter(fn(s) { s.value.subject == None }) + |> list.map(fn(s) { + #( + s.rkey, + external_id.all(s.value.external_ids |> option.unwrap([]), "discogs"), + ) + }) + |> dict.from_list +} + +/// The release's Discogs id off its externalIds, if any. +pub fn discogs_id(value: catalog_release.CatalogRelease) -> Option(String) { + external_id.find(value.external_ids |> option.unwrap([]), "discogs") +} + +/// Per-user read: public listRecords, no token; any failure drops the user. +pub fn fetch_user_releases(client: Client, user: KnownUser) -> List(BrowseRow) { + let query = + gleam_uri.query_to_string([ + #("repo", user.did), + #("collection", catalog_release.collection), + #("limit", per_user_limit), + ]) + let url = user.pds <> "/xrpc/com.atproto.repo.listRecords?" <> query + { + use resp <- result.try(xrpc.get(client, url, None)) + xrpc.parse(resp.body, records_decoder()) + } + |> result.unwrap([]) + |> list.map(fn(r) { + let #(uri, cid, value) = r + to_browse_row(user, uri, cid, value) + }) +} + +fn records_decoder() -> decode.Decoder( + List(#(String, String, catalog_release.CatalogRelease)), +) { + let row = { + use uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + use value <- decode.field( + "value", + catalog_release.catalog_release_decoder(), + ) + decode.success(#(uri, cid, value)) + } + use records <- decode.field("records", decode.list(row)) + decode.success(records) +} + +fn to_browse_row( + user: KnownUser, + uri: String, + cid: String, + value: catalog_release.CatalogRelease, +) -> BrowseRow { + BrowseRow( + uri:, + cid:, + title: value.title, + artist_display: value.artist_display, + genres: value.genres |> option.unwrap([]), + styles: value.styles |> option.unwrap([]), + released: value.released, + country: value.country, + cover: value.cover, + thumb_url: value.thumb_url, + discogs_id: discogs_id(value), + created_at: value.created_at, + publisher_did: user.did, + publisher_handle: user.handle, + publisher_pds: user.pds, + ) +} diff --git a/server/src/at_record_server/catalog_entities.gleam b/server/src/at_record_server/catalog_entities.gleam index 12a1c75..c2c86cd 100644 --- a/server/src/at_record_server/catalog_entities.gleam +++ b/server/src/at_record_server/catalog_entities.gleam @@ -10,6 +10,7 @@ import at_record/gen/catalog/artist as catalog_artist import at_record/gen/catalog/genre as catalog_genre import at_record/gen/defs import at_record_server/discogs_client +import at_record_server/external_id import at_record_server/oauth/sessions.{type OauthSession} import at_record_server/provenance import atproto/repo @@ -80,10 +81,9 @@ pub fn own_artists( rows |> list.flat_map(fn(row) { let #(uri, cid, ids) = row - ids - |> list.filter(fn(x) { x.provider == "discogs" }) - |> list.map(fn(x) { - #(x.id, defs.CatalogRef(uri:, cid:, external_ids: None)) + external_id.all(ids, "discogs") + |> list.map(fn(id) { + #(id, defs.CatalogRef(uri:, cid:, external_ids: None)) }) }) |> dict.from_list diff --git a/server/src/at_record_server/catalog_import.gleam b/server/src/at_record_server/catalog_import.gleam index a9c862a..2a15c33 100644 --- a/server/src/at_record_server/catalog_import.gleam +++ b/server/src/at_record_server/catalog_import.gleam @@ -15,6 +15,7 @@ import at_record_server/discogs_client import at_record_server/discogs_creds.{type Creds} import at_record_server/discogs_oauth import at_record_server/event_log +import at_record_server/external_id import at_record_server/oauth/sessions.{type OauthSession} import at_record_server/promotion import at_record_server/provenance @@ -153,9 +154,9 @@ pub fn seen_discogs_ids( stored: List(storage.StoredItem(ShelfEntry)), ) -> Set(String) { stored - |> list.flat_map(fn(s) { s.value.external_ids |> option.unwrap([]) }) - |> list.filter(fn(x) { x.provider == "discogs" }) - |> list.map(fn(x) { x.id }) + |> list.flat_map(fn(s) { + external_id.all(s.value.external_ids |> option.unwrap([]), "discogs") + }) |> set.from_list } @@ -171,10 +172,8 @@ fn initial_state( stored |> list.filter(fn(s) { s.value.subject == None }) |> list.flat_map(fn(s) { - s.value.external_ids - |> option.unwrap([]) - |> list.filter(fn(x) { x.provider == "discogs" }) - |> list.map(fn(x) { #(x.id, s) }) + external_id.all(s.value.external_ids |> option.unwrap([]), "discogs") + |> list.map(fn(id) { #(id, s) }) }) |> dict.from_list let covered_rkeys = diff --git a/server/src/at_record_server/clock.gleam b/server/src/at_record_server/clock.gleam new file mode 100644 index 0000000..b276513 --- /dev/null +++ b/server/src/at_record_server/clock.gleam @@ -0,0 +1,9 @@ +//// Wall-clock time as plain Unix seconds, shared by TTL sweeps and signing. + +import gleam/float +import gleam/time/timestamp + +/// The current Unix time in whole seconds. +pub fn now_seconds() -> Int { + float.round(timestamp.to_unix_seconds(timestamp.system_time())) +} diff --git a/server/src/at_record_server/config_env.gleam b/server/src/at_record_server/config_env.gleam index 0abed33..0dbd742 100644 --- a/server/src/at_record_server/config_env.gleam +++ b/server/src/at_record_server/config_env.gleam @@ -3,6 +3,9 @@ //// falls back to dev defaults with a log line. import at_record_server/discogs_client +import at_record_server/known_users +import at_record_server/known_users_memory +import at_record_server/known_users_postgres import at_record_server/oauth/sessions.{type Store} import at_record_server/oauth/sessions_memory import at_record_server/oauth/sessions_postgres @@ -64,14 +67,17 @@ pub fn static_directory() -> String { priv <> "/static" } -/// The session store and the Discogs credential store: one shared Postgres -/// pool, no sweep ttl on the durable Discogs tokens, both encrypted at rest. -pub fn stores() -> #(Store, Store) { +/// The session store, the Discogs credential store, and the known-users store: +/// one shared Postgres pool, no sweep ttl on the durable Discogs tokens. The +/// two credential stores are encrypted at rest; known_users is public, so it is +/// not sealed. +pub fn stores() -> #(Store, Store, known_users.Store) { let key = store_key() - let #(session_store, discogs_store) = raw_stores() + let #(session_store, discogs_store, known) = raw_stores() #( sealed_store.wrap(session_store, key), sealed_store.wrap(discogs_store, key), + known, ) } @@ -88,11 +94,11 @@ fn store_key() -> gose.Key(String) { } } -fn raw_stores() -> #(Store, Store) { +fn raw_stores() -> #(Store, Store, known_users.Store) { case envoy.get("DATABASE_URL") { Ok(url) -> case postgres_stores(url) { - Ok(pair) -> pair + Ok(triple) -> triple Error(e) -> { wisp.log_error( "Postgres stores unavailable (" <> e <> "); using in-memory", @@ -109,25 +115,29 @@ fn raw_stores() -> #(Store, Store) { } } -fn postgres_stores(url: String) -> Result(#(Store, Store), String) { +fn postgres_stores( + url: String, +) -> Result(#(Store, Store, known_users.Store), String) { use conn <- result.try(sessions_postgres.connect_pool(url)) use session_store <- result.try(sessions_postgres.table_store( conn, "oauth_sessions", option.Some(sessions.ttl_seconds), )) - use discogs_store <- result.map(sessions_postgres.table_store( + use discogs_store <- result.try(sessions_postgres.table_store( conn, "discogs_creds", option.None, )) - #(session_store, discogs_store) + use known <- result.map(known_users_postgres.table_store(conn)) + #(session_store, discogs_store, known) } -fn memory_stores() -> #(Store, Store) { +fn memory_stores() -> #(Store, Store, known_users.Store) { let assert Ok(session_store) = sessions_memory.start() let assert Ok(discogs_store) = sessions_memory.start() - #(session_store, discogs_store) + let assert Ok(known) = known_users_memory.start() + #(session_store, discogs_store, known) } /// App-level Discogs auth from env. Absent is fine: search still works, just diff --git a/server/src/at_record_server/context.gleam b/server/src/at_record_server/context.gleam index e398546..9e34454 100644 --- a/server/src/at_record_server/context.gleam +++ b/server/src/at_record_server/context.gleam @@ -5,6 +5,7 @@ import at_record_server/catalog_deps import at_record_server/discogs_client +import at_record_server/known_users import at_record_server/oauth/authed import at_record_server/oauth/config.{type Config} import at_record_server/oauth/session_store @@ -40,6 +41,7 @@ pub type Context { atproto: Atproto, discogs: Discogs, catalog: catalog_deps.Deps, + known_users: known_users.Store, oauth: Config, ) } diff --git a/server/src/at_record_server/covers.gleam b/server/src/at_record_server/covers.gleam index c58a8a8..8a24228 100644 --- a/server/src/at_record_server/covers.gleam +++ b/server/src/at_record_server/covers.gleam @@ -2,7 +2,9 @@ //// record is self-contained instead of hotlinking a CDN that may rot. import at_record_server/discogs_client +import at_record_server/promotion import atproto/blob.{type Blob} +import atproto/identity import atproto/repo import atproto/xrpc.{type Client} import gleam/bit_array @@ -81,6 +83,57 @@ pub fn fetch_and_upload( |> option.from_result } +/// Copy a foreign repo's cover blob into our own: a record of ours can only +/// reference blobs in our own repo, so the bytes are re-uploaded from the +/// source PDS's public URL. None on any resolve/fetch/upload failure. +pub fn copy_foreign_cover( + fetch_client: Client, + resolver: String, + pds_client: Client, + pds: String, + token: String, + source_did: String, + cover: Blob, +) -> Option(Blob) { + case identity.resolve_pds(fetch_client, resolver, source_did) { + Error(_) -> option.None + Ok(source_pds) -> + fetch_and_upload( + fetch_client, + pds_client, + pds, + token, + blob.public_url(source_pds, source_did, cover), + ) + } +} + +/// Copy the cover of the record at `at_uri` into our repo, resolving the +/// source repo from the uri's DID. None when the uri does not parse. +pub fn copy_cover_by_uri( + fetch_client: Client, + resolver: String, + pds_client: Client, + pds: String, + token: String, + at_uri: String, + cover: Blob, +) -> Option(Blob) { + promotion.parse_at_uri(at_uri) + |> option.then(fn(parsed) { + let #(did, _, _) = parsed + copy_foreign_cover( + fetch_client, + resolver, + pds_client, + pds, + token, + did, + cover, + ) + }) +} + fn strip_params(content_type: String) -> String { case string.split_once(content_type, ";") { Ok(#(mime, _)) -> string.trim(mime) diff --git a/server/src/at_record_server/crate.gleam b/server/src/at_record_server/crate.gleam index 15d5003..65f2ef7 100644 --- a/server/src/at_record_server/crate.gleam +++ b/server/src/at_record_server/crate.gleam @@ -73,6 +73,14 @@ pub fn status_string(status: Status) -> String { } } +/// The genesis event action implied by a requested status string. +pub fn action_from_status(status: String) -> String { + case status { + "wanted" -> "wanted" + _ -> "acquired" + } +} + fn fold_group( genesis: StoredItem(ShelfEntry), group: List(StoredItem(ShelfEntry)), @@ -156,7 +164,8 @@ pub fn encode_entry(e: CrateEntry) -> Json { ) } -fn opt( +/// Encode an optional value as one JSON field, or nothing when absent. +pub fn opt( key: String, value: Option(a), encode: fn(a) -> Json, diff --git a/server/src/at_record_server/discogs_client.gleam b/server/src/at_record_server/discogs_client.gleam index e71f349..ec3f148 100644 --- a/server/src/at_record_server/discogs_client.gleam +++ b/server/src/at_record_server/discogs_client.gleam @@ -167,8 +167,12 @@ fn map_error(r: Result(a, b), f: fn(b) -> c) -> Result(a, c) { } } -fn search_decoder() -> decode.Decoder(SearchPage) { - use releases <- decode.field("results", decode.list(release_decoder())) +// The one paginated envelope every Discogs list endpoint shares. +fn paged_decoder( + field: String, + row: decode.Decoder(Release), +) -> decode.Decoder(SearchPage) { + use releases <- decode.field(field, decode.list(row)) use page <- decode.optional_field( "pagination", 1, @@ -182,6 +186,10 @@ fn search_decoder() -> decode.Decoder(SearchPage) { decode.success(SearchPage(releases:, page:, pages:)) } +fn search_decoder() -> decode.Decoder(SearchPage) { + paged_decoder("results", release_decoder()) +} + fn release_decoder() -> decode.Decoder(Release) { use discogs_id <- decode.field("id", decode.int) use display <- decode.field("title", decode.string) @@ -423,21 +431,7 @@ pub fn collection_page( } fn collection_decoder() -> decode.Decoder(SearchPage) { - use releases <- decode.field( - "releases", - decode.list(collection_row_decoder()), - ) - use page <- decode.optional_field( - "pagination", - 1, - decode.at(["page"], decode.int), - ) - use pages <- decode.optional_field( - "pagination", - 1, - decode.at(["pages"], decode.int), - ) - decode.success(SearchPage(releases:, page:, pages:)) + paged_decoder("releases", collection_row_decoder()) } fn collection_row_decoder() -> decode.Decoder(Release) { @@ -511,18 +505,7 @@ pub fn wantlist_page( } fn wantlist_decoder() -> decode.Decoder(SearchPage) { - use releases <- decode.field("wants", decode.list(wantlist_row_decoder())) - use page <- decode.optional_field( - "pagination", - 1, - decode.at(["page"], decode.int), - ) - use pages <- decode.optional_field( - "pagination", - 1, - decode.at(["pages"], decode.int), - ) - decode.success(SearchPage(releases:, page:, pages:)) + paged_decoder("wants", wantlist_row_decoder()) } fn wantlist_row_decoder() -> decode.Decoder(Release) { diff --git a/server/src/at_record_server/discogs_oauth.gleam b/server/src/at_record_server/discogs_oauth.gleam index 691bb6a..ff328dc 100644 --- a/server/src/at_record_server/discogs_oauth.gleam +++ b/server/src/at_record_server/discogs_oauth.gleam @@ -2,11 +2,11 @@ //// exchange, and identity lookup. PLAINTEXT signatures (all traffic is HTTPS), //// so signing is string assembly, not crypto; access tokens never expire. +import at_record_server/clock import at_record_server/discogs_client.{type Auth, type Sender, Auth} import gleam/bit_array import gleam/crypto import gleam/dynamic/decode -import gleam/float import gleam/http import gleam/http/request.{type Request} import gleam/int @@ -15,7 +15,6 @@ import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string -import gleam/time/timestamp import gleam/uri const api_host = "api.discogs.com" @@ -65,7 +64,7 @@ pub fn auth_header( /// A fresh header for a user-signed API request (collection reads, identity). pub fn user_header(auth: Auth, access: TokenPair) -> String { - auth_header(auth, Some(access), [], fresh_nonce(), now_seconds()) + auth_header(auth, Some(access), [], fresh_nonce(), clock.now_seconds()) } /// Step 1: obtain a request token, registering the callback URL. @@ -80,7 +79,7 @@ pub fn request_token( None, [#("oauth_callback", callback_url)], fresh_nonce(), - now_seconds(), + clock.now_seconds(), ) api_request(http.Get, "/oauth/request_token", []) |> request.set_header("authorization", header) @@ -107,7 +106,7 @@ pub fn access_token( Some(request), [#("oauth_verifier", verifier)], fresh_nonce(), - now_seconds(), + clock.now_seconds(), ) api_request(http.Post, "/oauth/access_token", []) |> request.set_header("authorization", header) @@ -175,7 +174,3 @@ fn run(req: Request(String), send: Sender) -> Result(String, String) { fn fresh_nonce() -> String { bit_array.base64_url_encode(crypto.strong_random_bytes(16), False) } - -fn now_seconds() -> Int { - float.round(timestamp.to_unix_seconds(timestamp.system_time())) -} diff --git a/server/src/at_record_server/external_id.gleam b/server/src/at_record_server/external_id.gleam new file mode 100644 index 0000000..e0e2a09 --- /dev/null +++ b/server/src/at_record_server/external_id.gleam @@ -0,0 +1,20 @@ +//// Provider-scoped lookups over a record's `externalIds` list. + +import at_record/gen/defs.{type ExternalId} +import gleam/list +import gleam/option.{type Option} + +/// The first id carried for `provider`, if any. +pub fn find(ids: List(ExternalId), provider: String) -> Option(String) { + ids + |> list.find(fn(x) { x.provider == provider }) + |> option.from_result + |> option.map(fn(x) { x.id }) +} + +/// Every id carried for `provider`, in order. +pub fn all(ids: List(ExternalId), provider: String) -> List(String) { + ids + |> list.filter(fn(x) { x.provider == provider }) + |> list.map(fn(x) { x.id }) +} diff --git a/server/src/at_record_server/handlers/amend.gleam b/server/src/at_record_server/handlers/amend.gleam index 16560eb..70af964 100644 --- a/server/src/at_record_server/handlers/amend.gleam +++ b/server/src/at_record_server/handlers/amend.gleam @@ -16,11 +16,10 @@ import at_record_server/covers import at_record_server/crate import at_record_server/discogs_client import at_record_server/event_log +import at_record_server/external_id import at_record_server/oauth/sessions.{type OauthSession} -import at_record_server/promotion import at_record_server/provenance import atproto/blob -import atproto/identity import atproto/repo import atproto/xrpc.{type Client} import gleam/bit_array @@ -249,9 +248,8 @@ fn refetch_cover( use discogs_id <- option.then( old.external_ids |> option.unwrap([]) - |> list.find(fn(x) { x.provider == "discogs" }) - |> option.from_result - |> option.then(fn(x) { int.parse(x.id) |> option.from_result }), + |> external_id.find("discogs") + |> option.then(fn(id) { int.parse(id) |> option.from_result }), ) let details = discogs_client.release_details( @@ -344,17 +342,14 @@ fn copy_foreign_cover( ) -> Option(blob.Blob) { use b <- option.then(cover) use ref <- option.then(old_ref) - use #(did, _, _) <- option.then(promotion.parse_at_uri(ref.uri)) - use pds <- option.then( - identity.resolve_pds(ctx.atproto.client, ctx.atproto.resolver, did) - |> option.from_result, - ) - covers.fetch_and_upload( + covers.copy_cover_by_uri( ctx.atproto.client, + ctx.atproto.resolver, client, session.pds, session.access_token, - blob.public_url(pds, did, b), + ref.uri, + b, ) } diff --git a/server/src/at_record_server/handlers/browse.gleam b/server/src/at_record_server/handlers/browse.gleam new file mode 100644 index 0000000..ce8aef9 --- /dev/null +++ b/server/src/at_record_server/handlers/browse.gleam @@ -0,0 +1,203 @@ +//// HTTP glue for cross-user browsing: session handling, the fan-out read via +//// the `browse` domain module, and the add-from-browse write. Adding from +//// browse writes a genesis event that references the already-known canonical +//// release, skipping the adopt-or-mint discovery the manual add path runs. + +import at_record/gen/catalog/release as catalog_release +import at_record/gen/defs +import at_record/gen/repo/strong_ref.{RepoStrongRef} +import at_record/gen/shelf/entry.{ShelfEntry} +import at_record_server/browse as browse_domain +import at_record_server/context.{ + type Context, error_json, require_session, with_pds_client, +} +import at_record_server/covers +import at_record_server/crate +import at_record_server/discogs_client +import at_record_server/event_log +import at_record_server/oauth/sessions.{type OauthSession} +import at_record_server/provenance +import atproto/blob +import atproto/uri +import atproto/xrpc.{type Client} +import gleam/dynamic/decode +import gleam/json +import gleam/list +import gleam/option.{None, Some} +import gleam/string +import wisp.{type Request, type Response} + +// --- GET /api/browse --- + +pub fn browse(req: Request, ctx: Context) -> Response { + use id, session <- require_session(req, ctx) + use client, session <- with_pds_client(ctx, id, session) + // The crate load is best-effort: a miss just means no owned/wanted badges. + let own = case event_log.load(client, session) { + Ok(stored) -> browse_domain.ownership(stored) + Error(_) -> browse_domain.empty_ownership() + } + let rows = + ctx.known_users.list() + |> list.flat_map(fn(user) { + browse_domain.fetch_user_releases(ctx.atproto.client, user) + }) + |> browse_domain.dedup + |> list.sort(fn(a, b) { string.compare(b.created_at, a.created_at) }) + |> list.take(100) + json.object([#("releases", json.array(rows, encode_row(own, _)))]) + |> json.to_string + |> wisp.json_response(200) +} + +fn encode_row( + own: browse_domain.Ownership, + row: browse_domain.BrowseRow, +) -> json.Json { + let #(owned, wanted) = browse_domain.flags(own, row) + let cover_url = + row.cover + |> option.map(fn(b) { + blob.public_url(row.publisher_pds, row.publisher_did, b) + }) + json.object( + list.flatten([ + [ + #("uri", json.string(row.uri)), + #("cid", json.string(row.cid)), + #("title", json.string(row.title)), + #("genres", json.array(row.genres, json.string)), + #("styles", json.array(row.styles, json.string)), + #("publisherDid", json.string(row.publisher_did)), + #("publisherHandle", json.string(row.publisher_handle)), + #("owned", json.bool(owned)), + #("wanted", json.bool(wanted)), + ], + crate.opt("artistDisplay", row.artist_display, json.string), + crate.opt("coverUrl", cover_url, json.string), + crate.opt("thumbUrl", row.thumb_url, json.string), + crate.opt("released", row.released, json.string), + crate.opt("country", row.country, json.string), + ]), + ) +} + +// --- POST /api/browse/add --- + +type AddForm { + AddForm(uri: String, cid: String, status: String) +} + +fn add_decoder() -> decode.Decoder(AddForm) { + use uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + use status <- decode.optional_field("status", "owned", decode.string) + decode.success(AddForm(uri:, cid:, status:)) +} + +pub fn add(req: Request, ctx: Context) -> Response { + use id, session <- require_session(req, ctx) + use body <- wisp.require_json(req) + case decode.run(body, add_decoder()) { + Error(_) -> error_json(400, "expected { uri, cid, status }") + Ok(form) -> do_add(ctx, id, session, form) + } +} + +fn do_add( + ctx: Context, + id: String, + session: OauthSession, + form: AddForm, +) -> Response { + use client, session <- with_pds_client(ctx, id, session) + // Verify-before-trust: the index can be stale, so re-fetch and pin its cid. + case ctx.catalog.fetch_release(form.uri) { + None -> error_json(404, "release not found") + Some(#(cid, value)) -> + write_adoption(ctx, client, session, form, cid, value) + } +} + +fn write_adoption( + ctx: Context, + client: Client, + session: OauthSession, + form: AddForm, + cid: String, + value: catalog_release.CatalogRelease, +) -> Response { + let action = crate.action_from_status(form.status) + let ours = string.contains(form.uri, session.did) + let cover = case value.cover, ours { + None, _ -> None + Some(b), True -> Some(b) + Some(b), False -> + covers.copy_cover_by_uri( + ctx.atproto.client, + ctx.atproto.resolver, + client, + session.pds, + session.access_token, + form.uri, + b, + ) + } + let snapshot = + defs.Snapshot( + title: value.title, + artist_display: value.artist_display |> option.unwrap(""), + year: browse_domain.year_from_released(value.released), + format: first_format(value), + thumb_url: value.thumb_url, + cover:, + ) + let external_ids = case browse_domain.discogs_id(value) { + Some(did) -> + Some([ + defs.ExternalId( + id: did, + provider: "discogs", + url: Some(discogs_client.release_url(did)), + ), + ]) + None -> None + } + let event = + ShelfEntry( + subject: None, + action:, + snapshot: Some(snapshot), + external_ids:, + media_grade: None, + sleeve_grade: None, + rating: None, + folder: None, + notes: None, + release: Some(defs.CatalogRef(uri: form.uri, cid:, external_ids: None)), + price: None, + counterparty: None, + source: Some( + provenance.source("adoption") + |> provenance.with_record(RepoStrongRef(uri: form.uri, cid:)), + ), + created_at: provenance.now_rfc3339(), + ) + case event_log.append(client, session, event) { + Error(_) -> error_json(502, "could not write event to PDS") + Ok(created) -> + json.object([#("entryId", json.string(uri.rkey(created.uri)))]) + |> json.to_string + |> wisp.json_response(201) + } +} + +fn first_format( + value: catalog_release.CatalogRelease, +) -> option.Option(String) { + value.formats + |> option.unwrap([]) + |> list.first + |> option.from_result + |> option.map(fn(f) { f.name }) +} diff --git a/server/src/at_record_server/handlers/discogs.gleam b/server/src/at_record_server/handlers/discogs.gleam index f851b68..f9595bd 100644 --- a/server/src/at_record_server/handlers/discogs.gleam +++ b/server/src/at_record_server/handlers/discogs.gleam @@ -9,6 +9,7 @@ import at_record_server/discogs_creds.{type Creds} import at_record_server/discogs_oauth.{TokenPair} import at_record_server/event_log import at_record_server/oauth/sessions.{type OauthSession} +import atproto/xrpc.{type Client} import gleam/dynamic/decode import gleam/int import gleam/json @@ -254,14 +255,32 @@ fn pending_from_cookie(req: Request) -> Option(discogs_oauth.TokenPair) { } } -// --- collection import --- +// --- collection / wantlist import --- pub fn import_collection(req: Request, ctx: Context) -> Response { + run_import(req, ctx, catalog_import.run) +} + +pub fn import_wantlist(req: Request, ctx: Context) -> Response { + run_import(req, ctx, catalog_import.run_wantlist) +} + +fn run_import( + req: Request, + ctx: Context, + run: fn(Context, Client, OauthSession, discogs_client.Auth, Creds, Int) -> + catalog_import.ImportRun, +) -> Response { use id, session <- require_session(req, ctx) use auth <- require_app_credential(ctx) case discogs_creds.fetch(ctx.discogs.creds, session.did) { None -> error_json(409, "connect discogs first") - Some(creds) -> do_import(ctx, id, session, auth, creds, cap_param(req)) + Some(creds) -> { + let cap = cap_param(req) + use client, session <- context.with_pds_client(ctx, id, session) + run(ctx, client, session, auth, creds, cap) + |> encode_run + } } } @@ -275,44 +294,6 @@ fn cap_param(req: Request) -> Int { } } -fn do_import( - ctx: Context, - id: String, - session: OauthSession, - auth: discogs_client.Auth, - creds: Creds, - cap: Int, -) -> Response { - use client, session <- context.with_pds_client(ctx, id, session) - catalog_import.run(ctx, client, session, auth, creds, cap) - |> encode_run -} - -// --- wantlist import --- - -pub fn import_wantlist(req: Request, ctx: Context) -> Response { - use id, session <- require_session(req, ctx) - use auth <- require_app_credential(ctx) - case discogs_creds.fetch(ctx.discogs.creds, session.did) { - None -> error_json(409, "connect discogs first") - Some(creds) -> - do_import_wantlist(ctx, id, session, auth, creds, cap_param(req)) - } -} - -fn do_import_wantlist( - ctx: Context, - id: String, - session: OauthSession, - auth: discogs_client.Auth, - creds: Creds, - cap: Int, -) -> Response { - use client, session <- context.with_pds_client(ctx, id, session) - catalog_import.run_wantlist(ctx, client, session, auth, creds, cap) - |> encode_run -} - fn encode_run(run: catalog_import.ImportRun) -> Response { json.object( list.flatten([ diff --git a/server/src/at_record_server/handlers/oauth.gleam b/server/src/at_record_server/handlers/oauth.gleam index ef0cc99..3f84e72 100644 --- a/server/src/at_record_server/handlers/oauth.gleam +++ b/server/src/at_record_server/handlers/oauth.gleam @@ -3,6 +3,7 @@ //// these are just the HTTP-facing glue. import at_record_server/context.{type Context, error_json} +import at_record_server/known_users import at_record_server/oauth/flow import at_record_server/oauth/session_store import at_record_server/oauth/sessions @@ -125,6 +126,12 @@ fn exchange_and_start_session( case session_store.create(ctx.oauth.sessions, oauth_session) { Error(e) -> error_json(502, "could not persist session: " <> e) Ok(id) -> { + // Record the login for the browse fan-out; never let it fail the login. + ctx.known_users.upsert(known_users.KnownUser( + did: oauth_session.did, + handle: oauth_session.handle, + pds: oauth_session.pds, + )) wisp.log_info("oauth session created for " <> tok.sub) wisp.redirect("/") |> session_store.set(req, id) diff --git a/server/src/at_record_server/handlers/shelf.gleam b/server/src/at_record_server/handlers/shelf.gleam index 6363eae..3ee7b22 100644 --- a/server/src/at_record_server/handlers/shelf.gleam +++ b/server/src/at_record_server/handlers/shelf.gleam @@ -300,10 +300,7 @@ fn do_add( form: AddForm, ) -> Response { use client, session <- with_pds_client(ctx, id, session) - let action = case form.status { - "wanted" -> "wanted" - _ -> "acquired" - } + let action = crate.action_from_status(form.status) let external_ids = case form.discogs_id { Some(id) -> Some([ diff --git a/server/src/at_record_server/known_users.gleam b/server/src/at_record_server/known_users.gleam new file mode 100644 index 0000000..2c8c26e --- /dev/null +++ b/server/src/at_record_server/known_users.gleam @@ -0,0 +1,14 @@ +//// The known-users port: everyone who has ever logged in, keyed by did, so the +//// browse fan-out has a set of repos to enumerate. Not secret (public dids and +//// handles), so it is not sealed. Backends live in known_users_memory and +//// known_users_postgres; wiring picks one at the composition root. + +pub type KnownUser { + KnownUser(did: String, handle: String, pds: String) +} + +/// A pluggable backend. Upsert is fire-and-forget (login must never fail on it); +/// list is best-effort and returns an empty list on any backend error. +pub type Store { + Store(upsert: fn(KnownUser) -> Nil, list: fn() -> List(KnownUser)) +} diff --git a/server/src/at_record_server/known_users_memory.gleam b/server/src/at_record_server/known_users_memory.gleam new file mode 100644 index 0000000..8e8ecbd --- /dev/null +++ b/server/src/at_record_server/known_users_memory.gleam @@ -0,0 +1,43 @@ +//// In-memory known_users.Store backend: an actor over a Dict keyed by did. +//// Lost on restart (use known_users_postgres to persist), but the data is +//// rebuildable, so that is acceptable. + +import at_record_server/known_users.{type KnownUser, type Store, Store} +import gleam/dict.{type Dict} +import gleam/erlang/process.{type Subject} +import gleam/otp/actor +import gleam/result + +type Msg { + Upsert(KnownUser) + List(Subject(List(KnownUser))) +} + +pub fn start() -> Result(Store, actor.StartError) { + use started <- result.map( + actor.new(dict.new()) |> actor.on_message(handle) |> actor.start, + ) + let subject = started.data + Store( + upsert: fn(user) { + process.send(subject, Upsert(user)) + Nil + }, + list: fn() { + actor.call(subject, waiting: 1000, sending: fn(reply) { List(reply) }) + }, + ) +} + +fn handle( + state: Dict(String, KnownUser), + msg: Msg, +) -> actor.Next(Dict(String, KnownUser), Msg) { + case msg { + Upsert(user) -> actor.continue(dict.insert(state, user.did, user)) + List(reply) -> { + process.send(reply, dict.values(state)) + actor.continue(state) + } + } +} diff --git a/server/src/at_record_server/known_users_postgres.gleam b/server/src/at_record_server/known_users_postgres.gleam new file mode 100644 index 0000000..26150fa --- /dev/null +++ b/server/src/at_record_server/known_users_postgres.gleam @@ -0,0 +1,66 @@ +//// Postgres known_users.Store backend (pog), over a create-if-not-exists table +//// keyed by did. Shares one pool with the session/discogs stores. Writes +//// swallow-and-log: this is derived, rebuildable data, so a failed upsert never +//// fails the login that triggered it. + +import at_record_server/clock +import at_record_server/known_users.{ + type KnownUser, type Store, KnownUser, Store, +} +import gleam/dynamic/decode +import gleam/result +import pog +import wisp + +pub fn table_store(conn: pog.Connection) -> Result(Store, String) { + use _ <- result.map(migrate(conn)) + Store(upsert: fn(user) { upsert(conn, user) }, list: fn() { list(conn) }) +} + +fn migrate(conn: pog.Connection) -> Result(Nil, String) { + pog.query( + "create table if not exists known_users ( + did text primary key, + handle text not null, + pds text not null, + first_seen bigint not null + )", + ) + |> pog.execute(conn) + |> result.replace_error("known_users migration failed") + |> result.map(fn(_) { Nil }) +} + +fn upsert(conn: pog.Connection, user: KnownUser) -> Nil { + let outcome = + pog.query( + "insert into known_users (did, handle, pds, first_seen) values ($1, $2, $3, $4) + on conflict (did) do update set handle = excluded.handle, pds = excluded.pds", + ) + |> pog.parameter(pog.text(user.did)) + |> pog.parameter(pog.text(user.handle)) + |> pog.parameter(pog.text(user.pds)) + |> pog.parameter(pog.int(clock.now_seconds())) + |> pog.execute(conn) + case outcome { + Ok(_) -> Nil + Error(_) -> wisp.log_warning("known_users upsert failed for " <> user.did) + } +} + +fn list(conn: pog.Connection) -> List(KnownUser) { + let row = { + use did <- decode.field("did", decode.string) + use handle <- decode.field("handle", decode.string) + use pds <- decode.field("pds", decode.string) + decode.success(KnownUser(did:, handle:, pds:)) + } + case + pog.query("select did, handle, pds from known_users") + |> pog.returning(row) + |> pog.execute(conn) + { + Ok(pog.Returned(rows:, ..)) -> rows + Error(_) -> [] + } +} diff --git a/server/src/at_record_server/oauth/sessions_memory.gleam b/server/src/at_record_server/oauth/sessions_memory.gleam index e9a2986..ac306d0 100644 --- a/server/src/at_record_server/oauth/sessions_memory.gleam +++ b/server/src/at_record_server/oauth/sessions_memory.gleam @@ -1,14 +1,13 @@ //// In-memory sessions.Store backend: an actor over a string key-value map, with //// a TTL sweep on each write. Lost on restart (use sessions_postgres to persist). +import at_record_server/clock import at_record_server/oauth/sessions.{type Store, Store} import gleam/dict.{type Dict} import gleam/erlang/process.{type Subject} -import gleam/float import gleam/option.{type Option} import gleam/otp/actor import gleam/result -import gleam/time/timestamp type Entry { Entry(at: Int, value: String) @@ -43,7 +42,7 @@ fn handle( ) -> actor.Next(Dict(String, Entry), Msg) { case msg { Save(id, value) -> { - let now = now_seconds() + let now = clock.now_seconds() let fresh = dict.filter(state, fn(_k, e) { now - e.at < sessions.ttl_seconds }) actor.continue(dict.insert(fresh, id, Entry(now, value))) @@ -56,7 +55,3 @@ fn handle( Remove(id) -> actor.continue(dict.delete(state, id)) } } - -fn now_seconds() -> Int { - float.round(timestamp.to_unix_seconds(timestamp.system_time())) -} diff --git a/server/src/at_record_server/oauth/sessions_postgres.gleam b/server/src/at_record_server/oauth/sessions_postgres.gleam index ef9868d..c124f28 100644 --- a/server/src/at_record_server/oauth/sessions_postgres.gleam +++ b/server/src/at_record_server/oauth/sessions_postgres.gleam @@ -2,13 +2,12 @@ //// (id, data, created_at) table; rows older than the ttl (when given) are //// swept on each write. One shared pool can back several table stores. +import at_record_server/clock import at_record_server/oauth/sessions.{type Store, Store} import gleam/dynamic/decode import gleam/erlang/process -import gleam/float import gleam/option.{type Option, None, Some} import gleam/result -import gleam/time/timestamp import pog pub fn connect_pool(database_url: String) -> Result(pog.Connection, String) { @@ -70,7 +69,7 @@ fn save( ) |> pog.parameter(pog.text(id)) |> pog.parameter(pog.text(value)) - |> pog.parameter(pog.int(now_seconds())) + |> pog.parameter(pog.int(clock.now_seconds())) |> pog.execute(conn) |> result.replace_error("could not persist row") |> result.map(fn(_) { Nil }) @@ -106,13 +105,9 @@ fn sweep(conn: pog.Connection, table: String, ttl: Option(Int)) -> Nil { Some(seconds) -> { let _ = pog.query("delete from " <> table <> " where created_at < $1") - |> pog.parameter(pog.int(now_seconds() - seconds)) + |> pog.parameter(pog.int(clock.now_seconds() - seconds)) |> pog.execute(conn) Nil } } } - -fn now_seconds() -> Int { - float.round(timestamp.to_unix_seconds(timestamp.system_time())) -} diff --git a/server/src/at_record_server/oauth/store.gleam b/server/src/at_record_server/oauth/store.gleam index 44bde5f..24d0363 100644 --- a/server/src/at_record_server/oauth/store.gleam +++ b/server/src/at_record_server/oauth/store.gleam @@ -2,13 +2,12 @@ //// per-flow DPoP key and PKCE verifier (never sent to the browser). Stashed on //// redirect, taken on callback; entries past the TTL are swept on each write. +import at_record_server/clock import gleam/dict.{type Dict} import gleam/erlang/process.{type Subject} -import gleam/float import gleam/option.{type Option} import gleam/otp/actor import gleam/result -import gleam/time/timestamp import gose // Pending flows live at most 10 minutes (the authorize window). @@ -53,7 +52,7 @@ fn handle( ) -> actor.Next(Dict(String, Entry), Msg) { case msg { Put(key, flow) -> { - let now = now_seconds() + let now = clock.now_seconds() let fresh = dict.filter(state, fn(_k, e) { now - e.at < ttl_seconds }) actor.continue(dict.insert(fresh, key, Entry(now, flow))) } @@ -65,10 +64,6 @@ fn handle( } } -fn now_seconds() -> Int { - float.round(timestamp.to_unix_seconds(timestamp.system_time())) -} - pub fn put(store: Store, key: String, flow: PendingFlow) -> Nil { actor.send(store.subject, Put(key, flow)) } diff --git a/server/src/at_record_server/promotion.gleam b/server/src/at_record_server/promotion.gleam index b86a682..301204d 100644 --- a/server/src/at_record_server/promotion.gleam +++ b/server/src/at_record_server/promotion.gleam @@ -9,6 +9,7 @@ import at_record/gen/defs import at_record_server/catalog_deps.{type Deps} import at_record_server/catalog_entities import at_record_server/discogs_client +import at_record_server/external_id import at_record_server/oauth/sessions.{type OauthSession} import at_record_server/provenance import atproto/blob.{type Blob} @@ -66,11 +67,10 @@ pub fn own_releases( rows |> list.flat_map(fn(row) { let #(uri, cid, ids, cover_size) = row - ids - |> list.filter(fn(x) { x.provider == "discogs" }) - |> list.map(fn(x) { + external_id.all(ids, "discogs") + |> list.map(fn(id) { #( - x.id, + id, OwnRelease( ref: defs.CatalogRef(uri:, cid:, external_ids: None), cover_size:, @@ -196,7 +196,7 @@ fn verify( use fetched <- option.then(deps.fetch_release(uri)) let #(cid, value) = fetched let ids = value.external_ids |> option.unwrap([]) - case list.any(ids, fn(x) { x.provider == "discogs" && x.id == id }) { + case external_id.all(ids, "discogs") |> list.contains(id) { True -> Some(defs.CatalogRef(uri:, cid:, external_ids: None)) False -> None } diff --git a/server/src/at_record_server/router.gleam b/server/src/at_record_server/router.gleam index 444445a..3c6555a 100644 --- a/server/src/at_record_server/router.gleam +++ b/server/src/at_record_server/router.gleam @@ -3,6 +3,7 @@ import at_record_server/context.{type Context, error_json} import at_record_server/handlers/amend +import at_record_server/handlers/browse import at_record_server/handlers/discogs import at_record_server/handlers/oauth import at_record_server/handlers/shelf @@ -31,6 +32,8 @@ pub fn handle_request(req: Request, ctx: Context) -> Response { ["api", "oauth", "callback"], Get -> oauth.callback(req, ctx) ["api", "oauth", "whoami"], Get -> oauth.whoami(req, ctx) ["api", "logout"], Post -> oauth.logout(req, ctx) + ["api", "browse"], Get -> browse.browse(req, ctx) + ["api", "browse", "add"], Post -> browse.add(req, ctx) ["api", "shelf"], Get -> shelf.list_shelf(req, ctx) ["api", "shelf"], Post -> shelf.add_shelf_item(req, ctx) ["api", "shelf", entry_id], Get -> shelf.shelf_timeline(req, ctx, entry_id) diff --git a/server/src/at_record_server/wiring.gleam b/server/src/at_record_server/wiring.gleam index b4d503c..03d4529 100644 --- a/server/src/at_record_server/wiring.gleam +++ b/server/src/at_record_server/wiring.gleam @@ -22,6 +22,7 @@ pub fn context( constellation_host constellation_host: String, discogs_auth discogs_auth, discogs_creds discogs_creds, + known_users known_users, oauth oauth, ) -> Context { Context( @@ -33,6 +34,7 @@ pub fn context( send: text_sender(client), ), catalog: catalog_deps(client, constellation_host, resolver), + known_users:, oauth:, ) } diff --git a/server/test/browse_test.gleam b/server/test/browse_test.gleam new file mode 100644 index 0000000..a4d448f --- /dev/null +++ b/server/test/browse_test.gleam @@ -0,0 +1,138 @@ +import at_record/gen/defs.{CatalogRef, ExternalId} +import at_record/gen/shelf/entry.{type ShelfEntry, ShelfEntry} +import at_record/storage.{type StoredItem, StoredItem} +import at_record_server/browse.{type BrowseRow, BrowseRow} +import gleam/list +import gleam/option.{type Option, None, Some} + +fn row( + uri uri: String, + discogs_id discogs_id: Option(String), + created_at created_at: String, +) -> BrowseRow { + BrowseRow( + uri:, + cid: "bafyrow", + title: "Title", + artist_display: None, + genres: [], + styles: [], + released: None, + country: None, + cover: None, + thumb_url: None, + discogs_id:, + created_at:, + publisher_did: "did:plc:pub", + publisher_handle: "pub.test", + publisher_pds: "https://pds.test", + ) +} + +fn genesis( + rkey rkey: String, + action action: String, + release_uri release_uri: Option(String), + discogs_ids discogs_ids: List(String), +) -> StoredItem(ShelfEntry) { + let external_ids = case discogs_ids { + [] -> None + ids -> + Some( + list.map(ids, fn(id) { ExternalId(id:, provider: "discogs", url: None) }), + ) + } + StoredItem( + uri: "at://did:plc:me/dev.mokkenstorm.crate.shelf.entry/" <> rkey, + cid: "bafy" <> rkey, + rkey:, + value: ShelfEntry( + subject: None, + action:, + snapshot: None, + external_ids:, + media_grade: None, + sleeve_grade: None, + rating: None, + folder: None, + notes: None, + release: release_uri + |> option.map(fn(u) { + CatalogRef(uri: u, cid: "bafyrel", external_ids: None) + }), + price: None, + counterparty: None, + source: None, + created_at: "2026-01-01T00:00:00Z", + ), + ) +} + +pub fn dedup_keeps_first_per_discogs_id_test() { + let rows = [ + row(uri: "at://a/1", discogs_id: Some("42"), created_at: "z"), + row(uri: "at://b/2", discogs_id: Some("42"), created_at: "y"), + row(uri: "at://c/3", discogs_id: Some("99"), created_at: "x"), + ] + let kept = browse.dedup(rows) + assert list.map(kept, fn(r) { r.uri }) == ["at://a/1", "at://c/3"] +} + +pub fn dedup_keeps_all_rows_without_discogs_id_test() { + let rows = [ + row(uri: "at://a/1", discogs_id: None, created_at: "z"), + row(uri: "at://b/2", discogs_id: None, created_at: "y"), + ] + let kept = browse.dedup(rows) + assert list.length(kept) == 2 +} + +pub fn year_from_released_parses_leading_four_digits_test() { + assert browse.year_from_released(Some("1991-03-27")) == Some(1991) + assert browse.year_from_released(Some("1991")) == Some(1991) +} + +pub fn year_from_released_rejects_absent_or_non_numeric_test() { + assert browse.year_from_released(None) == None + assert browse.year_from_released(Some("")) == None + assert browse.year_from_released(Some("abcd")) == None +} + +pub fn ownership_matches_by_release_uri_test() { + let stored = [ + genesis( + rkey: "3owned", + action: "acquired", + release_uri: Some("at://did:plc:pub/rel/1"), + discogs_ids: [], + ), + ] + let own = browse.ownership(stored) + let hit = + row(uri: "at://did:plc:pub/rel/1", discogs_id: None, created_at: "z") + let miss = + row(uri: "at://did:plc:pub/rel/2", discogs_id: None, created_at: "z") + assert browse.flags(own, hit) == #(True, False) + assert browse.flags(own, miss) == #(False, False) +} + +pub fn ownership_matches_by_discogs_id_across_status_test() { + let stored = [ + genesis(rkey: "3owned", action: "acquired", release_uri: None, discogs_ids: [ + "42", + ]), + genesis(rkey: "3wanted", action: "wanted", release_uri: None, discogs_ids: [ + "99", + ]), + ] + let own = browse.ownership(stored) + let owned_row = + row(uri: "at://other/x", discogs_id: Some("42"), created_at: "z") + let wanted_row = + row(uri: "at://other/y", discogs_id: Some("99"), created_at: "z") + let unknown_row = + row(uri: "at://other/z", discogs_id: Some("7"), created_at: "z") + assert browse.flags(own, owned_row) == #(True, False) + assert browse.flags(own, wanted_row) == #(False, True) + assert browse.flags(own, unknown_row) == #(False, False) +} diff --git a/web/priv/static/app.css b/web/priv/static/app.css index 663cd13..2ea5a1f 100644 --- a/web/priv/static/app.css +++ b/web/priv/static/app.css @@ -522,6 +522,18 @@ input[type="number"]::-webkit-inner-spin-button { box-shadow: var(--shadow-sm); } +/* --- browse grid -------------------------------------------------------- */ +.browse-card__actions { + display: flex; + gap: 6px; + margin-top: 4px; +} +.browse-card__actions .btn { + flex: 1; + padding: 9px 8px; + font-size: 11px; +} + /* --- fab -------------------------------------------------------------- */ .fab { position: fixed; diff --git a/web/src/at_record_web.gleam b/web/src/at_record_web.gleam index 27c8a08..fe81b35 100644 --- a/web/src/at_record_web.gleam +++ b/web/src/at_record_web.gleam @@ -51,19 +51,28 @@ fn init(_flags) -> #(Model, Effect(Msg)) { publishing: False, amend: model.blank_amend(), avatar: None, + browse: [], + browse_adding: None, ) // modem.init wires up URL routing; clear_query drops a lingering callback ?error=. let routing = modem.init(on_url_change) - // Landing directly on /add (e.g. back from the Discogs callback) still needs - // the connection status, since no OnRouteChange fires for the initial route. - let discogs = case active_route { + // Landing directly on /add or /browse (e.g. back from the Discogs callback, + // or a shared link) still needs its per-route data, since no OnRouteChange + // fires for the initial route. + let route_effects = case active_route { model.Add -> [effects.discogs_status()] + model.Browse -> [effects.load_browse()] _ -> [] } let startup = case notice { Some(_) -> - effect.batch([routing, load_shelf("owned"), clear_query(), ..discogs]) - None -> effect.batch([routing, load_shelf("owned"), ..discogs]) + effect.batch([ + routing, + load_shelf("owned"), + clear_query(), + ..route_effects + ]) + None -> effect.batch([routing, load_shelf("owned"), ..route_effects]) } #(model, startup) } diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index 73120c2..ea13b4a 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -3,15 +3,16 @@ import at_record/gen/shelf/entry.{shelf_entry_decoder} import at_record_web/appview import at_record_web/browser import at_record_web/model.{ - type AmendDraft, type DiscogsResult, type Entry, type Form, type ReleaseInfo, - DiscogsResult, DiscogsSearchPage, Entry, HandleSuggestion, ImportRun, - ReleaseInfo, + type AmendDraft, type BrowseRelease, type DiscogsResult, type Entry, type Form, + type ReleaseInfo, BrowseRelease, DiscogsResult, DiscogsSearchPage, Entry, + HandleSuggestion, ImportRun, ReleaseInfo, } import at_record_web/msg.{ type Msg, BarcodeDetected, CameraUnsupported, CoverUploaded, GotAction, GotAdd, - GotAmend, GotAvatar, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, - GotDiscogsStatus, GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, - GotShelf, GotTimeline, ScanItemAdded, ScanLookup, ShelfData, TimelineData, + GotAmend, GotAvatar, GotBrowse, GotBrowseAdd, GotDiscogs, GotDiscogsDisconnect, + GotDiscogsImport, GotDiscogsStatus, GotHandleSuggestions, GotLogout, + GotScanResult, GotScanSeen, GotShelf, GotTimeline, ScanItemAdded, ScanLookup, + ShelfData, TimelineData, } import gleam/dict import gleam/dynamic/decode @@ -377,6 +378,91 @@ pub fn add_scanned(barcode: String, form: Form) -> Effect(Msg) { ) } +/// The shared catalog grid: releases published by known at-record users, +/// folded against the viewer's own crate. +pub fn load_browse() -> Effect(Msg) { + rsvp.get( + "/api/browse", + rsvp.expect_json( + decode.field( + "releases", + decode.list(browse_release_decoder()), + decode.success, + ), + GotBrowse, + ), + ) +} + +fn browse_release_decoder() -> decode.Decoder(BrowseRelease) { + use uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + use title <- decode.field("title", decode.string) + use genres <- decode.field("genres", decode.list(decode.string)) + use styles <- decode.field("styles", decode.list(decode.string)) + use publisher_did <- decode.field("publisherDid", decode.string) + use publisher_handle <- decode.field("publisherHandle", decode.string) + use owned <- decode.field("owned", decode.bool) + use wanted <- decode.field("wanted", decode.bool) + use artist_display <- decode.optional_field( + "artistDisplay", + None, + decode.optional(decode.string), + ) + use cover_url <- decode.optional_field( + "coverUrl", + None, + decode.optional(decode.string), + ) + use thumb_url <- decode.optional_field( + "thumbUrl", + None, + decode.optional(decode.string), + ) + use released <- decode.optional_field( + "released", + None, + decode.optional(decode.string), + ) + use country <- decode.optional_field( + "country", + None, + decode.optional(decode.string), + ) + decode.success(BrowseRelease( + uri:, + cid:, + title:, + genres:, + styles:, + publisher_did:, + publisher_handle:, + owned:, + wanted:, + artist_display:, + cover_url:, + thumb_url:, + released:, + country:, + )) +} + +/// Want-it / I-have-this quick action from the browse grid: the release is +/// already known, so this skips straight to a genesis write. +pub fn browse_add(uri: String, cid: String, status: String) -> Effect(Msg) { + let body = + json.object([ + #("uri", json.string(uri)), + #("cid", json.string(cid)), + #("status", json.string(status)), + ]) + rsvp.post( + "/api/browse/add", + body, + rsvp.expect_json(nil_decoder(), GotBrowseAdd(uri, _)), + ) +} + /// Append an action event with arbitrary extra fields to an entry. fn entry_event( entry_id: String, diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index ce8b08c..5c8b4af 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -16,6 +16,7 @@ pub type Route { Add Scan Record(entry_id: String) + Browse } /// A folded crate entry (the BFF reduces the event log into these). @@ -161,6 +162,27 @@ pub fn blank_amend() -> AmendDraft { ) } +/// One catalog release as shown on the browse grid, folded against the +/// viewer's own crate (`owned`/`wanted`). +pub type BrowseRelease { + BrowseRelease( + uri: String, + cid: String, + title: String, + genres: List(String), + styles: List(String), + publisher_did: String, + publisher_handle: String, + owned: Bool, + wanted: Bool, + artist_display: Option(String), + cover_url: Option(String), + thumb_url: Option(String), + released: Option(String), + country: Option(String), + ) +} + /// The outcome of one capped collection-import run. pub type ImportRun { ImportRun( @@ -203,6 +225,11 @@ pub type Model { publishing: Bool, amend: AmendDraft, avatar: Option(String), + browse: List(BrowseRelease), + // The uri/status of the browse row whose add write is in flight; None + // means idle. Carrying the status lets a successful write flip the right + // flag without re-deriving it from the response. + browse_adding: Option(#(String, String)), ) } diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index d4faf9b..6376cce 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -1,7 +1,7 @@ import at_record/gen/shelf/entry.{type ShelfEntry} import at_record_web/model.{ - type DiscogsResult, type DiscogsSearchPage, type Entry, type HandleSuggestion, - type ImportRun, type ReleaseInfo, type Route, + type BrowseRelease, type DiscogsResult, type DiscogsSearchPage, type Entry, + type HandleSuggestion, type ImportRun, type ReleaseInfo, type Route, } import gleam/dict import gleam/option.{type Option} @@ -81,4 +81,7 @@ pub type Msg { AddScannedItem(barcode: String) ScanItemAdded(barcode: String, result: Result(Nil, rsvp.Error(String))) RemoveScanRow(barcode: String) + GotBrowse(Result(List(BrowseRelease), rsvp.Error(String))) + BrowseAdd(uri: String, cid: String, status: String) + GotBrowseAdd(uri: String, result: Result(Nil, rsvp.Error(String))) } diff --git a/web/src/at_record_web/pages/browse.gleam b/web/src/at_record_web/pages/browse.gleam new file mode 100644 index 0000000..1a9b96c --- /dev/null +++ b/web/src/at_record_web/pages/browse.gleam @@ -0,0 +1,95 @@ +//// The Browse page: a grid of catalog releases from across at-record users. +//// Each card offers WANT IT / I HAVE THIS quick actions, or a badge when the +//// viewer already has the release, instead of linking into the add form. + +import at_record_web/model.{type BrowseRelease, type Model} +import at_record_web/msg.{type Msg, BrowseAdd} +import at_record_web/ui/components as c +import gleam/list +import gleam/option.{type Option, None, Some} +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event + +pub fn view(model: Model) -> Element(Msg) { + html.div([attr.class("body")], [ + grid(model.browse, model.browse_adding, model.busy), + ]) +} + +fn grid( + rows: List(BrowseRelease), + adding: Option(#(String, String)), + busy: Bool, +) -> Element(Msg) { + case rows, busy { + [], False -> + html.p([attr.class("hint")], [ + text("Nothing in the shared catalog yet."), + ]) + [], True -> element.none() + _, _ -> html.div([attr.class("grid")], list.map(rows, card(_, adding))) + } +} + +fn card(row: BrowseRelease, adding: Option(#(String, String))) -> Element(Msg) { + let artist = option.unwrap(row.artist_display, "") + let color = c.cover_color(row.title <> artist) + html.div([attr.class("cover-card browse-card")], [ + html.div([attr.class("cover-tile cover-tile--card cover-tile--" <> color)], [ + c.tile_art(option.or(row.cover_url, row.thumb_url), row.title), + ]), + html.div([attr.class("cover-card__info")], [ + html.span([attr.class("cover-card__title")], [text(row.title)]), + html.span([attr.class("cover-card__artist")], [text(artist)]), + html.span([attr.class("item-meta")], [ + text("via @" <> row.publisher_handle), + ]), + actions(row, adding), + ]), + ]) +} + +fn actions( + row: BrowseRelease, + adding: Option(#(String, String)), +) -> Element(Msg) { + case row.owned, row.wanted { + True, _ -> c.badge("IN YOUR CRATE", c.Owned) + _, True -> c.badge("WANTED", c.Wanted) + False, False -> quick_actions(row, adding) + } +} + +fn quick_actions( + row: BrowseRelease, + adding: Option(#(String, String)), +) -> Element(Msg) { + let busy = option.is_some(adding) + let clicked = case adding { + Some(#(uri, status)) if uri == row.uri -> Some(status) + _ -> None + } + html.div([attr.class("browse-card__actions")], [ + c.button(action_label("WANT IT", clicked, "wanted"), c.Ghost, [ + event.on_click(BrowseAdd(row.uri, row.cid, "wanted")), + attr.disabled(busy), + ]), + c.button(action_label("I HAVE THIS", clicked, "owned"), c.Primary, [ + event.on_click(BrowseAdd(row.uri, row.cid, "owned")), + attr.disabled(busy), + ]), + ]) +} + +fn action_label( + label: String, + clicked: Option(String), + status: String, +) -> String { + case clicked { + Some(s) if s == status -> "ADDING…" + _ -> label + } +} diff --git a/web/src/at_record_web/route.gleam b/web/src/at_record_web/route.gleam index 427d009..e825281 100644 --- a/web/src/at_record_web/route.gleam +++ b/web/src/at_record_web/route.gleam @@ -1,12 +1,13 @@ //// URL <-> Route mapping for modem. -import at_record_web/model.{type Route, Add, Crate, Record, Scan} +import at_record_web/model.{type Route, Add, Browse, Crate, Record, Scan} import gleam/uri.{type Uri} pub fn parse(target: Uri) -> Route { case uri.path_segments(target.path) { ["add"] -> Add ["scan"] -> Scan + ["browse"] -> Browse ["record", entry_id] -> Record(entry_id) _ -> Crate } @@ -17,6 +18,7 @@ pub fn to_path(route: Route) -> String { Crate -> "/" Add -> "/add" Scan -> "/scan" + Browse -> "/browse" Record(entry_id) -> "/record/" <> entry_id } } diff --git a/web/src/at_record_web/ui/components.gleam b/web/src/at_record_web/ui/components.gleam index a9b31e4..1596378 100644 --- a/web/src/at_record_web/ui/components.gleam +++ b/web/src/at_record_web/ui/components.gleam @@ -198,7 +198,7 @@ pub fn segment( const tile_colors = ["owned", "wanted", "accent", "sage"] /// A cover image over the letter-tile fallback (stable colour hashed from title+artist). -fn tile_art(thumb: Option(String), title: String) -> Element(msg) { +pub fn tile_art(thumb: Option(String), title: String) -> Element(msg) { case thumb { Some(src) -> element.fragment([ diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 660c29f..74195e1 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -1,25 +1,25 @@ import at_record_web/effects.{ - add_item, discogs_connect, discogs_disconnect, discogs_import, + add_item, browse_add, discogs_connect, discogs_disconnect, discogs_import, discogs_import_wantlist, discogs_search, discogs_status, entry_action, - handle_search, load_avatar, load_shelf, load_timeline, logout, oauth_login, - rate, regrade, + handle_search, load_avatar, load_browse, load_shelf, load_timeline, logout, + oauth_login, rate, regrade, } import at_record_web/model.{ - type Entry, type Model, Add, Discogs, Entry, Form, LoggedIn, LoggedOut, Model, - Record, Scan, blank_form, + type BrowseRelease, type Entry, type Model, Add, Browse, Discogs, Entry, Form, + LoggedIn, LoggedOut, Model, Record, Scan, blank_form, } import at_record_web/msg.{ type Msg, type ScanLookup, AddScannedItem, AmendField, ArmRemove, - BarcodeDetected, CameraUnsupported, CoverFileChosen, CoverUploaded, + BarcodeDetected, BrowseAdd, CameraUnsupported, CoverFileChosen, CoverUploaded, DiscogsConnect, DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, DiscogsLoadMore, DiscogsSearch, DiscogsVinylOnly, EntryAction, FormArtist, FormFormat, FormStatus, FormTitle, FormYear, GotAction, GotAdd, GotAmend, - GotAvatar, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, - GotDiscogsStatus, GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, - GotShelf, GotTimeline, HandleChanged, HandleSearch, Logout, OnRouteChange, - Rate, Regrade, RemoveScanRow, ScanItemAdded, SetView, StartLogin, SubmitAdd, - SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, ToggleEntry, - ToggleMenu, UseDiscogs, UseHandleSuggestion, + GotAvatar, GotBrowse, GotBrowseAdd, GotDiscogs, GotDiscogsDisconnect, + GotDiscogsImport, GotDiscogsStatus, GotHandleSuggestions, GotLogout, + GotScanResult, GotScanSeen, GotShelf, GotTimeline, HandleChanged, HandleSearch, + Logout, OnRouteChange, Rate, Regrade, RemoveScanRow, ScanItemAdded, SetView, + StartLogin, SubmitAdd, SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, + ToggleEntry, ToggleMenu, UseDiscogs, UseHandleSuggestion, } import gleam/bool import gleam/int @@ -48,7 +48,8 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ), effect.batch([leaving_scan(model), load_timeline(entry_id)]), ) - // Add refreshes Discogs connection status; scan starts/stops the camera. + // Add refreshes Discogs connection status; scan starts/stops the camera; + // browse reloads the shared catalog and clears any stale in-flight add. OnRouteChange(route) -> #( Model( ..model, @@ -59,6 +60,11 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { timeline: [], release_info: None, menu_open: False, + browse_adding: None, + busy: case route { + Browse -> True + _ -> model.busy + }, scan: case route { Scan -> model.blank_scan() _ -> model.scan @@ -69,6 +75,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case route { Add -> discogs_status() Scan -> effect.batch([effects.start_scanner(), effects.scan_seen()]) + Browse -> load_browse() _ -> effect.none() }, ]), @@ -616,6 +623,55 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ), effect.none(), ) + + // --- browse --- + GotBrowse(Ok(rows)) -> #( + Model(..model, browse: rows, busy: False), + effect.none(), + ) + GotBrowse(Error(rsvp.HttpError(resp))) -> + case resp.status { + 401 -> #( + Model(..model, auth: LoggedOut, items: [], busy: False), + effect.none(), + ) + _ -> #( + Model( + ..model, + busy: False, + notice: Some("Couldn't load the shared catalog (server error)."), + ), + effect.none(), + ) + } + GotBrowse(Error(_)) -> #( + Model(..model, busy: False, notice: Some("Couldn't reach the server.")), + effect.none(), + ) + + // Guarded so only one browse add can be in flight at a time. + BrowseAdd(uri, cid, status) -> + case model.browse_adding { + Some(_) -> #(model, effect.none()) + None -> #( + Model(..model, browse_adding: Some(#(uri, status))), + browse_add(uri, cid, status), + ) + } + GotBrowseAdd(uri, Ok(Nil)) -> #( + Model( + ..model, + browse: mark_browse_row(model.browse, uri, model.browse_adding), + browse_adding: None, + ), + effect.none(), + ) + GotBrowseAdd(_, Error(e)) -> + write_error( + Model(..model, browse_adding: None), + e, + "Could not save that record.", + ) } } @@ -732,6 +788,30 @@ fn after_write(model: Model) -> Effect(Msg) { } } +/// Flip the owned/wanted flag on the browse row that was just written, +/// per the status carried in `browse_adding` (set when the write started). +fn mark_browse_row( + rows: List(BrowseRelease), + uri: String, + adding: Option(#(String, String)), +) -> List(BrowseRelease) { + case adding { + Some(#(adding_uri, status)) if adding_uri == uri -> + list.map(rows, fn(row) { + case row.uri == uri { + True -> + model.BrowseRelease( + ..row, + owned: row.owned || status == "owned", + wanted: row.wanted || status == "wanted", + ) + False -> row + } + }) + _ -> rows + } +} + fn write_error( model: Model, error: rsvp.Error(String), diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index c5ba247..34d4b65 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -2,10 +2,11 @@ //// modules. import at_record_web/model.{ - type Entry, type Model, Add, Crate, LoggedIn, LoggedOut, Record, Scan, + type Entry, type Model, Add, Browse, Crate, LoggedIn, LoggedOut, Record, Scan, } import at_record_web/msg.{type Msg, Logout, ToggleMenu} import at_record_web/pages/add +import at_record_web/pages/browse import at_record_web/pages/crate import at_record_web/pages/login import at_record_web/pages/record @@ -61,6 +62,7 @@ fn page(model: Model) -> Element(Msg) { Crate -> crate.view(model) Add -> add.view(model) Scan -> scan.view(model) + Browse -> browse.view(model) Record(entry_id) -> case find_entry(model.items, entry_id) { Some(entry) -> record.view(model, entry) @@ -86,6 +88,7 @@ fn app_bar(model: Model, handle: String) -> Element(Msg) { ) Add -> back_bar("ADD RECORD") Scan -> back_bar("SCAN BARCODE") + Browse -> back_bar("BROWSE") Record(_) -> back_bar("RECORD") } } @@ -100,6 +103,7 @@ fn crate_actions( avatar: Option(String), ) -> Element(Msg) { html.div([attr.class("app-bar__right")], [ + c.icon_link("⌗", "/browse"), c.icon_link("⦀", "/scan"), account_menu(menu_open, handle, avatar), ]) diff --git a/web/test/at_record_web_test.gleam b/web/test/at_record_web_test.gleam index ea22940..0397362 100644 --- a/web/test/at_record_web_test.gleam +++ b/web/test/at_record_web_test.gleam @@ -1,15 +1,15 @@ import at_record_web/model.{ - type DiscogsResult, type Model, type ReleaseInfo, Added, Crate, Discogs, - DiscogsResult, Form, ImportRun, LoggedIn, LoggedOut, Matched, Model, NoMatch, - Pending, ReleaseInfo, ScanRow, ScanState, blank_amend, blank_discogs, - blank_form, blank_scan, + type BrowseRelease, type DiscogsResult, type Model, type ReleaseInfo, Added, + BrowseRelease, Crate, Discogs, DiscogsResult, Form, ImportRun, LoggedIn, + LoggedOut, Matched, Model, NoMatch, Pending, ReleaseInfo, ScanRow, ScanState, + blank_amend, blank_discogs, blank_form, blank_scan, } import at_record_web/msg.{ - AddScannedItem, ArmRemove, BarcodeDetected, DiscogsImportWantlist, EntryAction, - FormTitle, GotAdd, GotAvatar, GotDiscogsImport, GotDiscogsStatus, GotLogout, - GotScanResult, GotScanSeen, GotShelf, GotTimeline, HandleChanged, Logout, - OnRouteChange, RemoveScanRow, ScanItemAdded, ScanLookup, SubmitAdd, - TimelineData, + AddScannedItem, ArmRemove, BarcodeDetected, BrowseAdd, DiscogsImportWantlist, + EntryAction, FormTitle, GotAdd, GotAvatar, GotBrowse, GotBrowseAdd, + GotDiscogsImport, GotDiscogsStatus, GotLogout, GotScanResult, GotScanSeen, + GotShelf, GotTimeline, HandleChanged, Logout, OnRouteChange, RemoveScanRow, + ScanItemAdded, ScanLookup, SubmitAdd, TimelineData, } import at_record_web/update.{update} import gleam/dict @@ -49,6 +49,8 @@ fn base() -> Model { publishing: False, amend: blank_amend(), avatar: None, + browse: [], + browse_adding: None, ) } @@ -370,3 +372,77 @@ pub fn route_change_clears_release_info_test() { let #(model, _) = update(seeded, OnRouteChange(Crate)) assert model.release_info == None } + +// --- browse --- + +fn a_browse_release(uri: String) -> BrowseRelease { + BrowseRelease( + uri:, + cid: "cid-1", + title: "Spiderland", + genres: [], + styles: [], + publisher_did: "did:plc:abc", + publisher_handle: "alice.test", + owned: False, + wanted: False, + artist_display: Some("Slint"), + cover_url: None, + thumb_url: None, + released: None, + country: None, + ) +} + +pub fn got_browse_stores_rows_test() { + let rows = [a_browse_release("at://did:plc:abc/catalog.release/1")] + let #(model, _) = update(logged_in(), GotBrowse(Ok(rows))) + assert model.browse == rows + assert model.busy == False +} + +pub fn browse_add_marks_in_flight_test() { + let #(model, _) = + update(logged_in(), BrowseAdd("at://uri", "cid-1", "wanted")) + assert model.browse_adding == Some(#("at://uri", "wanted")) +} + +pub fn browse_add_is_guarded_while_already_adding_test() { + let seeded = + Model(..logged_in(), browse_adding: Some(#("at://other", "owned"))) + let #(model, _) = update(seeded, BrowseAdd("at://uri", "cid-1", "wanted")) + assert model.browse_adding == Some(#("at://other", "owned")) +} + +pub fn got_browse_add_success_flips_the_matching_row_and_clears_adding_test() { + let uri = "at://did:plc:abc/catalog.release/1" + let seeded = + Model( + ..logged_in(), + browse: [a_browse_release(uri)], + browse_adding: Some(#(uri, "owned")), + ) + let #(model, _) = update(seeded, GotBrowseAdd(uri, Ok(Nil))) + assert model.browse_adding == None + assert case model.browse { + [row] -> row.owned == True && row.wanted == False + _ -> False + } +} + +pub fn got_browse_add_error_clears_adding_with_notice_test() { + let uri = "at://did:plc:abc/catalog.release/1" + let seeded = + Model( + ..logged_in(), + browse: [a_browse_release(uri)], + browse_adding: Some(#(uri, "owned")), + ) + let #(model, _) = update(seeded, GotBrowseAdd(uri, Error(rsvp.NetworkError))) + assert model.browse_adding == None + assert model.notice != None + assert case model.browse { + [row] -> row.owned == False + _ -> False + } +} -- 2.51.2