Merge remote-tracking branch 'origin/main' into editor-merge

# Conflicts:
#	tests/test_web.py
This commit is contained in:
2026-10-15 16:36:27 -07:00
3 changed files with 105 additions and 5 deletions
+51 -3
View File
@@ -623,6 +623,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;
@@ -744,7 +749,8 @@ button { cursor: pointer; }
<form id="rebuild-all-form" method="dialog">
<h2 id="rebuild-all-heading">REBUILD ALL SONGS</h2>
<p>Reruns the songs ticked below from the chosen stage onwards with the
inputs they have now, for when a songtrace update improves that stage.</p>
inputs they have now, for when a songtrace update improves that stage.
Lines edited by hand are kept.</p>
<label class="field">
<span>Restart from</span>
<select name="force_from" required></select>
@@ -991,6 +997,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;
}
@@ -1066,7 +1084,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;
@@ -1074,7 +1095,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");
@@ -1085,6 +1106,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
+17 -2
View File
@@ -342,11 +342,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)
@@ -382,11 +392,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}")
+37
View File
@@ -2074,3 +2074,40 @@ def test_a_bad_break_is_refused(tmp_path):
job_id = make_charted_job(client)
for body in ({"0": 1.0}, {"9": 1.0}, {"1": -2}, {"1": "x"}):
assert client.put(f"/api/jobs/{job_id}/breaks", json=body).status_code == 400
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)