diff --git a/frame-history-viewer/README.md b/frame-history-viewer/README.md new file mode 100644 index 0000000..545470b --- /dev/null +++ b/frame-history-viewer/README.md @@ -0,0 +1,93 @@ +# Frame History Viewer + +Browse historical FT8/FT4 decode frames stored in JSONL log files. + +## Features + +- **Date browser**: pick a date to view its recorded slots +- **Band filter**: filter slots by amateur band (dynamically populated from the loaded data) +- **Band & frequency**: each slot shows the band (e.g. `20m`) and dial frequency +- **Expandable slots**: click a slot to reveal its decoded frames in a sortable table +- **Live search**: filter frames by message text with highlighted matches and 200ms debounce +- **SNR color coding**: color-coded SNR values (green ≥ −5, yellow ≥ −15, red < −15, TX marker) +- **Responsive layout**: adapts to narrow screens (e.g. mobile devices) +- **Internationalization**: English and Chinese (简体中文) with dynamic locale switching +- **Toolbar entry**: opens as a popover from the RadioControl toolbar +- **Dark/light theme**: matches the TX-5DR host theme via CSS Design Tokens + +## Installation + +1. Download the plugin ZIP from [marketplace](https://tx5dr.com/plugins/frame-history-viewer) or build from source. +2. Extract into your TX-5DR plugins directory: + + ``` + {dataDir}/plugins/frame-history-viewer/ + ├── index.js + ├── locales/ + └── ui/ + ``` + +3. Reload plugins in **Settings → Plugins**, or restart TX-5DR. + +## Usage + +After installation, a clock icon button appears in the RadioControl toolbar (to the right of the antenna tuner button). Click it to open the viewer popover. + +1. **Select a date** from the dropdown at the top. +2. Browse the list of slots — each shows time range, band/frequency, frame count, and mode. +3. **Click a slot** to expand and see its individual frames in a table. +4. **Search** by typing in the search box — matching frames are filtered in real time with highlights. + +## Internationalization + +The plugin supports English and Chinese (简体中文). The locale is determined automatically from the TX-5DR host locale and can switch dynamically at runtime via `window.tx5dr.onLocaleChange`. UI strings are bound to HTML elements through `data-i18n`, `data-i18n-placeholder`, and `data-i18n-title` attributes. + +Translation files are located at `src/locales/en.json` and `src/locales/zh.json`. + +## Data source + +The plugin reads JSONL files directly from: + +``` +{dataDir}/frames-logs/frames-{YYYY-MM-DD}.jsonl +``` + +Each line is a JSON object (`SlotPackStorageRecord`) containing a `slotPack` with decoded frames, `storedAt` timestamp, `operation` type (`created`/`updated`), and optional `mode` string. + +These files are written by TX-5DR's `SlotPackPersistence` subsystem. No network requests or admin API permissions are needed. + +### Data directory resolution + +The `dataDir` is resolved at runtime in the following order, mirroring the host's `tx5drPaths.getDataDir()`: + +1. `TX5DR_DATA_DIR` environment variable (injected by Docker, the Linux server package, and the Electron desktop app) +2. `%LOCALAPPDATA%\TX-5DR` (Windows) +3. `~/Library/Application Support/TX-5DR` (macOS) +4. `$XDG_DATA_HOME/TX-5DR` or `~/.local/share/TX-5DR` (standard Linux user installation) + +## Build from source + +```bash +npm install +npm run build +``` + +Output: `dist/index.js`. UI files are static and live in `ui/`. + +## Structure + +``` +frame-history-viewer/ +├── src/ +│ ├── index.ts # Plugin definition & server-side file reader +│ └── locales/ +│ ├── en.json # English translations +│ └── zh.json # Chinese (简体中文) translations +├── ui/ +│ ├── viewer.html # Iframe page entry +│ ├── viewer.css # Theme-aware styles +│ └── viewer.js # Client-side rendering & search +├── package.json # npm metadata & plugin manifest +├── package-lock.json # Locked dependency tree +└── tsconfig.json # TypeScript compiler configuration +``` diff --git a/frame-history-viewer/package-lock.json b/frame-history-viewer/package-lock.json new file mode 100644 index 0000000..9ffff69 --- /dev/null +++ b/frame-history-viewer/package-lock.json @@ -0,0 +1,1964 @@ +{ + "name": "tx5dr-plugin-frame-history-viewer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tx5dr-plugin-frame-history-viewer", + "version": "1.0.0", + "devDependencies": { + "@tx5dr/plugin-api": "^1.7.8", + "@types/node": "^22.19.20", + "typescript": "^5.0.0", + "vitest": "^1.6.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tx5dr/contracts": { + "version": "1.7.12", + "resolved": "https://registry.npmmirror.com/@tx5dr/contracts/-/contracts-1.7.12.tgz", + "integrity": "sha512-sQ5yqK/U3+NP9VkLG9F9kDQq0s3gXKI3gglG1dGNQt6dbx7NfDNnfuDewK04hwV3F9giN1VOZAcYesA5FZ5QUA==", + "dev": true, + "dependencies": { + "zod": "^3.22.0" + } + }, + "node_modules/@tx5dr/core": { + "version": "1.7.12", + "resolved": "https://registry.npmmirror.com/@tx5dr/core/-/core-1.7.12.tgz", + "integrity": "sha512-iXCu0kTaCGbTNI6vT2OdSdk/STAOZt3KI7zbfiLU2EMeFsHAUcqvhe8YxjKJN6yzTukrVzuTMN2aWo+8OWbP2Q==", + "dev": true, + "dependencies": { + "@tx5dr/contracts": "1.7.12", + "adif-parser-ts": "^0.6.22", + "eventemitter3": "^5.0.1" + } + }, + "node_modules/@tx5dr/plugin-api": { + "version": "1.7.12", + "resolved": "https://registry.npmmirror.com/@tx5dr/plugin-api/-/plugin-api-1.7.12.tgz", + "integrity": "sha512-SAxqHZJceXWkrz8rL2IrdSnzcWgIbnK7YbZl02AI4eVf4BQBbrE8IoCn9Bq535YL6lYhWqNkJG6rZwHSv1jL7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tx5dr/contracts": "1.7.12", + "@tx5dr/core": "1.7.12" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.20", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.20.tgz", + "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adif-parser-ts": { + "version": "0.6.39", + "resolved": "https://registry.npmmirror.com/adif-parser-ts/-/adif-parser-ts-0.6.39.tgz", + "integrity": "sha512-I4Lz7nahH1PAJ4Mhj7VuLuaijaApE5Yq7NP2VymSpKvDfpIcHWaKtOoaX4YrROVvXSC+pvvczWwwMRCh8J8jfg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">7.0.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmmirror.com/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/frame-history-viewer/package.json b/frame-history-viewer/package.json new file mode 100644 index 0000000..130d827 --- /dev/null +++ b/frame-history-viewer/package.json @@ -0,0 +1,68 @@ +{ + "name": "tx5dr-plugin-frame-history-viewer", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch", + "test": "vitest run" + }, + "tx5drPlugin": { + "pluginName": "frame-history-viewer", + "title": "Frame History Viewer", + "description": "Browse historical FT8/FT4 decode frames stored in JSONL log files.", + "minHostVersion": "1.0.0", + "author": "TX-5DR", + "license": "GPL-3.0-only", + "repository": "https://github.com/boybook/tx-5dr-plugins", + "homepage": "https://tx5dr.com/plugins/frame-history-viewer", + "categories": [ + "utility", + "debug", + "ui" + ], + "keywords": [ + "frames", + "history", + "decode", + "ft8", + "viewer", + "jsonl" + ], + "entry": "dist/index.js", + "include": [ + { + "from": "dist/index.js", + "to": "index.js" + }, + { + "from": "dist/locales", + "to": "locales" + }, + { + "from": "ui/viewer.html", + "to": "ui/viewer.html" + }, + { + "from": "ui/viewer.css", + "to": "ui/viewer.css" + }, + { + "from": "ui/viewer.js", + "to": "ui/viewer.js" + } + ] + }, + "devDependencies": { + "@tx5dr/plugin-api": "^1.7.8", + "@types/node": "^22.19.20", + "typescript": "^5.0.0", + "vitest": "^1.6.1" + } +} diff --git a/frame-history-viewer/src/__tests__/index.test.ts b/frame-history-viewer/src/__tests__/index.test.ts new file mode 100644 index 0000000..b220d03 --- /dev/null +++ b/frame-history-viewer/src/__tests__/index.test.ts @@ -0,0 +1,210 @@ +/** + * frame-history-viewer 服务端逻辑单元测试 + * + * 覆盖:日期校验(防目录穿越)、limit/cursor 归一化、JSONL 分页读取、 + * EOF 感知的 hasMore、损坏行跳过、日期列表扫描、数据目录解析。 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + assertValidDate, + clampLimit, + toNonNegativeCursor, + listDates, + loadRecords, + getDataDir, +} from '../index.js'; + +let tempDir: string; +let previousDataDir: string | undefined; + +beforeEach(() => { + previousDataDir = process.env.TX5DR_DATA_DIR; + tempDir = mkdtempSync(join(tmpdir(), 'fhv-test-')); + mkdirSync(join(tempDir, 'frames-logs'), { recursive: true }); + process.env.TX5DR_DATA_DIR = tempDir; +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + if (previousDataDir === undefined) { + delete process.env.TX5DR_DATA_DIR; + } else { + process.env.TX5DR_DATA_DIR = previousDataDir; + } +}); + +function writeLog(dateStr: string, lines: string[]): void { + const filePath = join(tempDir, 'frames-logs', `frames-${dateStr}.jsonl`); + writeFileSync(filePath, lines.length ? lines.join('\n') + '\n' : '', 'utf8'); +} + +function record(slotId: string): string { + return JSON.stringify({ + storedAt: 1, + operation: 'created', + version: '1.0.0', + slotPack: { + slotId, + startMs: 0, + endMs: 15000, + frames: [{ message: `CQ ${slotId}`, snr: -10, freq: 7074000, dt: 0.5 }], + stats: {}, + decodeHistory: [], + }, + }); +} + +describe('assertValidDate', () => { + it('接受合法 YYYY-MM-DD 格式', () => { + expect(() => assertValidDate('2025-01-02')).not.toThrow(); + expect(() => assertValidDate('2024-12-31')).not.toThrow(); + // 格式级校验:与 listDates 的文件名正则一致,不校验日历合法性 + expect(() => assertValidDate('2025-13-40')).not.toThrow(); + }); + + it('拒绝目录穿越与非法格式', () => { + for (const bad of [ + '', + '2025-1-2', + '2025/01/02', + '2025-01-02/../x', + '../2025-01-02', + '2025-01-02.jsonl', + 'frames-2025-01-02', + '..', + '2025-01-02\n', + ]) { + expect(() => assertValidDate(bad)).toThrow(); + } + }); +}); + +describe('clampLimit', () => { + it('未传时返回 undefined(不限)', () => { + expect(clampLimit(undefined)).toBeUndefined(); + expect(clampLimit(null)).toBeUndefined(); + }); + + it('将 limit 收敛到 [1, 2000]', () => { + expect(clampLimit(100)).toBe(100); + expect(clampLimit(0)).toBe(1); + expect(clampLimit(-5)).toBe(1); + expect(clampLimit(1e9)).toBe(2000); + expect(clampLimit(1.7)).toBe(1); + }); + + it('非法 limit 归 2000', () => { + expect(clampLimit('abc')).toBe(2000); + expect(clampLimit(Number.NaN)).toBe(2000); + }); +}); + +describe('toNonNegativeCursor', () => { + it('归一化为非负整数', () => { + expect(toNonNegativeCursor(undefined)).toBe(0); + expect(toNonNegativeCursor(5)).toBe(5); + expect(toNonNegativeCursor(-3)).toBe(0); + expect(toNonNegativeCursor(3.9)).toBe(3); + }); + + it('非法游标归 0', () => { + expect(toNonNegativeCursor('x')).toBe(0); + expect(toNonNegativeCursor(Number.NaN)).toBe(0); + expect(toNonNegativeCursor(Number.POSITIVE_INFINITY)).toBe(0); + }); +}); + +describe('getDataDir', () => { + it('TX5DR_DATA_DIR 环境变量优先', () => { + process.env.TX5DR_DATA_DIR = '/custom/data/dir'; + expect(getDataDir()).toBe('/custom/data/dir'); + }); +}); + +describe('listDates', () => { + it('扫描并排序 frames-*.jsonl 文件名', async () => { + writeLog('2025-12-31', [record('a')]); + writeLog('2025-01-01', [record('b')]); + writeLog('2025-06-15', [record('c')]); + writeFileSync(join(tempDir, 'frames-logs', 'not-a-log.txt'), 'x', 'utf8'); + expect(await listDates()).toEqual(['2025-01-01', '2025-06-15', '2025-12-31']); + }); + + it('frames-logs 目录不存在时返回空数组', async () => { + rmSync(join(tempDir, 'frames-logs'), { recursive: true, force: true }); + expect(await listDates()).toEqual([]); + }); +}); + +describe('loadRecords', () => { + it('文件不存在时返回空结果且无更多数据', async () => { + const result = await loadRecords('2025-01-01'); + expect(result).toEqual({ records: [], cursor: 0, hasMore: false }); + }); + + it('无 limit 时读取全部有效行,跳过损坏行与空行', async () => { + writeLog('2025-01-01', [ + record('a'), + 'not-json{', + '', + record('b'), + '{ broken', + ]); + const result = await loadRecords('2025-01-01'); + expect(result.records.map((r) => r.slotPack.slotId)).toEqual(['a', 'b']); + expect(result.hasMore).toBe(false); + }); + + it('limit 分页:提前截断时 hasMore=true,继续取下一页直至 EOF', async () => { + writeLog('2025-01-01', [ + record('a'), record('b'), record('c'), record('d'), record('e'), + ]); + + const page1 = await loadRecords('2025-01-01', { limit: 2 }); + expect(page1.records.map((r) => r.slotPack.slotId)).toEqual(['a', 'b']); + expect(page1.hasMore).toBe(true); + + const page2 = await loadRecords('2025-01-01', { limit: 2, cursor: page1.cursor }); + expect(page2.records.map((r) => r.slotPack.slotId)).toEqual(['c', 'd']); + expect(page2.hasMore).toBe(true); + + const page3 = await loadRecords('2025-01-01', { limit: 2, cursor: page2.cursor }); + expect(page3.records.map((r) => r.slotPack.slotId)).toEqual(['e']); + expect(page3.hasMore).toBe(false); + }); + + it('恰好读完 limit 条且文件已到末尾时 hasMore=false(EOF 边界)', async () => { + writeLog('2025-01-01', [record('a'), record('b')]); + const result = await loadRecords('2025-01-01', { limit: 2 }); + expect(result.records).toHaveLength(2); + expect(result.hasMore).toBe(false); + }); + + it('cursor 跳过已读行,游标越界时返回空', async () => { + writeLog('2025-01-01', [record('a'), record('b'), record('c')]); + const skipped = await loadRecords('2025-01-01', { cursor: 2 }); + expect(skipped.records.map((r) => r.slotPack.slotId)).toEqual(['c']); + + const beyond = await loadRecords('2025-01-01', { cursor: 99 }); + expect(beyond.records).toEqual([]); + expect(beyond.hasMore).toBe(false); + }); + + it('损坏行计入行号游标,不影响后续分页', async () => { + writeLog('2025-01-01', [record('a'), 'broken', record('b')]); + const page1 = await loadRecords('2025-01-01', { limit: 1 }); + expect(page1.records.map((r) => r.slotPack.slotId)).toEqual(['a']); + expect(page1.hasMore).toBe(true); + const page2 = await loadRecords('2025-01-01', { limit: 1, cursor: page1.cursor }); + expect(page2.records.map((r) => r.slotPack.slotId)).toEqual(['b']); + expect(page2.hasMore).toBe(false); + }); + + it('非法日期直接抛错(防穿越)', async () => { + await expect(loadRecords('../etc/passwd')).rejects.toThrow(); + }); +}); diff --git a/frame-history-viewer/src/index.ts b/frame-history-viewer/src/index.ts new file mode 100644 index 0000000..1af6973 --- /dev/null +++ b/frame-history-viewer/src/index.ts @@ -0,0 +1,293 @@ +/** + * Frame History Viewer — 服务端插件入口 + * + * 本插件是一个 utility + global 实例的工具插件,在 RadioControl 工具栏添加一个 + * iframe 按钮入口("帧历史查看器"),通过 Bridge SDK 通信,从 JSONL 帧日志文件中 + * 读取历史解码数据供前端浏览。 + * + * 数据来源:/frames-logs/frames-YYYY-MM-DD.jsonl + * 通信方式:iframe → tx5dr.invoke() → ctx.ui.registerPageHandler() + */ + +import type { PluginContext, PluginDefinition, SlotPack } from '@tx5dr/plugin-api'; +import { readdir, stat } from 'fs/promises'; +import { createReadStream } from 'fs'; +import { createInterface } from 'readline'; +import { join } from 'path'; +import { homedir } from 'os'; +import zhLocale from './locales/zh.json' with { type: 'json' }; +import enLocale from './locales/en.json' with { type: 'json' }; + +const PLUGIN_NAME = 'frame-history-viewer'; +const PAGE_ID = 'viewer'; +const PANEL_GROUP_ID = 'viewer-entry'; + +function buildPanelDescriptor(openInModal: boolean) { + return [{ + id: 'history-viewer-button', + title: 'historyViewerTitle', + component: 'iframe' as const, + pageId: PAGE_ID, + slot: 'radio-control-toolbar' as const, + icon: 'clock-rotate-left', + openMode: (openInModal ? 'modal' : 'popover') as 'modal' | 'popover', + uiSize: 'lg' as const, + params: { openMode: openInModal ? 'modal' : 'popover' }, + }]; +} + +/** + * 与服务端 SlotPackPersistence.ts 中定义的 SlotPackStorageRecord 等效。 + * 此处重新定义是为了在独立插件项目中不依赖 @tx5dr/contracts,直接按 JSONL + * 的存储格式反序列化。 + * + * slotPack.startMs / slotPack.endMs 均为毫秒级 UTC 时间戳; + * operation 标记该记录是"新建"还是"更新"(后者表示同一时隙的增量更新)。 + */ +interface SlotPackStorageRecord { + storedAt: number; + operation: 'updated' | 'created'; + slotPack: SlotPack; + mode?: string; + version: string; +} + +/** + * 获取 TX-5DR 数据根目录。 + * 解析顺序与宿主 tx5drPaths.getDataDir()(packages/server/src/utils/app-paths.ts)保持一致: + * 1. TX5DR_DATA_DIR 环境变量(Docker / Linux server 包 / Electron 桌面均会注入) + * 2. Windows: %LOCALAPPDATA%\TX-5DR + * 3. macOS: ~/Library/Application Support/TX-5DR + * 4. Linux: $XDG_DATA_HOME/TX-5DR,否则 ~/.local/share/TX-5DR + * 不做 /app/data、/var/lib/tx5dr 等目录的存在性探测,避免无环境变量时 + * 命中宿主并未实际使用的目录(例如桌面版与 server 包并存时读到错误数据)。 + */ +export function getDataDir(): string { + const env = process.env.TX5DR_DATA_DIR; + if (env) return env; + + const APP_DIR_NAME = 'TX-5DR'; + switch (process.platform) { + case 'win32': + return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), APP_DIR_NAME); + case 'darwin': + return join(homedir(), 'Library', 'Application Support', APP_DIR_NAME); + default: + return join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), APP_DIR_NAME); + } +} + +/** frames-logs 子目录路径 */ +function getFramesLogDir(): string { + return join(getDataDir(), 'frames-logs'); +} + +/** + * 扫描 frames-logs 目录,列出所有 JSONL 日志文件对应的日期。 + * 文件名格式:frames-YYYY-MM-DD.jsonl + * 返回按字典序排序的日期字符串数组,最新日期在最后。 + */ +export async function listDates(): Promise { + const dir = getFramesLogDir(); + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return []; + } + const dates: string[] = []; + for (const name of entries.sort()) { + const match = name.match(/^frames-(\d{4}-\d{2}-\d{2})\.jsonl$/); + if (match) dates.push(match[1]); + } + return dates; +} + +/** + * 读取指定日期的 JSONL 日志文件,支持游标分页。 + * + * 使用流式读取(createReadStream + readline)避免大文件一次性加载到内存。 + * 遇到格式损坏的行自动跳过,不影响其他有效行的读取。 + * + * @param dateStr - 日期字符串 "YYYY-MM-DD" + * @param options - 可选参数对象 + * @param options.limit - 最大返回记录数(默认 Infinity,向后兼容) + * @param options.cursor- 起始行号游标(默认 0) + * @returns 记录数组 + 下一游标 + hasMore 标记 + */ +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** 单次 loadRecords 允许返回的最大记录数,防止客户端传超大 limit 一次性读入整个文件 */ +const MAX_RECORDS_PER_PAGE = 2000; + +/** 将客户端传入的 limit 归一化为 [1, MAX_RECORDS_PER_PAGE];未传时返回 undefined(不限) */ +export function clampLimit(value: unknown): number | undefined { + if (value === undefined || value === null) return undefined; + const n = typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : MAX_RECORDS_PER_PAGE; + return Math.min(Math.max(1, n), MAX_RECORDS_PER_PAGE); +} + +/** 将游标归一化为非负整数;非法值归 0 */ +export function toNonNegativeCursor(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +} + +/** + * 校验日期字符串,防止目录穿越。 + * + * dateStr 直接拼进文件路径(index.ts 由 iframe invoke 透传,宿主仅做 session + * 校验后原样交给插件 handler),若含 `../` 等片段可逃逸 frames-logs 目录。 + * 与 listDates() 的 `frames-YYYY-MM-DD.jsonl` 正则保持一致。 + */ +export function assertValidDate(dateStr: string): void { + if (!DATE_RE.test(dateStr)) { + throw new Error(`Invalid date: ${dateStr}`); + } +} + +/** + * 读取指定日期的 JSONL 日志文件,支持游标分页。 + * + * 使用流式读取(createReadStream + readline)避免大文件一次性加载到内存。 + * 遇到格式损坏的行自动跳过,不影响其他有效行的读取。 + * + * @param dateStr - 日期字符串 "YYYY-MM-DD" + * @param options - 可选参数对象 + * @param options.limit - 最大返回记录数(默认 Infinity,向后兼容) + * @param options.cursor- 起始行号游标(默认 0) + * @returns 记录数组 + 下一游标 + hasMore 标记 + */ +export async function loadRecords( + dateStr: string, + options?: { limit?: number; cursor?: number } +): Promise<{ records: SlotPackStorageRecord[]; cursor: number; hasMore: boolean }> { + assertValidDate(dateStr); + const filePath = join(getFramesLogDir(), `frames-${dateStr}.jsonl`); + try { + const s = await stat(filePath); + if (!s.isFile()) return { records: [], cursor: 0, hasMore: false }; + } catch { + return { records: [], cursor: 0, hasMore: false }; + } + const records: SlotPackStorageRecord[] = []; + const limit = clampLimit(options?.limit) ?? Infinity; + const cursor = toNonNegativeCursor(options?.cursor); + let lineIdx = 0; + let truncated = false; + + try { + const stream = createReadStream(filePath, 'utf-8'); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + try { + const iterator = rl[Symbol.asyncIterator](); + while (true) { + const { done, value: line } = await iterator.next(); + if (done) break; // 读到 EOF + if (lineIdx++ < cursor) continue; + if (!line) continue; + try { + records.push(JSON.parse(line) as SlotPackStorageRecord); + if (limit !== Infinity && records.length >= limit) { + // 已满页:预取下一行确认是否还有更多,避免"恰好整页读到文件末尾"误报 hasMore + const next = await iterator.next(); + truncated = !next.done; + break; + } + } catch { + // 跳过格式损坏的行 + } + } + } finally { + rl.close(); + } + } catch { + return { records: [], cursor: 0, hasMore: false }; + } + + return { + records, + cursor: lineIdx, + // 仅当满页且文件仍有余行时才报告 hasMore(truncated 由预取结果决定) + hasMore: truncated, + }; +} + +const plugin: PluginDefinition = { + name: PLUGIN_NAME, + version: '1.0.0', + type: 'utility', + instanceScope: 'global', // 全局单例:不需要按操作员分别实例化 + description: 'pluginDescription', + + // 面板改为动态注册(由设置 openInModal 控制 openMode), + // 不在静态 panels 中声明,避免出现双按钮 + panels: [], + + settings: { + openInModal: { + type: 'boolean', + default: false, + label: 'openInModal', + description: 'openInModalDescription', + scope: 'global', + }, + }, + + ui: { + dir: 'ui', + pages: [{ + id: PAGE_ID, + title: 'historyViewerTitle', + entry: 'viewer.html', + accessScope: 'operator', // 操作员即可访问(与 radio-control-toolbar 入口定位一致) + resourceBinding: 'none', // 不绑定特定操作员/呼号 + }], + }, + + onLoad(ctx: PluginContext) { + ctx.log.debug('Plugin loaded'); + + // 根据当前配置动态创建工具栏面板 + const openInModal = ctx.config.openInModal === true; + ctx.ui.setPanelContributions(PANEL_GROUP_ID, buildPanelDescriptor(openInModal)); + + ctx.ui.registerPageHandler({ + async onMessage(_pageId: string, action: string, data: unknown) { + switch (action) { + case 'listDates': + return { dates: await listDates() }; + case 'loadRecords': { + const { date, limit, cursor } = data as { + date: string; limit?: number; cursor?: number; + }; + const result = await loadRecords(date, { limit, cursor }); + return { date, ...result }; + } + case 'getLocaleStrings': { + const { locale } = data as { locale: string }; + return locale === 'zh' ? zhLocale : enLocale; + } + default: + throw new Error(`Unknown action: ${action}`); + } + }, + }); + }, + + onUnload(ctx: PluginContext) { + ctx.ui.clearPanelContributions(PANEL_GROUP_ID); + ctx.log.debug('Plugin unloaded'); + }, + + hooks: { + onConfigChange(changes: Record, ctx: PluginContext) { + if ('openInModal' in changes) { + const openInModal = changes.openInModal === true; + ctx.ui.setPanelContributions(PANEL_GROUP_ID, buildPanelDescriptor(openInModal)); + } + }, + }, +}; + +export default plugin; diff --git a/frame-history-viewer/src/locales/en.json b/frame-history-viewer/src/locales/en.json new file mode 100644 index 0000000..0930605 --- /dev/null +++ b/frame-history-viewer/src/locales/en.json @@ -0,0 +1,24 @@ +{ + "pluginName": "Frame History Viewer", + "pluginDescription": "Browse historical FT8/FT4 decode frames stored in JSONL log files.", + "historyViewerTitle": "Frame History Viewer", + "uiEyebrow": "Frame History", + "uiLoading": "Loading...", + "uiError": "Failed to load", + "uiNoData": "No data available", + "uiNoRecords": "No records found", + "uiNoFrames": "No frames in this slot", + "uiSummary": "{0} slot(s), {1} frame(s)", + "uiSummaryFiltered": "{0}/{1} slot(s), {2}/{3} frame(s)", + "uiAllBands": "All bands", + "uiRefresh": "Refresh", + "uiSearchPlaceholder": "Search message...", + "uiFramesCount": "{0} frm", + "uiTime": "Time", + "uiSNR": "SNR", + "uiFreq": "Freq (Hz)", + "uiDT": "DT (s)", + "uiMessage": "Message", + "openInModal": "Open in window", + "openInModalDescription": "When enabled, opens the frame history viewer as a modal window instead of a popover" +} \ No newline at end of file diff --git a/frame-history-viewer/src/locales/zh.json b/frame-history-viewer/src/locales/zh.json new file mode 100644 index 0000000..d60486b --- /dev/null +++ b/frame-history-viewer/src/locales/zh.json @@ -0,0 +1,24 @@ +{ + "pluginName": "帧历史查看器", + "pluginDescription": "浏览存储在 JSONL 日志文件中的历史 FT8/FT4 解码帧。", + "historyViewerTitle": "帧历史查看器", + "uiEyebrow": "帧历史", + "uiLoading": "加载中...", + "uiError": "加载失败", + "uiNoData": "暂无数据", + "uiNoRecords": "未找到记录", + "uiNoFrames": "该时隙无帧数据", + "uiSummary": "{0} 个时隙,{1} 条帧记录", + "uiSummaryFiltered": "{0}/{1} 个时隙,{2}/{3} 条帧记录", + "uiAllBands": "所有波段", + "uiRefresh": "刷新", + "uiSearchPlaceholder": "搜索消息...", + "uiFramesCount": "{0} 帧", + "uiTime": "时间", + "uiSNR": "信噪比", + "uiFreq": "频率 (Hz)", + "uiDT": "时偏 (s)", + "uiMessage": "消息", + "openInModal": "窗口模式打开", + "openInModalDescription": "开启后工具栏按钮以模态窗口打开帧历史查看器,关闭则以浮层打开" +} \ No newline at end of file diff --git a/frame-history-viewer/tsconfig.build.json b/frame-history-viewer/tsconfig.build.json new file mode 100644 index 0000000..4dc23fb --- /dev/null +++ b/frame-history-viewer/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/__tests__"] +} diff --git a/frame-history-viewer/tsconfig.json b/frame-history-viewer/tsconfig.json new file mode 100644 index 0000000..3caa57b --- /dev/null +++ b/frame-history-viewer/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/frame-history-viewer/ui/viewer.css b/frame-history-viewer/ui/viewer.css new file mode 100644 index 0000000..86dd4aa --- /dev/null +++ b/frame-history-viewer/ui/viewer.css @@ -0,0 +1,392 @@ +/* ======================================== + Frame History Viewer — 样式表 + 使用 TX-5DR CSS Design Tokens 适配明暗主题 + ======================================== */ + +/* --- 全局重置 --- */ +* { box-sizing: border-box; margin: 0; padding: 0; } + +button, input, select { font: inherit; } + +/* --- 自定义滚动条(WebKit) --- */ +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: var(--tx5dr-border, #3f3f46); + border-radius: 3px; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--tx5dr-text-secondary, #a1a1aa); +} + +/* --- 基础布局 --- */ +html, body { + height: 100%; + font-family: var(--tx5dr-font, 'Inter', system-ui, -apple-system, sans-serif); +} + +body { + background: var(--tx5dr-bg, #18181b); + color: var(--tx5dr-text, #fafafa); + font-size: var(--tx5dr-font-size-sm, 13px); + line-height: 1.5; +} + +#app { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.fixed-header { + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 10; + background: var(--tx5dr-bg, #18181b); +} + +.scroll-body { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +/* --- 顶部工具栏 --- */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--tx5dr-spacing-sm, 8px); + padding: var(--tx5dr-spacing-md, 12px) 14px var(--tx5dr-spacing-sm, 8px); +} + +.eyebrow { + color: var(--tx5dr-primary, #006FEE); + font-size: 11px; + font-weight: 900; + letter-spacing: 0.18em; + text-transform: uppercase; + white-space: nowrap; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: var(--tx5dr-spacing-sm, 8px); +} + +.date-select, +.band-select { + background: var(--tx5dr-bg-content, #27272a); + border: 1px solid var(--tx5dr-border, #3f3f46); + border-radius: var(--tx5dr-radius-sm, 8px); + color: var(--tx5dr-text, #fafafa); + font-family: var(--tx5dr-font, 'Inter', system-ui, -apple-system, sans-serif); + font-size: var(--tx5dr-font-size-sm, 13px); + padding: 6px 10px; + outline: none; + cursor: pointer; + transition: border-color 0.15s; +} + +.date-select:focus, +.band-select:focus { + border-color: var(--tx5dr-primary, #006FEE); + box-shadow: 0 0 0 2px var(--tx5dr-focus-ring, rgba(0, 111, 238, 0.4)); +} + +.date-select { + max-width: 140px; +} + +.band-select { + max-width: 100px; +} + +.date-select option, +.band-select option { + background: var(--tx5dr-bg, #18181b); +} + +.icon-btn { + background: transparent; + border: 1px solid var(--tx5dr-border, #3f3f46); + border-radius: var(--tx5dr-radius-sm, 8px); + color: var(--tx5dr-text, #fafafa); + cursor: pointer; + font-family: var(--tx5dr-font, 'Inter', system-ui, -apple-system, sans-serif); + font-size: var(--tx5dr-font-size-md, 14px); + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 30px; + min-width: 30px; + padding: 0; + transition: background 120ms ease; +} + +.icon-btn:hover { + background: var(--tx5dr-bg-hover, #3f3f46); +} + +.icon-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--tx5dr-focus-ring, rgba(0, 111, 238, 0.4)); +} + +.loading { + color: var(--tx5dr-text-secondary, #a1a1aa); + padding: 40px 14px; + text-align: center; +} + +.banner { + border-radius: var(--tx5dr-radius-sm, 8px); + margin: 0 14px var(--tx5dr-spacing-sm, 8px); + padding: 10px 12px; + font-size: var(--tx5dr-font-size-sm, 13px); + line-height: 1.45; +} + +.banner.error { + background: color-mix(in srgb, var(--tx5dr-danger, #f31260) 18%, transparent); + border: 1px solid color-mix(in srgb, var(--tx5dr-danger, #f31260), transparent 45%); +} + +.search-input { + background: var(--tx5dr-bg-content, #27272a); + border: 1px solid var(--tx5dr-border, #3f3f46); + border-radius: var(--tx5dr-radius-sm, 8px); + color: var(--tx5dr-text, #fafafa); + font-family: var(--tx5dr-font, 'Inter', system-ui, -apple-system, sans-serif); + font-size: var(--tx5dr-font-size-sm, 13px); + padding: 6px 10px; + outline: none; + width: 140px; + transition: border-color 0.15s; +} + +.search-input::placeholder { + color: var(--tx5dr-text-secondary, #a1a1aa); + opacity: 0.7; +} + +.search-input:focus { + border-color: var(--tx5dr-primary, #006FEE); + box-shadow: 0 0 0 2px var(--tx5dr-focus-ring, rgba(0, 111, 238, 0.4)); +} + +/* --- 搜索高亮 --- */ +.highlight { + background: color-mix(in srgb, var(--tx5dr-warning, #f5a524) 35%, transparent); + border-radius: var(--tx5dr-radius-sm, 8px); + padding: 0 2px; +} + +/* --- 摘要统计栏 --- */ +.summary-bar { + border-top: 1px solid var(--tx5dr-border, #3f3f46); + color: var(--tx5dr-text-secondary, #a1a1aa); + font-size: 11px; + padding: 8px 14px; +} + +/* --- 时隙手风琴卡片 --- */ +.slot-card { + background: var(--tx5dr-bg-content, #27272a); + border: 1px solid var(--tx5dr-border, #3f3f46); + border-radius: var(--tx5dr-radius-md, 12px); + margin: 0 14px var(--tx5dr-spacing-sm, 8px); + overflow: hidden; + content-visibility: auto; + contain-intrinsic-size: 48px; +} + +.slot-header { + align-items: center; + cursor: pointer; + display: flex; + gap: 10px; + padding: 10px 12px; + transition: background 120ms ease; + user-select: none; +} + +.slot-header:hover { + background: var(--tx5dr-bg-hover, #3f3f46); +} + +.slot-header:active { + background: color-mix(in srgb, var(--tx5dr-bg-hover, #3f3f46) 60%, var(--tx5dr-bg-content, #27272a) 40%); +} + +.slot-header:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--tx5dr-focus-ring, rgba(0, 111, 238, 0.4)); +} + +.slot-header .chevron { + color: var(--tx5dr-text-secondary, #a1a1aa); + flex-shrink: 0; + font-size: 10px; + transition: transform 180ms ease; + width: 12px; +} + +.slot-header.open .chevron { + transform: rotate(90deg); +} + +.slot-header .slot-label { + flex: 1; + font-weight: 600; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.slot-header .slot-meta { + color: var(--tx5dr-text-secondary, #a1a1aa); + flex-shrink: 0; + font-size: 11px; +} + +.meta-band { + background: color-mix(in srgb, var(--tx5dr-primary, #006FEE) 18%, transparent); + border: 1px solid color-mix(in srgb, var(--tx5dr-primary, #006FEE) 35%, transparent); + border-radius: var(--tx5dr-radius-sm, 8px); + color: var(--tx5dr-primary, #006FEE); + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + margin-right: 4px; +} + +.slot-body { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 200ms ease, opacity 150ms ease; +} + +.slot-body.open { + max-height: 4000px; + opacity: 1; +} + +/* --- 帧详情表格 --- */ +.frames-table { + border-collapse: collapse; + font-size: 11px; + width: 100%; +} + +.frames-table th { + background: color-mix(in srgb, var(--tx5dr-bg, #18181b) 60%, transparent); + border-bottom: 1px solid var(--tx5dr-border, #3f3f46); + color: var(--tx5dr-text-secondary, #a1a1aa); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + padding: 7px 8px; + text-align: left; + text-transform: uppercase; + white-space: nowrap; +} + +.frames-table td { + border-bottom: 1px solid color-mix(in srgb, var(--tx5dr-border, #3f3f46) 40%, transparent); + padding: 6px 8px; + vertical-align: top; +} + +.frames-table tr:last-child td { + border-bottom: none; +} + +.frames-table tbody tr td { + transition: background 120ms ease; +} + +.frames-table tr:hover td { + background: var(--tx5dr-bg-hover, #3f3f46); +} + +.frames-table .col-time { white-space: nowrap; } +.frames-table .col-snr { text-align: right; white-space: nowrap; } +.frames-table .col-freq { text-align: right; white-space: nowrap; } +.frames-table .col-dt { text-align: right; white-space: nowrap; } +.frames-table .col-message { + font-family: var(--tx5dr-font-mono, 'JetBrains Mono', ui-monospace, monospace); + word-break: break-all; +} +.snr-good { color: var(--tx5dr-success, #17c964); } +.snr-ok { color: var(--tx5dr-warning, #f5a524); } +.snr-bad { color: var(--tx5dr-danger, #f31260); } + +.slot-empty { + color: var(--tx5dr-text-secondary, #a1a1aa); + font-size: 11px; + padding: 20px 12px; + text-align: center; +} + +/* --- 进度指示器 --- */ +.progressive-indicator { + color: var(--tx5dr-text-secondary, #a1a1aa); + font-size: 11px; + padding: 6px 14px; + text-align: center; +} + +/* --- 响应式:宽屏优化 --- */ +@media (min-width: 600px) { + .frames-table th, .frames-table td { + padding: 7px 10px; + } +} + +/* --- 响应式:窄屏优化(移动端/小浮层) --- */ +@media (max-width: 520px) { + .topbar { + flex-wrap: wrap; + gap: 6px; + padding: 10px 10px 6px; + } + .eyebrow { + width: 100%; + } + .topbar-actions { + width: 100%; + } + .search-input { + flex: 1; + min-width: 0; + width: auto; + } + .date-select { + max-width: 110px; + } + .slot-card { + margin: 0 10px 6px; + } + .frames-table th, + .frames-table td { + padding: 5px 6px; + } + .slot-header { + padding: 8px 10px; + gap: 6px; + } +} \ No newline at end of file diff --git a/frame-history-viewer/ui/viewer.html b/frame-history-viewer/ui/viewer.html new file mode 100644 index 0000000..8730894 --- /dev/null +++ b/frame-history-viewer/ui/viewer.html @@ -0,0 +1,42 @@ + + + + + + + Frame History Viewer + + + +
+ +
+
+
Frame History
+
+ + + + +
+
+
+
+ + +
+ + + +
+
+
+ + + + diff --git a/frame-history-viewer/ui/viewer.js b/frame-history-viewer/ui/viewer.js new file mode 100644 index 0000000..3a21c26 --- /dev/null +++ b/frame-history-viewer/ui/viewer.js @@ -0,0 +1,601 @@ +/// + +/** + * Frame History Viewer — iframe 前端页面 + * + * 在 RadioControl 工具栏按钮点击时弹出的浮层中运行。 + * 通过 Bridge SDK (window.tx5dr) 与服务端通信: + * - invoke(action, data) → onLoad 中的 registerPageHandler + * - 服务端读取 JSONL 帧日志文件后返回数据 + * + * 主要功能: + * 1. 日期选择:从 frames-logs 目录列出所有可用日期 + * 2. 波段过滤:从已加载记录中提取去重波段列表 + * 3. 消息搜索:按关键字过滤帧消息 + * 4. 手风琴式展开:按时隙折叠/展开查看帧详情 + */ + +/** + * 发射帧的信噪比标记值。 + * 服务端约定:snr = -999 表示该帧是本台发射的,非接收解码帧。 + * 参考 SlotPackPersistence.ts 中的实现。 + */ +const TX_SNR_MARKER = -999; + +let allRecords = []; // 当前已加载的全部帧记录(未过滤) +let currentDate = ''; // 当前选中的日期 +let currentBand = ''; // 当前选中的波段过滤条件 +let currentFilter = ''; // 当前搜索关键字 +let strings = {}; // 当前语言的 i18n 翻译字典 +let allBandsLabel = 'All bands'; +let cachedTotalSlots = 0; // 当前日期的总时隙数(用于 summary 栏显示) +let cachedTotalFrames = 0; // 当前日期的总帧数 + +// 分页状态 +let loadGeneration = 0; +let currentCursor = 0; +let hasMoreData = false; +let knownBands = new Set(); +const FIRST_PAGE_SIZE = 100; +const BACKGROUND_PAGE_SIZE = 500; + +const $ = (sel, ctx) => (ctx || document).querySelector(sel); +const $$ = (sel, ctx) => Array.from((ctx || document).querySelectorAll(sel)); + +/** + * i18n 翻译辅助函数。 + * 支持 {0} {1} 占位符替换,与服务端 locales JSON 格式对应。 + */ +function tr(key, ...args) { + const s = strings[key] ?? key; + if (args.length === 0) return s; + return s.replace(/\{(\d+)\}/g, (_, idx) => { + const i = parseInt(idx, 10); + return i < args.length ? String(args[i]) : ''; + }); +} + +/** 防抖包装:避免搜索时频繁渲染 */ +function debounce(fn, delay) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; +} + +/** + * 通过 Bridge SDK 向服务端发送请求。 + * 等待 tx5dr.ready 确保 SDK 初始化完成后才发起调用。 + */ +async function invoke(action, data) { + if (!window.tx5dr) throw new Error('Bridge SDK not available'); + await window.tx5dr.ready; + return window.tx5dr.invoke(action, data); +} + +/** + * 动态加载插件翻译。 + * 读取当前语言设置,从服务端获取该语言的翻译字典, + * 然后一次性更新所有带 data-i18n / data-i18n-placeholder / data-i18n-title 属性的元素。 + */ +async function loadStrings() { + try { + const locale = window.tx5dr?.locale || 'en'; + const result = await invoke('getLocaleStrings', { locale }); + strings = result || {}; + allBandsLabel = strings.uiAllBands || 'All bands'; + } catch { + strings = {}; + } + applyStaticStrings(); +} + +/** 将翻译字典应用到 HTML 模板中的 data-i18n 标记元素上 */ +function applyStaticStrings() { + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + el.textContent = tr(key); + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + el.placeholder = tr(key); + }); + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + el.title = tr(key); + }); + const titleAttr = document.querySelector('title')?.getAttribute('data-i18n'); + if (titleAttr) document.title = tr(titleAttr); +} + +/** + * 格式化毫秒时间戳为 UTC 时间字符串(HH:MM:SS)。 + * 帧日志中的时间戳均为 UTC,与 FT8 协议时间基准一致。 + */ +function formatTime(ms) { + if (ms == null) return '-'; + const d = new Date(ms); + return d.getUTCHours().toString().padStart(2, '0') + ':' + + d.getUTCMinutes().toString().padStart(2, '0') + ':' + + d.getUTCSeconds().toString().padStart(2, '0'); +} + +/** 按 SNR 值返回对应的 CSS class,用于颜色标记(容忍字符串形式的数值) */ +function snrClass(snr) { + if (Number(snr) === TX_SNR_MARKER) return ''; + const n = typeof snr === 'number' ? snr : Number(snr); + if (n >= -5) return 'snr-good'; + if (n >= -15) return 'snr-ok'; + return 'snr-bad'; +} + +/** 格式化 SNR 显示值:发射帧显示 "TX",解码帧显示实际数值(非数字字段转义后展示,防注入) */ +function formatSNR(snr) { + if (Number(snr) === TX_SNR_MARKER) return 'TX'; + return escapeHtml(String(snr ?? '-')); +} + +/** 安全的 HTML 转义,防止 XSS */ +function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; +} + +/** 搜索关键字高亮,同时防止 XSS */ +function highlightMessage(msg, filter) { + const escaped = escapeHtml(msg); + if (!filter) return escaped; + // 关键字先做与消息文本相同的 HTML 转义,再做正则转义, + // 保证含 & < > 等字符的关键字能匹配到转义后的文本 + const escapedFilter = escapeHtml(filter).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp('(' + escapedFilter + ')', 'gi'); + return escaped.replace(regex, '$1'); +} + +/** 通过 Bridge SDK 获取可用日期列表 */ +async function loadDates() { + try { + const result = await invoke('listDates'); + return result.dates || []; + } catch (err) { + showError(tr('uiError') + ': ' + err.message); + return []; + } +} + +/** 首屏加载:请求第一页数据(携带代数守卫,过期响应不提交分页状态) */ +async function loadFirstPage(date) { + const gen = loadGeneration; + try { + const result = await invoke('loadRecords', { date, limit: FIRST_PAGE_SIZE, cursor: 0 }); + if (gen !== loadGeneration) return null; // 已切换日期:丢弃过期响应 + currentCursor = result.cursor; + hasMoreData = result.hasMore; + return result.records || []; + } catch (err) { + if (gen !== loadGeneration) return null; + showError(tr('uiError') + ': ' + err.message); + return []; + } +} + +/** 后台加载:请求下一页数据(携带代数守卫,过期响应不提交分页状态) */ +async function loadNextPage() { + if (!hasMoreData) return []; + const gen = loadGeneration; + try { + const result = await invoke('loadRecords', { + date: currentDate, limit: BACKGROUND_PAGE_SIZE, cursor: currentCursor, + }); + if (gen !== loadGeneration) return []; // 已切换日期:丢弃过期响应 + currentCursor = result.cursor; + hasMoreData = result.hasMore; + return result.records || []; + } catch (err) { + if (gen !== loadGeneration) return []; + hasMoreData = false; // 请求失败后停止分页,避免无限重试 + showError(tr('uiError') + ': ' + err.message); + return []; + } +} + +function countFrames(records) { + return records.reduce((s, r) => { + const frames = r && typeof r === 'object' && Array.isArray(r.slotPack?.frames) ? r.slotPack.frames : []; + return s + frames.length; + }, 0); +} + +/** 显示错误横幅,隐藏其他 UI 状态 */ +function showError(msg) { + const el = $('#errorIndicator'); + el.textContent = msg; + el.hidden = false; + $('#loadingIndicator').hidden = true; + $('#emptyIndicator').hidden = true; + $('#slotList').innerHTML = ''; + $('#summaryBar').textContent = ''; +} + +/** 切换加载中指示器的显隐 */ +function showLoading(show) { + $('#loadingIndicator').hidden = !show; + $('#errorIndicator').hidden = true; + $('#emptyIndicator').hidden = true; + if (show) { + $('#slotList').innerHTML = ''; + $('#summaryBar').textContent = ''; + hideProgressiveIndicator(); // 重新加载时清除上一轮可能残留的进度指示器 + } +} + +/** 更新顶部的摘要统计栏:显示总时隙数/总帧数,若经过过滤则同时显示筛选后数据 */ +function updateSummary(records, totalSlots, totalFrames) { + const el = $('#summaryBar'); + if (totalSlots === 0) { + el.textContent = tr('uiNoRecords'); + return; + } + const shownSlots = records.length; + const shownFrames = records.reduce((s, r) => s + (r.slotPack?.frames?.length || 0), 0); + if (shownSlots === totalSlots && shownFrames === totalFrames) { + el.textContent = tr('uiSummary', totalSlots, totalFrames); + } else { + el.textContent = tr('uiSummaryFiltered', shownSlots, totalSlots, shownFrames, totalFrames); + } +} + +/** 从 frequencyContext 中提取可读的波段标签,如 "40m 7.074 MHz" */ +function bandLabel(fctx) { + if (!fctx) return ''; + const parts = []; + if (fctx.band) parts.push(fctx.band); + if (fctx.frequency) parts.push((fctx.frequency / 1_000_000).toFixed(3) + ' MHz'); + return parts.length ? parts.join(' ') : ''; +} + +/** + * 格式化时隙时间范围。 + * startMs / endMs 均为 UTC 毫秒时间戳。 + * 仅有 startMs 时只显示起始时间,有 endMs 时显示 "HH:MM:SS – HH:MM:SS"。 + */ +function formatTimeRange(startMs, endMs) { + if (!startMs) return '?'; + return endMs ? formatTime(startMs) + ' \u2013 ' + formatTime(endMs) : formatTime(startMs); +} + +/** + * 渲染单个时隙卡片(手风琴式)。 + * + * header 区域:显示时隙时间范围、波段标签、帧数、操作类型(新建/更新)、模式。 + * body 区域:展开后以表格形式展示该时隙内所有帧的消息详情。 + * 当 filter 非空时默认展开(expanded = true)以便快速查看搜索结果。 + * 点击 header 切换展开/折叠。 + */ +function renderSlot(record, filter, expanded) { + // 记录级类型保护:异常 JSONL 行(非对象 / frames 非数组)不应导致整行渲染崩溃 + const slotPack = record && typeof record === 'object' ? (record.slotPack || {}) : {}; + const frames = Array.isArray(slotPack.frames) ? slotPack.frames : []; + const startMs = slotPack.startMs; + const endMs = slotPack.endMs; + const timeLabel = formatTimeRange(startMs, endMs); + const opLabel = record.operation === 'created' ? '+' : ''; + const modeLabel = record.mode || ''; + const fctx = slotPack.frequencyContext || {}; + const band = bandLabel(fctx); + + const card = document.createElement('div'); + card.className = 'slot-card'; + + const header = document.createElement('div'); + header.className = 'slot-header' + (expanded ? ' open' : ''); + header.innerHTML = + '\u25B6' + + '' + escapeHtml(timeLabel) + '' + + '' + + (band ? '' + escapeHtml(band) + ' ' : '') + + tr('uiFramesCount', frames.length) + (opLabel || modeLabel ? ' |' : '') + (opLabel ? ' ' + escapeHtml(opLabel) : '') + (modeLabel ? ' ' + escapeHtml(modeLabel) : '') + + ''; + + const body = document.createElement('div'); + body.className = 'slot-body' + (expanded ? ' open' : ''); + + if (frames.length === 0) { + body.innerHTML = '
' + escapeHtml(tr('uiNoFrames')) + '
'; + } else { + const table = document.createElement('table'); + table.className = 'frames-table'; + table.innerHTML = + '' + + '' + escapeHtml(tr('uiTime')) + '' + + '' + escapeHtml(tr('uiSNR')) + '' + + '' + escapeHtml(tr('uiFreq')) + '' + + '' + escapeHtml(tr('uiDT')) + '' + + '' + escapeHtml(tr('uiMessage')) + '' + + ''; + const tbody = table.querySelector('tbody'); + + for (const frame of frames) { + // 帧内时间偏移:frame.dt 是相对于 startMs 的秒数偏移 + // 字段均做类型保护:宿主 schema 保证 number,但异常 JSONL 行不应导致整行渲染崩溃 + const hasStart = typeof startMs === 'number'; + const hasDt = typeof frame.dt === 'number'; + const frameTime = hasStart && hasDt + ? formatTime(startMs + frame.dt * 1000) + : (hasDt ? frame.dt.toFixed(1) + 's' : '-'); + const freqText = typeof frame.freq === 'number' ? frame.freq.toFixed(0) : String(frame.freq ?? '-'); + const dtText = hasDt ? frame.dt.toFixed(1) : String(frame.dt ?? '-'); + const msg = typeof frame.message === 'string' ? frame.message : ''; + const row = document.createElement('tr'); + row.innerHTML = + '' + escapeHtml(frameTime) + '' + + '' + formatSNR(frame.snr) + '' + + '' + escapeHtml(freqText) + '' + + '' + escapeHtml(dtText) + '' + + '' + highlightMessage(msg, filter) + ''; + tbody.appendChild(row); + } + + body.appendChild(table); + } + + header.addEventListener('click', () => { + const isOpen = body.classList.toggle('open'); + header.classList.toggle('open', isOpen); + }); + + card.appendChild(header); + card.appendChild(body); + return card; +} + +/** 批量追加 DOM(DocumentFragment),避免回流 */ +function appendRecords(records, filter) { + const container = $('#slotList'); + const expanded = !!filter; + const fragment = document.createDocumentFragment(); + for (const record of records) { + fragment.appendChild(renderSlot(record, filter, expanded)); + } + container.appendChild(fragment); + updateSummary(allRecords, cachedTotalSlots, cachedTotalFrames); +} + +const debouncedFilter = debounce(applyFilter, 200); + +/** 从已加载记录中提取去重并排序的波段列表,填充波段下拉框 */ +function populateBandSelect(records, incremental) { + const select = $('#bandSelect'); + + if (!incremental) { + select.innerHTML = ''; + select.add(new Option(allBandsLabel, '')); + knownBands = new Set(); + } + + for (const r of records) { + const band = r.slotPack?.frequencyContext?.band; + if (band) knownBands.add(band); + } + + // 波段名优先按数字排序(如 40m 在 20m 前),非数字名按字典序 + const sorted = Array.from(knownBands).sort((a, b) => { + const na = parseInt(a, 10); + const nb = parseInt(b, 10); + if (!isNaN(na) && !isNaN(nb)) return na - nb; + return a.localeCompare(b); + }); + const currentValue = select.value; + select.innerHTML = ''; + select.add(new Option(allBandsLabel, '')); + for (const b of sorted) { + select.add(new Option(b, b)); + } + select.value = currentValue && knownBands.has(currentValue) ? currentValue : ''; +} + +/** + * 按波段 + 消息文本双重过滤。 + * 波段过滤保留整条记录;消息搜索过滤时只保留匹配的帧,若某个时隙的所有帧都不匹配则移除该时隙。 + */ +function applyFilter() { + let records = allRecords; + + if (currentBand) { + records = records.filter(r => r && typeof r === 'object' && r.slotPack?.frequencyContext?.band === currentBand); + } + + const trimmedFilter = currentFilter.trim(); + const filter = trimmedFilter.toLowerCase(); + if (!filter) { + renderRecords(records, '', cachedTotalSlots, cachedTotalFrames); + return; + } + + const filtered = []; + for (const rec of records) { + const frames = (rec && typeof rec === 'object' && Array.isArray(rec.slotPack?.frames) ? rec.slotPack.frames : []).filter(f => + (f.message || '').toLowerCase().includes(filter) + ); + if (frames.length === 0) continue; + // 浅拷贝记录但替换已过滤的 frames 数组,保持 slotPack 其他字段不变 + filtered.push({ ...rec, slotPack: { ...rec.slotPack, frames } }); + } + renderRecords(filtered, trimmedFilter, cachedTotalSlots, cachedTotalFrames); +} + +/** 渲染所有记录到 slotList 容器,有搜索关键字时默认展开所有卡片 */ +function renderRecords(records, filter, totalSlots, totalFrames) { + const container = $('#slotList'); + container.innerHTML = ''; + if (records.length === 0) { + $('#emptyIndicator').hidden = false; + // 保留总数,搜索无匹配时显示 "0/N 个时隙" 而非 "未找到记录" + updateSummary(records, totalSlots, totalFrames); + return; + } + $('#emptyIndicator').hidden = true; + appendRecords(records, filter); +} + +/** + * 加载指定日期的记录并渲染。 + * 过滤掉无帧的空时隙记录,重置过滤条件,刷新波段下拉框。 + */ +async function progressiveLoadRemaining(gen) { + while (hasMoreData && gen === loadGeneration) { + const batch = await loadNextPage(); + if (gen !== loadGeneration) { + hideProgressiveIndicator(); + return; + } + const filtered = batch.filter(r => r && typeof r === 'object' && Array.isArray(r.slotPack?.frames) && r.slotPack.frames.length > 0); + if (filtered.length === 0) continue; + allRecords.push(...filtered); + cachedTotalSlots += filtered.length; + cachedTotalFrames = countFrames(allRecords); + populateBandSelect(filtered, true); + + if (currentBand || currentFilter.trim()) { + applyFilter(); + } else { + appendRecords(filtered, ''); + } + updateProgressiveIndicator(allRecords.length); + await new Promise(r => setTimeout(r, 0)); + } + hideProgressiveIndicator(); + // 后台加载完成后若确认没有任何有帧记录,再显示空状态(避免首屏误报) + if (allRecords.length === 0 && !hasMoreData) { + $('#emptyIndicator').hidden = false; + updateSummary([], 0, 0); + } +} + +function updateProgressiveIndicator(loaded) { + let el = $('#progIndicator'); + if (!el) { + el = document.createElement('div'); + el.id = 'progIndicator'; + el.className = 'progressive-indicator'; + const summaryBar = $('#summaryBar'); + summaryBar.parentNode.insertBefore(el, summaryBar.nextSibling); + } + el.textContent = '\u23F3 ' + loaded + '+ ...'; + el.hidden = false; +} + +function hideProgressiveIndicator() { + const el = $('#progIndicator'); + if (el) el.hidden = true; +} + +async function loadAndRender(date) { + loadGeneration++; + const gen = loadGeneration; + currentDate = date; + currentBand = ''; + currentFilter = ''; + hasMoreData = false; // 重置分页状态:避免切日期/加载失败后沿用上一个日期的游标 + currentCursor = 0; + $('#searchInput').value = ''; + knownBands = new Set(); + showLoading(true); + + const firstPage = await loadFirstPage(date); + if (gen !== loadGeneration) return; + allRecords = (firstPage || []).filter(r => r && typeof r === 'object' && Array.isArray(r.slotPack?.frames) && r.slotPack.frames.length > 0); + showLoading(false); + cachedTotalSlots = allRecords.length; + cachedTotalFrames = countFrames(allRecords); + populateBandSelect(allRecords); + $('#bandSelect').value = ''; + if (allRecords.length === 0 && hasMoreData) { + // 首屏全是空时隙且后台仍有数据:先不显示"无数据", + // 由 progressiveLoadRemaining 加载完成后统一收尾(避免误报空状态) + $('#emptyIndicator').hidden = true; + $('#summaryBar').textContent = ''; + } else { + renderRecords(allRecords, '', cachedTotalSlots, cachedTotalFrames); + } + + if (hasMoreData) { + await progressiveLoadRemaining(gen); + } +} + +/** + * 初始化日期下拉选择器。 + * 从服务端获取可用日期列表,默认选中最新日期并自动加载。 + */ +async function populateDateSelect() { + const select = $('#dateSelect'); + const dates = await loadDates(); + + select.innerHTML = ''; + if (dates.length === 0) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = tr('uiNoData'); + opt.disabled = true; + select.appendChild(opt); + return; + } + + for (const d of dates) { + const opt = document.createElement('option'); + opt.value = d; + opt.textContent = d; + select.appendChild(opt); + } + + select.value = dates[dates.length - 1]; + select.addEventListener('change', async () => { + if (select.value) await loadAndRender(select.value); + }); + + await loadAndRender(select.value); +} + +/** + * 页面初始化入口。 + * + * 启动顺序: + * 1. 加载翻译 → 2. 初始化日期选择并加载最新数据 → 3. 注册交互事件 + * + * 同时监听 onLocaleChange 事件,用户在 UI 中切换语言时自动重新加载翻译。 + */ +async function init() { + await loadStrings(); + + // 窗口模式(modal)下顶部"帧历史"标题与模态窗口标题重复,隐藏以节省空间 + if (window.tx5dr?.params?.openMode === 'modal') { + $('.eyebrow').hidden = true; + } + + $('#loadingIndicator').textContent = tr('uiLoading'); + $('#emptyIndicator').textContent = tr('uiNoData'); + await populateDateSelect(); + + $('#refreshBtn').addEventListener('click', () => { + if (currentDate) loadAndRender(currentDate); + }); + + $('#searchInput').addEventListener('input', () => { + currentFilter = $('#searchInput').value; + debouncedFilter(); + }); + + $('#bandSelect').addEventListener('change', () => { + currentBand = $('#bandSelect').value; + applyFilter(); + }); + + window.tx5dr?.onLocaleChange?.(() => { + loadStrings(); + }); +} + +document.addEventListener('DOMContentLoaded', init);