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
- - + +
@@ -175,6 +175,11 @@ async function doExtract() { try { const resp = await fetch(`${API}/extract?dir=${encodeURIComponent(isoDir)}&out=${encodeURIComponent(wavDir)}`); const data = await resp.json(); + if (data.error) { + status.textContent = data.error; + status.style.color = 'var(--accent)'; + return; + } status.textContent = data.message || 'Extraction complete'; status.style.color = 'var(--green)'; } catch (e) { @@ -183,25 +188,150 @@ async function doExtract() { } } -// ─── Helpers ────────────────────────────────────────────────────── +// ─── WAV Browser ────────────────────────────────────────────────── -function esc(s) { - if (!s) return ''; - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +let audioCtx = null; +let currentSource = null; +let currentAudioBuffer = null; +let startTime = 0; +let pauseOffset = 0; +let isPlaying = false; +let animationFrame = null; + +document.getElementById('wav-browse-btn').addEventListener('click', browseWavs); + +async function browseWavs() { + const dir = document.getElementById('wav-dir').value; + const list = document.getElementById('wav-list'); + list.innerHTML = 'Loading...'; + console.log('browseWavs: dir =', dir); + + try { + const url = `${API}/list-wavs?dir=${encodeURIComponent(dir)}`; + console.log('browseWavs: fetching', url); + const resp = await fetch(url); + console.log('browseWavs: response status', resp.status, 'ok', resp.ok); + + if (!resp.ok) { + list.innerHTML = 'HTTP ' + resp.status + ''; + return; + } + const data = await resp.json(); + console.log('browseWavs: parsed data', data, 'type', typeof data, 'isArray', Array.isArray(data)); + if (!data || data.error) { + list.innerHTML = 'Error: ' + (data && data.error ? data.error : 'server error') + ''; + return; + } + if (!Array.isArray(data) || data.length === 0) { + list.innerHTML = 'No WAV files found'; + return; + } + console.log('browseWavs: found', data.length, 'wavs'); + list.innerHTML = data.map(f => ` +
+ ${esc(f)} + +
+ `).join(''); + } catch (e) { + console.error('browseWavs: error', e); + list.innerHTML = 'Failed: ' + e.message; + } } -function sanitize(s) { - return s.replace(/[^a-zA-Z0-9_-]/g, '_'); +async function playWav(filename) { + console.log('playWav:', filename); + if (!audioCtx) { + audioCtx = new AudioContext(); + console.log('playWav: created AudioContext'); + } + + if (currentSource) { + currentSource.stop(); + currentSource = null; + } + if (animationFrame) { + cancelAnimationFrame(animationFrame); + animationFrame = null; + } + + const nowPlaying = document.getElementById('wav-now-playing'); + nowPlaying.textContent = 'Loading: ' + filename; + + try { + const resp = await fetch(`/wavs/${encodeURIComponent(filename)}`); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const arrayBuffer = await resp.arrayBuffer(); + currentAudioBuffer = await audioCtx.decodeAudioData(arrayBuffer); + + currentSource = audioCtx.createBufferSource(); + currentSource.buffer = currentAudioBuffer; + currentSource.connect(audioCtx.destination); + + currentSource.onended = () => { + if (isPlaying) { + isPlaying = false; + pauseOffset = 0; + nowPlaying.textContent = ''; + document.getElementById('wav-progress-fill').style.width = '0%'; + document.getElementById('wav-time').textContent = formatTime(0); + clearActiveItem(); + } + }; + + startTime = audioCtx.currentTime - pauseOffset; + currentSource.start(0, pauseOffset); + isPlaying = true; + pauseOffset = 0; + + nowPlaying.textContent = '▶ ' + filename; + setActiveItem(filename); + updateProgress(); + } catch (e) { + nowPlaying.textContent = 'Error: ' + e.message; + } } -function formatBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +function updateProgress() { + if (!isPlaying || !currentAudioBuffer) return; + + const elapsed = audioCtx.currentTime - startTime; + const duration = currentAudioBuffer.duration; + const pct = Math.min((elapsed / duration) * 100, 100); + + document.getElementById('wav-progress-fill').style.width = pct + '%'; + document.getElementById('wav-time').textContent = formatTime(elapsed); + + if (elapsed < duration) { + animationFrame = requestAnimationFrame(updateProgress); + } +} + +function setActiveItem(filename) { + document.querySelectorAll('.wav-item').forEach(el => { + el.classList.toggle('playing', el.dataset.file === filename); + }); +} + +function clearActiveItem() { + document.querySelectorAll('.wav-item').forEach(el => { + el.classList.remove('playing'); + }); } // Initial load doSearch(); + +// Event delegation for dynamically generated buttons +document.addEventListener('click', function(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + if (action === 'download') { + downloadItem(btn.dataset.identifier); + } else if (action === 'list') { + listItem(btn.dataset.identifier); + } else if (action === 'play') { + playWav(btn.dataset.wav); + } +}); diff --git a/web/ui/eslint.config.js b/web/ui/eslint.config.js new file mode 100644 index 0000000..2707048 --- /dev/null +++ b/web/ui/eslint.config.js @@ -0,0 +1,45 @@ +const globals = require('globals'); + +module.exports = [ + { + files: ['**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'script', + globals: { + ...globals.browser, + AudioContext: 'readonly', + requestAnimationFrame: 'readonly', + cancelAnimationFrame: 'readonly', + fetch: 'readonly', + EventSource: 'readonly', + document: 'readonly', + console: 'readonly', + setTimeout: 'readonly', + Math: 'readonly', + JSON: 'readonly', + location: 'readonly', + encodeURIComponent: 'readonly', + decodeURIComponent: 'readonly', + parseInt: 'readonly', + Number: 'readonly', + Promise: 'readonly', + Object: 'readonly', + Array: 'readonly', + Map: 'readonly', + process: 'readonly', + __dirname: 'readonly', + require: 'readonly', + module: 'readonly', + exports: 'readonly' + } + }, + rules: { + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "no-redeclare": "warn", + "no-unreachable": "warn", + "eqeqeq": ["error", "always"], + "prefer-const": "warn" + } + } +]; \ No newline at end of file diff --git a/web/ui/index.html b/web/ui/index.html index c0a6431..1794e9b 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -39,11 +39,28 @@
+
+

WAV Browser

+
+ + +
+
+
+
+
+ 0:00 +
+
+
+
+

Results

- + + diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json new file mode 100644 index 0000000..5cd4fa2 --- /dev/null +++ b/web/ui/package-lock.json @@ -0,0 +1,1098 @@ +{ + "name": "akai-utils-web-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "akai-utils-web-ui", + "version": "1.0.0", + "devDependencies": { + "eslint": "^9.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/web/ui/package.json b/web/ui/package.json new file mode 100644 index 0000000..8e99ccc --- /dev/null +++ b/web/ui/package.json @@ -0,0 +1,12 @@ +{ + "name": "akai-utils-web-ui", + "version": "1.0.0", + "description": "Web UI for AKAI Utils", + "scripts": { + "lint": "eslint *.js", + "test": "node --test util.test.js" + }, + "devDependencies": { + "eslint": "^9.0.0" + } +} \ No newline at end of file diff --git a/web/ui/style.css b/web/ui/style.css index 82bdc98..0262c08 100644 --- a/web/ui/style.css +++ b/web/ui/style.css @@ -205,4 +205,109 @@ h2 { margin-top: 10px; font-size: 13px; color: var(--text2); +} + +.wav-controls { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.wav-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; +} +.wav-controls button { + padding: 10px 20px; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: white; + font-size: 14px; + cursor: pointer; +} + +.wav-player { + margin-top: 14px; +} + +.now-playing { + font-size: 13px; + color: var(--green); + margin-bottom: 6px; + min-height: 18px; +} + +.audio-bar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.audio-progress { + flex: 1; + height: 6px; + background: var(--bg3); + border-radius: 3px; + overflow: hidden; +} + +.audio-progress-fill { + height: 100%; + background: var(--green); + border-radius: 3px; + width: 0%; + transition: width 0.1s; +} + +#wav-time { + font-size: 12px; + color: var(--text2); + min-width: 40px; +} + +.wav-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; +} + +.wav-item { + background: var(--bg); + border: 1px solid var(--bg3); + border-radius: var(--radius); + padding: 10px 12px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} +.wav-item:hover { + border-color: var(--accent); +} +.wav-item.playing { + border-color: var(--green); + background: rgba(78, 204, 163, 0.1); +} +.wav-item .wav-name { + flex: 1; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wav-item .wav-play-btn { + background: var(--accent2); + color: white; + border: none; + border-radius: 4px; + padding: 4px 10px; + font-size: 11px; + cursor: pointer; } \ No newline at end of file diff --git a/web/ui/util.js b/web/ui/util.js new file mode 100644 index 0000000..a40a83c --- /dev/null +++ b/web/ui/util.js @@ -0,0 +1,29 @@ +// AKAI Utils — shared utility functions + +function esc(s) { + if (!s) return '' + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +} + +function sanitize(s) { + return s.replace(/[^a-zA-Z0-9_-]/g, '_') +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +function formatTime(secs) { + const m = Math.floor(secs / 60) + const s = Math.floor(secs % 60) + return m + ':' + s.toString().padStart(2, '0') +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { esc, sanitize, formatBytes, formatTime } +} \ No newline at end of file diff --git a/web/ui/util.test.js b/web/ui/util.test.js new file mode 100644 index 0000000..26b461b --- /dev/null +++ b/web/ui/util.test.js @@ -0,0 +1,91 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const { esc, sanitize, formatBytes, formatTime } = require('./util.js') + +describe('esc', () => { + it('escapes HTML entities', () => { + assert.strictEqual(esc(''), + '<script>alert("xss")</script>') + }) + + it('handles ampersands', () => { + assert.strictEqual(esc('a & b'), 'a & b') + }) + + it('handles single quotes', () => { + assert.strictEqual(esc("it's"), 'it's') + }) + + it('returns empty string for falsy input', () => { + assert.strictEqual(esc(''), '') + assert.strictEqual(esc(null), '') + assert.strictEqual(esc(undefined), '') + }) + + it('passes through safe strings', () => { + assert.strictEqual(esc('Hello World'), 'Hello World') + }) +}) + +describe('sanitize', () => { + it('replaces special characters with underscores', () => { + assert.strictEqual(sanitize('hello world!'), 'hello_world_') + }) + + it('preserves alphanumeric and dashes', () => { + assert.strictEqual(sanitize('test-123_abc'), 'test-123_abc') + }) + + it('sanitizes dots and slashes', () => { + assert.strictEqual(sanitize('path/to/file.name'), + 'path_to_file_name') + }) +}) + +describe('formatBytes', () => { + it('formats zero', () => { + assert.strictEqual(formatBytes(0), '0 B') + }) + + it('formats bytes', () => { + assert.strictEqual(formatBytes(500), '500 B') + }) + + it('formats KB', () => { + assert.strictEqual(formatBytes(1024), '1 KB') + }) + + it('formats MB', () => { + assert.strictEqual(formatBytes(1048576), '1 MB') + }) + + it('formats GB', () => { + assert.strictEqual(formatBytes(1073741824), '1 GB') + }) + + it('formats fractional', () => { + assert.strictEqual(formatBytes(1536), '1.5 KB') + }) +}) + +describe('formatTime', () => { + it('formats zero', () => { + assert.strictEqual(formatTime(0), '0:00') + }) + + it('formats seconds', () => { + assert.strictEqual(formatTime(5), '0:05') + }) + + it('formats minutes and seconds', () => { + assert.strictEqual(formatTime(65), '1:05') + }) + + it('formats double-digit seconds', () => { + assert.strictEqual(formatTime(75), '1:15') + }) + + it('formats large times', () => { + assert.strictEqual(formatTime(3661), '61:01') + }) +}) \ No newline at end of file