Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ const els = {
// auto-split phrase pager (#2b)
capSplit: $('capSplit'), splitView: $('splitView'),
splitPrev: $('splitPrev'), splitNext: $('splitNext'),
splitPos: $('splitPos'), splitInfo: $('splitInfo'),
splitPos: $('splitPos'), splitInfo: $('splitInfo'), splitTags: $('splitTags'),
splitPlay: $('splitPlay'), splitStop: $('splitStop'),
splitDownload: $('splitDownload'), splitDownloadAll: $('splitDownloadAll'),
splitDownload: $('splitDownload'), splitDownloadEach: $('splitDownloadEach'),
splitDownloadAll: $('splitDownloadAll'),
// verbose on-page debug log
debugLog: $('debugLog'), dbgCopy: $('dbgCopy'), dbgClear: $('dbgClear'),
};
Expand Down Expand Up @@ -349,11 +350,24 @@ function renderPhrase() {
draw();
els.splitPos.textContent = `phrase ${splitIdx + 1} / ${splitChunks.length}`;
els.splitInfo.textContent =
`${ch.id} · bars ${ch.bar_lo}–${ch.bar_hi} · ${(ch.notes || []).length} notes · ${tags.length} tag(s)`;
`${ch.id} · bars ${ch.bar_lo}–${ch.bar_hi} · ${(ch.notes || []).length} notes`;
renderPhraseTags(tags);
els.splitPrev.disabled = splitIdx === 0;
els.splitNext.disabled = splitIdx === splitChunks.length - 1;
}

// Shows the phrase's resolved tags (chosen + notation-derived techniques) as
// ticked, read-only boxes, so detected techniques like slide/hammer_on are
// visible per phrase. Read-only on purpose: these reflect the phrase and do not
// feed the next split (that's the capture form's Tags above).
function renderPhraseTags(tags) {
if (!els.splitTags) return;
els.splitTags.innerHTML = tags.length
? tags.map((t) =>
`<label class="on"><input type="checkbox" checked disabled /> ${escapeHtml(t)}</label>`).join('')
: '<span class="muted small">no tags detected</span>';
}

