From 9a11db57947a493d8545e631e0b52efbc944d1ab Mon Sep 17 00:00:00 2001
From: Brad Anderson
Date: Mon, 12 Oct 2026 19:04:33 -0700
Subject: [PATCH] =?UTF-8?q?webui:=20mark=20songs=20with=20hand=20edits=20i?=
=?UTF-8?q?n=20the=20queue=20and=20REBUILD=20ALL=20FROM=E2=80=A6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A song's edits are kept through any rebuild, but nothing said which songs
had them. GET /api/jobs and GET /api/rebuild-all now report each song's
edited and set-aside lines, read from its edits file, on the job standing
for the song only, since the runs it replaced would claim the same edits.
Queue cards show them as a badge, and the rebuild dialog lists them per
song and says hand edits are kept.
---
src/songtrace/static/index.html | 54 +++++++++++++++++++++++++++++++--
src/songtrace/web.py | 19 ++++++++++--
tests/test_web.py | 37 ++++++++++++++++++++++
3 files changed, 105 insertions(+), 5 deletions(-)
diff --git a/src/songtrace/static/index.html b/src/songtrace/static/index.html
index e8ed268..390781c 100644
--- a/src/songtrace/static/index.html
+++ b/src/songtrace/static/index.html
@@ -463,6 +463,11 @@ button { cursor: pointer; }
}
.lineage:hover { border-bottom-color: var(--aqua); }
.job-mark { grid-area: 1 / 2; justify-self: end; font-size: 0.8rem; color: var(--text-muted); }
+.job-edits { grid-area: 2 / 2; justify-self: end; display: flex; gap: 6px; font-size: 0.72rem; }
+.job-edits-kept { color: var(--aqua); }
+.job-edits-stale { color: var(--warn); }
+.rebuild-all-kept { color: var(--aqua); }
+.rebuild-all-aside { color: var(--warn); }
#detail {
margin: 13px 0 0;
@@ -584,7 +589,8 @@ button { cursor: pointer; }
Restart from
@@ -829,6 +835,18 @@ function rebuildAllRow(song) {
undefined, { dateStyle: "medium", timeStyle: "short" });
meta.textContent = busy ? "in progress, skipped"
: `${song.status === "failed" ? "failed" : song.rebuilt_from ? "rebuilt" : "built"} ${when || ""}`.trim();
+ if (song.edited_lines) {
+ const kept = document.createElement("span");
+ kept.className = "rebuild-all-kept";
+ kept.textContent = ` · ${song.edited_lines} edited line${song.edited_lines === 1 ? "" : "s"} kept`;
+ meta.append(kept);
+ }
+ if (song.stale_edits) {
+ const aside = document.createElement("span");
+ aside.className = "rebuild-all-aside";
+ aside.textContent = ` · ${song.stale_edits} set aside`;
+ meta.append(aside);
+ }
row.append(box, name, meta);
return row;
}
@@ -904,7 +922,10 @@ function jobCard(job) {
markLabel.className = "sr-only";
mark.append(markGlyph, markLabel);
- card.append(title, artist, stateLine, time, mark);
+ const edits = document.createElement("span");
+ edits.className = "job-edits";
+
+ card.append(title, artist, stateLine, time, mark, edits);
card.addEventListener("click", () => selectJob(job.id));
paintCard(card, job);
return card;
@@ -912,7 +933,7 @@ function jobCard(job) {
function paintCard(card, job) {
const state = STATE[job.status] ?? { glyph: "·", cls: "st-queued" };
- const [title, artist, stateLine, time, mark] = card.children;
+ const [title, artist, stateLine, time, mark, edits] = card.children;
card.className = `job ${state.cls}`;
if (job.id === selectedId) card.setAttribute("aria-current", "true");
else card.removeAttribute("aria-current");
@@ -923,6 +944,33 @@ function paintCard(card, job) {
time.textContent = elapsed(job);
mark.children[0].textContent = job.rebuilt_from ? "↻" : "";
mark.children[1].textContent = job.rebuilt_from ? " rebuilt" : "";
+ paintEdits(edits, job);
+}
+
+// hand edits a rebuild keeps, and any set aside because the lyrics changed
+function paintEdits(node, job) {
+ const parts = [];
+ if (job.edited_lines) parts.push(["job-edits-kept", `✎ ${job.edited_lines}`]);
+ if (job.stale_edits) parts.push(["job-edits-stale", `⚠ ${job.stale_edits}`]);
+ const label = [
+ job.edited_lines ? `${job.edited_lines} edited line${job.edited_lines === 1 ? "" : "s"}` : "",
+ job.stale_edits ? `${job.stale_edits} set aside` : "",
+ ].filter(Boolean).join(", ");
+ if (node.title === label) return;
+ node.title = label;
+ node.replaceChildren(...parts.map(([cls, text]) => {
+ const span = document.createElement("span");
+ span.className = cls;
+ span.setAttribute("aria-hidden", "true");
+ span.textContent = text;
+ return span;
+ }));
+ if (label) {
+ const sr = document.createElement("span");
+ sr.className = "sr-only";
+ sr.textContent = label;
+ node.append(sr);
+ }
}
// patched in place rather than replaced wholesale: detaching a card would blur
diff --git a/src/songtrace/web.py b/src/songtrace/web.py
index 07890ec..b2f672a 100644
--- a/src/songtrace/web.py
+++ b/src/songtrace/web.py
@@ -340,11 +340,21 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
position = {job_id: n for n, job_id in enumerate(order)}
return sorted(latest.values(), key=lambda j: position[j.id], reverse=True)
+ def _edit_counts(job: Job) -> dict:
+ """How much of the song was edited by hand, from its edits file."""
+ path = cfg.work_dir / song_slug(job.inp) / edits.EDITS_FILE
+ try:
+ data = json.loads(path.read_text())
+ except (OSError, ValueError):
+ return {"edited_lines": 0, "stale_edits": 0}
+ return {"edited_lines": len(data.get("lines", {})),
+ "stale_edits": len(data.get("stale", []))}
+
@app.get("/api/rebuild-all")
def rebuild_all_candidates():
return [{"id": j.id, "title": j.title, "artist": j.artist,
"status": j.status, "rebuilt_from": j.rebuilt_from,
- "finished_at": j.finished_at}
+ "finished_at": j.finished_at, **_edit_counts(j)}
for j in _current_songs()]
@app.post("/api/rebuild-all", status_code=201)
@@ -380,11 +390,16 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
@app.get("/api/jobs")
def list_jobs():
+ # edits belong to the song as it is now, so only the job standing for
+ # it carries them; the runs it replaced would claim them twice
+ counts = {j.id: _edit_counts(j) for j in _current_songs()}
+ none = {"edited_lines": 0, "stale_edits": 0}
with lock:
return [{"id": j.id, "title": j.title, "artist": j.artist,
"status": j.status, "stage": j.stage,
"created_at": j.created_at, "started_at": j.started_at,
- "finished_at": j.finished_at, "rebuilt_from": j.rebuilt_from}
+ "finished_at": j.finished_at, "rebuilt_from": j.rebuilt_from,
+ **counts.get(j.id, none)}
for j in (jobs[i] for i in reversed(order))]
@app.get("/api/jobs/{job_id}")
diff --git a/tests/test_web.py b/tests/test_web.py
index 5896939..84e92e6 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -1977,3 +1977,40 @@ def test_a_stale_edit_can_be_discarded(tmp_path):
assert resp.status_code == 200 and resp.json()["stale"] == []
assert not (work / edits.EDITS_FILE).exists()
assert client.delete(f"/api/jobs/{second}/stale/0").status_code == 404
+
+
+def _listed(client, job_id):
+ return next(j for j in client.get("/api/jobs").json() if j["id"] == job_id)
+
+
+def test_the_queue_reports_a_songs_edited_lines(tmp_path):
+ client, _ = make_client(tmp_path, chart_runner)
+ job_id = make_charted_job(client)
+ assert _listed(client, job_id)["edited_lines"] == 0
+ client.put(f"/api/jobs/{job_id}/lines/1", json=EDIT)
+ assert _listed(client, job_id)["edited_lines"] == 1
+ assert _listed(client, job_id)["stale_edits"] == 0
+
+
+def test_edit_counts_follow_the_song_to_its_latest_run(tmp_path):
+ client, _ = make_client(tmp_path, chart_runner)
+ first = make_charted_job(client)
+ client.put(f"/api/jobs/{first}/lines/1", json=EDIT)
+ rerun = client.post(f"/api/jobs/{first}/rerun").json()["id"]
+ wait_status(client, rerun, "done")
+ assert _listed(client, rerun)["edited_lines"] == 1
+ assert _listed(client, first)["edited_lines"] == 0
+ candidates = client.get("/api/rebuild-all").json()
+ assert [(c["id"], c["edited_lines"]) for c in candidates] == [(rerun, 1)]
+
+
+def test_the_queue_reports_edits_set_aside(tmp_path):
+ client, _ = make_client(tmp_path, chart_runner)
+ first = make_charted_job(client)
+ client.put(f"/api/jobs/{first}/lines/1", json=EDIT)
+ second = make_charted_job(client, lyrics="one\nthree more")
+ # the next save looks at the edits against the new lyrics, as a rebuild does
+ client.put(f"/api/jobs/{second}/lines/0", json={"notes": [
+ {"start": 1.0, "duration": 0.5, "pitch": 0, "text": "one"}]})
+ listed = _listed(client, second)
+ assert (listed["edited_lines"], listed["stale_edits"]) == (1, 1)