feat: Sample Tag Viewer — read-only tag browser for AKAI disk images #17

Merged
david merged 5 commits from feat/sample-tag-editor into main 2026-06-22 18:13:53 +00:00
7 changed files with 592 additions and 4 deletions
+55 -4
View File
@@ -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) {
@@ -82,7 +82,7 @@ func listPartitions(isoPath string) ([]string, error) {
}
re := regexp.MustCompile(`^\s+([A-Z])\s+`)
var parts []string
parts := []string{}
for _, line := range strings.Split(out, "\n") {
if m := re.FindStringSubmatch(line); m != nil {
parts = append(parts, m[1])
@@ -110,7 +110,7 @@ func listVolumes(isoPath string, partitions []string) ([]volInfo, error) {
rePrompt := regexp.MustCompile(`/disk0/([A-Z])\s*>`)
reEntry := regexp.MustCompile(`^\s+\d+\s+(.+?)\s+-\s+`)
var vols []volInfo
vols := []volInfo{}
currentPart := ""
for _, line := range strings.Split(out, "\n") {
@@ -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.*)`)
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
}
+113
View File
@@ -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)
}
}
+73
View File
@@ -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,76 @@ 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
}
fi, err := os.Stat(iso)
if err != nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("cannot access %q: %v", iso, err)})
return
}
if fi.IsDir() {
writeJSON(w, map[string]any{"error": fmt.Sprintf("%q is a directory, not an ISO file", iso)})
return
}
if !strings.HasSuffix(strings.ToLower(fi.Name()), ".iso") {
writeJSON(w, map[string]any{"error": fmt.Sprintf("%q is not an ISO file", iso)})
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
}
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")
+89
View File
@@ -545,3 +545,92 @@ func TestHandleListWavs_NonExistentDir(t *testing.T) {
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))
}
}
+122
View File
@@ -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,121 @@ 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)}`)
if (!resp.ok) {
status.textContent = 'HTTP ' + resp.status
return
}
const data = await resp.json()
if (!data || data.error) {
status.textContent = (data && data.error) || 'server error'
return
}
status.textContent = data.length + ' partition' + (data.length === 1 ? '' : 's')
partsEl.innerHTML = data.map(p => `
<button class="tag-part-btn" data-action="tag-part" data-iso="${esc(iso)}" data-part="${esc(p)}">Partition ${esc(p)}</button>
`).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)}`)
if (!resp.ok) {
volsEl.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>'
return
}
const data = await resp.json()
if (!data || data.error) {
volsEl.innerHTML = '<span class="no-files">' + (data && data.error ? data.error : 'server error') + '</span>'
return
}
if (!Array.isArray(data) || data.length === 0) {
volsEl.innerHTML = '<span class="no-files">No volumes on Partition ' + part + '</span>'
return
}
volsEl.innerHTML = data.map(v => `
<div class="vol-item" data-action="tag-vol" data-iso="${esc(iso)}" data-part="${esc(part)}" data-vol="${esc(v.name)}">${esc(v.name)}</div>
`).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)}`)
if (!resp.ok) {
detailEl.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>'
return
}
const data = await resp.json()
if (!data || data.error) {
detailEl.innerHTML = '<span class="no-files">' + (data && data.error ? data.error : 'server error') + '</span>'
return
}
if (!Array.isArray(data) || data.length === 0) {
detailEl.innerHTML = '<span class="no-files">No tags on this volume</span>'
return
}
const colorKeys = TAG_COLORS
detailEl.innerHTML = `
<h4>Tags: ${esc(vol)}</h4>
<div class="tag-list">
${data.map(t => {
const color = colorKeys[(t.index - 1) % colorKeys.length]
return `<span class="tag-chip">
<span class="tag-dot" style="background:${color}"></span>
${esc(t.name)}
</span>`
}).join('')}
</div>`
} catch (e) {
detailEl.innerHTML = 'Failed: ' + e.message
}
}
+16
View File
@@ -39,6 +39,22 @@
<div id="extract-status"></div>
</section>
<section id="tag-section">
<h2 class="section-toggle" data-section="tag-body">Tag Viewer <span class="toggle-icon"></span></h2>
<div id="tag-body">
<div class="tag-controls">
<input type="text" id="tag-iso-path" placeholder="./output/isos/YOUR_ISO.iso" value="">
<button id="tag-browse-btn">Browse</button>
</div>
<div id="tag-results" style="display:none">
<div id="tag-status"></div>
<div id="tag-partitions" class="tag-partitions"></div>
<div id="tag-volumes" class="tag-volumes"></div>
<div id="tag-detail" class="tag-detail"></div>
</div>
</div>
</section>
<section id="wav-section">
<h2 class="section-toggle" data-section="wav-body">Sample Library <span class="toggle-icon"></span></h2>
<div id="wav-body">
+124
View File
@@ -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);