function stepPhrase(d) {
if (splitChunks.length === 0) return;
splitIdx = Math.min(splitChunks.length - 1, Math.max(0, splitIdx + d));
Expand All @@ -367,12 +381,27 @@ function downloadPhrase() {
capMsg(`saved ${ch.id}.chunk.json · bars ${ch.bar_lo}–${ch.bar_hi}`, false);
}

function downloadAllPhrases() {
// Saves one manifest-ready <id>_p<N>.chunk.json per phrase — what `griff manifest`
// ingests (it collects individual .chunk.json files, one ChunkMeta each).
// Staggered so the browser does not coalesce the rapid downloads.
function downloadEachPhrase() {
if (splitChunks.length === 0) { capMsg('split into phrases first', true); return; }
// Stagger the saves so the browser does not coalesce the rapid clicks.
splitChunks.forEach((ch, i) =>
setTimeout(() => saveBlob(`${ch.id}.chunk.json`, ch.chunk), i * 120));
capMsg(`saving ${splitChunks.length} phrase chunk(s)…`, false);
capMsg(`saving ${splitChunks.length} .chunk.json file(s) — for griff manifest`, false);
}

// Bundles every phrase chunk into a single JSON array — one file for sharing or
// review instead of N. NOTE: `griff manifest` reads individual .chunk.json files,
// not this array, so use "each phrase" for the corpus pipeline (#77).
function downloadBundle() {
if (splitChunks.length === 0) { capMsg('split into phrases first', true); return; }
const base = els.capId.value.trim() || (splitChunks[0].id || 'phrases').replace(/_p\d+$/, '');
const chunks = splitChunks.map((ch) => {
try { return JSON.parse(ch.chunk); } catch (_) { return { id: ch.id, error: 'unparseable chunk' }; }
});
saveBlob(`${base}.chunks.json`, JSON.stringify(chunks, null, 2));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the all-phrase export manifest-readable

When a curator clicks ⬇ all (one JSON) after splitting phrases and drops the downloaded file into the corpus folder, this now writes a .chunks.json file containing an array, but the documented capture flow still sends these downloads to griff manifest, and cmd_manifest only collects filenames ending in .chunk.json and deserializes each file as a single ChunkMeta (cli/src/main.rs lines 1109-1132). The old all-phrases button produced files that the manifest consumed; this replacement silently leaves every phrase out of the manifest unless another importer is added or the export remains a set/archive of individual .chunk.json records.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in b0cfbaa. You're right that cmd_manifest collects individual *.chunk.json files (one ChunkMeta each), so the array bundle would be silently skipped.

Rather than regress that path, I kept both, clearly labeled:

  • ⬇ each phrase → one <id>_p<N>.chunk.json per phrase (manifest-ready — the previous behavior, restored)
  • ⬇ all (one JSON) → a single <id>.chunks.json array (for sharing/review)

The bundle's .chunks.json suffix doesn't match manifest's .chunk.json filter, so it's safely ignored rather than mis-parsed. Teaching griff manifest to also ingest a .chunks.json array is captured as a follow-up on #77. Verified with a headless e2e (13 phrases, tag chips, bundle = 13-element array, no page errors).


Generated by Claude Code

capMsg(`saved ${base}.chunks.json · ${chunks.length} phrase(s) in one file — for sharing/review`, false);
}

// ---- drawing ----
Expand Down Expand Up @@ -520,7 +549,8 @@ function bind() {
els.splitPlay.addEventListener('click', () => { renderPhrase(); play(); });
els.splitStop.addEventListener('click', stop);
els.splitDownload.addEventListener('click', downloadPhrase);
els.splitDownloadAll.addEventListener('click', downloadAllPhrases);
els.splitDownloadEach.addEventListener('click', downloadEachPhrase);
els.splitDownloadAll.addEventListener('click', downloadBundle);
els.dbgCopy.addEventListener('click', copyDebug);
els.dbgClear.addEventListener('click', clearDebug);
window.addEventListener('resize', () => draw());
Expand Down
9 changes: 8 additions & 1 deletion web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,21 @@ <h2>Capture <span class="muted">· chunk.json</span></h2>
<button id="splitNext" type="button">Next ▶</button>
</div>
<output id="splitInfo" class="hint" role="status" aria-live="polite"></output>
<div id="splitTags" class="checkgrid tagsread" aria-label="detected tags for this phrase"></div>
<div class="transport">
<button id="splitPlay" type="button">▶ Play phrase</button>
<button id="splitStop" type="button">⏹ Stop</button>
</div>
<div class="transport">
<button id="splitDownload" type="button" class="primary">⬇ this phrase</button>
<button id="splitDownloadAll" type="button">⬇ all phrases</button>
<button id="splitDownloadEach" type="button">⬇ each phrase</button>
<button id="splitDownloadAll" type="button">⬇ all (one JSON)</button>
</div>
<p class="hint">
<strong>each phrase</strong> → one <code>.chunk.json</code> per phrase for
<code>griff manifest</code>; <strong>all (one JSON)</strong> → a single
<code>.chunks.json</code> array for sharing/review.
</p>
</section>
</section>

Expand Down
5 changes: 5 additions & 0 deletions web/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ h2 { font-size: 1.05rem; margin: 0 0 0.1rem; }
height: 20px;
accent-color: var(--accent);
}
/* Read-only per-phrase tags (detected, not split input): ticked, dimmed chips
tinted with part-A blue to set them apart from the green capture inputs. */
.tagsread { margin: 4px 0 2px; }
.tagsread label { color: var(--muted); }
.tagsread input[type="checkbox"] { accent-color: var(--a); }
label.row { flex-flow: row; display: flex; align-items: center; gap: 10px; color: var(--ink); }
label.row input[type="checkbox"] { width: 22px; height: 22px; accent-color: var(--accent); }
#capBounds { display: block; margin: 2px; min-height: 1em; }
Expand Down
Loading