diff --git a/.gitignore b/.gitignore index 3aadeeb..0ecb5b5 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,8 @@ wavs .DS_Store Thumbs.db +# JS dependencies +web/ui/node_modules/ + # Temp /tmp/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..00daf0e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,14 @@ +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - staticcheck + - unused + exclusions: + paths: + - web/ui + - electron + - third_party/akaiutil/ \ No newline at end of file diff --git a/electron/package.json b/electron/package.json index 6c79520..b6afa03 100644 --- a/electron/package.json +++ b/electron/package.json @@ -60,7 +60,7 @@ "build:linux": "electron-builder --linux" }, "devDependencies": { - "electron": "^33.0.0", + "electron": "33.4.11", "electron-builder": "^25.0.0" } } diff --git a/mise.toml b/mise.toml index 9cd4483..3221f7d 100644 --- a/mise.toml +++ b/mise.toml @@ -1,7 +1,7 @@ [tools] go = "1.26" golangci-lint = "latest" -node = "22" +node = "22.12" [env] CGO_ENABLED = "0" @@ -11,7 +11,9 @@ build = "go build -buildvcs=false -o fetch ." akaiutil = "make -C third_party/akaiutil" all = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil" } serve = { run = "go build -buildvcs=false -o fetch . && ./fetch serve" } -lint = "go vet ./..." +lint = "golangci-lint run" +js-lint = "cd web/ui && npm run lint" +js-test = "cd web/ui && npm test" electron-deps = "cd electron && npm install" electron-dev = { run = "go build -buildvcs=false -o fetch . && cd electron && npx electron ." } electron-build = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil && cd electron && npx electron-builder --dir" } diff --git a/scripts/check-toolchain.sh b/scripts/check-toolchain.sh index 8ec0112..a7f7c9d 100755 --- a/scripts/check-toolchain.sh +++ b/scripts/check-toolchain.sh @@ -123,5 +123,16 @@ else fi fi +echo "" +echo "=== JS Linting (ESLint) ===" +if [ -f web/ui/node_modules/.bin/eslint ]; then + ok "ESLint installed at web/ui/node_modules/.bin/eslint" + eslint_ver=$(cd web/ui && ./node_modules/.bin/eslint --version 2>/dev/null) + ok "ESLint: $eslint_ver" +else + warn "ESLint not installed (needed for web UI linting)" + echo " Install: cd web/ui && npm install" +fi + echo "" echo "=== Core toolchain check complete ===" diff --git a/serve.go b/serve.go index 8672db2..aad5e39 100644 --- a/serve.go +++ b/serve.go @@ -54,10 +54,20 @@ func cmdServe(args []string) { mux.HandleFunc("/api/search", handleSearch) mux.HandleFunc("/api/list", handleList) + mux.HandleFunc("/api/list-wavs", handleListWavs) mux.HandleFunc("/api/download", handleAPIDownload) mux.HandleFunc("/api/extract", handleAPIExtract) mux.HandleFunc("/api/progress", handleProgress) + wavsBase := "./output/wavs" + if abs, err := filepath.Abs(wavsBase); err == nil { + wavsBase = abs + } + if err := os.MkdirAll(wavsBase, 0755); err != nil { + fatal("create wavs dir: %v", err) + } + mux.Handle("/wavs/", http.StripPrefix("/wavs/", http.FileServer(http.Dir(wavsBase)))) + addr := fmt.Sprintf("%s:%d", host, port) listener, err := net.Listen("tcp", addr) if err != nil { @@ -254,6 +264,42 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"jobs": jobIDs}) } +func handleListWavs(w http.ResponseWriter, r *http.Request) { + dir := r.URL.Query().Get("dir") + if dir == "" { + dir = "./output/wavs" + } + if abs, err := filepath.Abs(dir); err == nil { + dir = abs + } + + files := []string{} + var walkErr error + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if path == dir { + walkErr = err + return filepath.SkipAll + } + return nil + } + if !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".wav") { + rel, _ := filepath.Rel(dir, path) + files = append(files, rel) + } + return nil + }) + if walkErr != nil { + writeJSON(w, map[string]any{"error": walkErr.Error()}) + return + } + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, files) +} + 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 eed25e2..1ea68d1 100644 --- a/serve_test.go +++ b/serve_test.go @@ -442,4 +442,106 @@ func TestHandleAPIExtract_ExtractISOError(t *testing.T) { if !strings.Contains(msg, "FAIL bad.iso") { t.Errorf("expected FAIL message, got: %s", msg) } +} + +func TestHandleListWavs_ReturnsWavFiles(t *testing.T) { + tmp := t.TempDir() + wavDir := filepath.Join(tmp, "wavs") + if err := os.MkdirAll(wavDir, 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"kick.wav", "snare.wav", "hihat.wav"} { + fd, _ := os.Create(filepath.Join(wavDir, name)) + _ = fd.Close() + } + fd, _ := os.Create(filepath.Join(wavDir, "readme.txt")) + _ = fd.Close() + + req := httptest.NewRequest("GET", "/api/list-wavs?dir="+wavDir, nil) + rr := httptest.NewRecorder() + handleListWavs(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + var files []string + if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(files) != 3 { + t.Errorf("expected 3 wav files, got %d: %v", len(files), files) + } +} + +func TestHandleListWavs_RecursiveSubdirs(t *testing.T) { + tmp := t.TempDir() + wavDir := filepath.Join(tmp, "wavs") + subDir := filepath.Join(wavDir, "Kicks") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatal(err) + } + fd, _ := os.Create(filepath.Join(wavDir, "root.wav")) + _ = fd.Close() + fd2, _ := os.Create(filepath.Join(subDir, "kick1.wav")) + _ = fd2.Close() + + req := httptest.NewRequest("GET", "/api/list-wavs?dir="+wavDir, nil) + rr := httptest.NewRecorder() + handleListWavs(rr, req) + + var files []string + if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(files) != 2 { + t.Errorf("expected 2 wav files, got %d: %v", len(files), files) + } + found := make(map[string]bool) + for _, f := range files { + found[f] = true + } + if !found["root.wav"] { + t.Errorf("missing root.wav, got %v", files) + } + if !found[filepath.Join("Kicks", "kick1.wav")] { + t.Errorf("missing Kicks/kick1.wav, got %v", files) + } +} + +func TestHandleListWavs_EmptyDir(t *testing.T) { + tmp := t.TempDir() + req := httptest.NewRequest("GET", "/api/list-wavs?dir="+tmp, nil) + rr := httptest.NewRecorder() + handleListWavs(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + var files []string + if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if files == nil { + t.Fatal("expected empty array [], got null") + } + if len(files) != 0 { + t.Errorf("expected 0 files, got %d", len(files)) + } +} + +func TestHandleListWavs_NonExistentDir(t *testing.T) { + req := httptest.NewRequest("GET", "/api/list-wavs?dir=/nonexistent/wavs", nil) + rr := httptest.NewRecorder() + handleListWavs(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if _, ok := resp["error"]; !ok { + t.Error("expected error for nonexistent dir") + } } \ No newline at end of file diff --git a/util_test.go b/util_test.go index 44a36f1..c17d8a2 100644 --- a/util_test.go +++ b/util_test.go @@ -99,6 +99,8 @@ func TestFindAkaiutil(t *testing.T) { }() t.Run("env var set to missing file", func(t *testing.T) { + tmp := t.TempDir() + t.Chdir(tmp) _ = os.Setenv("AKAIUTIL", "/nonexistent/akaiutil") os.Args[0] = "/some/binary" _, err := findAkaiutil() @@ -109,6 +111,7 @@ func TestFindAkaiutil(t *testing.T) { t.Run("env var set to existing file", func(t *testing.T) { tmp := t.TempDir() + t.Chdir(tmp) f := tmp + "/myutil" fd, _ := os.Create(f) defer func() { _ = fd.Close() }() @@ -124,6 +127,7 @@ func TestFindAkaiutil(t *testing.T) { t.Run("finds binary next to argv[0]", func(t *testing.T) { tmp := t.TempDir() + t.Chdir(tmp) exe := tmp + "/mybinary" fd, _ := os.Create(exe) defer func() { _ = fd.Close() }() @@ -143,6 +147,7 @@ func TestFindAkaiutil(t *testing.T) { t.Run("finds binary in third_party relative to argv[0]", func(t *testing.T) { tmp := t.TempDir() + t.Chdir(tmp) exe := tmp + "/somebin" fd, _ := os.Create(exe) defer func() { _ = fd.Close() }() @@ -166,6 +171,7 @@ func TestFindAkaiutil(t *testing.T) { t.Run("env var takes priority over path candidates", func(t *testing.T) { tmp := t.TempDir() + t.Chdir(tmp) envFile := tmp + "/envutil" pathFile := tmp + "/pathutil" fd1, _ := os.Create(envFile) @@ -185,6 +191,7 @@ func TestFindAkaiutil(t *testing.T) { t.Run("no akaiutil found returns error", func(t *testing.T) { tmp := t.TempDir() + t.Chdir(tmp) exe := tmp + "/nobinaryhere" fd, _ := os.Create(exe) defer func() { _ = fd.Close() }() diff --git a/web/ui/app.js b/web/ui/app.js index b5f92e9..29d57f2 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -46,8 +46,8 @@ function renderResults(items) { ${r.downloads.toLocaleString()} downloads