Compare commits

...
Author SHA1 Message Date
yonlu eb139cf872 docs: make the issue tracker the source of truth
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 6m9s
Work has been starting from a chat message and a plan file, so two
people could pick up the same thing and neither could see the other.
The tracker is where that is visible.

Search before starting, claim before the first edit -- not before the
commit, since the point is that the other person can see the work is
taken while it is being done. If no issue covers it, open one first:
that is what makes the tracker a description of the project rather than
a description of the past.

The conventions were already right and are written down rather than
reinvented -- the Kind/Area/Priority/Platform/Reviewed/Status taxonomy,
its exclusive scopes, #73 as the roadmap, real Gitea dependencies for
hard blockers, and PR #83's body shape.

What #83 also demonstrated is that a Closes list closes nothing
reliably: it listed ten and five of them sat open in main for a
fortnight. So closing is a step you take and verify, not a keyword you
trust.

.planning/ stops being a queue and keeps design documents and measured
history -- NOTES.md, the audits, the completed plans and the arguments
in them. plans/pending/ is gone, because a plan nobody is executing is
an issue; everything unimplemented in it is now #85-#91, and each
completed plan says which issue carries its remainder. autotag.md is
kept as a historical record, marked stale where the scoring overhaul
overtook it.

The commit grammar is unchanged and is load-bearing for a different
reason, so the issue number lives in the branch name and the PR body
rather than the commit subject.

Refs #92
2026-08-18 16:23:52 -04:00
yonlu ae82fd2233 chore(scripts): reach the issue tracker from the command line
Issues become this project's source of truth for what is wanted and what
is already being worked on, which puts "search the tracker" at the top of
every task rather than occasionally. Fifty-odd open issues make that a
real lookup, and a lookup nobody can remember the shape of is a lookup
that gets skipped -- the same way the CI log endpoint cost two sessions
to a tool that 404s.

Text reaches the API as JSON and never as shell, which is why the
formatting half is its own Python file: an issue body is arbitrary prose
carrying backticks, quotes and $, and every attempt to build that JSON
inside the shell ends in nested quoting nobody can verify. Same reasoning
that keeps release notes out of gitea-release.sh's argument list.

Claiming is an assignment, a label and a comment together, because any
one alone is a claim somebody has to go looking for. It resolves the
comment before it mutates anything -- reading it afterwards is how a
claim ends up half-made, with the issue saying it is taken without saying
by what work -- and refuses outright if somebody else holds it.

Three API shapes are pinned here because each fails quietly:

- Labels are resolved to ids rather than posted as names. Gitea accepts
  a list of unknown names with 200 and applies none of them, so a typo
  reports success and does nothing.
- The dependency endpoint takes a whole IssueMeta, not an index. A body
  of {"index": 88} answers 404, which reads exactly like a Gitea build
  without the feature.
- close drops Status/In Progress, or a claim outlives the work.

