diff --git a/extract.go b/extract.go index d75a26a..2ac9953 100644 --- a/extract.go +++ b/extract.go @@ -71,8 +71,8 @@ var runAkaiutil = func(isoPath, commands string) (string, error) { } type volInfo struct { - Part string - Name string + Part string `json:"part"` + Name string `json:"name"` } func listPartitions(isoPath string) ([]string, error) { @@ -210,3 +210,54 @@ var extractISO = func(isoPath, outDir string) (string, error) { } return fmt.Sprintf("Total WAV files: %d (%s)", total, elapsed), nil } + +type TagInfo struct { + Index int `json:"index"` + Name string `json:"name"` +} + +func listTags(isoPath, part, volName string) ([]TagInfo, error) { + var cmds strings.Builder + fmt.Fprintf(&cmds, "df\n") + fmt.Fprintf(&cmds, "cd /disk0/%s\n", part) + nav := strings.ReplaceAll(volName, " ", "_") + fmt.Fprintf(&cmds, "cd /disk0/%s/%s\n", part, nav) + fmt.Fprintf(&cmds, "lstags\n") + cmds.WriteString("q\n") + + out, err := runAkaiutil(isoPath, cmds.String()) + if err != nil { + return nil, fmt.Errorf("list tags: %w", err) + } + + return parseTags(out), nil +} + +func parseTags(output string) []TagInfo { + reTag := regexp.MustCompile(`^\s*(\d+)\s+(\S.*)`) + + var tags []TagInfo + inTags := false + + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimRight(line, " \r") + if strings.Contains(trimmed, "tag name") { + inTags = true + continue + } + if !inTags { + continue + } + if strings.HasPrefix(trimmed, "---") && len(tags) > 0 { + break + } + if m := reTag.FindStringSubmatch(trimmed); m != nil { + idx, _ := strconv.Atoi(m[1]) + name := strings.TrimSpace(m[2]) + if name != "" { + tags = append(tags, TagInfo{Index: idx, Name: name}) + } + } + } + return tags +} diff --git a/extract_test.go b/extract_test.go index 3144ce1..427b23d 100644 --- a/extract_test.go +++ b/extract_test.go @@ -368,3 +368,116 @@ func TestRunExtraction_CreatesVolumeDirs(t *testing.T) { t.Error("expected lcd / to be in commands") } } + +func TestParseTags(t *testing.T) { + t.Run("parses basic tag list", func(t *testing.T) { + out := ` +tag name +----------------- + 01 Drums + 02 Bass + 03 FX + 04 Vox + 05 Percussion +----------------- +` + tags := parseTags(out) + if len(tags) != 5 { + t.Fatalf("expected 5 tags, got %d", len(tags)) + } + if tags[0].Index != 1 || tags[0].Name != "Drums" { + t.Errorf("tag 0: got {%d %s}, want {1 Drums}", tags[0].Index, tags[0].Name) + } + if tags[4].Index != 5 || tags[4].Name != "Percussion" { + t.Errorf("tag 4: got {%d %s}, want {5 Percussion}", tags[4].Index, tags[4].Name) + } + }) + + t.Run("parses tags with spaces in names", func(t *testing.T) { + out := ` +tag name +----------------- + 01 Kick Drum + 02 Hi Hat + 03 Snare Drum +----------------- +` + tags := parseTags(out) + if len(tags) != 3 { + t.Fatalf("expected 3 tags, got %d", len(tags)) + } + if tags[0].Name != "Kick Drum" { + t.Errorf("got %q, want 'Kick Drum'", tags[0].Name) + } + }) + + t.Run("handles default tag names from akaiutil", func(t *testing.T) { + out := ` +tag name +----------------- + 01 TAG A + 02 TAG B + 03 MyTag +----------------- +` + tags := parseTags(out) + if len(tags) != 3 { + t.Fatalf("expected 3 tags, got %d", len(tags)) + } + }) + + t.Run("no tags present message returns empty", func(t *testing.T) { + out := "no tags present\n" + tags := parseTags(out) + if len(tags) != 0 { + t.Errorf("expected 0 tags, got %d", len(tags)) + } + }) + + t.Run("handles empty output", func(t *testing.T) { + tags := parseTags("") + if len(tags) != 0 { + t.Errorf("expected 0 tags, got %d", len(tags)) + } + }) + + t.Run("handles no tags in partition type", func(t *testing.T) { + out := "no tags in this partition type\n" + tags := parseTags(out) + if len(tags) != 0 { + t.Errorf("expected 0 tags, got %d", len(tags)) + } + }) +} + +func TestListTags_Integration(t *testing.T) { + origRun := runAkaiutil + defer func() { runAkaiutil = origRun }() + + runAkaiutil = func(isoPath, commands string) (string, error) { + return ` +/disk0/A > lstags + +tag name +----------------- + 01 Drums + 02 Bass + 03 FX +----------------- + `, nil + } + + tags, err := listTags("fake.iso", "A", "SomeVolume") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tags) != 3 { + t.Errorf("expected 3 tags, got %d", len(tags)) + } + if tags[0].Name != "Drums" { + t.Errorf("expected Drums, got %s", tags[0].Name) + } + if tags[2].Index != 3 || tags[2].Name != "FX" { + t.Errorf("tag 2: got {%d %s}, want {3 FX}", tags[2].Index, tags[2].Name) + } +} diff --git a/serve.go b/serve.go index aad5e39..24b94d2 100644 --- a/serve.go +++ b/serve.go @@ -55,6 +55,9 @@ func cmdServe(args []string) { mux.HandleFunc("/api/search", handleSearch) mux.HandleFunc("/api/list", handleList) mux.HandleFunc("/api/list-wavs", handleListWavs) + mux.HandleFunc("/api/disk/partitions", handleDiskPartitions) + mux.HandleFunc("/api/disk/volumes", handleDiskVolumes) + mux.HandleFunc("/api/volume/tags", handleVolumeTags) mux.HandleFunc("/api/download", handleAPIDownload) mux.HandleFunc("/api/extract", handleAPIExtract) mux.HandleFunc("/api/progress", handleProgress) @@ -300,6 +303,63 @@ func handleListWavs(w http.ResponseWriter, r *http.Request) { writeJSON(w, files) } +func handleDiskPartitions(w http.ResponseWriter, r *http.Request) { + iso := r.URL.Query().Get("iso") + if iso == "" { + writeJSON(w, map[string]any{"error": "missing iso parameter"}) + return + } + parts, err := listPartitions(iso) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, parts) +} + +func handleDiskVolumes(w http.ResponseWriter, r *http.Request) { + iso := r.URL.Query().Get("iso") + part := r.URL.Query().Get("part") + if iso == "" || part == "" { + writeJSON(w, map[string]any{"error": "missing iso or part parameter"}) + return + } + parts, err := listPartitions(iso) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + vols, err := listVolumes(iso, parts) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + var filtered []volInfo + for _, v := range vols { + if v.Part == part { + filtered = append(filtered, v) + } + } + writeJSON(w, filtered) +} + +func handleVolumeTags(w http.ResponseWriter, r *http.Request) { + iso := r.URL.Query().Get("iso") + part := r.URL.Query().Get("part") + vol := r.URL.Query().Get("vol") + if iso == "" || part == "" || vol == "" { + writeJSON(w, map[string]any{"error": "missing iso, part, or vol parameter"}) + return + } + + tags, err := listTags(iso, part, vol) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, tags) +} + func handleProgress(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") diff --git a/serve_test.go b/serve_test.go index 1ea68d1..04c9bca 100644 --- a/serve_test.go +++ b/serve_test.go @@ -544,4 +544,93 @@ func TestHandleListWavs_NonExistentDir(t *testing.T) { if _, ok := resp["error"]; !ok { t.Error("expected error for nonexistent dir") } +} + +func TestHandleDiskPartitions_ReturnsPartitions(t *testing.T) { + origRun := runAkaiutil + defer func() { runAkaiutil = origRun }() + + runAkaiutil = func(isoPath, commands string) (string, error) { + return ` +Disk /disk0 +Total blocks: 0 (0 KB each) 0 KB +Partition table: + A - S1000 HD (0 blocks) + B - S1000 HD (0 blocks) + `, nil + } + + req := httptest.NewRequest("GET", "/api/disk/partitions?iso=test.iso", nil) + rr := httptest.NewRecorder() + handleDiskPartitions(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + var parts []string + if err := json.Unmarshal(rr.Body.Bytes(), &parts); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(parts) != 2 { + t.Errorf("expected 2 partitions, got %d: %v", len(parts), parts) + } +} + +func TestHandleDiskPartitions_MissingISO(t *testing.T) { + req := httptest.NewRequest("GET", "/api/disk/partitions", nil) + rr := httptest.NewRecorder() + handleDiskPartitions(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + var resp map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &resp) + if _, ok := resp["error"]; !ok { + t.Error("expected error for missing iso") + } +} + +func TestHandleVolumeTags_MissingParams(t *testing.T) { + req := httptest.NewRequest("GET", "/api/volume/tags", nil) + rr := httptest.NewRecorder() + handleVolumeTags(rr, req) + + var resp map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &resp) + if resp["error"] == nil { + t.Error("expected error for missing params") + } +} + +func TestHandleVolumeTags_ReturnsTags(t *testing.T) { + origRun := runAkaiutil + defer func() { runAkaiutil = origRun }() + + runAkaiutil = func(isoPath, commands string) (string, error) { + return ` +/disk0/A/SomeVol > lstags + +tag name +----------------- + 01 Drums + 02 Bass +----------------- + `, nil + } + + req := httptest.NewRequest("GET", "/api/volume/tags?iso=test.iso&part=A&vol=SomeVol", nil) + rr := httptest.NewRecorder() + handleVolumeTags(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + var tags []TagInfo + if err := json.Unmarshal(rr.Body.Bytes(), &tags); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(tags) != 2 { + t.Errorf("expected 2 tags, got %d", len(tags)) + } } \ No newline at end of file diff --git a/web/ui/app.js b/web/ui/app.js index 2e934d7..ea7ca75 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -380,6 +380,10 @@ document.addEventListener('click', function(e) { listItem(btn.dataset.identifier); } else if (action === 'play') { playWav(btn.dataset.wav); + } else if (action === 'tag-part') { + selectPartition(btn.dataset.iso, btn.dataset.part); + } else if (action === 'tag-vol') { + viewVolumeTags(btn.dataset.iso, btn.dataset.part, btn.dataset.vol); } }); @@ -396,3 +400,109 @@ document.querySelectorAll('.section-toggle').forEach(h => { document.getElementById('wav-collapse-all').addEventListener('click', function () { document.querySelectorAll('.library-card').forEach(c => c.classList.add('collapsed')); }); + +// ─── Tag Viewer ───────────────────────────────────────────────────── + +const TAG_COLORS = [ + '#e94560', '#4ecca3', '#f0c040', '#7c3aed', '#06b6d4', + '#f97316', '#84cc16', '#ec4899', '#3b82f6', '#14b8a6', + '#a855f7', '#ef4444', '#22c55e', '#eab308', '#6366f1', + '#10b981', '#f43f5e', '#8b5cf6', '#0ea5e9', '#d946ef' +] + +document.getElementById('tag-browse-btn').addEventListener('click', browseISO) + +async function browseISO() { + const iso = document.getElementById('tag-iso-path').value + const results = document.getElementById('tag-results') + const status = document.getElementById('tag-status') + const partsEl = document.getElementById('tag-partitions') + const volsEl = document.getElementById('tag-volumes') + const detailEl = document.getElementById('tag-detail') + + partsEl.innerHTML = '' + volsEl.innerHTML = '' + detailEl.innerHTML = '' + status.textContent = 'Loading partitions...' + results.style.display = 'block' + + try { + const resp = await fetch(`${API}/disk/partitions?iso=${encodeURIComponent(iso)}`) + const data = await resp.json() + if (data.error) { + status.textContent = data.error + return + } + status.textContent = data.length + ' partition' + (data.length === 1 ? '' : 's') + partsEl.innerHTML = data.map(p => ` + + `).join('') + } catch (e) { + status.textContent = 'Failed: ' + e.message + } +} + +async function selectPartition(iso, part) { + const volsEl = document.getElementById('tag-volumes') + const detailEl = document.getElementById('tag-detail') + + document.querySelectorAll('.tag-part-btn').forEach(b => { + b.classList.toggle('active', b.dataset.part === part) + }) + volsEl.innerHTML = 'Loading volumes...' + detailEl.innerHTML = '' + + try { + const resp = await fetch(`${API}/disk/volumes?iso=${encodeURIComponent(iso)}&part=${encodeURIComponent(part)}`) + const data = await resp.json() + if (data.error) { + volsEl.innerHTML = '' + data.error + '' + return + } + if (!Array.isArray(data) || data.length === 0) { + volsEl.innerHTML = 'No volumes on Partition ' + part + '' + return + } + volsEl.innerHTML = data.map(v => ` +
${esc(v.Name)}
+ `).join('') + } catch (e) { + volsEl.innerHTML = 'Failed: ' + e.message + } +} + +async function viewVolumeTags(iso, part, vol) { + const detailEl = document.getElementById('tag-detail') + document.querySelectorAll('.vol-item').forEach(el => { + el.classList.toggle('selected', el.textContent === vol) + }) + detailEl.innerHTML = 'Loading tags...' + + try { + const resp = await fetch(`${API}/volume/tags?iso=${encodeURIComponent(iso)}&part=${encodeURIComponent(part)}&vol=${encodeURIComponent(vol)}`) + const data = await resp.json() + if (data.error) { + detailEl.innerHTML = '' + data.error + '' + return + } + if (!Array.isArray(data) || data.length === 0) { + detailEl.innerHTML = 'No tags on this volume' + return + } + + const colorKeys = TAG_COLORS + detailEl.innerHTML = ` +

Tags: ${esc(vol)}

+
+ ${data.map(t => { + const color = colorKeys[(t.index - 1) % colorKeys.length] + return ` + + ${esc(t.name)} + ` + }).join('')} +
` + } catch (e) { + detailEl.innerHTML = 'Failed: ' + e.message + } +} diff --git a/web/ui/index.html b/web/ui/index.html index 3f462e6..ca95147 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -39,6 +39,22 @@
+
+

Tag Viewer

+
+
+ + +
+ +
+
+

Sample Library

diff --git a/web/ui/style.css b/web/ui/style.css index 217dc61..0c96760 100644 --- a/web/ui/style.css +++ b/web/ui/style.css @@ -387,6 +387,130 @@ h2 { gap: 4px; } +/* Tag viewer */ +.tag-controls { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.tag-controls input { + flex: 1; + min-width: 200px; + padding: 10px 14px; + border: 1px solid var(--bg3); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 14px; +} +.tag-controls button { + padding: 10px 20px; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: white; + font-size: 14px; + cursor: pointer; +} +.tag-controls button:hover { opacity: 0.9; } + +.tag-status { + font-size: 13px; + color: var(--text2); + margin-top: 10px; + margin-bottom: 8px; +} + +.tag-partitions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.tag-part-btn { + padding: 6px 14px; + border: 1px solid var(--bg3); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 13px; + cursor: pointer; + transition: border-color 0.15s; +} +.tag-part-btn:hover { + border-color: var(--accent); +} +.tag-part-btn.active { + border-color: var(--accent); + background: var(--accent); + color: white; + cursor: default; +} + +.tag-volumes { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 6px; + margin-bottom: 12px; +} + +.vol-item { + padding: 8px 12px; + border: 1px solid var(--bg3); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.vol-item:hover { + border-color: var(--accent); +} +.vol-item.selected { + border-color: var(--green); + background: rgba(78, 204, 163, 0.1); +} + +.tag-detail { + margin-top: 8px; +} + +.tag-detail h4 { + font-size: 14px; + color: var(--text); + margin: 0 0 8px; +} + +.tag-list { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + background: var(--bg3); + color: var(--text); +} + +.tag-chip .tag-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + /* Sample cards */ .sample-card { background: var(--bg);