Refs #92
2026-08-18 16:23:39 -04:00
logan 3c3197df4b Small-fix batch: ten issues from the desktop backlog (#83)
Release / release (push) Successful in 33s
CI / e2e (push) Successful in 6m10s
CI / check (push) Successful in 2m26s
Build & publish the Android APK / apk (push) Successful in 1m26s
Build & publish Arch package / arch-package (push) Successful in 2m36s
Attach the desktop build to the release / linux (push) Successful in 56s
Sync Homebrew formula / sync-formula (push) Successful in 6s
Rolls up #82 (@yonlu) and #74, #75, #76, #77, #78, #79, #81 as one push, so the batch cuts one release rather than eight.

Closes #9, #13, #14, #19, #26, #29, #33, #35, #37, #41.
2026-08-18 16:18:36 +00:00
logan e16bd245bd Merge remote-tracking branch 'origin/fix/queue-toggle-state' into integration/small-fixes
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 6m14s
2026-08-18 11:37:33 -04:00
logan 887a9324b4 Merge remote-tracking branch 'origin/fix/drag-count-badge' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan fcb484ead5 Merge remote-tracking branch 'origin/fix/album-card-year' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 48de41cd69 Merge remote-tracking branch 'origin/fix/album-tracklist-heading' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 66a6ee63ab Merge remote-tracking branch 'origin/fix/seek-bar-clock-width' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 10660c8168 Merge remote-tracking branch 'origin/fix/wanted-without-client' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 441b67daaa Merge remote-tracking branch 'origin/fix/album-track-request-badge' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 026f26bdf6 Merge remote-tracking branch 'origin/fix/small-issue-batch' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 73dc80bdc9 fix(explore): stop hiding the request badge until the row is hovered
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Canceled after 0s
The badge on a row you do not own was transparent until the row was
hovered or focused. That rule was inherited from the green ticks it
replaced, and it does not survive the reason those went: a tick marked
the *common* case, while this marks the rows that are not here. A mark
on the exception is the information on this page, and one that appears
only under the pointer cannot be seen, counted, or reached by anyone
driving the app with a finger.

The repaint half of #33 is fixed in #82; this is only the visibility,
rebased to leave that alone.

Refs #33
2026-08-18 11:31:49 -04:00
logan 760021ea5a fix(downloads): stop searching a list there is nothing to search with
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m35s
CI / e2e (pull_request) Canceled after 0s
Every pass attempted every request, each came back "no download clients
are enabled", and RecordAttempt wrote that down as an attempt and put a
retry on the clock -- so a wanted list built deliberately without a
client accrued failures and announced "next check in 6 hours" about a
check that cannot happen.

Wanting something with no way to fetch it is supported. Being told it
is being looked for is a lie, and the row says what is true instead.

Everything above the attempt still runs: an artist subscription still
expands, and a request satisfied by some other route -- ripped, bought,
copied in -- is still retired. Neither needs a provider.

TestReconcileRespectsBatchSize now installs a client that finds
nothing, because a batch size is about how many requests one pass
searches for and that only means something when there is something to
search with.

Refs #37
2026-08-18 11:26:35 -04:00
logan a2ff0aed4c fix(ui): make the queue button say whether the queue is open
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Canceled after 5m49s
It looked identical in both states, so the only way to tell what
pressing it would do was to look at the other side of the window and
infer it -- and for anyone not looking there was nothing to infer from:
no aria-expanded, no aria-controls, no drawn state.

The state is reflected *from the panel* rather than kept beside the
click. This button is not the only thing that opens the queue --
now-playing-view sets the same attribute, because it hides the bar the
button lives in -- so a flag maintained by the click handler would be
right until something else opened the panel and then quietly wrong.
The panel's `open` attribute stays the one fact; a MutationObserver
reflects it.

Refs #26
2026-08-18 11:15:32 -04:00
logan 12e75ee24c feat(ui): badge an album drag with how many tracks it carries
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m25s
CI / e2e (pull_request) Successful in 6m5s
Dragging an album to the queue put its cover under the cursor and said
nothing about how much that was -- an album is 1 track or 30 and the
thumbnail is the same picture either way, so the one number the drop is
about was the one thing the drag did not show. Every other drag in the
app already says it; this was the exception, because it had a picture
to show instead.

A count of 1 draws no badge: "1" over a single cover is noise, and the
absence reads clearly beside a badge that only appears above one.

The badge sits inside the cover's box rather than overhanging it,
because setDragImage snapshots the element and anything outside it
risks being clipped -- while padding the box instead would move the
cover away from the cursor.

Refs #19
2026-08-18 11:10:09 -04:00
logan 792e87298b fix(ui): stop the album grid eating the year it was sorted by
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 6m5s
The year sat inside the same ellipsis box as the title, so it was the
first thing truncation took: a card wide enough for a long album name
never showed its year, and browsing the grid *by year* showed years
only for the albums with short names. The sort said one thing and the
cards showed another.

Title and year are now a flex row where only the title gives way. A
row rather than a second line, because the card's height is what the
virtualizer measures rows by.

Refs #29
2026-08-18 11:08:37 -04:00
logan 266e7032dd fix(explore): stop labelling the album tracklist "TRACKLIST"
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 6m9s
A list of numbered titles with durations, under the album's cover, was
the one thing on the page carrying a word above it saying what it is.

What goes is the ink and not the element: the section is a landmark and
the page's heading structure runs through it, so the h3 stays and is
clipped the way sr-only clips -- never display:none, which would take
it out of the accessibility tree along with the layout.

Refs #9
2026-08-18 11:06:14 -04:00
logan d6b48fb3ac fix(player): stop the seek bar resizing as its clocks count
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 6m16s
Two different things moved it and they need different answers. Digits
in a proportional font are different widths, so 1:11 is narrower than
4:08 and the bar breathed once a second -- tabular figures fix that.
The character *count* changes too, at the hundredth minute and whenever
the right-hand clock is toggled to remaining and grows a minus sign,
which a figure width cannot fix -- so each clock reserves the widest
string this track can put in it.

The budget is per track rather than a constant: reserving six
characters on every track would push the slider in by a character at
each end to buy nothing.

Measured in the component tier: 4.5px of drift across three positions
before, none after.

Refs #13
2026-08-18 11:04:21 -04:00
28 changed files with 1393 additions and 249 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ reviews. Nothing was changed.
Findings below are numbered `H-n` (hands-on) and cross-reference the Findings below are numbered `H-n` (hands-on) and cross-reference the
static reports where they overlap. The reconciliation plan built from static reports where they overlap. The reconciliation plan built from
all four files is `.planning/plans/pending/007-ui-reconciliation.md`. all four files is `.planning/plans/completed/007-ui-reconciliation.md`.
--- ---
@@ -1,5 +1,7 @@
# 012 — What we ask the network for, and what we already had # 012 — What we ask the network for, and what we already had
> **Completed.** Findings 1, 2 and 4 shipped. Finding 3 — the bound-but-uncalled methods — is now **#86**.
**Status:** all four findings fixed. Lint (3 configs), Go tests (3 **Status:** all four findings fixed. Lint (3 configs), Go tests (3
configs), `tsc` and 752 Vitest tests pass; **not driven against the configs), `tsc` and 752 Vitest tests pass; **not driven against the
real app**, so the numbers below are read off the code, not measured. real app**, so the numbers below are read off the code, not measured.
@@ -1,5 +1,7 @@
# 015 — Android release pipeline # 015 — Android release pipeline
> **Completed.** The pipeline ships a signed APK from CI on every `v*` tag; `docs/android-release.md` is its operating document.
Ship an Android APK from CI on every version tag, published to the Gitea Ship an Android APK from CI on every version tag, published to the Gitea
generic package registry so Obtainium can poll a plain URL. generic package registry so Obtainium can poll a plain URL.
@@ -1,5 +1,7 @@
# 015 — Multi-artist credits, navigable # 015 — Multi-artist credits, navigable
> **Completed.** Phases 1, 2 and 4 shipped. Running the ingest against the real dump and publishing an artifact that carries credits is **#88**; Phase 3 (`file_artists`) is **#89**, blocked on it.
## The problem ## The problem
A track credited to more than one artist has exactly one navigable A track credited to more than one artist has exactly one navigable
@@ -1,5 +1,7 @@
# 016 — What Android parity would actually take # 016 — What Android parity would actually take
> **Completed.** Sections A, B1, B2 and B4 shipped. B3, writing tags on the device, is now **#87**; the device-found UI faults are #51#72, sequenced by #73.
> **Status: all of section A is done.** A1A3 landed with "let the app > **Status: all of section A is done.** A1A3 landed with "let the app
> reach the user's music"; A4 (MediaSession, transport notification, > reach the user's music"; A4 (MediaSession, transport notification,
> audio focus) landed with "survive the screen locking". The direction > audio focus) landed with "survive the screen locking". The direction
@@ -1,5 +1,7 @@
# Autotag (v1.3) — MusicBrainz Autotagger # Autotag (v1.3) — MusicBrainz Autotagger
> **Historical record.** Phases 008010 shipped, and the scoring engine was subsequently overhauled (`recommend.go`, `rank.go`, `mixedbag.go`), which makes the 011/012 sections below stale in their details. What is actually left is **#90** (auto-accept and entry points) and **#91** (settings, and a way back from the dismissed file-write warning).
The MusicBrainz autotagger, collectively **v1.3**. Builds on the explore-browser API client + cache foundation. Five sequential phases (008012), each depending on the prior one. The MusicBrainz autotagger, collectively **v1.3**. Builds on the explore-browser API client + cache foundation. Five sequential phases (008012), each depending on the prior one.
| Phase | Title | Status | | Phase | Title | Status |
@@ -1,195 +0,0 @@
# 010 — Owned albums, offline
**Status:** not started — and **much smaller than when it was written**
**Branch:** none yet
**Created:** 2026-08-13
**Depends on:** nothing
**Related:** the `AlbumReleasesFailed` fix that prompted it, and the
tag-derived completeness that landed after it (same session)
---
## What already shipped, and what it leaves
The common case is solved without this plan. `GetAlbumCompleteness`
reads the "5/12" denominator off the files' own tags — persisted to
`release_group_recordings.total_tracks`, having been extracted at every
scan since forever and discarded — and an album that is **MBID-matched
and complete** now opens with **no catalog call at all**. Identity from
the MBID, tracklist from the tags; those were the two things the browse
was being spent on.
So the set this plan still has to serve is not "albums you own a track
of". It is:
- albums that are genuinely **incomplete** (the catalog is the only way
to say *which* tracks are missing — tags give the count, not the
names), and
- albums whose tags **never declared a total**, where completeness is
unknowable locally and the catalog is the only source.
On a well-tagged library that is a small minority, which changes the
economics below considerably: the run is shorter, and the rate limiter
contention that dominates this design is proportionally less severe.
Re-measure before building — the answer may now be "the prefetch is
enough".
---
## The problem
Opening an album detail page for an album **you already own** hits
MusicBrainz. Every time it is not in the response cache, which for most
of a library is every time, because nothing warms that cache except a
capped prefetch on the artist page.
The user's framing: *this is a classic example of an album we should
have had locally.*
## Why we do not have it, despite the discography backfill
`BackfillLibraryDiscographies` / `EnsureArtistDiscography`
(`backend/explore/searchindex.go:301`, `:397`) do less than the name
suggests. Per artist, `indexOneArtist` fetches:
- `fetchTopReleaseGroups` — capped at `indexMaxRGs` (50)
- `fetchTopRecordings` — capped at `indexMaxRecs` (200)
and writes them as **flat `explore_index` rows**. There is no release
group → tracklist relation anywhere in the index, and no release-level
rows at all. `explore_index` recordings carry `caa_release_mbid` and
`release_name`, which name the release used for cover art — not a
tracklist.
So "we have full discographies for library artists" means *we know
which albums the artist made, offline*. It has never meant we know
what is on any of them.
The only store of release-level catalog data in the app is `http_cache`
under `mb:browse:releases:<rg>` (90-day TTL, `musicbrainz.go:27`),
populated **only** by a live `BrowseReleases` with
`Includes: ["recordings", "media"]` at `MaxLimit` — the most expensive
call the app makes to MusicBrainz. It is warmed by exactly one thing:
`PrefetchReleases` (`explore.go:746`), capped at 8, called only when an
artist page renders.
An album opened from the library grid therefore always browses live.
## What to build
**A post-scan backfill that warms the release cache for release groups
that are owned but not known-complete** — bounded, resumable, and
shaped exactly like `BackfillLibraryDiscographies`, which is the proven
pattern for this in the codebase.
The scoping rule is the user's and it is the right one: not "every
album by every artist in the library" (50 release groups per artist,
mostly never opened) but albums with owned tracks — narrowed further,
now, to the ones a local answer cannot already cover. The query gains
one clause: skip release groups whose `GetAlbumCompleteness` reports
`complete`.
Sketch:
1. A query for release groups with ≥1 owned track and no warm release
cache entry. `release_groups.mbid` is the key; the owned-track join
is `audio_files → recordings → release_group_recordings`, the same
shape `unenrichedLibraryArtistMBIDs` already uses one table over.
2. Order by owned-track count descending, so the albums the user has
most of are warmed first — same reasoning as the discography
backfill's ordering, same benefit if a run is cut short.
3. Run through `releasesSF`, so it never double-fetches a release group
an interactive open is already handling.
4. Bound a run (`discogBackfillMaxPerRun` has a value to copy) and make
it resumable: the resume marker is the response cache itself —
`BrowseReleasesCached` already answers "is this one done", so unlike
the discography path this needs **no new flag column**.
5. Trigger it where `BackfillLibraryDiscographies` is triggered, and
register it with `jobs` so it has progress, pause and cancel like
every other long-running operation.
### The rate limiter is the whole design constraint
> **Update (2026-08-13): the priority half is built, and the sentence
> below is wrong on a detail.** `e.mb` runs on `mbSearchLimiter`
> (`NewRateLimiterBurst(3, 1)`); the 1 req/s `NewRateLimiter()` cited
> here is the *artist image* limiter. Both are shared and both were
> FIFO. `RateLimiter.WithBackgroundLane` + `WithBackgroundPriority(ctx)`
> now make a marked caller yield to interactive work and pace at 1/s,
> and `jobs.KindCatalogEnrich` + `startBackfillJob` give the existing
> backfills progress and cancel. **"Do not start until the priority
> question has an answer" is satisfied** — mark this backfill's context
> and register it the way `BackfillLibraryDiscographies` now is.
> `PrefetchReleases`' cap of 8 is still unrevisited.
One shared `NewRateLimiter()` at 1 req/s (`explore.go:84`) serves this,
`PrefetchReleases`, and every interactive browse. A backfill over a
few thousand owned albums is *hours* of wall clock at that rate — which
is fine for a background job, and not fine if it starves the album page
the user is looking at right now.
That is the real work in this plan, and it is not the query:
- Interactive browses need to **jump the queue**. Today they cannot;
there is one limiter and it is FIFO.
- `PrefetchReleases`' cap of 8 was sized when nothing else competed for
the limiter. Revisit it in the same change.
- The 60 s fallback the `AlbumReleasesFailed` fix installed is sized
for today's contention. If a backfill can queue behind it, that
number is wrong again — which is an argument for priority, not for a
bigger number.
Do not start the query until the priority question has an answer.
## The alternative that was considered and rejected
**Project release-group tracklists in the dump build and ship them in
the artifact.** The data is there: `canonical_musicbrainz_data.csv`
carries `release_mbid` *and* `recording_mbid`
(`dumpcatalog.go:520`), and `release_to_rg` already maps release →
release group. It is derivable from bytes the index build already
streams, with no new API surface at all, and it would work offline on
first launch with no per-user backfill.
It is rejected **for this plan** because the artifact is built
centrally and is byte-identical for every user, so "albums the user
owns a track of" cannot be a filter on it. Shipping tracklists for the
whole catalog means per-recording rows against a ~900 MB artifact
budget (~426 B/row measured), and gating on a popularity floor means it
is absent for exactly the obscure albums a local backfill would have
covered.
Worse than absent, in fact — and this is the argument that actually
kills it. The floor is not one number over artists; it is a **per
artist track budget** (`dumpcatalog.go:58-89`): 50 tracks for a tier-A
artist, 25 for tier B, 12 for tier C. A projected tracklist would
therefore be *whichever* of an album's tracks survived that budget,
with nothing marking the rest as absent — so the album page would count
owned against a truncated denominator and render "Play 7 of 9" for a
twelve-track album. That is a confident lie, where the honest states
this plan's alternative produces (complete / incomplete / unknown) are
at worst silent.
Note that `markLibraryArtists` (`dumpcatalog.go:246`) already grants
every library artist full coverage — 500 tracks, 100 release groups —
by reading the local library, so the per-user tailoring this option
supposedly cannot have does exist in code. It is a no-op in the CI
build (empty library), and reaching it means a **local** dump build:
the ~205 GB, half-a-day download the entire artifact design exists to
avoid. Whoever finds that function next should read this paragraph
before getting excited about it.
Worth revisiting if the artifact ever gains per-user tailoring, or if a
measurement shows the row count is smaller than feared. Note it also
yields the *canonical* tracklist rather than MusicBrainz's full version
list, so the versions dropdown would still browse live when opened.
## Done when
- Opening an owned album that has never been opened before renders its
catalog tracklist with no network call, after one backfill run.
- An interactive browse issued while the backfill is running is not
delayed by it.
- The backfill appears in the jobs indicator, and can be paused and
cancelled there.
- A second run after a completed one does approximately nothing.
+86 -6
View File
@@ -6,16 +6,85 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
YellowJacket is a cross-platform desktop music player built with Go (backend) and TypeScript/Lit (frontend), using the Wails framework to bridge them. It supports MP3, FLAC, OGG Vorbis, and WAV playback. YellowJacket is a cross-platform desktop music player built with Go (backend) and TypeScript/Lit (frontend), using the Wails framework to bridge them. It supports MP3, FLAC, OGG Vorbis, and WAV playback.
## Issues
**The tracker is the source of truth for what is wanted and what is
already being worked on**, and it is shared with a collaborator who
cannot see this session. `scripts/issue.sh` is the whole interface to
it (`list`, `mine`, `search`, `show`, `new`, `claim`, `unclaim`,
`comment`, `close`, `label`, `depends`, `labels`); it needs a
`GITEA_TOKEN` with `write:issue`.
**Search the tracker before starting any work, and claim what you
find.** Fifty-odd issues make that a real lookup rather than a
formality. `./scripts/issue.sh search <terms>` covers open and closed —
closed matters, because "that was fixed three weeks ago" is the
cheapest possible answer.
**Claiming happens before the first edit, not before the commit.** The
whole point is that the collaborator can see the work is taken *while
it is being done*, so `claim` sets the assignee, applies
`Status/In Progress` and posts a comment naming the branch and the
approach — all three, or none. It refuses outright if somebody else
already holds it, and that refusal is the feature: talk to them rather
than working around it.
**If no issue covers the work, open one first.** The issue exists
before the branch does. That is what makes the tracker a description
of the project rather than a description of the past.
**Findings get filed.** A bug tripped over while doing something else
is an issue with a reproduction, not a sentence in a chat message
nobody can search. So is a piece of work deliberately not done — the
issue is where "we decided not to, and here is why" survives.
Four conventions are already established and are not up for
reinvention:
- **The labels are a taxonomy**, not tags: `Kind/*`, `Area/*`,
`Priority/*`, `Platform/*`, plus `Reviewed/Confirmed` (the code was
read and the defect confirmed) and the `Status/*` family. `Status/*`
and `Reviewed/*` are **exclusive scopes** — one of each at most, so
applying a second replaces the first.
- **#73 is the roadmap.** It states the order the backlog should be
worked in and the soft relations that are not expressible as
blockers. Picking work off the open list by eye when a meta issue
states the sequence is how the sequence stops meaning anything.
- **Hard blockers are real Gitea dependencies**, which render on the
issue itself, and the blocked issue carries `Status/Blocked`.
- **A PR body carries a commit-to-issue table, the verification
actually run, and a `Closes` list** — PR #83 is the shape.
**And the `Closes` list does not reliably close anything.** #83 listed
ten and five of them stayed open, shipped in `main`, for a fortnight.
So closing is a step you take and check, not a keyword you trust:
`./scripts/issue.sh close <n>` after the merge, with a comment naming
the commit that shipped it. `close` also drops `Status/In Progress`,
because a claim outlives the work if nothing takes the label off.
## Planning ## Planning
Active and historical plans live in `.planning/`: `.planning/` is **design documents and measured history**, not a queue
— the queue is the tracker, and a plan file that describes work nobody
has started is a second, staler answer to "what are we doing next".
- `.planning/NOTES.md` — gotchas, deferred items, open architecture questions, the "we already considered and rejected" list. - `.planning/NOTES.md` — gotchas, measured facts, open architecture
- `.planning/plans/active/` — work currently in progress (read first). questions, and the "we already considered and rejected" list. Dated,
- `.planning/plans/pending/` — sequenced future work. because several are properties of someone else's server. **This is
- `.planning/plans/completed/` — one concise recap per shipped milestone. where a decision reached on an issue gets written down** when it
outlives the issue.
- `.planning/plans/completed/` — one recap per shipped milestone, kept
for the arguments in it. Where a plan shipped incompletely, its
header says which issue carries the remainder.
- `.planning/audits/` — the read-only audits that produced the
reconciliation plans. Historical evidence; not a backlog.
- `.planning/plans/active/` — a multi-phase design document for work
**in flight**, linked from the issue that tracks it. Empty is the
normal state. There is no `pending/`: a plan nobody is executing is
an issue.
Numbering is sequential and stable across status moves (a plan keeps its `NNN-` prefix as it migrates between `pending → active → completed`). Abandoned plans are deleted; paused work stays in `pending/`. Numbering is sequential and stable across status moves (a plan keeps
its `NNN-` prefix). Abandoned plans are deleted.
## Commands ## Commands
@@ -2066,6 +2135,17 @@ branch** (`enable_push: false`, an empty push whitelist, and `CI / check*`
the pre-receive hook. This file said otherwise for a long time. Tags are the pre-receive hook. This file said otherwise for a long time. Tags are
*not* protected, which is what lets `release.yml` push one. *not* protected, which is what lets `release.yml` push one.
**A branch answers a claimed issue** — see "Issues" above. The commit
grammar is unchanged and is load-bearing for a different reason
(semantic-release reads it), so the issue number lives in the branch
name and the PR body rather than in the commit subject.
**A batch of small fixes can be one PR**, which is what #83 did: eight
branches preserved as merges under one integration branch, so
authorship survives and the batch lands as one release rather than
eight. The cost is that its `Closes` list has to be checked afterwards
— it half-worked.
Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite. Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite.
## CI ## CI
+26 -6
View File
@@ -310,15 +310,35 @@ func (r *Reconciler) run(ctx context.Context, force bool) (Summary, error) {
summary.Synced = r.syncExternalLists(ctx) summary.Synced = r.syncExternalLists(ctx)
attempted, started, err := r.attemptDue(ctx, force) // Nothing is searched for when there is nothing to search with, and
if err != nil { // the point is what that *does not* do to the list.
return summary, err //
// Attempting anyway is not merely wasted work: every request comes
// back "no download clients are enabled", which RecordAttempt writes
// down as an attempt and schedules a retry for -- so a user who has
// deliberately built a wanted list with no client watched their
// requests accrue failures and announce "next check in 6 hours"
// about a check that cannot happen. Wanting something without a way
// to fetch it is a supported thing to do; being told it is being
// looked for is a lie.
//
// Everything above this line still runs: an artist subscription
// still expands, and a request the user satisfied by some other
// route -- ripped, bought, copied in -- is still retired, because
// neither needs a provider.
summary.NoProviders = len(r.manager.enabledProviders()) == 0
if !summary.NoProviders {
attempted, started, err := r.attemptDue(ctx, force)
if err != nil {
return summary, err
}
summary.Attempted = attempted
summary.Started = started
} }
summary.Attempted = attempted
summary.Started = started
summary.Waiting = r.countWaiting(ctx) summary.Waiting = r.countWaiting(ctx)
summary.NoProviders = len(r.manager.enabledProviders()) == 0
r.logger.Info( r.logger.Info(
"reconciled request list", "reconciled request list",
+78
View File
@@ -453,6 +453,15 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
f := newReconcileFixture(t) f := newReconcileFixture(t)
ctx := context.Background() ctx := context.Background()
// A client that searches and finds nothing. The batch size is about
// how many requests one pass *searches for*, which only means
// anything when there is something to search with -- a pass with no
// provider now attempts nothing at all, deliberately.
f.manager.installProvider(
Config{ID: 1, Priority: 50},
NewFakeProvider(1, "finds-nothing", Caps{CanSearch: true}),
)
f.reconciler.SetBatch(2) f.reconciler.SetBatch(2)
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} { for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
@@ -593,3 +602,72 @@ func TestSummaryReportsNoProviders(t *testing.T) {
t.Error("summary did not report that no download client is enabled") t.Error("summary did not report that no download client is enabled")
} }
} }
// ...and it does not search, which is the part the user sees.
//
// Attempting with no provider fails every request with "no download
// clients are enabled", and RecordAttempt writes that down as an
// attempt and schedules a retry -- so a wanted list built deliberately
// without a client accrued failures and announced "next check in 6
// hours" about a check that cannot happen. Wanting something with no
// way to fetch it is supported; being told it is being looked for is
// a lie.
func TestNoProvidersMeansNoAttempt(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
summary, err := f.reconciler.RunNow(ctx)
if err != nil {
t.Fatalf("RunNow: %v", err)
}
if summary.Attempted != 0 {
t.Errorf("attempted %d requests with no client to search with, want 0",
summary.Attempted)
}
// The list still knows what is on it: "nothing happened" has to be
// reportable as "nothing was searched for, of the one thing you
// want" rather than as silence.
if summary.Waiting != 1 {
t.Errorf("summary reported %d waiting, want 1", summary.Waiting)
}
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if req.Attempts != 0 {
t.Errorf("attempts = %d, want 0: a pass that could not search did not",
req.Attempts)
}
if req.LastError != "" {
t.Errorf("lastError = %q, want empty: the request did not fail, it "+
"was never tried", req.LastError)
}
// A new request is due immediately (next_try_at is set to now on
// insert), so the fault is not the presence of a time -- it is a
// time pushed into the future by a failed attempt, which is what the
// UI renders as "next check in 6 hours".
if req.NextTryAt.After(time.Now().Add(time.Minute)) {
t.Errorf("next try scheduled for %v: a check that cannot happen was "+
"put on the clock", req.NextTryAt)
}
}
+66
View File
@@ -0,0 +1,66 @@
import { test, expect } from '../support/fixtures.js';
/**
* The queue button says whether the queue is open.
*
* It used to look identical in both states, so the only way to tell
* what pressing it would do was to look at the other side of the window
* and infer it — and for anyone not looking at all there was nothing to
* infer from: no `aria-expanded`, no `aria-controls`, no pressed state.
*
* The state is reflected *from the panel*, not kept beside the click,
* because the button is not the only thing that opens the queue —
* `now-playing-view` sets the same attribute, since it hides the bar
* this button lives in. A flag maintained by the click handler would be
* right until something else opened the panel and then quietly wrong,
* which is the second test here.
*/
test.describe('the queue toggle', () => {
test('reports open and closed, and names what it controls', async ({
app,
}) => {
const toggle = app.locator('#queue-button');
await expect(toggle).toHaveAttribute('aria-controls', 'queue-panel');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
// The state is not only in the accessibility tree: a control that
// announces a state it does not draw is half a fix.
//
// Background rather than colour, because the pointer is still on
// the button after the click and `:hover` paints it the same accent
// the open state does -- so a colour comparison here passes on the
// broken build and proves nothing.
const [open, closed] = await toggle.evaluate((el) => {
const now = getComputedStyle(el).backgroundColor;
el.setAttribute('aria-expanded', 'false');
const shut = getComputedStyle(el).backgroundColor;
el.setAttribute('aria-expanded', 'true');
return [now, shut];
});
expect(open).not.toBe(closed);
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
test('follows the panel when something else opens it', async ({ app }) => {
const toggle = app.locator('#queue-button');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
// Exactly what `now-playing-view`'s queue button does.
await app.evaluate(() =>
document.getElementById('queue-panel')?.setAttribute('open', ''),
);
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
});
});
+11
View File
@@ -207,6 +207,17 @@ body div.sidebar {
color: var(--yj-accent, #ffd43b); color: var(--yj-accent, #ffd43b);
} }
/* An open queue is a state this button can be in, and it used to
look exactly like the closed one -- so the only way to tell what
pressing it would do was to look at the other side of the window
and infer it. `aria-expanded` is the same fact for anyone not
looking at all, and it points at the panel it controls. */
#queue-button[aria-expanded='true'] {
color: var(--yj-accent, #ffd43b);
background: var(--yj-bg-overlay, #404040);
border-radius: 4px;
}
#queue-button.drag-over { #queue-button.drag-over {
color: var(--yj-accent, #ffd43b); color: var(--yj-accent, #ffd43b);
outline: 2px dashed var(--yj-accent, #ffd43b); outline: 2px dashed var(--yj-accent, #ffd43b);
+2 -1
View File
@@ -37,7 +37,8 @@
<footer class="bottom-bar"> <footer class="bottom-bar">
<now-playing></now-playing> <now-playing></now-playing>
<audio-player></audio-player> <audio-player></audio-player>
<button aria-label="Toggle queue" id="queue-button"> <button aria-label="Toggle queue" aria-controls="queue-panel" aria-expanded="false"
id="queue-button">
<wa-icon name="list"></wa-icon> <wa-icon name="list"></wa-icon>
</button> </button>
</footer> </footer>
+22
View File
@@ -521,6 +521,28 @@ if (queueButton && queuePanel) {
} }
}); });
// The button says whether the panel is open, and it learns that
// from the panel rather than from its own click handler.
//
// It is not the only thing that opens the queue -- `now-playing-view`
// sets the same attribute, because it hides the bar this button
// lives in -- so a state kept beside the click would be right until
// something else opened the panel and then quietly wrong. The panel's
// `open` attribute is the one fact; this reflects it.
const reflectQueueState = () => {
queueButton.setAttribute(
'aria-expanded',
String(queuePanel.hasAttribute('open')),
);
};
new MutationObserver(reflectQueueState).observe(queuePanel, {
attributes: true,
attributeFilter: ['open'],
});
reflectQueueState();
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Queue button as drop target (when queue panel is closed) // Queue button as drop target (when queue panel is closed)
// --------------------------------------------------------------- // ---------------------------------------------------------------
@@ -68,6 +68,29 @@ export class SeekBar extends LitElement {
align-items: center; align-items: center;
} }
/* The clocks must not resize as they count.
Two things move them, and they need different answers. Digits in
a proportional font are different widths, so 1:11 is narrower
than 4:08 and the bar breathed once a second -- that is what
tabular figures fix. The character *count* changes too, at the
hundredth minute and whenever the right-hand clock is toggled to
remaining and grows a minus sign, and a figure width cannot fix
that -- so each clock also reserves the widest string this track
can put in it. The budget is per track rather than a constant
because reserving six characters on every track would push the
slider in by a character at each end for nothing. */
#seek-bar-container small,
.time-toggle {
font-variant-numeric: tabular-nums;
flex: 0 0 auto;
min-width: calc(var(--yj-clock-chars, 5) * 1ch);
}
#seek-bar-container small {
text-align: left;
}
.time-toggle { .time-toggle {
background: none; background: none;
border: none; border: none;
@@ -76,6 +99,9 @@ export class SeekBar extends LitElement {
font: inherit; font: inherit;
font-size: var(--wa-font-size-s, 0.875rem); font-size: var(--wa-font-size-s, 0.875rem);
cursor: pointer; cursor: pointer;
/* One more for the minus sign the remaining form carries. */
min-width: calc((var(--yj-clock-chars, 5) + 1) * 1ch);
text-align: right;
} }
.time-toggle:hover, .time-toggle:hover,
@@ -219,8 +245,19 @@ export class SeekBar extends LitElement {
: formatSeconds(this.trackLength); : formatSeconds(this.trackLength);
const rightTime = this.hasTrack ? rightLabel : '--:--'; const rightTime = this.hasTrack ? rightLabel : '--:--';
// The widest string either clock can hold for *this* track. The
// duration is the longest elapsed value there can be, so its length
// is the budget; `--:--` is five, which is also the floor.
const clockChars = Math.max(
5,
this.hasTrack ? formatSeconds(this.trackLength).length : 0,
);
return html` return html`
<div id="seek-bar-container"> <div
id="seek-bar-container"
style="--yj-clock-chars: ${clockChars}"
>
<small data-testid="elapsed-time">${elapsedTime}</small> <small data-testid="elapsed-time">${elapsedTime}</small>
<wa-slider <wa-slider
label="Seek" label="Seek"
@@ -111,13 +111,32 @@ const gridStyles = css`
scale: 0.95; scale: 0.95;
} }
/* Title and year on one line, and only the title truncates.
The year used to be part of the same run of text, so it was the
first thing an ellipsis ate: a card wide enough for a long album
name never showed its year, and browsing by year showed years
only for the albums with short names -- the sort said one thing
and the cards showed another.
A flex row rather than a second line, because the card's height
is what the virtualizer measures rows by. */
.album-name { .album-name {
font-size: var(--album-name-font, 14px); font-size: var(--album-name-font, 14px);
font-weight: 400; font-weight: 400;
color: var(--yj-text-primary, #fff); color: var(--yj-text-primary, #fff);
display: flex;
justify-content: center;
align-items: baseline;
gap: 0.35em;
min-width: 0;
}
.album-title {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
min-width: 0;
} }
.artist-name { .artist-name {
@@ -131,6 +150,8 @@ const gridStyles = css`
.album-year { .album-year {
color: var(--yj-text-tertiary, #888); color: var(--yj-text-tertiary, #888);
flex: 0 0 auto;
white-space: nowrap;
} }
/* ======================================== /* ========================================
@@ -1480,12 +1480,16 @@ export class CoverGrid
source: 'cover-grid', source: 'cover-grid',
}); });
// Single album: show cover art thumbnail. // Single album: show its cover, badged with how many tracks are
// Multiple albums: show track-count badge. // on the way -- an album is 1 track or 30 and the thumbnail is
// the same picture either way, so the number the drop is about
// was the one thing this drag did not say.
// Multiple albums: show the track-count badge alone.
if (isSingleAlbum && hit.album.CoverArtPath) { if (isSingleAlbum && hit.album.CoverArtPath) {
this.dragImageEl = this.dragImageEl =
createAlbumArtDragImage( createAlbumArtDragImage(
this.getCoverUrl(hit.album), this.getCoverUrl(hit.album),
filePaths.length,
); );
} else { } else {
this.dragImageEl = createDragImage( this.dragImageEl = createDragImage(
@@ -1898,10 +1902,10 @@ export class CoverGrid
class="album-name" class="album-name"
title="${album.Name}" title="${album.Name}"
> >
${album.Name}${album.Year <span class="album-title">${album.Name}</span
? html` >${album.Year
<span class="album-year"> ? html`<span class="album-year"
(${album.Year})</span >(${album.Year})</span
>` >`
: nothing} : nothing}
</div> </div>
@@ -512,7 +512,9 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
${request.artist ? `${request.artist}` : ''}${request.title || ${request.artist ? `${request.artist}` : ''}${request.title ||
request.mbid} request.mbid}
</div> </div>
<div class="detail">${requestDetail(request, this.nowMs)}</div> <div class="detail">
${requestDetail(request, this.nowMs, this.canDownload)}
</div>
</div> </div>
<div class="actions"> <div class="actions">
${request.state === 'satisfied' ${request.state === 'satisfied'
@@ -706,10 +708,20 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
* looked for rather than as an error, because that is what it is — the * looked for rather than as an error, because that is what it is — the
* retry is already scheduled and there is nothing for the user to do. * retry is already scheduled and there is nothing for the user to do.
*/ */
function requestDetail(request: Request, nowMs: number): string { function requestDetail(
request: Request,
nowMs: number,
canDownload: boolean,
): string {
if (request.state === 'satisfied') return 'In your library'; if (request.state === 'satisfied') return 'In your library';
if (request.state === 'paused') return 'Paused — not being looked for'; if (request.state === 'paused') return 'Paused — not being looked for';
// With no client there is no search and no retry clock — the
// backend stopped scheduling one — so a row must not imply either.
// "Queued" and "next check in 6 hours" are both promises nothing is
// in a position to keep.
if (!canDownload) return 'On your list — no download client to search with';
if (request.attempts === 0) return 'Queued — not searched for yet'; if (request.attempts === 0) return 'Queued — not searched for yet';
const tries = `Searched ${request.attempts} time${request.attempts === 1 ? '' : 's'}`; const tries = `Searched ${request.attempts} time${request.attempts === 1 ? '' : 's'}`;
@@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js'; import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { import {
LookupReleaseGroup, LookupReleaseGroup,
BrowseReleases, BrowseReleases,
@@ -289,6 +290,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
designTokens, designTokens,
exploreLinkStyles, exploreLinkStyles,
contextMenuStyles, contextMenuStyles,
srOnly,
css` css`
:host { :host {
display: flex; display: flex;
@@ -662,34 +664,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
font-weight: 400; font-weight: 400;
} }
/* The request control is only offered where there is /* The request control is offered on every row that has
* something to request, and only when the row is being * something to request, and is not revealed on hover.
* attended to — a column of plus signs down a mostly-owned
* album is the clutter the green ticks were.
* *
* Hidden with opacity, never display:none or visibility, * It used to be transparent until the row was hovered or
* so it keeps its place in the layout (rows do not reflow * focused, on the reasoning that a column of plus signs
* as the pointer moves) and stays in the tab order and the * down a mostly-owned album is clutter. That reasoning was
* accessibility tree. focus-within is what makes it * inherited from the green ticks it replaced and does not
* reachable without a mouse: tabbing to the button reveals * survive the rule those were removed for: a tick marked
* it, and the row's own focus reveals it before you get * the *common* case, while this marks the rows that are
* there. */ * **not** here. A mark on the exception is the information
* on this page — and one that appears only under the
* pointer cannot be seen, counted, or reached by anyone
* driving this with a finger. */
.track-row .track-request { .track-row .track-request {
flex-shrink: 0; flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s ease;
}
.track-row:hover .track-request,
.track-row:focus-within .track-request,
.track-row .track-request:focus-visible {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.track-row .track-request {
transition: none;
}
} }
`, `,
]; ];
@@ -3047,11 +3036,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
/* ── Tracklist ── */ /* ── Tracklist ── */
/**
* The heading is there and is not drawn.
*
* A list of numbered titles with durations under an album's cover
* does not need a word above it saying what it is — it was the
* only thing on this page labelling something already obvious. But
* the section is a landmark and the page's heading structure runs
* through it, so what goes is the *ink*, not the element: a reader
* jumping by heading still finds the tracklist.
*/
private renderTracklist() { private renderTracklist() {
if (this.loadingReleases) { if (this.loadingReleases) {
return html` return html`
<section> <section>
<h3 class="section-header">Tracklist</h3> <h3 class="sr-only">Tracklist</h3>
<div class="section-loading">Loading tracks\u2026</div> <div class="section-loading">Loading tracks\u2026</div>
</section> </section>
`; `;
@@ -3064,7 +3063,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
if (!current) { if (!current) {
return html` return html`
<section> <section>
<h3 class="section-header">Tracklist</h3> <h3 class="sr-only">Tracklist</h3>
<div class="section-error"> <div class="section-error">
<wa-icon name="triangle-exclamation"></wa-icon> <wa-icon name="triangle-exclamation"></wa-icon>
No release data available. No release data available.
@@ -3077,7 +3076,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
if (tracks.length === 0) { if (tracks.length === 0) {
return html` return html`
<section> <section>
<h3 class="section-header">Tracklist</h3> <h3 class="sr-only">Tracklist</h3>
<div <div
style="color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-md)" style="color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-md)"
> >
@@ -3093,7 +3092,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return html` return html`
<section> <section>
<h3 class="section-header">Tracklist</h3> <h3 class="sr-only">Tracklist</h3>
<div class="tracklist"> <div class="tracklist">
${discNumbers.map((discNum) => { ${discNumbers.map((discNum) => {
const discTracks = discMap.get(discNum) ?? []; const discTracks = discMap.get(discNum) ?? [];
+53 -2
View File
@@ -29,11 +29,23 @@ export function createDragImage(count: number): HTMLElement {
} }
/** /**
* Creates a drag image showing an album cover art thumbnail. * Creates a drag image showing an album cover art thumbnail, with a
* Falls back to the track-count badge if the image fails to load. * corner badge saying how many tracks are on the way.
*
* The count is not decoration. The cover says *what* is being dragged
* and nothing said *how much* — an album is 1 track or 30 and the
* thumbnail is identical either way, so the one number the drop is
* about was the one thing the drag did not show. Every other drag in
* the app says it (`createDragImage` is a count and nothing else);
* this one was the exception because it had a picture to show instead.
*
* A count of 1 draws no badge: "1" over a single album cover is noise,
* and the absence is unambiguous next to a badge that only ever
* appears when there is more than one.
*/ */
export function createAlbumArtDragImage( export function createAlbumArtDragImage(
coverUrl: string, coverUrl: string,
count = 1,
): HTMLElement { ): HTMLElement {
const size = 64; const size = 64;
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
@@ -44,6 +56,12 @@ export function createAlbumArtDragImage(
'left: -1000px', 'left: -1000px',
'pointer-events: none', 'pointer-events: none',
'z-index: 9999', 'z-index: 9999',
// The badge is positioned against this box, and the box stays
// exactly the cover's size: anything outside it risks being
// clipped out of the snapshot the browser takes, and padding
// it instead would move the cover away from the cursor.
`width: ${size}px`,
`height: ${size}px`,
].join(';'); ].join(';');
const img = document.createElement('img'); const img = document.createElement('img');
@@ -61,11 +79,44 @@ export function createAlbumArtDragImage(
].join(';'); ].join(';');
wrapper.appendChild(img); wrapper.appendChild(img);
if (count > 1) {
wrapper.appendChild(countBadge(count));
}
document.body.appendChild(wrapper); document.body.appendChild(wrapper);
return wrapper; return wrapper;
} }
/** The corner badge on a multi-track drag image. */
function countBadge(count: number): HTMLElement {
const badge = document.createElement('span');
badge.className = 'drag-count-badge';
badge.textContent = String(count);
badge.style.cssText = [
'position: absolute',
'top: 3px',
'right: 3px',
'min-width: 20px',
'height: 20px',
'padding: 0 5px',
'box-sizing: border-box',
'border-radius: 10px',
'background: #ffd43b',
'color: #000',
'font-size: 12px',
'font-weight: 600',
'font-family: inherit',
'line-height: 20px',
'text-align: center',
'box-shadow: 0 1px 4px rgba(0,0,0,0.5)',
].join(';');
return badge;
}
/** /**
* Creates a drag image styled like a queue track card showing the * Creates a drag image styled like a queue track card showing the
* track title and artist. Used when dragging a single track. * track title and artist. Used when dragging a single track.
@@ -0,0 +1,87 @@
/**
* The year on an album card survives a long album name.
*
* The year used to be part of the same run of text as the title, inside
* one `text-overflow: ellipsis` box — so it was the first thing the
* ellipsis ate. A card wide enough for a long name never showed its
* year at all, which means sorting the grid *by year* showed years only
* for the albums with short names: the sort said one thing and the
* cards showed another.
*
* The fix is a flex row in which only the title truncates, rather than
* a second line, because the card's height is what the virtualizer
* measures rows by.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/cover-grid/cover-grid';
import { emit, stub, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadowAll } from '@test/support/render';
const LONG =
'The Rise and Fall of a Midwest Princess in the Key of Everything';
/** Long names throughout: the fault only shows on a card under
* pressure, and a grid of "Album 3" proves nothing. */
const ALBUMS = Array.from({ length: 12 }, (_, i) => ({
ID: i + 1,
Name: `${LONG} ${i + 1}`,
ArtistName: 'Aurora Fields',
Year: 2019 + (i % 5),
}));
/** Give the virtualizer a viewport; a zero-height host renders nothing. */
function sized(el: HTMLElement): void {
el.style.display = 'block';
el.style.height = '600px';
el.style.width = '900px';
}
async function settle(el: LitElement): Promise<void> {
await flush();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 80));
}
describe('the album cards year', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetTracks', []);
emit(Events.LibraryScanComplete);
});
it('is rendered on every card, however long the name', async () => {
const el = await fixture<LitElement>('cover-grid');
sized(el);
await settle(el);
const cards = shadowAll(el, '.album-card');
const years = shadowAll(el, '.album-year');
expect(cards.length).toBeGreaterThan(0);
expect(years).toHaveLength(cards.length);
expect(years.every((y) => /^\(\d{4}\)$/.test(y.textContent!.trim()))).toBe(
true,
);
});
it('is not what the ellipsis eats', async () => {
const el = await fixture<LitElement>('cover-grid');
sized(el);
await settle(el);
const year = shadowAll(el, '.album-year')[0]!;
const title = shadowAll(el, '.album-title')[0]!;
// The title is the box that gives way...
expect(title.scrollWidth).toBeGreaterThan(title.clientWidth);
// ...and the year keeps every pixel it asked for.
expect(year.clientWidth).toBeGreaterThan(0);
expect(year.scrollWidth).toBeLessThanOrEqual(year.clientWidth + 1);
});
});
@@ -0,0 +1,97 @@
/**
* The request badge on an unowned row is there without being hovered.
*
* It used to be transparent until the row was hovered or focused, on
* the reasoning that a column of plus signs down a mostly-owned album
* is clutter. That reasoning came from the green ticks it replaced and
* does not survive the rule those were removed for: a tick marked the
* **common** case, while this marks the rows that are *not* here. A
* mark on the exception is the information on this page, and one that
* exists only under the pointer cannot be seen, counted, or reached by
* anyone driving the app with a finger.
*
* That the badge *repaints* when clicked is the other half of #33 and
* is covered by `album-track-request.test.ts`.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadowAll } from '@test/support/render';
function track(n: number, owned: boolean) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `mbid-${n}`,
inLibrary: owned,
};
}
/** An album with one owned track and one that is not here. */
async function albumWithAnUnownedTrack(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
releaseGroupMBID: 'rg-1',
});
stub('library.Library.GetFilePathsByRecordingMBIDs', {
'mbid-1': ['/music/mbid-1.mp3'],
});
Object.assign(el, {
versionEntries: [
{
key: 'v1',
label: '2019',
sublabel: '2 tracks',
tracks: [track(1, true), track(2, false)],
},
],
selectedVersionKey: 'v1',
loadingReleases: false,
loadingInfo: false,
});
el.requestUpdate();
await flush();
await el.updateComplete;
return el;
}
const badges = (el: LitElement) =>
shadowAll(el, 'library-status-indicator.track-request');
describe('the tracklists request badge', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetFilePathsByAlbums', {});
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('download.Service.ProviderKinds', []);
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
});
it('is visible without a pointer anywhere near it', async () => {
const el = await albumWithAnUnownedTrack();
const [badge] = badges(el);
expect(badge).toBeTruthy();
// Computed opacity rather than the absence of a rule, because the
// rule could come back under a different selector.
expect(getComputedStyle(badge!).opacity).toBe('1');
});
it('is still only on the rows with something to request', async () => {
// Always-visible is not the same as everywhere: an owned track has
// nothing left to ask for, and a badge on it would be the column of
// green ticks this page deliberately stopped drawing.
expect(badges(await albumWithAnUnownedTrack())).toHaveLength(1);
});
});
@@ -0,0 +1,89 @@
/**
* The tracklist's own heading.
*
* A list of numbered titles with durations, under the album's cover, is
* the one thing on this page that did not need a word above it saying
* what it was — "TRACKLIST" labelled the only thing already obvious.
*
* What goes is the *ink*, not the element. The section is a landmark
* and the page's heading structure runs through it, so a reader moving
* by heading still has to be able to find it, and it is hidden the way
* `sr-only` hides things: `clip-path`, never `display: none`, which
* would take it out of the accessibility tree along with the layout.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadowAll } from '@test/support/render';
function track(n: number) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `mbid-${n}`,
inLibrary: true,
};
}
async function albumPage(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
releaseGroupMBID: 'rg-1',
});
Object.assign(el, {
versionEntries: [
{
key: 'v1',
label: '2019',
sublabel: '2 tracks',
tracks: [track(1), track(2)],
},
],
selectedVersionKey: 'v1',
loadingReleases: false,
loadingInfo: false,
});
el.requestUpdate();
await flush();
await el.updateComplete;
return el;
}
const tracklistHeading = (el: LitElement) =>
shadowAll(el, 'h3').find((h) => h.textContent?.trim() === 'Tracklist');
describe('the album tracklist heading', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetFilePathsByAlbums', {});
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('download.Service.ProviderKinds', []);
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
});
it('is still in the tree', async () => {
expect(tracklistHeading(await albumPage())).toBeTruthy();
});
it('takes up no room on the page', async () => {
const heading = tracklistHeading(await albumPage())!;
const box = heading.getBoundingClientRect();
expect(box.width).toBeLessThanOrEqual(1);
expect(box.height).toBeLessThanOrEqual(1);
// Hidden by clipping, not by removal: display:none and
// visibility:hidden both take it out of the accessibility tree.
expect(getComputedStyle(heading).display).not.toBe('none');
expect(getComputedStyle(heading).visibility).not.toBe('hidden');
});
});
@@ -0,0 +1,90 @@
/**
* What a wanted list says when there is nothing to search with.
*
* Wanting something without a download client is a supported thing to
* do — the list is kept, and it starts moving when a client is added.
* What was not supported was the app *claiming to be looking*: every
* pass attempted each request, failed it with "no download clients are
* enabled", recorded that as an attempt and scheduled a retry, so a row
* read "Searched 3 times, no download clients are enabled · next check
* in 6 hours" about a check that could not happen.
*
* The backend half is `TestNoProvidersMeansNoAttempt`. This is the row.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/downloads-view/downloads-view';
import { stub, emit, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadowAll } from '@test/support/render';
/** A request that has been tried and is waiting on a retry — the shape
* a list with a client in it produces. */
const WAITING = {
id: 1,
mbid: 'rg-1',
entity: 'release-group',
libraryId: 1,
artist: 'Aurora Fields',
title: 'Glass Harbour',
state: 'wanted',
attempts: 3,
lastError: 'no source has it yet',
nextTryAt: new Date(Date.now() + 6 * 3600_000).toISOString(),
};
const PROVIDER = {
id: 1,
kind: 'slskd',
name: 'Sound',
enabled: true,
priority: 50,
};
/**
* The download store is a singleton whose `init()` runs once per
* session, so a second mount does not re-read the provider list. The
* event is how the app itself learns a client was added, and is what
* makes this test independent of which case ran first.
*/
async function view(providers: unknown[]): Promise<LitElement> {
stub('download.Service.ListProviders', providers);
const el = await fixture<LitElement>('downloads-view');
emit(Events.DownloadProvidersChanged);
await flush();
await el.updateComplete;
return el;
}
const details = (el: LitElement) =>
shadowAll(el, '.detail').map((d) => d.textContent!.trim());
describe('a request row with no download client', () => {
beforeEach(() => {
resetHarness();
stub('download.Service.ProviderKinds', []);
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', [WAITING]);
});
it('does not promise a check that cannot happen', async () => {
const el = await view([]);
expect(details(el)).toHaveLength(1);
expect(details(el)[0]).toBe(
'On your list — no download client to search with',
);
expect(details(el)[0]).not.toMatch(/next check/);
});
it('reports the retry schedule again once a client exists', async () => {
const el = await view([PROVIDER]);
expect(details(el)[0]).toMatch(/next check/);
});
});
@@ -0,0 +1,71 @@
/**
* A drag says how much it is carrying.
*
* Every drag in the app already did — `createDragImage` is a count and
* nothing else — except the one with a picture to show instead. An
* album dragged to the queue put its cover under the cursor and said
* nothing about how many tracks that was, and an album is 1 track or 30
* with the same thumbnail either way. The number is the thing the drop
* is about.
*
* A count of 1 draws no badge: "1" over a single cover is noise, and
* the absence reads unambiguously beside a badge that only ever appears
* when there is more than one.
*/
import { describe, expect, it, afterEach } from 'vitest';
import {
createAlbumArtDragImage,
removeDragImage,
} from '@utils/drag-image';
const made: HTMLElement[] = [];
function dragImage(count?: number): HTMLElement {
const el =
count === undefined
? createAlbumArtDragImage('data:image/gif;base64,R0lGODlhAQABAAAAACw=')
: createAlbumArtDragImage(
'data:image/gif;base64,R0lGODlhAQABAAAAACw=',
count,
);
made.push(el);
return el;
}
const badge = (el: HTMLElement) =>
el.querySelector<HTMLElement>('.drag-count-badge');
describe('the album drag image', () => {
afterEach(() => {
while (made.length > 0) removeDragImage(made.pop()!);
});
it('says how many tracks are being dragged', () => {
expect(badge(dragImage(12))?.textContent).toBe('12');
});
it('says nothing when there is only one track', () => {
expect(badge(dragImage(1))).toBeNull();
});
it('still draws a bare cover for a caller that gives no count', () => {
// The count is optional so the helper stays usable from a call site
// that has a cover and no list; it must not badge such a drag "1".
expect(badge(dragImage())).toBeNull();
});
it('keeps the badge inside the cover', () => {
// setDragImage snapshots the element, and anything outside its box
// risks being clipped out of that snapshot — while padding the box
// instead would move the cover away from the cursor.
const el = dragImage(30);
const outer = el.getBoundingClientRect();
const mark = badge(el)!.getBoundingClientRect();
expect(mark.right).toBeLessThanOrEqual(outer.right);
expect(mark.top).toBeGreaterThanOrEqual(outer.top);
});
});
@@ -227,6 +227,41 @@ describe('<seek-bar>', () => {
expect(text(el, '[data-testid="remaining-time"]')).toBe('01:30'); expect(text(el, '[data-testid="remaining-time"]')).toBe('01:30');
}); });
it('keeps the slider still as the clocks count', async () => {
const el = await fixture('seek-bar');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 31 });
await flush();
await el.updateComplete;
const slider = () =>
shadow(el, 'wa-slider')!.getBoundingClientRect();
const before = slider();
// 1:11 against 4:08 is the reported jitter: different digits, and
// in a proportional font different widths. Toggling the right-hand
// clock is the other half -- the minus sign is a whole character.
for (const positionSeconds of [8, 71, 88]) {
emit(Events.PlaybackPositionChanged, {
positionSeconds,
trackLength: 90,
trackChangeId: 31,
seq: positionSeconds,
playing: true,
});
await flush();
await el.updateComplete;
expect(slider().width).toBeCloseTo(before.width, 1);
expect(slider().left).toBeCloseTo(before.left, 1);
}
await click(el, '[data-testid="remaining-time"]');
await el.updateComplete;
expect(slider().width).toBeCloseTo(before.width, 1);
});
it('renders the position the backend reports rather than its own count', async () => { it('renders the position the backend reports rather than its own count', async () => {
const el = await fixture('seek-bar'); const el = await fixture('seek-bar');
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env bash
#
# The tracker, from the command line.
#
# Issues are this project's source of truth for what is wanted and what is
# already being worked on, which means "search the tracker" runs at the top
# of every task rather than occasionally. Fifty-odd open issues make that a
# real lookup, and a lookup nobody can remember the shape of is a lookup that
# gets skipped — so it is one command here instead of a curl re-derived from
# prose each time. See CLAUDE.md, "Issues".
#
# **Text reaches the API as JSON, never as shell.** An issue body is
# arbitrary prose carrying backticks, quotes and `$`, so bodies are read from
# a file or from stdin and encoded by python3, on the same reasoning that
# keeps release notes out of `gitea-release.sh`'s argument list. Only issue
# numbers and label names cross as arguments, and the numbers are validated.
#
# **Claiming is an assignment, a label and a comment, together.** Any one of
# them alone is a claim somebody else has to go looking for: the assignee is
# what shows in the issue list, `Status/In Progress` is what filters, and the
# comment is what says which branch and what approach. `claim` does all
# three, and refuses outright if somebody else already holds it.
#
# Usage:
# scripts/issue.sh list [--state open|closed|all] [--label L] [--assignee U]
# scripts/issue.sh mine
# scripts/issue.sh search <text...>
# scripts/issue.sh show <n>
# scripts/issue.sh new --title <t> [--labels A,B] [--body-file F]
# scripts/issue.sh claim <n> [--branch <name>] [--body-file F]
# scripts/issue.sh unclaim <n>
# scripts/issue.sh comment <n> [--body-file F]
# scripts/issue.sh close <n> [--body-file F]
# scripts/issue.sh label <n> +Kind/Bug -Status/Blocked
# scripts/issue.sh depends <n> <blocker-n>
# scripts/issue.sh labels
#
# Where a body is taken and no --body-file is given, it is read from stdin.
#
# Environment:
# GITEA_TOKEN a PAT with write:issue (plus write:repository and read:user,
# which the rest of this repo's tooling reaches for)
# GITEA_URL defaults to https://git.ljones.me
# GITEA_REPO defaults to yonlu/yellowjacket
set -euo pipefail
server="${GITEA_URL:-https://git.ljones.me}"
repo="${GITEA_REPO:-yonlu/yellowjacket}"
api="$server/api/v1/repos/$repo"
: "${GITEA_TOKEN:?issue.sh: GITEA_TOKEN is not set}"
command -v python3 >/dev/null || { echo "issue.sh: python3 is required" >&2; exit 1; }
py="$(dirname "$0")/issue_fmt.py"
# ---------------------------------------------------------------- plumbing
call() {
local method="$1" path="$2"
if [ "$method" = GET ]; then
curl -sS -H "Authorization: token $GITEA_TOKEN" "$api$path"
else
curl -sS -X "$method" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @- "$api$path"
fi
}
num() {
printf '%s' "${1:-}" | grep -qE '^[0-9]+$' || {
echo "issue.sh: '${1:-}' is not an issue number" >&2
exit 1
}
printf '%s' "$1"
}
# Read a body from a file or stdin. A file of "-" is stdin.
read_body() {
local file="${1:--}"
if [ "$file" = "-" ]; then cat; else cat "$file"; fi
}
me() { curl -sS -H "Authorization: token $GITEA_TOKEN" "$server/api/v1/user" | python3 "$py" login; }
label_id() { call GET "/labels?limit=100" | python3 "$py" label-id "$1"; }
# Labels are resolved to ids rather than posted as names: Gitea accepts a list
# of unknown *names* with 200 and applies none of them, so a typo — or a label
# somebody renamed — reports success and does nothing.
add_labels() {
local n="$1" ids
shift
ids="$(call GET "/labels?limit=100" | python3 "$py" label-ids "$(IFS=,; printf '%s' "$*")")"
python3 "$py" add-label-ids "$ids" | call POST "/issues/$n/labels" |
python3 "$py" check >/dev/null
}
drop_label() {
local n="$1" name="$2" id
id="$(label_id "$name" 2>/dev/null)" || return 0
curl -sS -o /dev/null -X DELETE -H "Authorization: token $GITEA_TOKEN" \
"$api/issues/$n/labels/$id"
}
post_comment() {
local n="$1" text
text="$(cat)"
# Checked here rather than left to the API, which answers an empty body
# with "[Body]: Required" and then this pipeline reports a second, more
# confusing error from the request that was built anyway.
if [ -z "${text//[[:space:]]/}" ]; then
echo "issue.sh: refusing to post an empty comment on #$n" >&2
exit 1
fi
printf '%s\n' "$text" | python3 "$py" wrap-body |
call POST "/issues/$n/comments" | python3 "$py" check >/dev/null
}
# ---------------------------------------------------------------- commands
cmd_list() {
local state=open label="" assignee="" limit=100 q=""
while [ $# -gt 0 ]; do
case "$1" in
--state) state="$2"; shift 2 ;;
--label) label="$2"; shift 2 ;;
--assignee) assignee="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--q) q="$2"; shift 2 ;;
*) echo "issue.sh list: unknown option $1" >&2; exit 1 ;;
esac
done
local path="/issues?type=issues&state=$state&limit=$limit"
[ -n "$label" ] && path="$path&labels=$(python3 "$py" urlquote "$label")"
[ -n "$assignee" ] && path="$path&assigned_by=$assignee"
[ -n "$q" ] && path="$path&q=$(python3 "$py" urlquote "$q")"
call GET "$path" | python3 "$py" list
}
cmd_mine() { cmd_list --assignee "$(me)" "$@"; }
cmd_search() {
[ $# -gt 0 ] || { echo "usage: issue.sh search <text...>" >&2; exit 1; }
echo "-- open --"
cmd_list --state open --q "$*"
echo "-- closed --"
cmd_list --state closed --q "$*"
}
cmd_show() {
local n; n="$(num "${1:-}")"
call GET "/issues/$n" | python3 "$py" show
echo "-- depends on --"
call GET "/issues/$n/dependencies" | python3 "$py" deps
echo "-- comments --"
call GET "/issues/$n/comments" | python3 "$py" comments
}
cmd_new() {
local title="" labels="" file="-"
while [ $# -gt 0 ]; do
case "$1" in
--title) title="$2"; shift 2 ;;
--labels) labels="$2"; shift 2 ;;
--body-file) file="$2"; shift 2 ;;
*) echo "issue.sh new: unknown option $1" >&2; exit 1 ;;
esac
done
[ -n "$title" ] || { echo "issue.sh new: --title is required" >&2; exit 1; }
# Label names are resolved to ids first, so a typo is an error here rather
# than an issue filed with a label silently absent.
local ids="[]"
if [ -n "$labels" ]; then
ids="$(call GET "/labels?limit=100" | python3 "$py" label-ids "$labels")"
fi
read_body "$file" | python3 "$py" new-issue "$title" "$ids" |
call POST "/issues" | python3 "$py" created
}
cmd_claim() {
local n; n="$(num "${1:-}")"; shift || true
local branch="" file=""
while [ $# -gt 0 ]; do
case "$1" in
--branch) branch="$2"; shift 2 ;;
--body-file) file="$2"; shift 2 ;;
*) echo "issue.sh claim: unknown option $1" >&2; exit 1 ;;
esac
done
local who holder note
who="$(me)"
holder="$(call GET "/issues/$n" | python3 "$py" assignees)"
# The whole point of the workflow, so it is a hard failure.
if [ -n "$holder" ] && [ "$holder" != "$who" ]; then
echo "issue.sh: #$n is already claimed by $holder — talk to them before starting" >&2
exit 1
fi
# The comment is resolved *before* anything is mutated. Reading it after
# the assignment is how a claim ends up half-made: the assignee and the
# label land, the comment is rejected as empty, and the issue says it is
# taken without saying by what work.
if [ -n "$file" ]; then
note="$(read_body "$file")"
elif [ ! -t 0 ]; then
note="$(read_body -)"
fi
if [ -z "${note//[[:space:]]/}" ]; then
note="Starting work on this${branch:+ on \`$branch\`}."
fi
python3 "$py" assign "$who" | call PATCH "/issues/$n" | python3 "$py" check >/dev/null
add_labels "$n" "Status/In Progress"
printf '%s\n' "$note" | post_comment "$n"
echo "claimed #$n as $who${branch:+ (branch $branch)}"
}
cmd_unclaim() {
local n; n="$(num "${1:-}")"
python3 "$py" assign | call PATCH "/issues/$n" | python3 "$py" check >/dev/null
drop_label "$n" "Status/In Progress"
echo "unclaimed #$n"
}
cmd_comment() {
local n; n="$(num "${1:-}")"; shift || true
local file="-"
[ "${1:-}" = "--body-file" ] && file="$2"
read_body "$file" | post_comment "$n"
echo "commented on #$n"
}
cmd_close() {
local n; n="$(num "${1:-}")"; shift || true
local file=""
[ "${1:-}" = "--body-file" ] && file="$2"
if [ -n "$file" ]; then
read_body "$file" | post_comment "$n"
elif [ ! -t 0 ]; then
read_body - | post_comment "$n"
fi
printf '{"state":"closed"}' | call PATCH "/issues/$n" | python3 "$py" check >/dev/null
# A claim outlives the work if nothing takes the label off.
drop_label "$n" "Status/In Progress"
echo "closed #$n"
}
# Hard blockers are real Gitea dependencies, which render on the issue itself
# — see #73, whose graph is the reason this is not just prose in a comment.
#
# The endpoint takes a whole IssueMeta, not an index: a body of {"index": 88}
# answers **404**, which reads exactly like a missing endpoint on a Gitea
# build that does not have the feature.
cmd_depends() {
local n blocker
n="$(num "${1:-}")"
blocker="$(num "${2:-}")"
python3 "$py" issue-meta "$repo" "$blocker" |
call POST "/issues/$n/dependencies" | python3 "$py" check >/dev/null
echo "#$n now depends on #$blocker"
}
cmd_label() {
local n; n="$(num "${1:-}")"; shift
local add=() del=()
for spec in "$@"; do
case "$spec" in
+*) add+=("${spec#+}") ;;
-*) del+=("${spec#-}") ;;
*) echo "issue.sh label: expected +Name or -Name, got '$spec'" >&2; exit 1 ;;
esac
done
if [ ${#add[@]} -gt 0 ]; then
add_labels "$n" "${add[@]}"
fi
local name
for name in ${del[@]+"${del[@]}"}; do
drop_label "$n" "$name"
done
echo "relabelled #$n"
}
# ---------------------------------------------------------------- dispatch
sub="${1:-}"
[ $# -gt 0 ] && shift
case "$sub" in
list) cmd_list "$@" ;;
mine) cmd_mine "$@" ;;
search) cmd_search "$@" ;;
show) cmd_show "$@" ;;
new) cmd_new "$@" ;;
claim) cmd_claim "$@" ;;
unclaim) cmd_unclaim "$@" ;;
comment) cmd_comment "$@" ;;
close) cmd_close "$@" ;;
label) cmd_label "$@" ;;
depends) cmd_depends "$@" ;;
labels) call GET "/labels?limit=100" | python3 "$py" labels ;;
*)
sed -n '/^# Usage:/,/^# Environment:/p' "$0" | sed 's/^# \{0,1\}//'
exit 1
;;
esac
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""JSON encoding and formatting for scripts/issue.sh.
It is a separate file rather than a heredoc for one reason: an issue body is
arbitrary prose, and every attempt to build that JSON inside the shell ends in
nested quoting nobody can read or verify. Here the shell passes only argv and
stdin, and every string that reaches the API is encoded by json.dumps.
A Gitea error is a JSON object with "message", and it arrives with HTTP 200 in
enough cases that printing it as data is how a wrong token scope reads as an
empty tracker. check() is what turns it into a non-zero exit instead.
"""
import json
import sys
import urllib.parse
def die(msg):
sys.exit("issue.sh: " + msg)
def load():
raw = sys.stdin.read()
if not raw.strip():
die("empty response from the API")
try:
return json.loads(raw)
except json.JSONDecodeError:
die("unreadable response: " + raw[:200])
def check(d):
if isinstance(d, dict) and "message" in d and "number" not in d:
die(d["message"])
return d
def emit(d):
json.dump(d, sys.stdout)
def names(items, key="name"):
return ", ".join(i[key] for i in items) or "-"
def main(argv):
cmd = argv[1] if len(argv) > 1 else ""
args = argv[2:]
if cmd == "check":
emit(check(load()))
elif cmd == "urlquote":
print(urllib.parse.quote(args[0]))
elif cmd == "login":
print(check(load())["login"])
elif cmd == "list":
for i in check(load()):
labels = ",".join(x["name"] for x in i["labels"])
who = ",".join(a["login"] for a in (i.get("assignees") or [])) or "-"
title = i["title"][:62]
print("#%-4d %-6s %-8s %-62s [%s]" % (i["number"], i["state"], who, title, labels))
elif cmd == "show":
d = check(load())
print("#%d %s" % (d["number"], d["title"]))
print("state: " + d["state"])
print("labels: " + names(d["labels"]))
print("assignees: " + names(d.get("assignees") or [], "login"))
print("url: " + d["html_url"])
print()
print(d.get("body") or "(no body)")
print()
elif cmd == "deps":
d = check(load())
if not d:
print(" (none)")
for i in d:
print(" #%d [%s] %s" % (i["number"], i["state"], i["title"][:70]))
elif cmd == "comments":
d = check(load())
if not d:
print(" (none)")
for c in d:
print(" %s (%s): %s" % (c["user"]["login"], c["created_at"][:10], c["body"][:600]))
elif cmd == "labels":
for label in check(load()):
print("%-24s %s" % (label["name"], (label.get("description") or "")[:60]))
elif cmd == "label-id":
have = {label["name"]: label["id"] for label in check(load())}
if args[0] not in have:
die("no such label: " + args[0])
print(have[args[0]])
elif cmd == "label-ids":
want = [s.strip() for s in args[0].split(",") if s.strip()]
have = {label["name"]: label["id"] for label in check(load())}
missing = [w for w in want if w not in have]
if missing:
die("no such label(s): " + ", ".join(missing))
emit([have[w] for w in want])
elif cmd == "wrap-body":
body = sys.stdin.read().strip()
if not body:
die("refusing to post an empty comment")
emit({"body": body})
elif cmd == "new-issue":
title, ids = args[0], json.loads(args[1])
body = sys.stdin.read().strip()
if not body:
die("an issue needs a body — the tracker is the record, not the title")
emit({"title": title, "body": body, "labels": ids})
elif cmd == "created":
d = check(load())
print("created #%d %s" % (d["number"], d["html_url"]))
elif cmd == "assignees":
print(",".join(a["login"] for a in (check(load()).get("assignees") or [])))
elif cmd == "assign":
emit({"assignees": list(args)})
elif cmd == "add-label-ids":
emit({"labels": json.loads(args[0])})
elif cmd == "issue-meta":
owner, _, name = args[0].partition("/")
emit({"owner": owner, "repo": name, "index": int(args[1])})
else:
die("unknown formatter command: " + cmd)
if __name__ == "__main__":
main(sys.argv)