diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45922ad..d9812e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Build Executables (uv + PyInstaller) +name: CI on: push: @@ -12,8 +12,24 @@ permissions: contents: write jobs: + test: + name: Test (ubuntu-latest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run unit tests + run: uv run python -m pytest tests/ -v + build: name: Build (${{ matrix.os }}) + needs: test strategy: fail-fast: false matrix: @@ -35,66 +51,37 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python (Windows) - if: matrix.os == 'windows-latest' - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install uv uses: astral-sh/setup-uv@v3 - - name: Setup conda (macOS & Linux) - if: matrix.os != 'windows-latest' - uses: conda-incubator/setup-miniconda@v3 - with: - auto-update-conda: true - python-version: "3.12" - channels: conda-forge - - - name: Install tk (macOS & Linux) - if: matrix.os != 'windows-latest' - shell: bash -l {0} - run: | - conda install -y tk + - name: Install dependencies + run: uv sync - - name: Install dependencies (Windows) - if: matrix.os == 'windows-latest' - run: | - uv pip install --system -r requirements.txt pyinstaller pillow - - - name: Install dependencies (macOS & Linux) - if: matrix.os != 'windows-latest' - shell: bash -l {0} - run: | - uv pip install --system -r requirements.txt pyinstaller pillow + - name: Install PyInstaller + run: uv pip install pyinstaller - name: Build (Windows) if: matrix.os == 'windows-latest' run: | - pyinstaller --onefile --windowed --icon=app.png --name=IndexRipper --hidden-import tkinter --hidden-import tkinter.ttk index_ripper.py + uv run pyinstaller --onefile --windowed --icon=app.png --name=IndexRipper --collect-all customtkinter --paths src src/index_ripper/__main__.py - name: Build (macOS) if: startsWith(matrix.os, 'macos-') - shell: bash -l {0} run: | - pyinstaller -F --windowed --name=IndexRipper \ - --hidden-import tkinter --hidden-import tkinter.ttk --icon=app.png index_ripper.py + uv run pyinstaller -F --windowed --name=IndexRipper \ + --collect-all customtkinter --icon=app.png --paths src src/index_ripper/__main__.py [ -f dist/IndexRipper ] && rm -f dist/IndexRipper || true - name: Sign app (ad-hoc) if: startsWith(matrix.os, 'macos-') - shell: bash -l {0} run: | - # 依你提供的方法,於建置後進行 ad-hoc 簽章 codesign --force --deep --sign - "dist/IndexRipper.app" - name: Build (Linux) if: matrix.os == 'ubuntu-latest' - shell: bash -l {0} run: | - pyinstaller --onefile --windowed --name=IndexRipper \ - --hidden-import tkinter --hidden-import tkinter.ttk --icon=app.png index_ripper.py + uv run pyinstaller --onefile --windowed --name=IndexRipper \ + --collect-all customtkinter --icon=app.png --paths src src/index_ripper/__main__.py - name: Verify artifact exists shell: bash @@ -142,14 +129,10 @@ jobs: zip_name="${dir}.app.zip" app_src="" if [ -d "$dir/IndexRipper.app" ]; then - # 常見情況:artifact 內已有 .app 目錄 app_src="$dir/IndexRipper.app" elif [ -d "$dir/dist/IndexRipper.app" ]; then - # 另一種情況:保留了 dist 前綴 app_src="$dir/dist/IndexRipper.app" elif [ -d "$dir/Contents" ]; then - # 展平情況:artifact 只包含 .app 內容,重建包裹 - # 先清理可能存在的 _pkg,再逐一搬移,並忽略 _pkg 本身,避免遞迴移動 rm -rf "$dir/_pkg" mkdir -p "$dir/_pkg/IndexRipper.app" shopt -s dotglob nullglob @@ -184,8 +167,6 @@ jobs: mv artifacts/IndexRipper-Windows/IndexRipper.exe artifacts/IndexRipper-Windows.exe fi if [ -f artifacts/IndexRipper-Linux/IndexRipper ]; then - # 目的地若與資料夾同名會造成 mv 視為移動到同一資料夾,導致「same file」錯誤 - # 因此改以 .bin 作為檔名,避免與資料夾名稱衝突 mv artifacts/IndexRipper-Linux/IndexRipper artifacts/IndexRipper-Linux.bin fi diff --git a/.gitignore b/.gitignore index 35e0c25..fe1038f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,11 @@ __pycache__/ *.egg-info/ .sisyphus/ +.worktrees/ + +electron-app/node_modules/ +electron-app/dist/ +electron-app/out/ +electron-app/*.tsbuildinfo + +.superpowers/ diff --git a/POC_MATRIX.md b/POC_MATRIX.md deleted file mode 100644 index 2ba86e6..0000000 --- a/POC_MATRIX.md +++ /dev/null @@ -1,136 +0,0 @@ -# PySide6 POC Decision Matrix - -Goal: decide whether a PySide6-based UI POC is acceptable for this repo, using -repeatable, command-driven measurements. - -Artifacts: -- `pyside_poc.py` (POC UI entrypoint) - -## Gates (PASS/FAIL) - -| Metric | Gate (PASS) | FAIL rule | Why it matters | -| --- | ---: | --- | --- | -| Disk footprint (PySide6 venv delta) | <= 600 MB | > 600 MB | Impacts CI cache, local dev disk usage, and distribution practicality | -| Startup time (to `window.show()`) | <= 1.50 s | > 1.50 s | Impacts perceived responsiveness and UX viability | -| Idle memory (RSS after 5s) | <= 250 MB | > 250 MB | Impacts background resource cost and multi-app workflows | - -Notes: -- These gates are intentionally strict enough to be decision-driving. -- Always record OS, Python version, and PySide6 version with the measurements. - -## Measurement Steps (Commands) - -All steps below are manual, but command-driven (no subjective judgments). - -### 0) Create a clean venv for the POC - -Unix/macOS: - -```bash -python3 -m venv .venv-pyside6-poc -source .venv-pyside6-poc/bin/activate -python3 -m pip install -U pip -``` - -Windows PowerShell: - -```powershell -python -m venv .venv-pyside6-poc -.\.venv-pyside6-poc\Scripts\Activate.ps1 -python -m pip install -U pip -``` - -### 1) Disk footprint (venv delta) - -1) Measure baseline venv size (before PySide6 install): - -Unix/macOS: - -```bash -du -sk .venv-pyside6-poc -``` - -Windows PowerShell: - -```powershell -(Get-ChildItem -Recurse .\.venv-pyside6-poc | Measure-Object -Sum Length).Sum -``` - -2) Install PySide6: - -```bash -python3 -m pip install PySide6 -``` - -3) Measure venv size again: - -Unix/macOS: - -```bash -du -sk .venv-pyside6-poc -``` - -Windows PowerShell: - -```powershell -(Get-ChildItem -Recurse .\.venv-pyside6-poc | Measure-Object -Sum Length).Sum -``` - -4) Record delta: -- Unix/macOS: delta_MB = (after_kb - before_kb) / 1024 -- Windows: delta_MB = (after_bytes - before_bytes) / (1024 * 1024) - -### 2) Startup time (to `window.show()`) - -1) Capture versions: - -```bash -python3 -V -python3 -c "import PySide6; print('PySide6', PySide6.__version__)" -``` - -2) Run the POC with timing enabled (auto-quit so it is scriptable): - -```bash -python3 pyside_poc.py --measure-startup --quit-after 0.2 -``` - -Expected output includes a single line like: - -```text -startup_seconds import=0.1234 ui=0.0456 show=0.2345 -``` - -Gate uses: `show`. - -Optional (repeat 5 times): - -```bash -for i in 1 2 3 4 5; do python3 pyside_poc.py --measure-startup --quit-after 0.2; done -``` - -### 3) Idle memory (RSS after 5 seconds) - -1) Start the app and print PID (keep it alive long enough to sample): - -```bash -python3 pyside_poc.py --print-pid --quit-after 30 -``` - -2) In a second terminal, wait 5 seconds then sample RSS: - -Unix/macOS: - -```bash -ps -o rss= -p -``` - -Interpretation: output is RSS in KB. Convert to MB: MB = KB / 1024. - -Windows PowerShell: - -```powershell -Get-Process -Id | Select-Object Id, WorkingSet64 -``` - -Interpretation: `WorkingSet64` is bytes. Convert to MB: MB = bytes / (1024 * 1024). diff --git a/README.md b/README.md index 40bd054..0751f30 100644 --- a/README.md +++ b/README.md @@ -66,35 +66,19 @@ A graphical tool specifically designed for downloading files from "Index of" pag - Click file type checkboxes to select/deselect - Use Select all/Deselect all buttons for quick operations -## System Requirements - -- Python 3.10 or higher -- Supports Windows, macOS, Linux +## UI Architecture -### Windows Users +IndexRipper uses **CustomTkinter** as its UI framework, providing a modern look with automatic dark/light theme switching. -- When installing Python, check "tcl/tk and IDLE" -- If not checked, re-run installer and modify +The file tree uses a custom `FileTree` component (built on `CTkScrollableFrame`) with emoji icons instead of pixel art: +- 📁 folder / 🖼️ image / 📄 document / 🗜️ archive / 💻 code / 🎵 audio / 🎬 video / 📝 text / ⚙️ binary -### macOS Users - -```bash -# Install Python and Tkinter using Homebrew -brew install python-tk@3.10 -``` - -### Linux Users - -```bash -# Ubuntu/Debian -sudo apt-get install python3-tk +## System Requirements -# Fedora -sudo dnf install python3-tkinter +- Python **3.11** or higher +- Supports Windows, macOS, Linux -# Arch Linux -sudo pacman -S tk -``` +> CustomTkinter bundles its own Tcl/Tk — **no separate system Tkinter installation needed**. ## Installing Dependencies @@ -104,39 +88,26 @@ Prefer uv for faster, reproducible installs: # Install uv (one-time) curl -LsSf https://astral.sh/uv/install.sh | sh -# Install required packages -uv pip install -r requirements.txt +# Install required packages (reads pyproject.toml) +uv sync ``` ## Running the Application ```bash -# Run with uv -uv run python index_ripper.py - -# Run ttkbootstrap UI entry -uv run python index_ripper_ttkb.py +# Run with uv (CustomTkinter UI) +uv run python -m index_ripper # Quick non-interactive smoke check -uv run python index_ripper.py --smoke - -# UI smoke check (requires tkinter) -uv run python index_ripper.py --ui-smoke +uv run python -m index_ripper --smoke -# ttkbootstrap UI smoke check -uv run python index_ripper_ttkb.py --ui-smoke +# UI smoke check +uv run python -m index_ripper --ui-smoke # Deterministic self-test (no real network) -uv run python index_ripper.py --self-test - -# Deterministic self-test for ttkbootstrap entry -uv run python index_ripper_ttkb.py --self-test +uv run python -m index_ripper --self-test ``` -`index_ripper_ttkb.py --ui-smoke` intentionally uses a minimal/safe UI construction path, -so it can validate Tk window startup across environments where some Pillow Tk image -bridges may be unavailable. - ## Download Prebuilt Binaries You can download prebuilt executables from GitHub Actions artifacts: @@ -192,21 +163,21 @@ Build platform-specific executables with uv + PyInstaller. Windows (PowerShell): ```powershell -uv pip install -r requirements.txt pyinstaller pillow +uv pip install pyinstaller uv run pyinstaller --onefile --windowed --icon=app.png --name=IndexRipper ` - --hidden-import tkinter --hidden-import tkinter.ttk index_ripper.py + --collect-all customtkinter --paths src src/index_ripper/__main__.py ``` macOS/Linux (bash): ```bash -uv pip install -r requirements.txt pyinstaller pillow +uv pip install pyinstaller # macOS .app uv run pyinstaller -F --windowed --name=IndexRipper \ - --hidden-import tkinter --hidden-import tkinter.ttk --icon=app.png index_ripper.py + --collect-all customtkinter --icon=app.png --paths src src/index_ripper/__main__.py # Linux single binary uv run pyinstaller --onefile --windowed --name=IndexRipper \ - --hidden-import tkinter --hidden-import tkinter.ttk --icon=app.png index_ripper.py + --collect-all customtkinter --icon=app.png --paths src src/index_ripper/__main__.py ``` ## Important Notes @@ -224,13 +195,16 @@ MIT License ### 1. tkinter Related Errors -If you encounter "No module named '_tkinter'" error: +CustomTkinter bundles its own Tcl/Tk. If issues persist: -- Windows: Reinstall Python, ensure "tcl/tk and IDLE" is checked -- macOS: Run `brew install python-tk@3.10` -- Linux: Install tkinter package for your distribution +- macOS (Homebrew Python): `brew install python-tk@3.11` +- Linux: `sudo apt-get install python3-tk` (Ubuntu/Debian) ### 2. Display Issues - If interface displays abnormally, it might be a DPI scaling issue - Windows users can right-click Python.exe → Properties → Compatibility → Change high DPI settings, and enable high DPI scaling override + +### 3. Dark/Light Theme + +IndexRipper follows the system appearance by default, with automatic switching for macOS and Windows dark mode. diff --git a/README_zh.md b/README_zh.md index 63bc031..67824dd 100644 --- a/README_zh.md +++ b/README_zh.md @@ -66,35 +66,19 @@ - 點擊檔案類型勾選框選擇/取消選擇 - 使用全選/取消全選按鈕快速操作 -## 系統需求 - -- Python 3.10 或更高版本 -- 支援 Windows、macOS、Linux 系統 +## UI 架構 -### Windows 使用者 +IndexRipper 使用 **CustomTkinter** 作為 UI 框架,提供現代外觀與深色/淺色主題切換。 -- Python 安裝時請勾選 "tcl/tk and IDLE" -- 如果沒有勾選,可以重新執行安裝程式並修改 +檔案樹採用自訂 `FileTree` 元件(基於 `CTkScrollableFrame`),以 emoji 圖示取代像素圖,支援: +- 📁 資料夾 / 🖼️ 圖片 / 📄 文件 / 🗜️ 壓縮檔 / 💻 程式碼 / 🎵 音訊 / 🎬 影片 / 📝 文字 / ⚙️ 二進位 -### macOS 使用者 - -```bash -# 使用 Homebrew 安裝 Python 和 Tkinter -brew install python-tk@3.10 -``` - -### Linux 使用者 - -```bash -# Ubuntu/Debian -sudo apt-get install python3-tk +## 系統需求 -# Fedora -sudo dnf install python3-tkinter +- Python **3.11** 或更高版本 +- 支援 Windows、macOS、Linux 系統 -# Arch Linux -sudo pacman -S tk -``` +> CustomTkinter 內建 Tcl/Tk,**不需要**額外安裝系統 Tkinter 套件。 ## 安裝依賴 @@ -104,38 +88,26 @@ sudo pacman -S tk # 安裝 uv(僅需一次) curl -LsSf https://astral.sh/uv/install.sh | sh -# 安裝所需套件 -uv pip install -r requirements.txt +# 安裝所需套件(讀取 pyproject.toml) +uv sync ``` ## 執行方式 ```bash -# 使用 uv 執行 -uv run python index_ripper.py - -# 執行 ttkbootstrap UI 入口 -uv run python index_ripper_ttkb.py +# 使用 uv 執行(CustomTkinter UI) +uv run python -m index_ripper # 快速無互動 smoke 檢查 -uv run python index_ripper.py --smoke - -# UI smoke 檢查(需要 tkinter) -uv run python index_ripper.py --ui-smoke +uv run python -m index_ripper --smoke -# ttkbootstrap UI smoke 檢查 -uv run python index_ripper_ttkb.py --ui-smoke +# UI smoke 檢查 +uv run python -m index_ripper --ui-smoke # 可重現的 self-test(不依賴真網路) -uv run python index_ripper.py --self-test - -# ttkbootstrap 入口的可重現 self-test -uv run python index_ripper_ttkb.py --self-test +uv run python -m index_ripper --self-test ``` -`index_ripper_ttkb.py --ui-smoke` 會刻意使用最小且安全的 UI 建立路徑, -讓不同環境即使缺少部分 Pillow Tk 影像橋接能力,也能穩定驗證 Tk 視窗可啟動。 - ## 下載已編譯執行檔 可於 GitHub Actions 的 artifacts 下載預先編譯好的執行檔: @@ -191,21 +163,21 @@ chmod +x "/path/to/IndexRipper.app/Contents/MacOS/IndexRipper" Windows(PowerShell): ```powershell -uv pip install -r requirements.txt pyinstaller pillow +uv pip install pyinstaller uv run pyinstaller --onefile --windowed --icon=app.png --name=IndexRipper ` - --hidden-import tkinter --hidden-import tkinter.ttk index_ripper.py + --collect-all customtkinter --paths src src/index_ripper/__main__.py ``` macOS/Linux(bash): ```bash -uv pip install -r requirements.txt pyinstaller pillow +uv pip install pyinstaller # macOS .app uv run pyinstaller -F --windowed --name=IndexRipper \ - --hidden-import tkinter --hidden-import tkinter.ttk --icon=app.png index_ripper.py + --collect-all customtkinter --icon=app.png --paths src src/index_ripper/__main__.py # Linux 單一執行檔 uv run pyinstaller --onefile --windowed --name=IndexRipper \ - --hidden-import tkinter --hidden-import tkinter.ttk --icon=app.png index_ripper.py + --collect-all customtkinter --icon=app.png --paths src src/index_ripper/__main__.py ``` ## 注意事項 @@ -223,13 +195,16 @@ MIT License ### 1. tkinter 相關錯誤 -如果遇到 "No module named '_tkinter'" 錯誤: +CustomTkinter 內建 Tcl/Tk,通常不需要額外安裝。若仍出現問題: -- Windows:重新安裝 Python,確保勾選 "tcl/tk and IDLE" -- macOS:執行 `brew install python-tk@3.10` -- Linux:安裝對應發行版的 tkinter 套件 +- macOS(Homebrew Python):`brew install python-tk@3.11` +- Linux:`sudo apt-get install python3-tk`(Ubuntu/Debian) ### 2. 畫面顯示問題 - 如果介面顯示異常,可能是 DPI 縮放問題 - Windows 用戶可以右鍵點擊 Python.exe → 內容 → 相容性 → 變更高 DPI 設定,並啟用高 DPI 縮放覆寫 + +### 3. 深色/淺色主題 + +IndexRipper 預設跟隨系統外觀設定(system),支援 macOS 與 Windows 的深色模式自動切換。 diff --git a/docs/plans/2026-03-09-ctk-migration-design.md b/docs/plans/2026-03-09-ctk-migration-design.md new file mode 100644 index 0000000..7ec469f --- /dev/null +++ b/docs/plans/2026-03-09-ctk-migration-design.md @@ -0,0 +1,121 @@ +# Design: 遷移 UI 至 CustomTkinter + +日期:2026-03-09 +狀態:已核准 + +## 背景 + +目前 UI 混用 ttkbootstrap(`ui_ttkb.py`)和 CustomTkinter(`ui_downloads.py`),造成以下問題: +- 兩套主題系統互相干擾,跨平台外觀不一致 +- `ui_theme.py` 的 design token 是 CTk 格式,卻套用在 ttkbootstrap widget 上 +- 維護成本高,新功能難以保持風格一致 + +## 目標 + +- 統一使用 CustomTkinter 作為唯一 UI 框架 +- 保留所有現有功能,一個都不減 +- 確保多執行緒安全(Queue + `after()` 模式) +- 好看、輕量、跨平台一致 + +## 架構 + +### 檔案變動 + +| 檔案 | 動作 | +|---|---| +| `ui_ctk.py` | **新增**,主 UI 類別,取代 `ui_ttkb.py` | +| `ui_ttkb.py` | 保留不動(歷史參考),入口不再指向它 | +| `ui_downloads.py` | 小幅清理,確保與新主題一致 | +| `ui_theme.py` | 移除 ttkbootstrap 相關函式,保留 CTk token + Treeview 樣式 | +| `index_ripper.py` | 更新入口,改為載入 `ui_ctk.py` | +| `index_ripper_ttkb.py` | 保留,作為 ttkb 備用入口 | + +### Widget 對應 + +| ttkbootstrap | CustomTkinter | 備注 | +|---|---|---| +| `ttkb.Window` | `ctk.CTk()` | | +| `ttkb.Frame` | `ctk.CTkFrame` | | +| `ttkb.Button` | `ctk.CTkButton` | | +| `ttkb.Label` | `ctk.CTkLabel` | | +| `ttkb.Entry` | `ctk.CTkEntry` | | +| `ttkb.Progressbar` | `ctk.CTkProgressBar` | | +| `ttkb.Notebook` | `ctk.CTkTabview` | | +| `ttkb.Combobox` | `ctk.CTkOptionMenu` | Threads 選單 | +| `ttkb.Treeview` | `ttk.Treeview`(保留)| CTk 無此 widget | +| `tk.Text` | `ctk.CTkTextbox` | Logs | +| `tk.Checkbutton` | `ctk.CTkCheckBox` | 檔案類型篩選 | +| Scrollbar | `ctk.CTkScrollableFrame` | Downloads list | + +> `ttk.Treeview` 是唯一例外,透過 `ttk.Style()` 配合 CTk 主題色統一外觀。 + +## 功能清單(必須全部實作) + +### 輸入區 +- [ ] URL 輸入框 +- [ ] Cmd/Ctrl+V paste 處理(含右鍵選單) +- [ ] 全域 paste 偵測(焦點在 URL 欄時自動填入) +- [ ] Status label(Ready / Scanning / Error 等狀態) + +### 掃描控制 +- [ ] Scan 按鈕 +- [ ] Pause Scan / Resume Scan 按鈕(掃描中才啟用) +- [ ] Clear 按鈕 +- [ ] Ctrl+F 聚焦搜尋欄 +- [ ] Ctrl+L 聚焦 Logs + +### 篩選區 +- [ ] 動態產生 Checkbox(依掃描到的副檔名) +- [ ] Select All Types / Deselect All Types 按鈕 +- [ ] 水平可捲動容器(檔案類型多時) + +### 下載控制 +- [ ] Download Selected 按鈕 +- [ ] Pause / Resume 下載按鈕(下載中才啟用) +- [ ] Choose Folder 按鈕 +- [ ] Threads 數量選單(1–10) +- [ ] Hide / Show Panels 切換 + +### Treeview(掃描結果) +- [ ] 欄位:Path / Size / Type(full_path 隱藏) +- [ ] 搜尋即時過濾 +- [ ] 欄位排序(點標題) +- [ ] 行勾選(✔ 標記,`checked` tag) +- [ ] Cmd/Ctrl+A 全選 +- [ ] Space 切換勾選 +- [ ] Enter 確認 +- [ ] 拖曳多選 +- [ ] 右鍵選單(Select All / Deselect All / Expand All / Collapse All) + +### 進度區 +- [ ] 整體進度條(`ctk.CTkProgressBar`) +- [ ] 進度文字 label + +### Tabs 面板 +- [ ] CTkTabview,兩個 tab:Downloads / Logs +- [ ] Downloads tab:每項有名稱、進度條、狀態文字、取消按鈕 +- [ ] Logs tab:`ctk.CTkTextbox`,可捲動,`log_message()` 方法 + +### 執行緒安全 +- [ ] Queue + `window.after()` 輪詢模式完整保留 +- [ ] `scan_item_buffer` 批次 flush(throttle) +- [ ] `files_dict_lock` / `folders_dict_lock` 保留 + +### 其他 +- [ ] `--ui-smoke` 模式(最小化 UI,不啟動完整介面) +- [ ] `INDEX_RIPPER_DEBUG` 環境變數支援 +- [ ] `INDEX_RIPPER_MODAL_DIALOGS` 環境變數支援 +- [ ] `on_closing` 視窗關閉處理 +- [ ] 所有鍵盤快捷鍵綁定 + +## 執行緒安全說明 + +所有背景執行緒(掃描、下載)只能寫入 Queue,不直接操作 widget。 +主執行緒透過 `window.after(N, poll_fn)` 週期性讀取 Queue 並更新 UI。 +CTk 的 `after()` 繼承自 tkinter,行為完全相同,現有模式無需修改。 + +## 測試策略 + +- 現有 `test_ui_ttkb.py` 作為功能對照參考 +- 新增 `test_ui_ctk.py`,覆蓋相同的 smoke / unit 測試 +- `--ui-smoke` 模式用於 CI 驗證視窗可正常建立並關閉 diff --git a/docs/plans/2026-03-09-ctk-migration.md b/docs/plans/2026-03-09-ctk-migration.md new file mode 100644 index 0000000..ef3ab84 --- /dev/null +++ b/docs/plans/2026-03-09-ctk-migration.md @@ -0,0 +1,1195 @@ +# CustomTkinter 遷移實作計劃 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 建立 `ui_ctk.py`,以 CustomTkinter 統一 UI 框架,取代 ttkbootstrap 版本,保留所有現有功能。 + +**Architecture:** 保留 `ui_ttkb.py` 的模組化架構(Queue + after() 執行緒安全模式),以 CTk widget 替換 ttkbootstrap widget。`ui_downloads.py` 幾乎不動(已是 CTk)。`ttk.Treeview` 保留(CTk 無此 widget),以 `ttk.Style()` 配合 CTk 主題色。 + +**Tech Stack:** customtkinter>=5.2.0、tkinter(內建)、ttk.Treeview(內建)、requests、backend.py(不動) + +**References:** +- `ui_ttkb.py`:架構參考(模組化、Queue 模式) +- `index_ripper.py`:CTk widget 用法參考(舊版但已是 CTk) +- `ui_downloads.py`:DownloadsPanel(直接沿用) +- `ui_theme.py`:design token(清理後沿用) +- `docs/plans/2026-03-09-ctk-migration-design.md`:設計文件 + +--- + +### Task 1: 清理 ui_theme.py + +**目的:** 移除 ttkbootstrap bridge,只保留 CTk 相關的 token 與 Treeview style。 + +**Files:** +- Modify: `ui_theme.py` + +**Step 1: 確認現有內容** + +讀 `ui_theme.py` 全文,確認要刪的函式: +- `apply_bootstrap_theme()` — 刪除(ttkb 專用) +- 其餘全部保留 + +**Step 2: 刪除 apply_bootstrap_theme** + +從 `ui_theme.py` 移除 `apply_bootstrap_theme` 函式(第 10–22 行)。 + +修改後確保剩下: +- `apply_app_theme(ctk)` ✓ +- `ui_tokens()` ✓ +- `action_button_style_name(kind)` ✓ +- `configure_action_button_styles(window, ctk, ttk)` ✓ +- `configure_treeview_style(window, ctk, ttk)` ✓ +- `treeview_tag_colors(window)` ✓ + +**Step 3: 確認 index_ripper.py 的 import 仍正常** + +`index_ripper.py` 第 66–73 行 import 了 `apply_bootstrap_theme`,也要同步移除該行。 + +**Step 4: 執行現有測試確認不壞** + +```bash +python -m pytest test_ui_theme.py -v +``` + +Expected: 所有 PASS(若有失敗,修正後再繼續) + +**Step 5: Commit** + +```bash +git add ui_theme.py index_ripper.py +git commit -m "refactor(theme): 移除 ttkbootstrap bridge,統一用 CTk token" +``` + +--- + +### Task 2: ui_ctk.py 骨架 + 視窗建立 + +**目的:** 建立 `ui_ctk.py`,包含 `WebsiteCopierCtk` 類別的骨架、視窗初始化、以及 smoke 模式支援。 + +**Files:** +- Create: `ui_ctk.py` +- Create: `test_ui_ctk.py` + +**Step 1: 先寫失敗測試** + +建立 `test_ui_ctk.py`: + +```python +"""Smoke tests for ui_ctk.WebsiteCopierCtk.""" +import os +import sys +import unittest + +os.environ.setdefault("INDEX_RIPPER_MODAL_DIALOGS", "0") + + +class TestWebsiteCopierCtkSmoke(unittest.TestCase): + def _make(self): + import importlib + mod = importlib.import_module("ui_ctk") + return mod.WebsiteCopierCtk(ui_smoke=True) + + def test_import(self): + import ui_ctk # noqa: F401 + + def test_smoke_init_and_destroy(self): + app = self._make() + app.window.after(0, app.window.destroy) + app.run() + + def test_has_url_var(self): + app = self._make() + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "url_var")) + + def test_has_log_text(self): + app = self._make() + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "log_text")) + + +if __name__ == "__main__": + unittest.main() +``` + +**Step 2: 執行確認失敗** + +```bash +python -m pytest test_ui_ctk.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'ui_ctk'` + +**Step 3: 建立 ui_ctk.py 骨架** + +```python +from __future__ import annotations + +import os +import threading +from concurrent.futures import ThreadPoolExecutor +from queue import Empty, Queue + +import tkinter as tk +from tkinter import filedialog, messagebox, ttk + +import customtkinter as ctk +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from app_utils import default_download_folder, normalize_extension, safe_join, sanitize_filename +from backend import Backend +from ui_downloads import DownloadsPanel +from ui_theme import ( + apply_app_theme, + configure_action_button_styles, + configure_treeview_style, + treeview_tag_colors, + ui_tokens, +) + + +def should_skip_file_row(existing_entry) -> bool: + return existing_entry is not None + + +class WebsiteCopierCtk: + USER_AGENT = "IndexRipper/2.0" + + def __init__(self, ui_smoke: bool = False): + self._ui_smoke = bool(ui_smoke) + self.debug_enabled = os.environ.get("INDEX_RIPPER_DEBUG", "0") != "0" + self.use_modal_dialogs = os.environ.get("INDEX_RIPPER_MODAL_DIALOGS", "0") == "1" + + apply_app_theme(ctk) + self.window = ctk.CTk() + self.window.title("Index Ripper") + self.window.geometry("1200x900") + self.window.minsize(900, 650) + configure_action_button_styles(self.window, ctk, ttk) + self.ui_tokens = ui_tokens() + + self.backend = Backend(self) + + self.pause_event = threading.Event() + self.pause_event.set() + self.scan_pause_event = threading.Event() + self.scan_pause_event.set() + + self.is_scanning = False + self.scanned_urls = 0 + self.total_urls = 0 + + self.files_dict_lock = threading.Lock() + self.folders_dict_lock = threading.Lock() + self.files_dict: dict[str, dict] = {} + self.folders: dict[str, str] = {} + self.checked_items: set[str] = set() + self.checkbox_checked = "✔ " + + self.dir_queue = Queue() + self.file_queue = Queue() + self.scan_item_buffer = Queue() + self.is_processing_dirs = False + self.is_processing_files = False + self.scan_flush_interval_ms = 16 + self.scan_flush_batch_size = 200 + self.scan_flush_job = None + self._last_logged_queue_size = None + + self.file_types: dict[str, tk.BooleanVar] = {} + self.file_type_counts: dict[str, int] = {} + self.file_type_widgets: dict[str, ctk.CTkCheckBox] = {} + + self.download_path = "" + self.download_queue = Queue() + self.max_workers = 5 + self.executor = ThreadPoolExecutor(max_workers=self.max_workers) + self.active_downloads = [] + + retry_strategy = Retry( + total=3, + backoff_factor=0.1, + status_forcelist=[500, 502, 503, 504], + ) + self.session = requests.Session() + adapter = HTTPAdapter( + max_retries=retry_strategy, + pool_connections=10, + pool_maxsize=10, + pool_block=False, + ) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + self.timeout = (10, 20) + + self._build_ui() + + self.window.protocol("WM_DELETE_WINDOW", self.on_closing) + if not self._ui_smoke: + self.window.bind("", self.focus_search) + self.window.bind("", self.focus_logs) + self.window.bind("", self.clear_search) + self.window.bind_all("", self._on_global_url_paste, add="+") + self.window.bind_all("", self._on_global_url_paste, add="+") + self.window.bind_all("", self._on_global_url_paste, add="+") + self.window.bind_all("", self._on_global_url_paste, add="+") + self.window.bind_all("<>", self._on_global_url_paste, add="+") + + def _build_ui(self) -> None: + if self._ui_smoke: + self._build_ui_smoke_only() + return + self._build_full_ui() + + def _build_ui_smoke_only(self) -> None: + self.url_var = tk.StringVar(value="https://example.com/") + self.search_var = tk.StringVar() + self.log_text = ctk.CTkTextbox(self.window, height=80) + self.log_text.pack(fill="both", expand=True, padx=10, pady=10) + self.log_text.insert("end", "UI smoke minimal view initialized.\n") + self.panels_notebook = None + self.logs_tab = None + + def _build_full_ui(self) -> None: + # 後續 Task 中逐步實作 + pass + + def run(self) -> None: + self.window.mainloop() + + def on_closing(self) -> None: + self.executor.shutdown(wait=False) + self.window.destroy() + + def log_message(self, message: str) -> None: + try: + self.log_text.insert("end", message + "\n") + self.log_text.see("end") + except Exception: + pass + + def _debug(self, message: str) -> None: + if not self.debug_enabled: + return + try: + print(f"[DEBUG] {message}") + except Exception: + pass + try: + if hasattr(self, "log_text"): + self.log_text.insert("end", f"[DEBUG] {message}\n") + self.log_text.see("end") + except Exception: + pass + + def notify_info(self, title: str, message: str) -> None: + if not self.use_modal_dialogs: + self.log_message(f"[INFO] {title}: {message}") + return + try: + messagebox.showinfo(title, message) + except Exception: + self.log_message(f"[INFO] {title}: {message}") + + def notify_warning(self, title: str, message: str) -> None: + if not self.use_modal_dialogs: + self.log_message(f"[WARN] {title}: {message}") + return + try: + messagebox.showwarning(title, message) + except Exception: + self.log_message(f"[WARN] {title}: {message}") + + def notify_error(self, title: str, message: str) -> None: + if not self.use_modal_dialogs: + self.log_message(f"[ERROR] {title}: {message}") + return + try: + messagebox.showerror(title, message) + except Exception: + self.log_message(f"[ERROR] {title}: {message}") +``` + +**Step 4: 執行測試確認通過** + +```bash +python -m pytest test_ui_ctk.py -v +``` + +Expected: 4 PASS + +**Step 5: Commit** + +```bash +git add ui_ctk.py test_ui_ctk.py +git commit -m "feat(ui): 新增 WebsiteCopierCtk 骨架與 smoke 測試" +``` + +--- + +### Task 3: Header 列(URL 輸入 + Status + 掃描按鈕) + +**目的:** 實作視窗頂部的 URL 輸入框、狀態標籤、Scan / Pause Scan / Clear 按鈕。 + +**Files:** +- Modify: `ui_ctk.py`(`_build_full_ui` 內容) +- Modify: `test_ui_ctk.py` + +**Step 1: 新增失敗測試** + +在 `test_ui_ctk.py` 加入: + +```python +def test_full_ui_has_scan_btn(self): + import ui_ctk + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "scan_btn")) + self.assertTrue(hasattr(app, "url_entry")) + self.assertTrue(hasattr(app, "status_label")) +``` + +**Step 2: 執行確認失敗** + +```bash +python -m pytest test_ui_ctk.py::TestWebsiteCopierCtkSmoke::test_full_ui_has_scan_btn -v +``` + +Expected: FAIL(`AttributeError: scan_btn`) + +**Step 3: 實作 _build_full_ui header 部分** + +在 `ui_ctk.py` 的 `_build_full_ui` 中加入: + +```python +def _build_full_ui(self) -> None: + self.window.grid_columnconfigure(0, weight=1) + self.window.grid_rowconfigure(2, weight=1) + + # --- Header --- + header = ctk.CTkFrame(self.window, fg_color="transparent") + header.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 0)) + header.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel(header, text="URL", font=ctk.CTkFont(weight="bold")).grid( + row=0, column=0, sticky="w", padx=(0, 8) + ) + self.url_var = tk.StringVar() + self.url_entry = ctk.CTkEntry(header, textvariable=self.url_var, placeholder_text="https://") + self.url_entry.grid(row=0, column=1, sticky="ew", padx=(0, 8)) + + self.status_label = ctk.CTkLabel(header, text="Ready", text_color="#059669") + self.status_label.grid(row=0, column=2, sticky="e", padx=(0, 8)) + + actions = ctk.CTkFrame(header, fg_color="transparent") + actions.grid(row=0, column=3, sticky="e") + + self.scan_btn = ctk.CTkButton(actions, text="Scan", command=self.start_scan) + self.scan_btn.pack(side="left", padx=3) + + self.scan_pause_btn = ctk.CTkButton( + actions, text="Pause Scan", + fg_color=("gray70", "gray30"), + command=self.toggle_scan_pause, + state="disabled", + ) + self.scan_pause_btn.pack(side="left", padx=3) + + self.clear_scan_btn = ctk.CTkButton( + actions, text="Clear", + fg_color=("gray70", "gray30"), + command=self.clear_scan_results, + ) + self.clear_scan_btn.pack(side="left", padx=3) + + # URL paste bindings + self.url_context_menu = tk.Menu(self.window, tearoff=0) + self.url_context_menu.add_command(label="Paste", command=self._paste_into_url_entry) + self.url_entry.bind("", self._on_url_paste) + self.url_entry.bind("", self._on_url_paste) + self.url_entry.bind("", self._on_url_paste) + self.url_entry.bind("", self._on_url_paste) + self.url_entry.bind("", self._on_url_paste) + self.url_entry.bind("", self._show_url_context_menu) + self.url_entry.bind("", self._show_url_context_menu) + + # 繼續後面的 Task + self._build_filters_row() + self._build_treeview() + self._build_progress_section() + self._build_panels() + self._build_download_controls() + + # 初始化其他狀態 + self.sort_reverse = False + self.full_tree_backup = {} + self.drag_anchor_item = "" + + context_menu = tk.Menu(self.window, tearoff=0) + context_menu.add_command(label="Select All", command=self.select_all) + context_menu.add_command(label="Deselect All", command=self.deselect_all) + context_menu.add_separator() + context_menu.add_command(label="Expand All", command=self.expand_all) + context_menu.add_command(label="Collapse All", command=self.collapse_all) + self.context_menu = context_menu +``` + +並新增 URL paste 輔助方法(從 `ui_ttkb.py` 直接搬): + +```python +def _on_url_paste(self, _event=None): + self._paste_into_url_entry() + return "break" + +def _on_global_url_paste(self, _event=None): + try: + focused = self.window.focus_get() + except tk.TclError: + return None + if focused is self.url_entry._entry: # CTkEntry 內部 widget + self._paste_into_url_entry() + return "break" + return None + +def _paste_into_url_entry(self): + try: + text = self.window.clipboard_get() + except tk.TclError: + return + if not text: + return + self.url_entry.delete(0, "end") + self.url_entry.insert(0, text) + +def _show_url_context_menu(self, event): + try: + self.url_context_menu.tk_popup(event.x_root, event.y_root) + finally: + self.url_context_menu.grab_release() + +def _set_status(self, text: str, color: str = "#059669") -> None: + try: + self.status_label.configure(text=text, text_color=color) + except Exception: + pass +``` + +**Step 4: 執行測試** + +```bash +python -m pytest test_ui_ctk.py -v +``` + +Expected: 5 PASS(新的失敗的測試現在應通過) + +> 注意:`_build_filters_row` 等還未存在,需先加入空的 stub: +```python +def _build_filters_row(self): pass +def _build_treeview(self): pass +def _build_progress_section(self): pass +def _build_panels(self): pass +def _build_download_controls(self): pass +``` + +**Step 5: Commit** + +```bash +git add ui_ctk.py test_ui_ctk.py +git commit -m "feat(ui): 實作 header 列(URL、狀態、掃描按鈕)" +``` + +--- + +### Task 4: 篩選列(檔案類型 Checkboxes) + +**目的:** 動態產生 CTkCheckBox(依副檔名),支援水平捲動,Select All / Deselect All。 + +**Files:** +- Modify: `ui_ctk.py`(`_build_filters_row` + 相關方法) +- Modify: `test_ui_ctk.py` + +**Step 1: 新增失敗測試** + +```python +def test_has_filters_frame(self): + import ui_ctk + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "filters_container")) +``` + +**Step 2: 執行確認失敗** + +```bash +python -m pytest test_ui_ctk.py::TestWebsiteCopierCtkSmoke::test_has_filters_frame -v +``` + +**Step 3: 實作 _build_filters_row** + +```python +def _build_filters_row(self) -> None: + filters_and_controls = ctk.CTkFrame(self.window, fg_color="transparent") + filters_and_controls.grid(row=1, column=0, sticky="ew", padx=10, pady=(4, 0)) + filters_and_controls.grid_columnconfigure(0, weight=1) + + filters_frame = ctk.CTkFrame(filters_and_controls, fg_color="transparent") + filters_frame.grid(row=0, column=0, sticky="ew") + + # 可水平捲動的容器 + self.filters_container = ctk.CTkScrollableFrame( + filters_frame, height=85, orientation="horizontal", fg_color="transparent" + ) + self.filters_container.pack(fill="x") + + type_actions = ctk.CTkFrame(filters_frame, fg_color="transparent") + type_actions.pack(fill="x", pady=(4, 0)) + ctk.CTkButton( + type_actions, text="Select All Types", + fg_color=("gray80", "gray25"), + text_color=("gray10", "gray90"), + command=self.select_all_types, + width=140, + ).pack(side="left", padx=(0, 6)) + ctk.CTkButton( + type_actions, text="Deselect All Types", + fg_color=("gray80", "gray25"), + text_color=("gray10", "gray90"), + command=self.deselect_all_types, + width=150, + ).pack(side="left") +``` + +並新增 filter 方法 stub(從 `ui_ttkb.py` 搬): + +```python +def select_all_types(self): + for var in self.file_types.values(): + var.set(True) + +def deselect_all_types(self): + for var in self.file_types.values(): + var.set(False) + +def _add_file_type_filter(self, ext: str) -> None: + if ext in self.file_types: + return + var = tk.BooleanVar(value=True) + self.file_types[ext] = var + self.file_type_counts[ext] = 0 + cb = ctk.CTkCheckBox( + self.filters_container, + text=ext or "(no ext)", + variable=var, + onvalue=True, + offvalue=False, + ) + cb.pack(side="left", padx=4) + self.file_type_widgets[ext] = cb +``` + +**Step 4: 執行測試** + +```bash +python -m pytest test_ui_ctk.py -v +``` + +Expected: 全部 PASS + +**Step 5: Commit** + +```bash +git add ui_ctk.py test_ui_ctk.py +git commit -m "feat(ui): 實作檔案類型篩選列(CTkCheckBox + 捲動容器)" +``` + +--- + +### Task 5: 下載控制列 + +**目的:** Download Selected、Pause/Resume、Choose Folder、Threads 選單、Hide/Show Panels。 + +**Files:** +- Modify: `ui_ctk.py`(`_build_download_controls`) + +**Step 1: 新增失敗測試** + +```python +def test_has_download_controls(self): + import ui_ctk + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "download_btn")) + self.assertTrue(hasattr(app, "pause_btn")) + self.assertTrue(hasattr(app, "threads_var")) +``` + +**Step 2: 實作 _build_download_controls** + +```python +def _build_download_controls(self) -> None: + controls_frame = ctk.CTkFrame(self.window, fg_color="transparent") + # 放在 filters_and_controls 右側(與 Task 4 的 grid 同 row) + # 重新取得 filters_and_controls 參考,或在 Task 4 儲存 self._filters_and_controls + # 建議:在 Task 4 儲存 self._filters_ctrl_frame = filters_and_controls + controls_frame.grid(row=1, column=0, sticky="e", padx=10) + + self.download_btn = ctk.CTkButton( + controls_frame, text="Download Selected", command=self.download_selected + ) + self.download_btn.grid(row=0, column=0, padx=4) + + self.pause_btn = ctk.CTkButton( + controls_frame, text="Pause", + fg_color=("gray70", "gray30"), + command=self.toggle_pause, + state="disabled", + ) + self.pause_btn.grid(row=0, column=1, padx=4) + + self.path_btn = ctk.CTkButton( + controls_frame, text="Choose Folder", + fg_color=("gray70", "gray30"), + command=self.choose_download_path, + ) + self.path_btn.grid(row=0, column=2, padx=4) + + ctk.CTkLabel(controls_frame, text="Threads").grid(row=0, column=3, padx=(12, 4)) + self.threads_var = tk.StringVar(value="5") + self.threads_combo = ctk.CTkOptionMenu( + controls_frame, + values=[str(i) for i in range(1, 11)], + variable=self.threads_var, + command=self.update_thread_count, + width=70, + ) + self.threads_combo.grid(row=0, column=4) + + self.panels_visible = True + self.toggle_panels_btn = ctk.CTkButton( + controls_frame, text="Hide Panels", + fg_color=("gray80", "gray25"), + text_color=("gray10", "gray90"), + command=self.toggle_panels, + width=110, + ) + self.toggle_panels_btn.grid(row=0, column=5, padx=(10, 0)) +``` + +> **重要:** `_build_download_controls` 需要嵌入 `filters_and_controls` 這個 frame(同一個 grid row)。 +> 建議在 Task 4 修改,讓 `_build_filters_row` 存 `self._ctrl_row_frame = filters_and_controls`, +> 然後在 `_build_download_controls` 直接用 `self._ctrl_row_frame`。 + +新增 stub 方法: + +```python +def choose_download_path(self) -> None: + path = filedialog.askdirectory(title="Choose Download Location") + if path: + self.download_path = path + +def update_thread_count(self, value=None) -> None: + try: + n = int(self.threads_var.get()) + except (ValueError, AttributeError): + return + self.max_workers = max(1, min(10, n)) + +def toggle_panels(self) -> None: + if self.panels_visible: + if hasattr(self, "_panels_widget"): + self._panels_widget.grid_remove() + self.panels_visible = False + self.toggle_panels_btn.configure(text="Show Panels") + else: + if hasattr(self, "_panels_widget"): + self._panels_widget.grid() + self.panels_visible = True + self.toggle_panels_btn.configure(text="Hide Panels") +``` + +**Step 3: 執行測試** + +```bash +python -m pytest test_ui_ctk.py -v +``` + +**Step 4: Commit** + +```bash +git add ui_ctk.py test_ui_ctk.py +git commit -m "feat(ui): 實作下載控制列" +``` + +--- + +### Task 6: Treeview(掃描結果列表) + +**目的:** 建立 `ttk.Treeview`,含搜尋欄、欄位排序、行勾選、鍵盤與滑鼠互動、右鍵選單。 + +**Files:** +- Modify: `ui_ctk.py`(`_build_treeview` + 互動方法) +- Modify: `test_ui_ctk.py` + +**Step 1: 新增失敗測試** + +```python +def test_has_treeview(self): + import ui_ctk + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "tree")) + self.assertTrue(hasattr(app, "search_var")) +``` + +**Step 2: 實作 _build_treeview** + +```python +def _build_treeview(self) -> None: + tree_frame = ctk.CTkFrame(self.window, fg_color="transparent") + tree_frame.grid(row=2, column=0, sticky="nsew", padx=10, pady=(4, 0)) + tree_frame.grid_columnconfigure(0, weight=1) + tree_frame.grid_rowconfigure(1, weight=1) + + # 搜尋欄 + search_bar = ctk.CTkFrame(tree_frame, fg_color="transparent") + search_bar.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 4)) + search_bar.grid_columnconfigure(1, weight=1) + ctk.CTkLabel(search_bar, text="Search").grid(row=0, column=0, padx=(0, 6)) + self.search_var = tk.StringVar() + self.search_entry = ctk.CTkEntry(search_bar, textvariable=self.search_var) + self.search_entry.grid(row=0, column=1, sticky="ew") + self.search_var.trace_add("write", self.on_search_filter_changed) + + # Treeview + configure_treeview_style(self.window, ctk, ttk) + self.tree = ttk.Treeview( + tree_frame, + columns=("size", "type", "full_path"), + show="tree headings", + selectmode="extended", + ) + self.tree.heading("#0", text="Path", command=lambda: self.sort_tree("#0")) + self.tree.heading("size", text="Size", command=lambda: self.sort_tree("size")) + self.tree.heading("type", text="Type", command=lambda: self.sort_tree("type")) + self.tree.column("#0", width=600, stretch=True) + self.tree.column("size", width=120, stretch=False, anchor="e") + self.tree.column("type", width=240, stretch=False) + self.tree.column("full_path", width=0, stretch=False) + + tag_colors = treeview_tag_colors(self.window) + self.tree.tag_configure("checked", foreground=tag_colors["checked"]) + + style = ttk.Style() + style.configure("Treeview", font=("SF Pro Text", 14), rowheight=34) + style.configure("Treeview.Heading", font=("SF Pro Text", 13, "bold")) + + self.tree.grid(row=1, column=0, sticky="nsew") + + tree_scroll = tk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=tree_scroll.set) + tree_scroll.grid(row=1, column=1, sticky="ns") + + # 鍵盤與滑鼠綁定 + self.tree.bind("", self._on_tree_select_all) + self.tree.bind("", self._on_tree_select_all) + self.tree.bind("", self.on_tree_click) + self.tree.bind("", self.show_context_menu) + self.tree.bind("", self.show_context_menu) + self.tree.bind("", self.show_context_menu) + self.tree.bind("", self.on_tree_drag_select) + self.tree.bind("", self.on_tree_space) + self.tree.bind("", self.on_tree_enter) +``` + +**Step 3: 搬移 Treeview 互動方法(從 ui_ttkb.py)** + +以下方法直接從 `ui_ttkb.py` 搬移,邏輯不變: + +- `on_tree_click(event)` — 點擊切換 ✔ 勾選 +- `on_tree_drag_select(event)` — 拖曳多選 +- `on_tree_space(event)` — Space 切換勾選 +- `on_tree_enter(event)` — Enter 確認 +- `_on_tree_select_all(event)` — Cmd/Ctrl+A +- `show_context_menu(event)` — 右鍵選單 +- `select_all()` / `deselect_all()` — 全選/取消全選 +- `expand_all()` / `collapse_all()` — 展開/收合 +- `sort_tree(col)` — 欄位排序 + +在 `ui_ttkb.py` 中找到這些方法並直接複製(它們純粹操作 ttk.Treeview,與框架無關)。 + +**Step 4: 執行測試** + +```bash +python -m pytest test_ui_ctk.py -v +``` + +**Step 5: Commit** + +```bash +git add ui_ctk.py test_ui_ctk.py +git commit -m "feat(ui): 實作 Treeview 與互動邏輯" +``` + +--- + +### Task 7: 進度條區塊 + +**目的:** 整體下載進度條 + 進度文字。 + +**Files:** +- Modify: `ui_ctk.py`(`_build_progress_section`) + +**Step 1: 新增失敗測試** + +```python +def test_has_progress_bar(self): + import ui_ctk + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "progress_bar")) + self.assertTrue(hasattr(app, "progress_label")) +``` + +**Step 2: 實作** + +```python +def _build_progress_section(self) -> None: + progress_frame = ctk.CTkFrame(self.window, fg_color="transparent") + progress_frame.grid(row=3, column=0, sticky="ew", padx=10, pady=(4, 0)) + progress_frame.grid_columnconfigure(0, weight=1) + + self.progress_var = tk.DoubleVar(value=0) + self.progress_bar = ctk.CTkProgressBar(progress_frame) + self.progress_bar.set(0) + self.progress_bar.grid(row=0, column=0, sticky="ew") + + self.progress_label = ctk.CTkLabel(progress_frame, text="") + self.progress_label.grid(row=1, column=0, sticky="w") +``` + +**Step 3: 執行測試** + +```bash +python -m pytest test_ui_ctk.py -v +``` + +**Step 4: Commit** + +```bash +git add ui_ctk.py test_ui_ctk.py +git commit -m "feat(ui): 實作進度條區塊" +``` + +--- + +### Task 8: Tabs 面板(Downloads + Logs) + +**目的:** `ctk.CTkTabview` 包含 Downloads tab(DownloadsPanel)和 Logs tab(CTkTextbox)。 + +**Files:** +- Modify: `ui_ctk.py`(`_build_panels`) +- Modify: `test_ui_ctk.py` + +**Step 1: 新增失敗測試** + +```python +def test_has_panels_notebook(self): + import ui_ctk + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "panels_notebook")) + self.assertTrue(hasattr(app, "log_text")) + self.assertTrue(hasattr(app, "downloads_panel")) +``` + +**Step 2: 實作 _build_panels** + +```python +def _build_panels(self) -> None: + self.panels_notebook = ctk.CTkTabview(self.window, height=180) + self.panels_notebook.grid(row=4, column=0, sticky="ew", padx=10, pady=(4, 10)) + self._panels_widget = self.panels_notebook # toggle_panels 用 + + downloads_tab = self.panels_notebook.add("Downloads") + logs_tab = self.panels_notebook.add("Logs") + self.panels_notebook.set("Logs") # 預設顯示 Logs + + # Downloads tab + downloads_scroll = ctk.CTkScrollableFrame(downloads_tab, height=120) + downloads_scroll.pack(fill="both", expand=True) + self.downloads_panel = DownloadsPanel( + parent_frame=downloads_scroll, + ctk=ctk, + tk=tk, + tokens=self.ui_tokens, + ) + + # Logs tab + self.log_text = ctk.CTkTextbox(logs_tab, height=120, wrap="word") + self.log_text.pack(fill="both", expand=True) +``` + +**Step 3: 更新 log_message 方法** + +`ctk.CTkTextbox` 的 API 與 `tk.Text` 略不同: + +```python +def log_message(self, message: str) -> None: + try: + self.log_text.configure(state="normal") + self.log_text.insert("end", message + "\n") + self.log_text.see("end") + except Exception: + pass +``` + +**Step 4: 執行測試** + +```bash +python -m pytest test_ui_ctk.py -v +``` + +**Step 5: Commit** + +```bash +git add ui_ctk.py test_ui_ctk.py +git commit -m "feat(ui): 實作 CTkTabview(Downloads + Logs)" +``` + +--- + +### Task 9: Queue 輪詢 + 執行緒安全 + +**目的:** 確保 `after()` 輪詢 Queue 的機制完整移植,包含 scan buffer throttle。 + +**Files:** +- Modify: `ui_ctk.py` + +**Step 1: 從 ui_ttkb.py 搬移以下方法(邏輯不變)** + +在 `WebsiteCopierCtk.__init__` 末尾啟動輪詢: + +```python +# 在 _build_ui() 之後加入 +if not self._ui_smoke: + self.window.after(100, self._poll_scan_queue) + self.window.after(100, self._poll_file_queue) +``` + +從 `ui_ttkb.py` 直接搬移以下方法: +- `_poll_scan_queue()` +- `_poll_file_queue()` +- `_flush_scan_buffer()` +- `_schedule_flush()` + +這些方法只用到 `self.scan_item_buffer`、`self.dir_queue`、`self.file_queue` 與 Treeview 操作,與框架無關,可直接複製。 + +**Step 2: 新增測試** + +```python +def test_poll_methods_exist(self): + import ui_ctk + app = ui_ctk.WebsiteCopierCtk(ui_smoke=True) + self.assertTrue(hasattr(app, "_poll_scan_queue")) + self.assertTrue(hasattr(app, "_flush_scan_buffer")) + app.window.after(0, app.window.destroy) + app.run() +``` + +**Step 3: 執行測試** + +```bash +python -m pytest test_ui_ctk.py -v +``` + +**Step 4: Commit** + +```bash +git add ui_ctk.py test_ui_ctk.py +git commit -m "feat(ui): 移植 Queue 輪詢與 scan buffer throttle" +``` + +--- + +### Task 10: Backend 橋接方法(掃描、下載、樹狀填充) + +**目的:** 移植所有與 backend 互動的方法,確保掃描與下載功能正常。 + +**Files:** +- Modify: `ui_ctk.py` + +**Step 1: 從 ui_ttkb.py 搬移以下方法(邏輯不變)** + +掃描相關: +- `start_scan()` +- `toggle_scan_pause()` +- `clear_scan_results()` +- `add_folder(folder_url, parent_id)` +- `add_file(file_url, size, file_type, parent_id)` +- `update_progress(scanned, total)` + +下載相關: +- `download_selected()` +- `toggle_pause()` +- `_download_file(url, file_path, cancel_event)` +- `_update_download_progress(file_path, progress)` +- `_set_download_status(file_path, text)` + +搜尋相關: +- `on_search_filter_changed(*args)` +- `_apply_search_filter(query)` +- `focus_search(event=None)` +- `focus_logs(event=None)` +- `clear_search(event=None)` + +**Step 2: 調整 CTkProgressBar 差異** + +`ctk.CTkProgressBar` 的 API 與 ttkb.Progressbar 不同: + +```python +# ttkb 版本(舊) +self.progress_var.set(value) + +# CTk 版本(新) +self.progress_bar.set(value / 100) # CTkProgressBar 接受 0.0–1.0 +``` + +搜尋 `ui_ttkb.py` 中所有 `progress_var.set` 的地方,改為 `self.progress_bar.set(value / 100)`。 + +**Step 3: 新增測試** + +```python +def test_scan_methods_exist(self): + import ui_ctk + for method in ("start_scan", "toggle_scan_pause", "clear_scan_results", + "download_selected", "toggle_pause"): + self.assertTrue(hasattr(ui_ctk.WebsiteCopierCtk, method), f"Missing: {method}") +``` + +**Step 4: 執行測試** + +```bash +python -m pytest test_ui_ctk.py -v +``` + +**Step 5: Commit** + +```bash +git add ui_ctk.py test_ui_ctk.py +git commit -m "feat(ui): 移植 backend 橋接方法(掃描、下載、搜尋)" +``` + +--- + +### Task 11: 更新入口 index_ripper.py + +**目的:** 讓 `index_ripper.py` 預設啟動 CTk 新版本(`ui_ctk.py`)。 + +**Files:** +- Modify: `index_ripper.py`(只改主入口的 import,舊 `WebsiteCopier` 類別保留) + +**Step 1: 確認現有入口結構** + +讀 `index_ripper.py` 最後的 `main()` / `if __name__ == "__main__"` 部分。 + +**Step 2: 修改入口** + +在 `index_ripper.py` 底部找到啟動 `WebsiteCopier` 的地方,改為: + +```python +def main() -> int: + if "--ui-smoke" in sys.argv: + from ui_ctk import WebsiteCopierCtk + app = WebsiteCopierCtk(ui_smoke=True) + app.run() + print("UI_SMOKE_OK") + return 0 + + from ui_ctk import WebsiteCopierCtk + app = WebsiteCopierCtk() + app.run() + return 0 +``` + +**Step 3: 執行 smoke 測試** + +```bash +python index_ripper.py --smoke +``` + +Expected: `SMOKE_OK` + +**Step 4: Commit** + +```bash +git add index_ripper.py +git commit -m "feat: 更新入口,預設啟動 CTk 版 UI" +``` + +--- + +### Task 12: 整合驗證與清理 + +**目的:** 完整執行所有測試,確認功能正常,移除不必要的 ttkbootstrap import。 + +**Files:** +- Modify: `ui_ctk.py`(若有殘留的 ttkb import) +- Modify: `requirements.txt`(可選:將 ttkbootstrap 移至 optional) + +**Step 1: 執行完整測試套件** + +```bash +python -m pytest test_ui_ctk.py test_ui_theme.py test_ui_downloads.py test_backend.py -v +``` + +Expected: 全部 PASS + +**Step 2: 手動 UI smoke** + +```bash +python index_ripper.py --ui-smoke +``` + +Expected: 視窗出現並正常關閉,輸出 `UI_SMOKE_OK` + +**Step 3: 確認 ttkbootstrap 仍在 requirements.txt(保留舊入口用)** + +`index_ripper_ttkb.py` 仍可用 ttkb,不移除 dependency,只確認不影響新版。 + +**Step 4: 最終 commit** + +```bash +git add . +git commit -m "test: 整合驗證通過,CTk 遷移完成" +``` + +--- + +## 完整功能對照檢查 + +實作完畢後,逐一對照設計文件的功能清單: + +``` +docs/plans/2026-03-09-ctk-migration-design.md +``` + +每個 `[ ]` 項目都應手動驗證可運作。 + +--- + +## 已知限制 + +- `ctk.CTkEntry` 的 `_entry` 屬性是內部 API,`_on_global_url_paste` 中需改用 `focused is self.url_entry` 的比較方式(CTk 5.x 的 entry focus 行為與 tk.Entry 略有不同,需測試後調整) +- `ctk.CTkOptionMenu` 不支援 `readonly` state,threads 輸入值需在 command callback 中做範圍驗證 +- `ttk.Treeview` 的 style 在每次 `configure_treeview_style()` 呼叫時會重設 `theme_use("default")`,確保只在 `_build_treeview()` 呼叫一次 diff --git a/docs/plans/2026-03-10-filetree-redesign-design.md b/docs/plans/2026-03-10-filetree-redesign-design.md new file mode 100644 index 0000000..db5ca7f --- /dev/null +++ b/docs/plans/2026-03-10-filetree-redesign-design.md @@ -0,0 +1,107 @@ +# FileTree Redesign — Design Document + +**Date:** 2026-03-10 +**Status:** Approved + +## Goal + +Replace `ttk.Treeview` with a custom `FileTree` component built on `CTkScrollableFrame` + emoji icons, delivering a modern UI while preserving all existing functional interfaces. + +## Problem + +`ttk.Treeview` forces icons to be `tk.PhotoImage` pixel art (limited quality) and prevents per-row custom styling (hover, checked background, badges). The current pixel-art icons look like plain colored blocks. + +## Design + +### Architecture — three layers + +**1. Data model (pure Python, no widgets)** + +```python +@dataclass +class TreeNode: + node_id: str + parent_id: str # "" for root + name: str + kind: str # "folder" | "file" + full_path: str # "" for folders + size: str + file_type: str + icon_group: str # "folder" | "image" | "document" | ... + checked: bool + expanded: bool + children: list[str] # ordered child node_ids +``` + +```python +# Stored on WebsiteCopierCtk: +self.tree_nodes: dict[str, TreeNode] +self.tree_roots: list[str] # top-level node_ids in order +``` + +**2. View layer (CTkScrollableFrame)** + +- Container: `ctk.CTkScrollableFrame` replaces `ttk.Treeview` +- `_visible_nodes: list[str]` — flat ordered list of currently visible nodes +- `_row_widgets: dict[str, RowWidget]` — node_id → row frame (created lazily) +- `_rebuild_visible()` — recomputes `_visible_nodes` from data model (expand/collapse, filter) +- `_sync_rows()` — creates/removes row widgets to match `_visible_nodes` + +**3. Row widget (RowWidget)** + +Each row is a `ctk.CTkFrame` containing: +``` +[indent spacer] [▶/▼ or spacer] [emoji label] [name label] [size label] [type badge] +``` +- `indent` = `depth * 20` px spacer +- Expand/collapse button: 16×16 `ctk.CTkButton` with `▶`/`▼` text, no border +- Emoji icon: `ctk.CTkLabel` 22px font +- Name: `ctk.CTkLabel`, bold for folders +- Size/type: right-aligned small labels + +### Visual spec + +| State | Background | +|---|---| +| Normal | transparent (inherits CTkScrollableFrame) | +| Hover | `("#E2E8F0", "#2D3748")` | +| Checked | `("#DBEAFE", "#1E3A5F")` | +| Checked + Hover | `("#BFDBFE", "#1E40AF")` | + +Row height: 34px (via `height` on inner frame) +Font: SF Pro Text 13 (name), 11 (size/type) + +### Emoji icon mapping + +| group | emoji | +|---|---| +| folder | 📁 | +| image | 🖼️ | +| document | 📄 | +| archive | 🗜️ | +| code | 💻 | +| audio | 🎵 | +| video | 🎬 | +| text | 📝 | +| binary | ⚙️ | + +### Preserved public interfaces (no changes to callers) + +- `add_folder(dir_path, url) → str` +- `add_file(dir_path, url, file_name, size, file_type, full_path)` +- `toggle_check(item_id, force_check=None, _skip_children=False)` +- `select_all() / deselect_all()` +- `expand_all() / collapse_all()` +- `_backup_full_tree() / _restore_full_tree()` +- `_filter_tree_by_term(term)` +- `sort_tree(col)` +- `checked_items: set[str]` — still maintained + +### Implementation notes + +- `add_folder` / `add_file` still called from main thread (via `_poll_scan_queue`) +- `_rebuild_visible()` is O(n); call only on expand/collapse/filter, not per row +- `_sync_rows()` uses `grid()` / `grid_forget()` for show/hide (no widget destruction) +- Search filter: set `node.hidden = True` on non-matching nodes; `_rebuild_visible` skips them +- Backup/restore: serialise/deserialise `tree_nodes` + `tree_roots` dicts +- `checked_items` set is the source of truth for download; unchanged diff --git a/docs/plans/2026-03-10-filetree-redesign.md b/docs/plans/2026-03-10-filetree-redesign.md new file mode 100644 index 0000000..a5b33ac --- /dev/null +++ b/docs/plans/2026-03-10-filetree-redesign.md @@ -0,0 +1,960 @@ +# FileTree Redesign Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace `ttk.Treeview` with a custom `CTkScrollableFrame`-based tree that uses emoji icons and supports modern per-row styling (hover, checked background). + +**Architecture:** A `TreeNode` dataclass holds all state (no widgets). `_rebuild_visible()` computes the flat ordered list of visible nodes. `_sync_rows()` creates/updates `RowWidget` frames to match that list. All existing caller interfaces (`add_folder`, `add_file`, `toggle_check`, etc.) are preserved. + +**Tech Stack:** Python 3.11+, customtkinter 5.x, tkinter, `dataclasses` + +--- + +## Key files + +- **Modify:** `ui_ctk.py` — all changes are here +- **Remove usage of:** `configure_treeview_style`, `treeview_tag_colors` from `ui_theme.py` (imports only; functions stay in ui_theme.py for now) +- **Test:** `test_ui_ttkb.py` (smoke tests exist; run after each task to ensure nothing broken) + +## Baseline: run tests before starting + +```bash +uv run pytest test_ui_ttkb.py test_ui_downloads.py -v 2>&1 | tail -20 +``` + +--- + +### Task 1: Add `TreeNode` dataclass and data model state + +**Goal:** Pure-Python data model for the tree. No widgets yet. + +**Files:** +- Modify: `ui_ctk.py` — add dataclass near top (after imports, before `should_skip_file_row`) +- Modify: `ui_ctk.py:61-80` — add state fields to `__init__` + +**Step 1: Add import and dataclass at top of file (after existing imports, before `should_skip_file_row` on line 30)** + +```python +from dataclasses import dataclass, field + +@dataclass +class TreeNode: + node_id: str + parent_id: str # "" for root-level nodes + name: str + kind: str # "folder" | "file" + full_path: str # "" for folders + size: str + file_type: str + icon_group: str # "folder"|"image"|"document"|"archive"|"code"|"audio"|"video"|"text"|"binary" + checked: bool = False + expanded: bool = False + hidden: bool = False # True when filtered out by search + children: list = field(default_factory=list) # ordered list of child node_ids +``` + +**Step 2: Add state fields to `__init__` (after `self.checkbox_checked = "✔ "` on line 70)** + +```python +# FileTree data model +self.tree_nodes: dict[str, TreeNode] = {} +self.tree_roots: list[str] = [] # top-level node_ids in insertion order +self._node_counter: int = 0 # monotonic counter for unique node_ids +``` + +**Step 3: Write a quick manual verification** + +```python +# In a scratch test (not committed): confirm dataclass works +node = TreeNode(node_id="n1", parent_id="", name="test", kind="folder", + full_path="", size="", file_type="", icon_group="folder") +assert node.children == [] +assert not node.checked +``` + +**Step 4: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: add TreeNode dataclass and data model state" +``` + +--- + +### Task 2: Build `RowWidget` class + +**Goal:** A single row frame with indent, chevron, emoji icon, name, size, and type badge. Handles hover highlighting and checked-state background. + +**Files:** +- Modify: `ui_ctk.py` — add `RowWidget` class after `TreeNode` dataclass, before `should_skip_file_row` + +**Step 1: Add the `RowWidget` class** + +```python +_EMOJI_ICONS = { + "folder": "📁", + "image": "🖼️", + "document": "📄", + "archive": "🗜️", + "code": "💻", + "audio": "🎵", + "video": "🎬", + "text": "📝", + "binary": "⚙️", +} + +_BG_NORMAL = ("gray95", "gray17") +_BG_HOVER = ("#E2E8F0", "#2D3748") +_BG_CHECKED = ("#DBEAFE", "#1E3A5F") +_BG_CHECKED_HOVER = ("#BFDBFE", "#1E40AF") + + +class RowWidget: + """One visible row in the FileTree.""" + + INDENT_PX = 20 + ROW_HEIGHT = 34 + + def __init__(self, parent, app, node: TreeNode, depth: int): + self.app = app + self.node_id = node.node_id + self._checked = node.checked + self._hovered = False + + self.frame = ctk.CTkFrame(parent, height=self.ROW_HEIGHT, corner_radius=4) + self.frame.pack(fill="x", padx=4, pady=1) + self.frame.pack_propagate(False) + + # Indent spacer + if depth > 0: + ctk.CTkFrame( + self.frame, width=depth * self.INDENT_PX, + fg_color="transparent", height=self.ROW_HEIGHT, + ).pack(side="left") + + # Chevron (folders) or invisible spacer (files) + if node.kind == "folder": + self.chevron = ctk.CTkButton( + self.frame, text="▶" if not node.expanded else "▼", + width=22, height=22, fg_color="transparent", + hover_color=("gray85", "gray30"), text_color=("gray40", "gray60"), + font=ctk.CTkFont(size=10), + command=lambda: app._on_chevron_click(self.node_id), + ) + self.chevron.pack(side="left", padx=(2, 0)) + else: + ctk.CTkFrame( + self.frame, width=26, fg_color="transparent", height=self.ROW_HEIGHT, + ).pack(side="left") + + # Emoji icon + ctk.CTkLabel( + self.frame, + text=_EMOJI_ICONS.get(node.icon_group, "📄"), + font=ctk.CTkFont(size=16), + width=28, + ).pack(side="left", padx=(2, 4)) + + # Name label + self.name_label = ctk.CTkLabel( + self.frame, + text=node.name, + anchor="w", + font=ctk.CTkFont(size=13, weight="bold" if node.kind == "folder" else "normal"), + ) + self.name_label.pack(side="left", fill="x", expand=True) + + # Size + type (right side) + if node.kind == "file": + if node.size: + ctk.CTkLabel( + self.frame, + text=node.size, + font=ctk.CTkFont(size=11), + text_color=("gray50", "gray60"), + width=80, + anchor="e", + ).pack(side="right", padx=(0, 4)) + if node.icon_group and node.icon_group != "binary": + ctk.CTkLabel( + self.frame, + text=node.icon_group, + font=ctk.CTkFont(size=10), + text_color=("gray50", "gray60"), + width=60, + anchor="e", + ).pack(side="right", padx=(0, 2)) + + self._update_bg() + + # Hover + click bindings on frame and all children + self._bind_all(self.frame) + + def _bind_all(self, widget): + widget.bind("", self._on_enter) + widget.bind("", self._on_leave) + widget.bind("", self._on_click) + for child in widget.winfo_children(): + self._bind_all(child) + + def _on_enter(self, _event=None): + self._hovered = True + self._update_bg() + + def _on_leave(self, _event=None): + self._hovered = False + self._update_bg() + + def _on_click(self, event): + self.app._on_row_click(self.node_id, event) + + def set_checked(self, checked: bool): + self._checked = checked + self._update_bg() + + def set_chevron(self, expanded: bool): + if hasattr(self, "chevron"): + self.chevron.configure(text="▼" if expanded else "▶") + + def _update_bg(self): + if self._checked and self._hovered: + color = _BG_CHECKED_HOVER + elif self._checked: + color = _BG_CHECKED + elif self._hovered: + color = _BG_HOVER + else: + color = _BG_NORMAL + self.frame.configure(fg_color=color) + + def destroy(self): + self.frame.destroy() +``` + +**Step 2: Run smoke tests** + +```bash +uv run pytest test_ui_ttkb.py -v -k "smoke" 2>&1 | tail -10 +``` + +Expected: passes (RowWidget not yet wired up) + +**Step 3: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: add RowWidget class with emoji icons and hover/checked styling" +``` + +--- + +### Task 3: Build `_build_filetree()` to replace `_build_treeview()` + +**Goal:** Replace the `ttk.Treeview` container with a `CTkScrollableFrame`. Wire up search bar. Store references needed by later tasks. + +**Files:** +- Modify: `ui_ctk.py:393-455` — replace `_build_treeview` entirely +- Modify: `ui_ctk.py:136` — change `self._build_treeview()` to `self._build_filetree()` + +**Step 1: Replace `_build_treeview` method (lines 393–455 inclusive)** + +```python +def _build_filetree(self) -> None: + outer = ctk.CTkFrame(self.window, fg_color="transparent") + outer.grid(row=2, column=0, sticky="nsew", padx=10, pady=(4, 0)) + outer.grid_columnconfigure(0, weight=1) + outer.grid_rowconfigure(1, weight=1) + + # Search bar + search_bar = ctk.CTkFrame(outer, fg_color="transparent") + search_bar.grid(row=0, column=0, sticky="ew", pady=(0, 4)) + search_bar.grid_columnconfigure(1, weight=1) + ctk.CTkLabel(search_bar, text="Search").grid(row=0, column=0, padx=(0, 6)) + self.search_var = tk.StringVar() + self.search_entry = ctk.CTkEntry(search_bar, textvariable=self.search_var) + self.search_entry.grid(row=0, column=1, sticky="ew") + self.search_var.trace_add("write", self.on_search_filter_changed) + + # Scrollable tree container + self.tree_scroll_frame = ctk.CTkScrollableFrame( + outer, + fg_color=("gray95", "gray17"), + corner_radius=8, + ) + self.tree_scroll_frame.grid(row=1, column=0, sticky="nsew") + + # Runtime state for view layer + self._visible_nodes: list[str] = [] + self._row_widgets: dict[str, RowWidget] = {} +``` + +**Step 2: Update `_build_full_ui` to call the new method (line 136)** + +Change: +```python + self._build_treeview() +``` +To: +```python + self._build_filetree() +``` + +**Step 3: Update `_build_full_ui` state init (line 141-145): remove `self.sort_reverse` etc. temporarily is OK; keep them to avoid AttributeErrors** + +No change needed here — those lines stay. + +**Step 4: Update `_build_ui_smoke_only` — add missing attributes that callers expect** + +Find `_build_ui_smoke_only` (around line 121). After the existing lines add: + +```python + self.tree_nodes = {} + self.tree_roots = [] + self._node_counter = 0 + self._visible_nodes = [] + self._row_widgets = {} + self.tree_scroll_frame = None + self.full_tree_backup = {} + self.sort_reverse = False + self.drag_anchor_item = "" + self._last_toggle_time = 0.0 + self._search_after_id = None + self.context_menu = tk.Menu(self.window, tearoff=0) +``` + +**Step 5: Remove old imports that are no longer needed** + +In the `from ui_theme import (...)` block, remove: +- `configure_treeview_style` +- `treeview_tag_colors` + +Also remove `ttk` from `from tkinter import filedialog, messagebox, ttk` → `from tkinter import filedialog, messagebox` + +Wait — `ttk` is still used in `configure_action_button_styles` calls inside `_build_full_ui` (line 50). Leave `ttk` import for now; clean up in Task 8. + +**Step 6: Run smoke tests** + +```bash +uv run python index_ripper.py --ui-smoke 2>&1 | tail -5 +uv run pytest test_ui_ttkb.py -v 2>&1 | tail -15 +``` + +Expected: app launches without error; smoke tests pass. + +**Step 7: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: replace _build_treeview with _build_filetree (CTkScrollableFrame)" +``` + +--- + +### Task 4: Implement `_rebuild_visible()` and `_sync_rows()` + +**Goal:** Core view logic. `_rebuild_visible()` does a DFS walk of `tree_nodes` respecting `expanded` and `hidden`. `_sync_rows()` creates new `RowWidget`s and removes stale ones. + +**Files:** +- Modify: `ui_ctk.py` — add two methods after `_build_filetree` + +**Step 1: Add `_rebuild_visible` and `_sync_rows` methods** + +```python +def _rebuild_visible(self) -> None: + """Recompute self._visible_nodes from data model (DFS, respects expanded/hidden).""" + result: list[str] = [] + + def _walk(node_id: str) -> None: + node = self.tree_nodes.get(node_id) + if node is None or node.hidden: + return + result.append(node_id) + if node.kind == "folder" and node.expanded: + for child_id in node.children: + _walk(child_id) + + for root_id in self.tree_roots: + _walk(root_id) + + self._visible_nodes = result + +def _sync_rows(self) -> None: + """Create/destroy RowWidgets to match _visible_nodes.""" + if self.tree_scroll_frame is None: + return + + new_ids = set(self._visible_nodes) + # Remove rows no longer visible + for node_id in list(self._row_widgets): + if node_id not in new_ids: + self._row_widgets.pop(node_id).destroy() + + # Repack all rows in correct order + for widget in self.tree_scroll_frame.winfo_children(): + widget.pack_forget() + + for node_id in self._visible_nodes: + node = self.tree_nodes[node_id] + depth = self._node_depth(node_id) + if node_id not in self._row_widgets: + self._row_widgets[node_id] = RowWidget( + self.tree_scroll_frame, self, node, depth + ) + else: + self._row_widgets[node_id].frame.pack(fill="x", padx=4, pady=1) + +def _node_depth(self, node_id: str) -> int: + depth = 0 + node = self.tree_nodes.get(node_id) + while node and node.parent_id: + depth += 1 + node = self.tree_nodes.get(node.parent_id) + return depth + +def _next_node_id(self) -> str: + self._node_counter += 1 + return f"n{self._node_counter}" +``` + +**Step 2: Add click handlers referenced by RowWidget** + +```python +def _on_row_click(self, node_id: str, event=None) -> None: + now = time.monotonic() + if now - self._last_toggle_time < 0.25: + return + self._last_toggle_time = now + node = self.tree_nodes.get(node_id) + if node is None: + return + if node.kind == "folder": + self._on_chevron_click(node_id) + else: + self.toggle_check(node_id) + +def _on_chevron_click(self, node_id: str) -> None: + node = self.tree_nodes.get(node_id) + if node is None or node.kind != "folder": + return + node.expanded = not node.expanded + row = self._row_widgets.get(node_id) + if row: + row.set_chevron(node.expanded) + self._rebuild_visible() + self._sync_rows() +``` + +**Step 3: Run smoke tests** + +```bash +uv run pytest test_ui_ttkb.py -v 2>&1 | tail -15 +``` + +**Step 4: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: implement _rebuild_visible and _sync_rows for FileTree view layer" +``` + +--- + +### Task 5: Rewrite `add_folder()` and `add_file()` + +**Goal:** Insert nodes into the data model (not Treeview), then call `_rebuild_visible` + `_sync_rows`. + +**Files:** +- Modify: `ui_ctk.py:1092–1202` — replace both methods entirely + +**Step 1: Replace `add_folder` (currently line 1092)** + +```python +def add_folder(self, dir_path: str, url: str) -> str: + """Ensure all path segments exist as folder nodes; return leaf node_id.""" + if not dir_path: + dir_path = "/" + parts = [p for p in dir_path.split("/") if p] + + parent_id = "" + current_path = "" + for part in parts: + current_path = current_path + "/" + part + with self.folders_dict_lock: + existing_id = self.folders.get(current_path) + if existing_id: + parent_id = existing_id + continue + + node_id = self._next_node_id() + node = TreeNode( + node_id=node_id, + parent_id=parent_id, + name=part, + kind="folder", + full_path="", + size="", + file_type="", + icon_group="folder", + ) + self.tree_nodes[node_id] = node + if parent_id: + self.tree_nodes[parent_id].children.append(node_id) + else: + self.tree_roots.append(node_id) + + with self.folders_dict_lock: + self.folders[current_path] = node_id + parent_id = node_id + + self._rebuild_visible() + self._sync_rows() + return parent_id +``` + +**Step 2: Replace `add_file` (currently line 1135)** + +```python +def add_file(self, dir_path: str, url: str, file_name: str, size, file_type: str, full_path: str) -> None: + """Add a file node to the tree.""" + if not file_name: + return + is_html_dir_like = ( + isinstance(file_type, str) + and "text/html" in file_type.lower() + and "." not in (file_name or "") + ) + parent_id = self.add_folder(dir_path, url) + + with self.files_dict_lock: + existing_entry = self.files_dict.get(full_path) + if should_skip_file_row(existing_entry): + return + if is_html_dir_like: + self.files_dict.pop(full_path, None) + else: + self.files_dict[full_path] = { + "url": url, + "file_name": file_name, + "size": size, + "file_type": file_type, + "path": dir_path, + } + + if is_html_dir_like: + folder_path = f"{dir_path.rstrip('/')}/{file_name}".replace("//", "/") + self.add_folder(folder_path, url) + return + + ext = normalize_extension(file_name) + self._add_file_type_filter(ext) + self.file_type_counts[ext] = self.file_type_counts.get(ext, 0) + 1 + cb = self.file_type_widgets.get(ext) + if cb: + label = ext if ext else "(no ext)" + cb.configure(text=f"{label} ({self.file_type_counts[ext]})") + var = self.file_types.get(ext) + if var is not None and not var.get(): + return + + group = self._file_icon_and_group(file_name, file_type) + node_id = self._next_node_id() + node = TreeNode( + node_id=node_id, + parent_id=parent_id, + name=file_name, + kind="file", + full_path=full_path or "", + size=size or "", + file_type=file_type or "", + icon_group=group, + checked=full_path in self.checked_items, + ) + self.tree_nodes[node_id] = node + if parent_id: + self.tree_nodes[parent_id].children.append(node_id) + else: + self.tree_roots.append(node_id) + + self._rebuild_visible() + self._sync_rows() +``` + +**Note:** `_rebuild_visible` + `_sync_rows` is called once per file during scan. This is acceptable for ~1000 items (each call is O(visible_nodes)). If performance is sluggish, batch them by adding a `_pending_rebuild` flag and scheduling via `window.after(0, ...)`. + +**Step 3: Run the app and do a real scan to verify folders and files appear** + +```bash +uv run python index_ripper.py +``` + +Enter a URL, click Scan, verify file/folder rows appear in the tree. + +**Step 4: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: rewrite add_folder and add_file to use TreeNode data model" +``` + +--- + +### Task 6: Rewrite `toggle_check()`, `select_all()`, `deselect_all()` + +**Goal:** Checkbox logic using data model. Update `RowWidget` background. Maintain `checked_items`. + +**Files:** +- Modify: `ui_ctk.py:821–932` — replace `toggle_check`, `select_all`, `deselect_all`, and related methods + +**Step 1: Replace `toggle_check` (currently line 821)** + +```python +def toggle_check(self, node_id: str, force_check=None, _skip_children: bool = False) -> None: + node = self.tree_nodes.get(node_id) + if node is None: + return + new_checked = (not node.checked) if force_check is None else bool(force_check) + node.checked = new_checked + + if node.kind == "file" and node.full_path: + if new_checked: + self.checked_items.add(node.full_path) + else: + self.checked_items.discard(node.full_path) + + row = self._row_widgets.get(node_id) + if row: + row.set_checked(new_checked) + + if node.kind == "folder" and not _skip_children: + for child_id in node.children: + self.toggle_check(child_id, force_check=new_checked) +``` + +**Step 2: Replace `select_all` and `deselect_all` (currently lines 926-932)** + +```python +def select_all(self) -> None: + for node_id in list(self.tree_nodes): + self.toggle_check(node_id, force_check=True, _skip_children=True) + +def deselect_all(self) -> None: + for node_id in list(self.tree_nodes): + self.toggle_check(node_id, force_check=False, _skip_children=True) +``` + +**Step 3: Replace `_all_tree_items` — keep for API compatibility but use tree_nodes** + +```python +def _all_tree_items(self) -> list[str]: + """Return all node_ids in DFS order.""" + out: list[str] = [] + def _walk(node_id: str) -> None: + out.append(node_id) + node = self.tree_nodes.get(node_id) + if node: + for child_id in node.children: + _walk(child_id) + for root_id in self.tree_roots: + _walk(root_id) + return out +``` + +**Step 4: Remove or stub old helper methods that referenced `self.tree` (ttk.Treeview)** + +Remove these methods entirely (they're Treeview-specific and no longer needed): +- `_set_item_checked_visual` +- `_strip_checkmark` +- `_focused_tree_item` +- `on_tree_click` — replace with `_on_row_click` (already added in Task 4) +- `on_tree_drag_select` +- `on_tree_space` +- `on_tree_enter` +- `_on_tree_select_all` +- `show_context_menu` — keep but update to not use `self.tree.identify_row` + +**Step 5: Update `show_context_menu`** + +```python +def show_context_menu(self, event) -> None: + try: + self.context_menu.tk_popup(event.x_root, event.y_root) + finally: + self.context_menu.grab_release() +``` + +**Step 6: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: rewrite toggle_check, select_all, deselect_all using TreeNode model" +``` + +--- + +### Task 7: Rewrite `expand_all`, `collapse_all`, `sort_tree`, and `_on_type_filter_changed` + +**Goal:** These methods previously walked the Treeview; rewrite to use `tree_nodes`. + +**Files:** +- Modify: `ui_ctk.py:934–961, 1486–1515` + +**Step 1: Replace `expand_all` and `collapse_all`** + +```python +def expand_all(self, parent: str = "") -> None: + targets = self.tree_roots if not parent else self.tree_nodes[parent].children + for node_id in targets: + node = self.tree_nodes.get(node_id) + if node and node.kind == "folder": + node.expanded = True + self.expand_all(node_id) + self._rebuild_visible() + self._sync_rows() + +def collapse_all(self, parent: str = "") -> None: + targets = self.tree_roots if not parent else self.tree_nodes[parent].children + for node_id in targets: + node = self.tree_nodes.get(node_id) + if node and node.kind == "folder": + node.expanded = False + self.collapse_all(node_id) + self._rebuild_visible() + self._sync_rows() +``` + +**Step 2: Replace `sort_tree`** + +```python +def sort_tree(self, col: str = "name") -> None: + def sort_children(children: list[str]) -> None: + if not children: + return + def key(node_id: str) -> str: + node = self.tree_nodes.get(node_id) + if node is None: + return "" + if col == "size": + return node.size.lower() + if col == "type": + return node.icon_group.lower() + return node.name.lower() + children.sort(key=key, reverse=self.sort_reverse) + for node_id in children: + node = self.tree_nodes.get(node_id) + if node: + sort_children(node.children) + + sort_children(self.tree_roots) + self.sort_reverse = not self.sort_reverse + self._rebuild_visible() + self._sync_rows() +``` + +**Step 3: Replace `_on_type_filter_changed`** + +```python +def _on_type_filter_changed(self, ext: str) -> None: + if self.full_tree_backup: + return # search filter active; handled by _filter_tree_by_term + var = self.file_types.get(ext) + if var is None: + return + visible = var.get() + for node in self.tree_nodes.values(): + if node.kind == "file" and normalize_extension(node.name) == ext: + node.hidden = not visible + self._rebuild_visible() + self._sync_rows() +``` + +**Step 4: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: rewrite expand_all, collapse_all, sort_tree, type filter using TreeNode" +``` + +--- + +### Task 8: Rewrite `_backup_full_tree`, `_restore_full_tree`, `_filter_tree_by_term` + +**Goal:** Search filter backup/restore using data model instead of Treeview state. + +**Files:** +- Modify: `ui_ctk.py:1373–1448` + +**Step 1: Replace `_backup_full_tree`** + +```python +def _backup_full_tree(self) -> None: + """Snapshot tree_nodes and tree_roots for search filter restore.""" + import copy + self.full_tree_backup = { + "nodes": copy.deepcopy(self.tree_nodes), + "roots": list(self.tree_roots), + "folders": dict(self.folders), + } +``` + +**Step 2: Replace `_restore_full_tree`** + +```python +def _restore_full_tree(self) -> None: + """Restore tree_nodes from backup (undo search filter).""" + if not self.full_tree_backup: + return + self.tree_nodes = self.full_tree_backup["nodes"] + self.tree_roots = self.full_tree_backup["roots"] + with self.folders_dict_lock: + self.folders = self.full_tree_backup["folders"] + self.full_tree_backup = {} + + # Destroy all existing row widgets and rebuild + for row in self._row_widgets.values(): + row.destroy() + self._row_widgets.clear() + self._rebuild_visible() + self._sync_rows() +``` + +**Step 3: Replace `_filter_tree_by_term`** + +```python +def _filter_tree_by_term(self, term: str) -> None: + """Mark nodes hidden if they (and their descendants) don't match term.""" + term = term.lower() + + def matches(node_id: str) -> bool: + node = self.tree_nodes.get(node_id) + if node is None: + return False + if term in node.name.lower() or term in node.full_path.lower(): + return True + return any(matches(child_id) for child_id in node.children) + + def apply_visibility(node_id: str) -> None: + node = self.tree_nodes.get(node_id) + if node is None: + return + if matches(node_id): + node.hidden = False + node.expanded = True # auto-expand matching folders + else: + node.hidden = True + for child_id in node.children: + apply_visibility(child_id) + + for root_id in self.tree_roots: + apply_visibility(root_id) + + self._rebuild_visible() + self._sync_rows() +``` + +**Step 4: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: rewrite backup/restore/filter using TreeNode model" +``` + +--- + +### Task 9: Rewrite `clear_scan_results` and remove all remaining `self.tree` (Treeview) references + +**Goal:** Final cleanup. Replace every `self.tree.*` call with tree_nodes model. Remove unused imports. + +**Files:** +- Modify: `ui_ctk.py:1040–1062` — `clear_scan_results` +- Modify: `ui_ctk.py` — remove `self.checkbox_checked`, remove `ttk`/`configure_treeview_style`/`treeview_tag_colors` imports + +**Step 1: Replace `clear_scan_results`** + +```python +def clear_scan_results(self) -> None: + if self.is_scanning: + return + self._drain_queues() + + # Clear data model + self.tree_nodes.clear() + self.tree_roots.clear() + self._node_counter = 0 + with self.folders_dict_lock: + self.folders.clear() + + # Destroy row widgets + for row in self._row_widgets.values(): + row.destroy() + self._row_widgets.clear() + self._visible_nodes.clear() + + # Clear other state + with self.files_dict_lock: + self.files_dict.clear() + if hasattr(self, "filters_container"): + for widget in self.filters_container.winfo_children(): + widget.destroy() + self.file_types.clear() + self.file_type_counts.clear() + self.file_type_widgets.clear() + self.checked_items.clear() + self.full_tree_backup.clear() if isinstance(self.full_tree_backup, dict) else None + self.full_tree_backup = {} + self.scanned_urls = 0 + self.total_urls = 0 + self.progress_bar.set(0) + self.progress_label.configure(text="") + self._set_status("Ready", "#059669") +``` + +**Step 2: Search for remaining `self.tree` references** + +```bash +grep -n "self\.tree\b" ui_ctk.py | grep -v "tree_nodes\|tree_roots\|tree_scroll\|_tree_icons\|file_tree\|tree_backup\|_node_counter\|_visible\|_row_widget" +``` + +Expected: only innocuous hits like `self.tree_roots` etc. Fix any remaining `self.tree.xxx` (Treeview) calls. + +**Step 3: Remove unused imports** + +- Remove `ttk` from `from tkinter import filedialog, messagebox, ttk` if no ttk usage remains +- Remove `configure_treeview_style`, `treeview_tag_colors` from `ui_theme` import +- Remove `_init_tree_icons` method (entire method, no longer needed) +- Remove `self.checkbox_checked = "✔ "` from `__init__` + +**Step 4: Run full test suite** + +```bash +uv run pytest test_ui_ttkb.py test_ui_downloads.py test_ui_theme.py -v 2>&1 | tail -20 +``` + +**Step 5: Launch the app and do an end-to-end test** + +```bash +uv run python index_ripper.py +``` + +Test checklist: +- [ ] App launches without errors +- [ ] Scan a URL → files appear with emoji icons +- [ ] Click a file → row turns blue (checked) +- [ ] Click a folder → expands/collapses +- [ ] Type in search → non-matching rows disappear +- [ ] Clear search → rows reappear +- [ ] Select All / Deselect All works +- [ ] Expand All / Collapse All works +- [ ] File type checkboxes show/hide rows + +**Step 6: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: complete FileTree migration - remove all ttk.Treeview references" +``` + +--- + +## Done + +All 9 tasks complete. The `ttk.Treeview` is fully replaced with a `CTkScrollableFrame`-based custom tree using emoji icons, hover effects, and checked-state background highlighting. diff --git a/docs/plans/2026-03-10-ui-redesign-design.md b/docs/plans/2026-03-10-ui-redesign-design.md new file mode 100644 index 0000000..5e85bc4 --- /dev/null +++ b/docs/plans/2026-03-10-ui-redesign-design.md @@ -0,0 +1,168 @@ +# UI/UX Redesign — Design Document + +**Date:** 2026-03-10 +**Status:** Approved + +## Goal + +Simplify and clean up the IndexRipper UI to match a macOS-native single-window app aesthetic. The primary workflow is linear: enter URL → scan → select files → download. + +## Layout Overview + +``` +┌─────────────────────────────────────────────────────────┐ +│ Row 0 Toolbar: URL + Scan + Status │ +├─────────────────────────────────────────────────────────┤ +│ Row 1 Search + Types filter │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ Row 2 FileTree (weight=1, fills remaining space) │ +│ │ +├─────────────────────────────────────────────────────────┤ +│ Row 3 Progress bar + Bottom control bar │ +├─────────────────────────────────────────────────────────┤ +│ Row 4 Logs / Downloads panel (collapsible, default │ +│ hidden) │ +└─────────────────────────────────────────────────────────┘ +``` + +Window: `1100×800`, minsize `800×550`. `grid_rowconfigure(2, weight=1)`. + +--- + +## Row 0 — Toolbar + +``` +[URL_________________________________] [Scan] ● Ready +``` + +- `CTkEntry` for URL, `fill="x"`, height 36px, `column=0 weight=1` +- **Scan button**: text toggles `"Scan"` ↔ `"Stop Scan"` (single button, no separate Stop) +- **Pause Scan button**: hidden by default (`grid_remove`); shown only while scanning; toggles `"Pause Scan"` ↔ `"Resume Scan"` +- **Status indicator**: small colored dot (6×6 `CTkFrame`, `corner_radius=3`) + text label, right-aligned + - Ready → green `#059669` + - Scanning → amber `#B45309` + - Stopped / Error → red `#B91C1C` +- **Remove Clear button**: scanning automatically clears previous results (`clear_scan_results()` called at scan start) + +--- + +## Row 1 — Search + Types + +``` +🔍 [Search____________] │ □ mp4 (12) □ zip (3) □ jpg (8) ↔ +``` + +- Left: `CTkEntry` with 🔍 prefix label, fixed width 260px, `height=34` +- Vertical separator: 1px `CTkFrame(width=1, fg_color=("gray80","gray30"))` +- Right: `CTkScrollableFrame(orientation="horizontal", height=38)` — same as current `filters_container`, `fill="x" expand=True` +- **Remove "Types:" label** and **"✓ All / ✗ All" buttons** from Row 1 +- Replace with right-click context menu on `filters_container`: + - `Select All Types` + - `Deselect All Types` +- Row total height: ~42px + +--- + +## Row 2 — FileTree + +``` +📁 videos/ ▶ + 🎬 movie.mp4 1.2 GB + 🎬 trailer.mp4 240 MB +📁 docs/ ▶ + 📄 readme.txt 4 KB +``` + +Changes from current implementation: + +| Property | Before | After | +|----------|--------|-------| +| Chevron position | left of name | right edge of row | +| Checked style | blue background fill | 3px blue left border accent | +| Row height | 38px | 36px | +| Row gap (pady) | 1px | 0px | +| Type badge | shown right (e.g. "video") | **removed** | +| Size column | right-aligned, width=90 | right-aligned, width=80 | +| Empty state | blank | "Scanning…" during scan; "No files found" after | + +### Checked accent border implementation + +`RowWidget` uses a narrow `CTkFrame(width=3)` as the leftmost child, colored: +- unchecked: `fg_color="transparent"` +- checked: `fg_color=("#2563EB","#3B82F6")` + +Background colors (hover/checked) remain but become more subtle: + +| State | Light | Dark | +|-------|-------|------| +| Normal | transparent | transparent | +| Hover | `#F1F5F9` | `#1E293B` | +| Checked | `#EFF6FF` | `#172554` | +| Checked+Hover | `#DBEAFE` | `#1E3A5F` | + +### Chevron on right + +`RowWidget` packs chevron with `side="right"` before other right-side widgets, so it appears at the far right. + +--- + +## Row 3 — Progress + Control bar + +### Progress bar (Row 3a) + +``` +████████████░░░░░░ 68% Scanning… 34/50 +``` + +- `CTkProgressBar(height=8, corner_radius=4)` — slimmer than current 14px +- Progress text on **same row** to the right of bar: `CTkLabel`, `anchor="w"`, auto-hide when text is empty +- When idle: progress bar hidden entirely (`grid_remove`); shown when scanning or downloading + +### Control bar (Row 3b) + +``` +[Select All] [Deselect All] [⬇ Download] [⏸ Pause] [📁 Folder] Threads [5▾] [Logs ▾] +``` + +- Left group: `Select All` / `Deselect All` (secondary style, height=30) +- Right group (right-aligned): + - `⬇ Download Selected` — primary blue button + - `⏸ Pause` — secondary, `state="disabled"` until download starts + - `📁 Folder` — secondary + - `Threads` label + `CTkOptionMenu` (1–10, width=65) + - `Logs ▾` toggle button — expands/collapses Row 4 panel + +--- + +## Row 4 — Logs / Downloads panel + +- **Default state: hidden** (`grid_remove` on startup) +- Fixed height: 160px +- `CTkTabview` with tabs: `Downloads` | `Logs` +- Default active tab: `Logs` +- Toggle button text: `"Logs ▾"` (collapsed) ↔ `"Logs ▴"` (expanded) + +--- + +## Preserved public interfaces (no changes to callers) + +All existing methods remain: +- `add_folder`, `add_file`, `toggle_check`, `select_all`, `deselect_all` +- `expand_all`, `collapse_all`, `sort_tree` +- `_backup_full_tree`, `_restore_full_tree`, `_filter_tree_by_term` +- `on_scan_started`, `on_scan_progress`, `on_scan_finished` +- `update_progress`, `update_download_status` +- `checked_items: set[str]` + +--- + +## Implementation notes + +- `_build_header()` → rename to `_build_toolbar()`, restructure columns +- `_build_filters_row()` → split into `_build_search_types_row()`; remove "Types:" label and All/None buttons; add context menu binding +- `_build_filetree()` → update `RowWidget` (chevron right, accent border, no type badge, smaller row) +- `_build_progress_section()` + `_build_download_controls()` → merge into `_build_bottom_bar()` +- `_build_panels()` → update default to hidden; update toggle button label +- `_build_full_ui()` → update `grid_rowconfigure`, call renamed builders +- `clear_scan_results()` → call automatically from `start_scan()` before starting thread diff --git a/docs/plans/2026-03-10-ui-redesign.md b/docs/plans/2026-03-10-ui-redesign.md new file mode 100644 index 0000000..92daf5f --- /dev/null +++ b/docs/plans/2026-03-10-ui-redesign.md @@ -0,0 +1,953 @@ +# UI/UX Redesign Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Redesign IndexRipper's layout to a clean macOS-native style — slim toolbar, merged search+types row, dominant FileTree, slim bottom bar with collapsible panel. + +**Architecture:** All changes are confined to `ui_ctk.py`. No new files. Five builder methods are renamed/rewritten; `RowWidget` gets cosmetic updates. All public interfaces and backend hooks are preserved unchanged. + +**Tech Stack:** Python 3.11+, CustomTkinter (ctk), tkinter (tk/ttk) + +**Design reference:** `docs/plans/2026-03-10-ui-redesign-design.md` + +--- + +### Task 1: Update RowWidget — accent border, chevron right, remove type badge + +**Files:** +- Modify: `ui_ctk.py:57-186` (module-level constants + RowWidget class) + +**Context:** +`RowWidget` is the class that renders one visible row in the FileTree. Currently: +- Chevron (`▶`/`▼`) is on the **left** side +- Checked state = blue full-row background fill +- Type badge is shown on the right (`node.icon_group` text) +- `ROW_HEIGHT = 38`, `pady=1` in `pack()` + +After this task: +- Chevron moves to **right** side +- Checked state = transparent/subtle background + **3px blue left accent bar** +- Type badge **removed** +- `ROW_HEIGHT = 36`, `pady=0` + +**Step 1: Update module-level color constants** + +Replace lines 57-60 in `ui_ctk.py`: + +```python +_BG_NORMAL = "transparent" +_BG_HOVER = ("#F1F5F9", "#1E293B") +_BG_CHECKED = ("#EFF6FF", "#172554") +_BG_CHECKED_HOVER = ("#DBEAFE", "#1E3A5F") +``` + +**Step 2: Update `RowWidget.__init__` — ROW_HEIGHT, pady, accent bar, chevron right, no type badge** + +Replace the entire `RowWidget` class body (lines ~63-186) with: + +```python +class RowWidget: + """One visible row in the FileTree.""" + + INDENT_PX = 20 + ROW_HEIGHT = 36 + + def __init__(self, parent, app, node: TreeNode, depth: int): + self.app = app + self.node_id = node.node_id + self._checked = node.checked + self._hovered = False + + self.frame = ctk.CTkFrame(parent, height=self.ROW_HEIGHT, corner_radius=4) + self.frame.pack(fill="x", padx=4, pady=0) + self.frame.pack_propagate(False) + + # 3-px accent bar (left edge, blue when checked) + self._accent = ctk.CTkFrame( + self.frame, width=3, corner_radius=0, + fg_color="#2563EB" if node.checked else "transparent", + ) + self._accent.pack(side="left", fill="y") + + # Indent spacer + if depth > 0: + ctk.CTkFrame( + self.frame, width=depth * self.INDENT_PX, + fg_color="transparent", height=self.ROW_HEIGHT, + ).pack(side="left") + + # Emoji icon + ctk.CTkLabel( + self.frame, + text=_EMOJI_ICONS.get(node.icon_group, "📄"), + font=ctk.CTkFont(size=18), + width=28, + ).pack(side="left", padx=(4, 4)) + + # Name label + self.name_label = ctk.CTkLabel( + self.frame, + text=node.name, + anchor="w", + font=ctk.CTkFont(size=14, weight="bold" if node.kind == "folder" else "normal"), + ) + self.name_label.pack(side="left", fill="x", expand=True) + + # Chevron on RIGHT (folders only) + if node.kind == "folder": + self.chevron = ctk.CTkButton( + self.frame, text="▼" if node.expanded else "▶", + width=22, height=22, fg_color="transparent", + hover_color=("gray85", "gray30"), text_color=("gray40", "gray60"), + font=ctk.CTkFont(size=10), + command=lambda: app._on_chevron_click(self.node_id), + ) + self.chevron.pack(side="right", padx=(4, 4)) + elif node.kind == "file" and node.size: + # Size label (right side, files only) + ctk.CTkLabel( + self.frame, + text=node.size, + font=ctk.CTkFont(size=12), + text_color=("gray50", "gray60"), + width=80, + anchor="e", + ).pack(side="right", padx=(0, 8)) + + self._bind_all(self.frame) + + def _bind_all(self, widget) -> None: + widget.bind("", self._on_enter) + widget.bind("", self._on_leave) + widget.bind("", self._on_click) + for child in widget.winfo_children(): + self._bind_all(child) + + def _on_enter(self, _event=None) -> None: + self._hovered = True + self._update_bg() + + def _on_leave(self, _event=None) -> None: + self._hovered = False + self._update_bg() + + def _on_click(self, event) -> None: + self.app._on_row_click(self.node_id, event) + + def set_checked(self, checked: bool) -> None: + self._checked = checked + self._accent.configure( + fg_color="#2563EB" if checked else "transparent" + ) + self._update_bg() + + def set_chevron(self, expanded: bool) -> None: + if hasattr(self, "chevron"): + self.chevron.configure(text="▼" if expanded else "▶") + + def _update_bg(self) -> None: + if self._checked and self._hovered: + color = _BG_CHECKED_HOVER + elif self._checked: + color = _BG_CHECKED + elif self._hovered: + color = _BG_HOVER + else: + color = _BG_NORMAL + self.frame.configure(fg_color=color) + + def destroy(self) -> None: + self.frame.destroy() +``` + +**Step 3: Verify smoke test still passes** + +```bash +uv run python index_ripper.py --ui-smoke +``` + +Expected: exits with code 0, no exception. + +**Step 4: Run unit tests** + +```bash +uv run python -m pytest test_ui_theme.py test_ui_downloads.py test_backend.py -v +``` + +Expected: all pass. + +**Step 5: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: RowWidget — accent border, chevron right, remove type badge" +``` + +--- + +### Task 2: Rewrite `_build_header` → `_build_toolbar` + +**Files:** +- Modify: `ui_ctk.py` — method `_build_header` (~line 350) + +**Context:** +Current `_build_header`: +- Row layout: `URL label` | `URL entry` | `Status label` | `[Scan] [Pause Scan] [Clear]` +- Status is a plain text label; Pause Scan is always visible; Clear is a separate button + +After this task: +- Status = colored 6px dot + text (right-aligned) +- Pause Scan = hidden by default (`grid_remove`), shown only during scan +- Clear button = **removed** (auto-clear happens in `start_scan`) +- Method renamed to `_build_toolbar` + +**Step 1: Replace `_build_header` with `_build_toolbar`** + +Replace the entire `_build_header` method with: + +```python +def _build_toolbar(self) -> None: + toolbar = ctk.CTkFrame(self.window, fg_color="transparent") + toolbar.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 0)) + toolbar.grid_columnconfigure(1, weight=1) + + self.url_var = tk.StringVar() + self.url_entry = ctk.CTkEntry( + toolbar, textvariable=self.url_var, + placeholder_text="https://", + font=ctk.CTkFont(size=14), + height=36, + ) + self.url_entry.grid(row=0, column=0, sticky="ew", columnspan=2, padx=(0, 8)) + toolbar.grid_columnconfigure(0, weight=0) + toolbar.grid_columnconfigure(1, weight=1) + + # Re-do: URL takes column 0 with weight=1; buttons on right + # Use a simpler single-row approach: + toolbar.grid_columnconfigure(0, weight=1) + toolbar.grid_columnconfigure(1, weight=0) + toolbar.grid_columnconfigure(2, weight=0) + + self.url_entry.grid(row=0, column=0, sticky="ew", padx=(0, 8)) + + # Scan button (toggles Scan ↔ Stop Scan) + self.scan_btn = ctk.CTkButton(toolbar, text="Scan", command=self.start_scan, width=90) + self.scan_btn.grid(row=0, column=1, padx=(0, 4)) + + # Pause Scan button — hidden by default, shown only while scanning + self.scan_pause_btn = ctk.CTkButton( + toolbar, text="Pause", + fg_color=("gray70", "gray30"), + hover_color=("gray60", "gray40"), + command=self.toggle_scan_pause, + width=80, + state="disabled", + ) + self.scan_pause_btn.grid(row=0, column=2, padx=(0, 8)) + self.scan_pause_btn.grid_remove() # hidden until scan starts + + # Status: dot + text in a small frame + status_frame = ctk.CTkFrame(toolbar, fg_color="transparent") + status_frame.grid(row=0, column=3, sticky="e") + + self._status_dot = ctk.CTkFrame( + status_frame, width=8, height=8, corner_radius=4, + fg_color="#059669", + ) + self._status_dot.pack(side="left", padx=(0, 5)) + + self.status_label = ctk.CTkLabel( + status_frame, text="Ready", + text_color="#059669", font=ctk.CTkFont(size=13), + ) + self.status_label.pack(side="left") + + # URL context menu (right-click paste) + self.url_context_menu = tk.Menu(self.window, tearoff=0) + self.url_context_menu.add_command(label="Paste", command=self._paste_into_url_entry) + self.url_entry.bind("", self._show_url_context_menu) + self.url_entry.bind("", self._show_url_context_menu) +``` + +**Step 2: Update `_set_status` to also configure the dot color** + +Replace the `_set_status` method: + +```python +def _set_status(self, text: str, color: str = "#059669") -> None: + try: + self.status_label.configure(text=text, text_color=color) + self._status_dot.configure(fg_color=color) + except Exception: + pass +``` + +**Step 3: Update `on_scan_started` — show Pause Scan button** + +In `on_scan_started`: +```python +def on_scan_started(self, *, url: str = "") -> None: + self.scan_btn.configure(text="Stop Scan") + self.scan_pause_btn.grid() # show + self.scan_pause_btn.configure(state="normal") + self.progress_bar.set(0) + self.progress_label.configure(text="Scanning…") + self._set_status("Scanning", "#B45309") +``` + +**Step 4: Update `on_scan_finished` — hide Pause Scan button** + +In `on_scan_finished` inside `_finish()`: +```python +self.scan_btn.configure(text="Scan") +self.scan_pause_btn.grid_remove() # hide +self.scan_pause_btn.configure(text="Pause", state="disabled") +``` + +**Step 5: Update `_build_full_ui` to call `_build_toolbar` instead of `_build_header`** + +In `_build_full_ui`, replace the call `self._build_header()` with `self._build_toolbar()`. + +**Step 6: Smoke test + unit tests** + +```bash +uv run python index_ripper.py --ui-smoke +uv run python -m pytest test_backend.py test_ui_theme.py -v +``` + +Expected: all pass. + +**Step 7: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: toolbar redesign — status dot, hide pause btn, remove clear btn" +``` + +--- + +### Task 3: Rewrite `_build_filters_row` → `_build_search_types_row` + +**Files:** +- Modify: `ui_ctk.py` — method `_build_filters_row` (~line 556) + +**Context:** +Current `_build_filters_row` builds **two rows**: +- Row 1 (ctrl_row_frame): placeholder, later filled by `_build_download_controls` +- Row 2 (types_row): "Types:" label + `[✓ All]` `[✗ All]` + horizontal scrollable checkboxes + +After this task: **single row** combining Search + Types: +- Left: 🔍 + Search entry (fixed width 260px) +- Separator (1px vertical line) +- Right: horizontal scrollable checkboxes (no label, no ✓/✗ All buttons) +- Right-click on checkboxes area: context menu with "Select All Types" / "Deselect All Types" + +The `_ctrl_row_frame` is still created (Task 4 uses it for download controls). + +**Step 1: Replace `_build_filters_row`** + +```python +def _build_search_types_row(self) -> None: + # ── Row 1: download controls placeholder (filled in _build_bottom_bar) ── + self._ctrl_row_frame = ctk.CTkFrame(self.window, fg_color="transparent") + self._ctrl_row_frame.grid(row=1, column=0, sticky="ew", padx=10, pady=(6, 0)) + self._ctrl_row_frame.grid_columnconfigure(0, weight=1) + + # ── Row 2: Search + separator + Types ──────────────────────────────── + row = ctk.CTkFrame(self.window, fg_color="transparent") + row.grid(row=2, column=0, sticky="ew", padx=10, pady=(6, 0)) + row.grid_columnconfigure(2, weight=1) + + # Search icon + entry + ctk.CTkLabel(row, text="🔍", font=ctk.CTkFont(size=15)).grid( + row=0, column=0, padx=(0, 4) + ) + self.search_var = tk.StringVar() + self.search_entry = ctk.CTkEntry( + row, textvariable=self.search_var, + placeholder_text="Search files…", + width=260, height=34, + ) + self.search_entry.grid(row=0, column=1, padx=(0, 8)) + self.search_var.trace_add("write", self.on_search_filter_changed) + + # Vertical separator + ctk.CTkFrame(row, width=1, height=30, fg_color=("gray80", "gray30")).grid( + row=0, column=2, sticky="ns", padx=(0, 8) + ) + row.grid_columnconfigure(2, weight=0) + row.grid_columnconfigure(3, weight=1) + + # Horizontal scrollable types + self.filters_container = ctk.CTkScrollableFrame( + row, + height=38, + orientation="horizontal", + fg_color="transparent", + ) + self.filters_container.grid(row=0, column=3, sticky="ew") + self._bind_hscroll_wheel(self.filters_container) + + # Right-click context menu on types container + self._types_context_menu = tk.Menu(self.window, tearoff=0) + self._types_context_menu.add_command( + label="Select All Types", command=self.select_all_types + ) + self._types_context_menu.add_command( + label="Deselect All Types", command=self.deselect_all_types + ) + self.filters_container.bind("", self._show_types_context_menu) + self.filters_container.bind("", self._show_types_context_menu) +``` + +**Step 2: Add `_show_types_context_menu` helper** + +```python +def _show_types_context_menu(self, event) -> None: + try: + self._types_context_menu.tk_popup(event.x_root, event.y_root) + finally: + self._types_context_menu.grab_release() +``` + +**Step 3: Update `_build_full_ui` to call `_build_search_types_row` instead of `_build_filters_row`** + +Replace `self._build_filters_row()` with `self._build_search_types_row()`. + +Also update `grid_rowconfigure` in `_build_full_ui` — the filetree is now at **row 3** (search+types moved from row 2 to row 2, filetree stays at row 3): + +```python +def _build_full_ui(self) -> None: + self.window.grid_columnconfigure(0, weight=1) + self.window.grid_rowconfigure(3, weight=1) # filetree + self.window.grid_rowconfigure(5, weight=0) # panels + + self._build_toolbar() # row 0 + self._build_search_types_row() # rows 1 (ctrl placeholder) + 2 (search+types) + self._build_filetree() # row 3 + self._build_bottom_bar() # rows 4 (progress) + … + self._build_panels() # row 5 + # _build_download_controls() content is now in _build_bottom_bar() + + self.sort_reverse = False + self.full_tree_backup = {} + + self.context_menu = tk.Menu(self.window, tearoff=0) + self.context_menu.add_command(label="Select All", command=self.select_all) + self.context_menu.add_command(label="Deselect All", command=self.deselect_all) + self.context_menu.add_separator() + self.context_menu.add_command(label="Expand All", command=self.expand_all) + self.context_menu.add_command(label="Collapse All", command=self.collapse_all) +``` + +**Step 4: Smoke test** + +```bash +uv run python index_ripper.py --ui-smoke +``` + +Expected: exits 0. + +**Step 5: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: merge search + types into single row, add types context menu" +``` + +--- + +### Task 4: Rewrite progress + controls → `_build_bottom_bar` + +**Files:** +- Modify: `ui_ctk.py` — methods `_build_progress_section`, `_build_download_controls`, `_build_full_ui` + +**Context:** +Currently: +- `_build_progress_section` (row 4): progress bar (height=14) + label on separate line +- `_build_download_controls` (appended into `_ctrl_row_frame` row 1): Select All, Deselect All, Download, Pause, Folder, Threads, Hide Panels + +After this task: +- **Row 4a** (progress row): slim bar (height=8) + text on same line; hidden when idle +- **Row 4b** (control bar): Select All, Deselect All | Download, Pause, Folder, Threads | **Logs ▾** toggle +- `_build_download_controls` is **removed** (absorbed into `_build_bottom_bar`) +- `_build_progress_section` is **removed** (absorbed into `_build_bottom_bar`) + +**Step 1: Add `_build_bottom_bar` method** + +```python +def _build_bottom_bar(self) -> None: + _s = dict( + fg_color=("gray80", "gray25"), + text_color=("gray10", "gray90"), + hover_color=("gray70", "gray35"), + height=30, + font=ctk.CTkFont(size=12), + ) + + # ── Row 4: progress bar + text ───────────────────────────────────── + progress_frame = ctk.CTkFrame(self.window, fg_color="transparent") + progress_frame.grid(row=4, column=0, sticky="ew", padx=10, pady=(4, 0)) + progress_frame.grid_columnconfigure(0, weight=1) + progress_frame.grid_remove() # hidden until scanning/downloading + self._progress_frame = progress_frame + + self.progress_bar = ctk.CTkProgressBar( + progress_frame, height=8, corner_radius=4 + ) + self.progress_bar.set(0) + self.progress_bar.grid(row=0, column=0, sticky="ew", padx=(0, 8)) + + self.progress_label = ctk.CTkLabel( + progress_frame, text="", + font=ctk.CTkFont(size=12), + anchor="w", width=220, + ) + self.progress_label.grid(row=0, column=1, sticky="w") + + # ── Row 5: control bar ───────────────────────────────────────────── + ctrl = ctk.CTkFrame(self.window, fg_color="transparent") + ctrl.grid(row=5, column=0, sticky="ew", padx=10, pady=(4, 6)) + ctrl.grid_columnconfigure(1, weight=1) # elastic gap between left and right + + # Left: select controls + left = ctk.CTkFrame(ctrl, fg_color="transparent") + left.grid(row=0, column=0, sticky="w") + ctk.CTkButton(left, text="Select All", command=self.select_all, width=100, **_s).pack(side="left", padx=(0, 4)) + ctk.CTkButton(left, text="Deselect All", command=self.deselect_all, width=110, **_s).pack(side="left") + + # Right: download controls + logs toggle + right = ctk.CTkFrame(ctrl, fg_color="transparent") + right.grid(row=0, column=2, sticky="e") + + self.download_btn = ctk.CTkButton( + right, text="⬇ Download", command=self.download_selected, height=30, + ) + self.download_btn.pack(side="left", padx=(0, 4)) + + self.pause_btn = ctk.CTkButton( + right, text="⏸ Pause", + fg_color=("gray70", "gray30"), + hover_color=("gray60", "gray40"), + command=self.toggle_pause, + state="disabled", height=30, + ) + self.pause_btn.pack(side="left", padx=(0, 4)) + + self.path_btn = ctk.CTkButton( + right, text="📁 Folder", + fg_color=("gray70", "gray30"), + hover_color=("gray60", "gray40"), + command=self.choose_download_path, height=30, + ) + self.path_btn.pack(side="left", padx=(0, 8)) + + ctk.CTkLabel(right, text="Threads", font=ctk.CTkFont(size=12)).pack(side="left", padx=(0, 4)) + + self.threads_var = tk.StringVar(value="5") + self.threads_combo = ctk.CTkOptionMenu( + right, + values=[str(i) for i in range(1, 11)], + variable=self.threads_var, + command=self.update_thread_count, + width=65, height=30, + ) + self.threads_combo.pack(side="left", padx=(0, 12)) + + self.panels_visible = False + self.toggle_panels_btn = ctk.CTkButton( + right, text="Logs ▾", + command=self.toggle_panels, + width=80, **_s, + ) + self.toggle_panels_btn.pack(side="left") +``` + +**Step 2: Update `toggle_panels` for new button text and row index** + +```python +def toggle_panels(self) -> None: + if self.panels_visible: + if hasattr(self, "_panels_widget"): + self._panels_widget.grid_remove() + self.panels_visible = False + self.toggle_panels_btn.configure(text="Logs ▾") + else: + if hasattr(self, "_panels_widget"): + self._panels_widget.grid() + self.panels_visible = True + self.toggle_panels_btn.configure(text="Logs ▴") +``` + +**Step 3: Update progress visibility** + +Add a helper to show/hide the progress row: + +```python +def _show_progress(self, visible: bool) -> None: + try: + if visible: + self._progress_frame.grid() + else: + self._progress_frame.grid_remove() + except Exception: + pass +``` + +Update `on_scan_started`: +```python +def on_scan_started(self, *, url: str = "") -> None: + self.scan_btn.configure(text="Stop Scan") + self.scan_pause_btn.grid() + self.scan_pause_btn.configure(state="normal") + self.progress_bar.set(0) + self.progress_label.configure(text="Scanning…") + self._show_progress(True) + self._set_status("Scanning", "#B45309") +``` + +Update `on_scan_finished` `_finish()`: +```python +self.scan_btn.configure(text="Scan") +self.scan_pause_btn.grid_remove() +self.scan_pause_btn.configure(text="Pause", state="disabled") +if stopped: + self.progress_bar.set(0) + self.progress_label.configure(text="Scan stopped") + self._set_status("Stopped", "#B91C1C") + self._show_progress(False) +else: + self.progress_bar.set(1) + n = len(self.files_dict) + self.progress_label.configure(text=f"Done — {n} files found") + self._set_status("Ready", "#059669") + # keep progress bar visible to show "done" state; hide after 3 s + self.window.after(3000, lambda: self._show_progress(False)) +``` + +**Step 4: Remove `_build_download_controls` and `_build_progress_section`** + +Delete both methods entirely from `ui_ctk.py`. + +**Step 5: Update `_build_full_ui` grid rows** + +```python +def _build_full_ui(self) -> None: + self.window.grid_columnconfigure(0, weight=1) + self.window.grid_rowconfigure(3, weight=1) # filetree + + self._build_toolbar() # row 0 + self._build_search_types_row() # row 1 (ctrl placeholder) + row 2 (search+types) + self._build_filetree() # row 3 + self._build_bottom_bar() # row 4 (progress) + row 5 (controls) + self._build_panels() # row 6 + + self.sort_reverse = False + self.full_tree_backup = {} + + self.context_menu = tk.Menu(self.window, tearoff=0) + self.context_menu.add_command(label="Select All", command=self.select_all) + self.context_menu.add_command(label="Deselect All", command=self.deselect_all) + self.context_menu.add_separator() + self.context_menu.add_command(label="Expand All", command=self.expand_all) + self.context_menu.add_command(label="Collapse All", command=self.collapse_all) +``` + +**Step 6: Smoke test** + +```bash +uv run python index_ripper.py --ui-smoke +uv run python -m pytest test_backend.py test_ui_theme.py test_ui_downloads.py -v +``` + +Expected: exits 0 / all pass. + +**Step 7: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: merge progress + controls into _build_bottom_bar; panels default hidden" +``` + +--- + +### Task 5: Update `_build_panels` and `_build_filetree` — row index + empty state + +**Files:** +- Modify: `ui_ctk.py` — methods `_build_panels`, `_build_filetree` + +**Context:** +After Task 4, the panels tab is at **row 6** (not row 5). Also: +- Panels default to **hidden** (already set in `_build_bottom_bar`) +- FileTree should show an empty-state label when no nodes exist + +**Step 1: Update `_build_panels` row index** + +```python +def _build_panels(self) -> None: + self.panels_notebook = ctk.CTkTabview(self.window, height=160) + self.panels_notebook.grid(row=6, column=0, sticky="ew", padx=10, pady=(0, 10)) + self._panels_widget = self.panels_notebook + + downloads_tab = self.panels_notebook.add("Downloads") + logs_tab = self.panels_notebook.add("Logs") + self.panels_notebook.set("Logs") + + downloads_scroll = ctk.CTkScrollableFrame(downloads_tab, height=100) + downloads_scroll.pack(fill="both", expand=True) + self.downloads_panel = DownloadsPanel( + parent_frame=downloads_scroll, + ctk=ctk, + tk=tk, + tokens=self.ui_tokens, + ) + + self.log_text = ctk.CTkTextbox(logs_tab, height=100, wrap="word") + self.log_text.pack(fill="both", expand=True) + + # Start hidden (toggle via Logs ▾ button) + self.panels_notebook.grid_remove() +``` + +**Step 2: Add empty-state label to `_build_filetree`** + +In `_build_filetree`, after creating `tree_scroll_frame`, add: + +```python +# Empty state label (shown when no nodes; hidden when rows exist) +self._empty_label = ctk.CTkLabel( + self.tree_scroll_frame, + text="Enter a URL above and click Scan", + text_color=("gray50", "gray60"), + font=ctk.CTkFont(size=14), +) +self._empty_label.pack(expand=True) +``` + +**Step 3: Update `_sync_rows` to show/hide empty label** + +At the end of `_sync_rows`: + +```python +# Show empty state if no visible rows +if hasattr(self, "_empty_label"): + if self._visible_nodes: + self._empty_label.pack_forget() + else: + self._empty_label.pack(expand=True) +``` + +**Step 4: Update empty label text during scanning** + +In `on_scan_started`: +```python +if hasattr(self, "_empty_label"): + self._empty_label.configure(text="Scanning…") +``` + +In `on_scan_finished` inside `_finish()`: +```python +if hasattr(self, "_empty_label") and not self.tree_nodes: + self._empty_label.configure( + text="No files found" if not stopped else "Enter a URL above and click Scan" + ) +``` + +**Step 5: Smoke test** + +```bash +uv run python index_ripper.py --ui-smoke +uv run python -m pytest -v +``` + +Expected: all pass. + +**Step 6: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: panels at row 6, default hidden; filetree empty state label" +``` + +--- + +### Task 6: Auto-clear on scan start + window size + final wiring + +**Files:** +- Modify: `ui_ctk.py` — `__init__`, `start_scan`, `_build_ui_smoke_only` + +**Context:** +- `start_scan` should call `clear_scan_results()` automatically (removing need for a Clear button) +- Window default size changed to `1100×800`, minsize `800×550` +- `_build_ui_smoke_only` must initialise `_progress_frame` stub to avoid AttributeError +- `_ctrl_row_frame` in `_build_full_ui` is no longer filled by `_build_download_controls` — it was the Row 1 placeholder used by the old control flow. It's now empty and can be removed entirely. Check if anything still references it. + +**Step 1: Update window geometry in `__init__`** + +```python +self.window.geometry("1100x800") +self.window.minsize(800, 550) +``` + +**Step 2: Update `start_scan` to auto-clear** + +```python +def start_scan(self) -> None: + url = self.url_var.get().strip() + if self.is_scanning: + self.backend.should_stop = True + self._set_status("Stopping", "#B45309") + return + if not url: + self.notify_error("Error", "Please enter a URL") + return + + # Auto-clear previous results before starting + self.clear_scan_results() + + self.backend.should_stop = False + try: + self.download_path = default_download_folder(url, os.getcwd()) + except Exception: + self.download_path = os.path.join(os.getcwd(), "downloads") + + t = threading.Thread(target=self.backend.scan_website, args=(url,), daemon=True) + t.start() +``` + +**Step 3: Update `clear_scan_results` to also reset progress and empty label** + +Add at end of `clear_scan_results`: +```python +self._show_progress(False) +if hasattr(self, "_empty_label"): + self._empty_label.configure(text="Enter a URL above and click Scan") + self._empty_label.pack(expand=True) +``` + +**Step 4: Remove `_ctrl_row_frame` (Row 1 placeholder) from `_build_search_types_row`** + +Check: `_ctrl_row_frame` is no longer referenced by any method after removing `_build_download_controls`. Remove these lines from `_build_search_types_row`: + +```python +# DELETE these lines: +self._ctrl_row_frame = ctk.CTkFrame(self.window, fg_color="transparent") +self._ctrl_row_frame.grid(row=1, column=0, sticky="ew", padx=10, pady=(6, 0)) +self._ctrl_row_frame.grid_columnconfigure(0, weight=1) +``` + +And update `_build_full_ui` grid: rows are now `0=toolbar`, `1=search+types`, `2=filetree`, `3=progress`, `4=controls`, `5=panels`: + +```python +def _build_full_ui(self) -> None: + self.window.grid_columnconfigure(0, weight=1) + self.window.grid_rowconfigure(2, weight=1) # filetree at row 2 + + self._build_toolbar() # row 0 + self._build_search_types_row() # row 1 + self._build_filetree() # row 2 + self._build_bottom_bar() # row 3 (progress) + row 4 (controls) + self._build_panels() # row 5 + + self.sort_reverse = False + self.full_tree_backup = {} + + self.context_menu = tk.Menu(self.window, tearoff=0) + self.context_menu.add_command(label="Select All", command=self.select_all) + self.context_menu.add_command(label="Deselect All", command=self.deselect_all) + self.context_menu.add_separator() + self.context_menu.add_command(label="Expand All", command=self.expand_all) + self.context_menu.add_command(label="Collapse All", command=self.collapse_all) +``` + +Update `_build_search_types_row` to use **row=1** for the search+types row (not row 2): + +```python +row.grid(row=1, column=0, sticky="ew", padx=10, pady=(6, 0)) +``` + +Update `_build_filetree` to use **row=2**: +```python +outer.grid(row=2, column=0, sticky="nsew", padx=10, pady=(4, 0)) +``` + +Update `_build_bottom_bar` rows: progress=**3**, controls=**4**: +```python +progress_frame.grid(row=3, ...) +ctrl.grid(row=4, ...) +``` + +Update `_build_panels` to use **row=5**: +```python +self.panels_notebook.grid(row=5, ...) +``` + +**Step 5: Fix `_build_ui_smoke_only` stubs** + +Add `_progress_frame`, `_status_dot`, `_empty_label` stubs: + +```python +def _build_ui_smoke_only(self) -> None: + self.url_var = tk.StringVar(value="https://example.com/") + self.search_var = tk.StringVar() + self.log_text = ctk.CTkTextbox(self.window, height=80) + self.log_text.pack(fill="both", expand=True, padx=10, pady=10) + self.log_text.insert("end", "UI smoke minimal view initialized.\n") + self.panels_notebook = None + self.logs_tab = None + self._visible_nodes = [] + self._row_widgets = {} + self.tree_scroll_frame = None + self.full_tree_backup = {} + self.sort_reverse = False + self._last_toggle_time = 0.0 + self._search_after_id = None + self.tree_nodes = {} + self.tree_roots = [] + self._node_counter = 0 + self.context_menu = tk.Menu(self.window, tearoff=0) + # Stubs for attributes used by hooks + self._progress_frame = None + self._status_dot = None + self._empty_label = None + self.progress_bar = type("_Stub", (), {"set": lambda s, v: None, "grid": lambda s, **kw: None, "grid_remove": lambda s: None})() + self.progress_label = type("_Stub", (), {"configure": lambda s, **kw: None})() + self.status_label = type("_Stub", (), {"configure": lambda s, **kw: None})() + self.scan_btn = type("_Stub", (), {"configure": lambda s, **kw: None})() + self.scan_pause_btn = type("_Stub", (), {"configure": lambda s, **kw: None, "grid": lambda s: None, "grid_remove": lambda s: None})() +``` + +**Step 6: Run full test suite + smoke test** + +```bash +uv run python index_ripper.py --ui-smoke +uv run python -m pytest -v +``` + +Expected: smoke exits 0, all pytest tests pass. + +**Step 7: Commit** + +```bash +git add ui_ctk.py +git commit -m "feat: auto-clear on scan, final row wiring, window 1100x800" +``` + +--- + +## Final verification + +After all tasks complete, run: + +```bash +# Smoke test +uv run python index_ripper.py --ui-smoke + +# Full test suite +uv run python -m pytest -v + +# Self test (no real network) +uv run python index_ripper.py --self-test +``` + +All three should succeed with no errors. diff --git a/docs/plans/2026-03-13-code-review-fixes.md b/docs/plans/2026-03-13-code-review-fixes.md new file mode 100644 index 0000000..cd08e92 --- /dev/null +++ b/docs/plans/2026-03-13-code-review-fixes.md @@ -0,0 +1,194 @@ +# Index-Ripper Code Review Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix 3 issues identified in code review: (1) test failure due to UI architecture mismatch, (2) partial file cleanup on download failure, (3) ThreadPoolExecutor resource management. + +**Architecture:** Incremental fixes to existing code without architectural changes. + +**Tech Stack:** Python 3.11+, CustomTkinter, requests, BeautifulSoup + +--- + +### Task 1: Fix test_has_treeview Failure + +**Files:** +- Modify: `test_ui_ctk.py:86-93` +- Verify: Run `uv run pytest test_ui_ctk.py::TestWebsiteCopierCtkSmoke::test_has_treeview -v` + +**Root Cause:** Test checks for `app.tree` (ttk.Treeview) but `WebsiteCopierCtk` uses custom UI with `tree_nodes`, `tree_roots`, `_row_widgets`, `tree_scroll_frame`. + +**Step 1: Update test to check correct attributes** + +```python +def test_has_treeview(self): + import ui_ctk, importlib + importlib.reload(ui_ctk) + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + # CTk version uses custom FileTree components, not ttk.Treeview + self.assertTrue(hasattr(app, "tree_nodes")) + self.assertTrue(hasattr(app, "tree_roots")) + self.assertTrue(hasattr(app, "tree_scroll_frame")) + self.assertTrue(hasattr(app, "search_var")) +``` + +**Step 2: Run test to verify it passes** + +Run: `uv run pytest test_ui_ctk.py::TestWebsiteCopierCtkSmoke::test_has_treeview -v` +Expected: PASS + +**Step 3: Commit** + +```bash +git add test_ui_ctk.py +git commit -m "fix(test): update test_has_treeview to match CTk UI architecture" +``` + +--- + +### Task 2: Add Partial File Cleanup on Download Failure + +**Files:** +- Modify: `backend.py:303-351` + +**Root Cause:** When download is canceled/fails/stopped, partial file remains on disk. + +**Step 1: Add cleanup helper function** + +Add at top of `backend.py` after imports: +```python +def _cleanup_partial_file(file_path: str) -> None: + """Remove partial download file if it exists.""" + try: + import os + if os.path.exists(file_path): + os.remove(file_path) + except OSError: + pass # Best-effort cleanup +``` + +**Step 2: Add cleanup calls in download_file** + +Modify `backend.py` - add cleanup in these 3 locations: + +1. After cancel detection (around line 314): +```python +if cancel_event is not None and cancel_event.is_set(): + self.ui_manager.log_message(f"[Download] Canceled: {file_name}") + _cleanup_partial_file(file_path) # ADD THIS LINE + try: + self.ui_manager.update_download_status(file_path, "Canceled") + except AttributeError: + pass + return False +``` + +2. After should_stop check (around line 317): +```python +if self.should_stop: + self._log(f"[Download] Stopping download for {file_name}") + _cleanup_partial_file(file_path) # ADD THIS LINE + return False +``` + +3. In exception handlers (around lines 341 and 351): +```python +except requests.exceptions.RequestException as ex: + self._log(f"[Download] Error downloading {file_name}: {str(ex)}") + _cleanup_partial_file(file_path) # ADD THIS LINE + try: + self.ui_manager.update_download_status(file_path, "Failed") + self.ui_manager.log_message(f"[Download] Error: {file_name} - {ex}") + except AttributeError: + pass + return False +``` + +And: +```python +except IOError as ex: + self._log(f"[Download] File error for {file_name}: {str(ex)}") + _cleanup_partial_file(file_path) # ADD THIS LINE + try: + self.ui_manager.update_download_status(file_path, "Failed") + self.ui_manager.log_message(f"[Download] File error: {file_name} - {ex}") + except AttributeError: + pass + return False +``` + +**Step 3: Run tests to verify no regressions** + +Run: `uv run pytest test_backend.py -v` +Expected: All pass + +**Step 4: Commit** + +```bash +git add backend.py +git commit -m "fix(backend): cleanup partial files on download cancel/failure" +``` + +--- + +### Task 3: Fix ThreadPoolExecutor Resource Management + +**Files:** +- Modify: `index_ripper.py:1183-1194` +- Verify: Run `uv run pytest test_ui_ctk.py -v` + +**Root Cause:** `update_thread_count` shuts down old executor without waiting, then immediately creates new one, potentially causing resource leaks. + +**Step 1: Fix executor shutdown sequence** + +Modify `index_ripper.py` lines 1183-1194: + +```python +def update_thread_count(self, new_count_str: str): + """Updates the number of concurrent download threads.""" + try: + new_count = int(new_count_str) + if 1 <= new_count <= 10: + old_executor = self.executor + self.max_workers = new_count + self.executor = ThreadPoolExecutor(max_workers=self.max_workers) + # Shutdown old executor after new one is ready + old_executor.shutdown(wait=True, cancel_futures=False) + except (ValueError, TypeError): + pass +``` + +**Step 2: Run tests to verify no regressions** + +Run: `uv run pytest test_ui_ctk.py -v` +Expected: All pass + +**Step 3: Commit** + +```bash +git add index_ripper.py +git commit -m "fix: properly shutdown ThreadPoolExecutor before creating new one" +``` + +--- + +### Task 4: Final Verification + +**Step 1: Run full test suite** + +Run: `uv run pytest -v` +Expected: All tests pass (44 passed, 1 skipped) + +**Step 2: Verify no lint errors** + +Run: `uv run python -m py_compile index_ripper.py backend.py test_ui_ctk.py` +Expected: No output (success) + +**Step 3: Commit** + +```bash +git add -A +git commit -m "chore: code review fixes - test update, partial file cleanup, executor management" +``` diff --git a/docs/superpowers/plans/2026-03-22-electron-rewrite.md b/docs/superpowers/plans/2026-03-22-electron-rewrite.md new file mode 100644 index 0000000..1b29b84 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-electron-rewrite.md @@ -0,0 +1,1298 @@ +# Index Ripper Electron Rewrite — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a new Electron desktop app (React + TypeScript + Node.js) that replaces the Python version with full feature parity plus multi-site task queue support. + +**Architecture:** Electron main process handles scanning and downloads (cheerio + Electron net + p-queue). React renderer with Zustand stores displays a split-panel UI. IPC bridge connects them via typed channels. All shared types in `src/shared/`. + +**Tech Stack:** Electron 35+, electron-vite, React 19, TypeScript, Tailwind CSS v4, shadcn/ui, Zustand, cheerio, p-queue, @tanstack/react-virtual, electron-store, electron-builder + +**Spec:** `docs/superpowers/specs/2026-03-22-electron-rewrite-design.md` + +--- + +## Task 1: Project Scaffolding + +**Files:** +- Create: `electron-app/package.json` +- Create: `electron-app/electron.vite.config.ts` +- Create: `electron-app/tsconfig.json` +- Create: `electron-app/tsconfig.node.json` +- Create: `electron-app/tsconfig.web.json` +- Create: `electron-app/src/main/index.ts` +- Create: `electron-app/src/preload/index.ts` +- Create: `electron-app/src/renderer/index.html` +- Create: `electron-app/src/renderer/main.tsx` +- Create: `electron-app/src/renderer/App.tsx` +- Create: `electron-app/electron-builder.yml` +- Create: `electron-app/resources/icon.png` + +- [ ] **Step 1: Scaffold project with electron-vite** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +npm create @quick-start/electron@latest electron-app -- --template react-ts +``` + +If the interactive scaffolding fails, create manually. The result should be a working electron-vite project with React + TypeScript. + +- [ ] **Step 2: Install core dependencies** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app +npm install zustand cheerio p-queue electron-store +npm install -D tailwindcss @tailwindcss/vite @tanstack/react-virtual vitest +``` + +- [ ] **Step 3: Configure Tailwind CSS v4** + +In `electron-app/src/renderer/main.css`: +```css +@import "tailwindcss"; +``` + +In `electron-app/electron.vite.config.ts`, add Tailwind plugin to renderer config: +```typescript +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + // ... + renderer: { + plugins: [react(), tailwindcss()], + }, +}) +``` + +- [ ] **Step 4: Set up shadcn/ui** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app +npx shadcn@latest init +``` + +Configure with: TypeScript, New York style, Slate base color, CSS variables. + +Add commonly needed components: +```bash +npx shadcn@latest add button tabs checkbox scroll-area context-menu progress sonner +``` + +- [ ] **Step 5: Create minimal App.tsx** + +```tsx +export default function App(): React.ReactElement { + return ( +
+

Index Ripper

+
+ ) +} +``` + +- [ ] **Step 6: Verify app launches** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app +npm run dev +``` + +Expected: Electron window opens showing "Index Ripper" text on dark background. + +- [ ] **Step 7: Copy app icon** + +```bash +cp /Users/guantou/Desktop/Index-Ripper/app.png /Users/guantou/Desktop/Index-Ripper/electron-app/resources/icon.png +``` + +- [ ] **Step 8: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/ +git commit -m "feat(electron): scaffold project with electron-vite, React, TypeScript, Tailwind, shadcn/ui" +``` + +--- + +## Task 2: Shared Types & Utilities + +**Files:** +- Create: `electron-app/src/shared/types.ts` +- Create: `electron-app/src/shared/icons.ts` +- Create: `electron-app/src/main/utils.ts` +- Test: `electron-app/src/main/__tests__/utils.test.ts` + +- [ ] **Step 1: Create shared types** + +```typescript +// electron-app/src/shared/types.ts + +export interface TreeNode { + id: string + parentId: string + name: string + kind: 'folder' | 'file' + url: string + fullPath: string + size: string + fileType: string + iconGroup: string + checked: boolean + expanded: boolean + hidden: boolean + children: string[] +} + +export interface Task { + id: string + url: string + status: 'idle' | 'scanning' | 'scanned' | 'downloading' | 'done' | 'error' | 'cancelled' + nodes: Record + roots: string[] + checkedFiles: string[] + downloads: Record + scanProgress: { scanned: number; total: number } + downloadPath: string +} + +export interface DownloadItem { + id: string + fileName: string + url: string + destPath: string + status: 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'cancelled' + progress: number + speed: number + totalSize: number + downloadedSize: number +} + +export type ScanStatus = Task['status'] +export type DownloadStatus = DownloadItem['status'] +``` + +- [ ] **Step 2: Create icon mapping** + +```typescript +// electron-app/src/shared/icons.ts +import { extname } from 'path' + +const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico'] +const DOC_EXTS = ['.md', '.txt', '.pdf', '.doc', '.docx', '.rtf'] +const ARCHIVE_EXTS = ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz'] +const CODE_EXTS = [ + '.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.rs', '.java', '.c', '.cpp', + '.h', '.hpp', '.json', '.yaml', '.yml', '.toml', '.xml', '.html', '.css', '.sh', +] + +export function getIconGroup(fileName: string, mimeType: string): string { + const ext = extname(fileName).toLowerCase() + if (IMAGE_EXTS.includes(ext)) return 'image' + if (DOC_EXTS.includes(ext)) return 'document' + if (ARCHIVE_EXTS.includes(ext)) return 'archive' + if (CODE_EXTS.includes(ext)) return 'code' + if (mimeType.includes('audio/')) return 'audio' + if (mimeType.includes('video/')) return 'video' + if (mimeType.includes('text/')) return 'text' + if (mimeType.includes('image/')) return 'image' + return 'binary' +} + +export const EMOJI_ICONS: Record = { + folder: '📁', image: '🖼️', document: '📄', archive: '🗜️', + code: '💻', audio: '🎵', video: '🎬', text: '📝', binary: '⚙️', +} +``` + +- [ ] **Step 3: Write utils tests** + +```typescript +// electron-app/src/main/__tests__/utils.test.ts +import { describe, it, expect } from 'vitest' +import { sanitizePathSegment, sanitizeFilename, isUrlInScope, safeJoin, defaultDownloadFolder } from '../utils' + +describe('sanitizePathSegment', () => { + it('blocks traversal patterns', () => { + expect(sanitizePathSegment('..')).toBe('_') + expect(sanitizePathSegment('a/b')).toBe('a_b') + expect(sanitizePathSegment('a\\b')).toBe('a_b') + }) + it('strips Windows-illegal chars', () => { + expect(sanitizePathSegment('<>:"|?*')).toBe('_______') + }) + it('returns _ for empty', () => { + expect(sanitizePathSegment('')).toBe('_') + }) +}) + +describe('isUrlInScope', () => { + it('accepts same origin and path prefix', () => { + expect(isUrlInScope('https://example.com/a/b/', 'https://example.com/a/b/file.txt')).toBe(true) + }) + it('rejects different host', () => { + expect(isUrlInScope('https://example.com/a/', 'https://evil.com/a/file.txt')).toBe(false) + }) + it('rejects different scheme', () => { + expect(isUrlInScope('https://example.com/a/', 'http://example.com/a/file.txt')).toBe(false) + }) +}) + +describe('safeJoin', () => { + it('rejects path escape', () => { + expect(() => safeJoin('/tmp/root', ['..', 'outside.txt'])).toThrow() + }) + it('accepts normal path', () => { + const result = safeJoin('/tmp/root', ['sub', 'file.txt']) + expect(result).toContain('root') + expect(result).toContain('file.txt') + }) +}) + +describe('defaultDownloadFolder', () => { + it('derives folder from hostname', () => { + const result = defaultDownloadFolder('https://example.com/files/') + expect(result).toContain('example.com') + }) +}) +``` + +- [ ] **Step 4: Run tests to verify they fail** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app +npx vitest run src/main/__tests__/utils.test.ts +``` + +Expected: FAIL — module not found + +- [ ] **Step 5: Implement utils** + +```typescript +// electron-app/src/main/utils.ts +import { resolve, normalize, join } from 'path' + +const WINDOWS_INVALID = /[<>:"/\\|?*]/g + +export function sanitizePathSegment(name: string): string { + let value = decodeURIComponent(String(name || '')) + value = value.replace(/[/\\]/g, '_') + value = value.replace(WINDOWS_INVALID, '_') + value = value.trim().replace(/[. ]+$/, '') + if (!value || value === '.' || value === '..') return '_' + return value +} + +export function sanitizeFilename(name: string): string { + return sanitizePathSegment(name) +} + +export function isUrlInScope(baseUrl: string, candidateUrl: string): boolean { + let base: URL, candidate: URL + try { + base = new URL(baseUrl) + candidate = new URL(candidateUrl) + } catch { + return false + } + if (base.protocol !== candidate.protocol) return false + if (base.hostname.toLowerCase() !== candidate.hostname.toLowerCase()) return false + if (base.port !== candidate.port) return false + const basePath = normalize(base.pathname).replace(/\/$/, '') + const candPath = normalize(candidate.pathname) + if (basePath === '/' || basePath === '.') return true + return candPath === basePath || candPath.startsWith(basePath + '/') +} + +export function safeJoin(root: string, parts: string[]): string { + const rootReal = resolve(root) + const target = resolve(join(rootReal, ...parts)) + if (!target.startsWith(rootReal)) { + throw new Error('Path escapes root') + } + return target +} + +export function defaultDownloadFolder(url: string): string { + try { + const hostname = new URL(url).hostname.replace(/:/g, '_') + return join(require('os').homedir(), 'Downloads', hostname || 'downloads') + } catch { + return join(require('os').homedir(), 'Downloads', 'downloads') + } +} + +export function normalizeExtension(fileName: string): string { + const dot = fileName.lastIndexOf('.') + if (dot < 0) return '.(No Extension)' + return fileName.slice(dot).toLowerCase() +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app +npx vitest run src/main/__tests__/utils.test.ts +``` + +Expected: All tests PASS + +- [ ] **Step 7: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/src/shared/ electron-app/src/main/utils.ts electron-app/src/main/__tests__/ +git commit -m "feat(electron): add shared types, icons, and utility functions with tests" +``` + +--- + +## Task 3: Scanner Service + +**Files:** +- Create: `electron-app/src/main/scanner.ts` +- Test: `electron-app/src/main/__tests__/scanner.test.ts` + +- [ ] **Step 1: Write scanner tests** + +```typescript +// electron-app/src/main/__tests__/scanner.test.ts +import { describe, it, expect, vi } from 'vitest' +import { parseDirectoryListing, isDirectoryHref } from '../scanner' + +describe('parseDirectoryListing', () => { + it('extracts links from Index of page', () => { + const html = ` + + sub/ + file.txt + Parent Directory + Name + + ` + const links = parseDirectoryListing(html, 'https://example.com/files/') + expect(links).toContainEqual({ url: 'https://example.com/files/sub/', isDirectory: true }) + expect(links).toContainEqual({ url: 'https://example.com/files/file.txt', isDirectory: false }) + expect(links.find(l => l.url.includes('..'))).toBeUndefined() + expect(links.find(l => l.url.includes('?'))).toBeUndefined() + }) + + it('ignores out-of-scope links', () => { + const html = 'hack' + const links = parseDirectoryListing(html, 'https://example.com/') + expect(links).toHaveLength(0) + }) +}) + +describe('isDirectoryHref', () => { + it('returns true for trailing slash', () => { + expect(isDirectoryHref('sub/')).toBe(true) + }) + it('returns false for files', () => { + expect(isDirectoryHref('file.txt')).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app +npx vitest run src/main/__tests__/scanner.test.ts +``` + +Expected: FAIL + +- [ ] **Step 3: Implement scanner** + +```typescript +// electron-app/src/main/scanner.ts +import * as cheerio from 'cheerio' +import { net } from 'electron' +import { basename, dirname } from 'path' +import { EventEmitter } from 'events' +import type { TreeNode } from '../shared/types' +import { isUrlInScope } from './utils' +import { getIconGroup } from '../shared/icons' + +export interface ParsedLink { + url: string + isDirectory: boolean +} + +export function isDirectoryHref(href: string): boolean { + return href.endsWith('/') +} + +export function parseDirectoryListing(html: string, pageUrl: string): ParsedLink[] { + const $ = cheerio.load(html) + const links: ParsedLink[] = [] + const seen = new Set() + + $('a').each((_, el) => { + const href = $(el).attr('href') + if (!href || href === '.' || href === '..' || href === '/' || href.startsWith('?')) return + if (href.includes('..')) return + + let fullUrl: string + try { + fullUrl = new URL(href, pageUrl).href + } catch { + return + } + + // Strip query/fragment + const parsed = new URL(fullUrl) + fullUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}` + + if (!isUrlInScope(pageUrl, fullUrl)) return + if (seen.has(fullUrl)) return + seen.add(fullUrl) + + links.push({ url: fullUrl, isDirectory: isDirectoryHref(href) }) + }) + + return links +} + +export interface ScanCallbacks { + onItem: (node: Partial & { isDirectory: boolean }) => void + onProgress: (scanned: number, total: number) => void + onError: (message: string) => void + onFinished: (stopped: boolean) => void +} + +export class Scanner extends EventEmitter { + private aborted = false + private paused = false + private pausePromise: Promise | null = null + private pauseResolve: (() => void) | null = null + + abort(): void { this.aborted = true } + + pause(): void { + if (this.paused) return + this.paused = true + this.pausePromise = new Promise(r => { this.pauseResolve = r }) + } + + resume(): void { + this.paused = false + this.pauseResolve?.() + this.pausePromise = null + } + + private async waitIfPaused(): Promise { + if (this.pausePromise) await this.pausePromise + } + + async scan(url: string, callbacks: ScanCallbacks): Promise { + try { + // Phase 1: Discover all URLs recursively + const allUrls = await this.discoverUrls(url, callbacks) + if (this.aborted) { callbacks.onFinished(true); return } + + // Phase 2: Process each URL (HEAD requests for files) + let scanned = 0 + const total = allUrls.length + callbacks.onProgress(0, total) + + for (const item of allUrls) { + if (this.aborted) break + await this.waitIfPaused() + + if (item.isDirectory) { + const dirPath = new URL(item.url).pathname.replace(/\/$/, '') + callbacks.onItem({ isDirectory: true, fullPath: dirPath, name: basename(dirPath) || '/', kind: 'folder' }) + } else { + await this.processFile(item.url, callbacks) + } + scanned++ + callbacks.onProgress(scanned, total) + } + + callbacks.onFinished(this.aborted) + } catch (err) { + callbacks.onError(String(err)) + callbacks.onFinished(false) + } + } + + private async discoverUrls( + url: string, + callbacks: ScanCallbacks, + visited = new Set() + ): Promise { + const normalized = url.endsWith('/') ? url : url + '/' + if (visited.has(normalized) || this.aborted) return [] + visited.add(normalized) + + const html = await this.fetchHtml(url) + if (!html) return [] + + const links = parseDirectoryListing(html, url) + const result: ParsedLink[] = [...links] + + for (const link of links) { + if (this.aborted) break + if (link.isDirectory) { + const subLinks = await this.discoverUrls(link.url, callbacks, visited) + result.push(...subLinks) + } + } + + return result + } + + private async fetchHtml(url: string): Promise { + return new Promise((resolve) => { + const request = net.request(url) + let data = '' + request.on('response', (response) => { + response.on('data', (chunk) => { data += chunk.toString() }) + response.on('end', () => resolve(data)) + response.on('error', () => resolve(null)) + }) + request.on('error', () => resolve(null)) + request.end() + }) + } + + private async processFile(url: string, callbacks: ScanCallbacks): Promise { + const parsed = new URL(url) + const fileName = decodeURIComponent(basename(parsed.pathname)) + const dirPath = decodeURIComponent(dirname(parsed.pathname)) + if (!fileName) return + + const fullPath = (dirPath + '/' + fileName).replace(/^\//, '') + + try { + const head = await this.headRequest(url) + const size = head.contentLength + ? `${(head.contentLength / 1024).toFixed(2)} KB` + : 'Unknown' + const fileType = head.contentType || 'Unknown' + const iconGroup = getIconGroup(fileName, fileType) + + callbacks.onItem({ + isDirectory: false, + name: fileName, + kind: 'file', + url, + fullPath, + size, + fileType, + iconGroup, + }) + } catch { + callbacks.onError(`Could not process file ${url}`) + } + } + + private headRequest(url: string): Promise<{ contentLength: number | null; contentType: string }> { + return new Promise((resolve, reject) => { + const request = net.request({ url, method: 'HEAD' }) + request.on('response', (response) => { + resolve({ + contentLength: response.headers['content-length'] + ? parseInt(String(response.headers['content-length']), 10) + : null, + contentType: String(response.headers['content-type'] || ''), + }) + response.on('data', () => {}) // drain + response.on('end', () => {}) + }) + request.on('error', reject) + request.end() + }) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app +npx vitest run src/main/__tests__/scanner.test.ts +``` + +Expected: PASS (pure parsing tests; Scanner class needs Electron runtime for full test) + +- [ ] **Step 5: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/src/main/scanner.ts electron-app/src/main/__tests__/scanner.test.ts +git commit -m "feat(electron): implement scanner service with directory listing parser" +``` + +--- + +## Task 4: Download Manager + +**Files:** +- Create: `electron-app/src/main/downloader.ts` + +- [ ] **Step 1: Implement downloader** + +```typescript +// electron-app/src/main/downloader.ts +import { net } from 'electron' +import { createWriteStream, mkdirSync, unlinkSync, existsSync } from 'fs' +import { dirname } from 'path' +import PQueue from 'p-queue' +import { EventEmitter } from 'events' +import type { DownloadItem } from '../shared/types' + +export interface DownloadCallbacks { + onProgress: (fileId: string, progress: number, speed: number) => void + onStatus: (fileId: string, status: DownloadItem['status']) => void + onFinished: (completed: number, total: number) => void + onLog: (message: string) => void +} + +export class Downloader extends EventEmitter { + private queue: PQueue + private aborted = false + private paused = false + private pausePromise: Promise | null = null + private pauseResolve: (() => void) | null = null + private cancelledFiles = new Set() + + constructor(concurrency = 5) { + super() + this.queue = new PQueue({ concurrency }) + } + + setConcurrency(n: number): void { + this.queue.concurrency = Math.max(1, Math.min(10, n)) + } + + pause(): void { + if (this.paused) return + this.paused = true + this.queue.pause() + this.pausePromise = new Promise(r => { this.pauseResolve = r }) + } + + resume(): void { + this.paused = false + this.queue.start() + this.pauseResolve?.() + this.pausePromise = null + } + + cancelFile(fileId: string): void { + this.cancelledFiles.add(fileId) + } + + cancelAll(): void { + this.aborted = true + this.queue.clear() + } + + async downloadFiles( + files: Array<{ id: string; url: string; destPath: string; fileName: string }>, + callbacks: DownloadCallbacks + ): Promise { + this.aborted = false + this.cancelledFiles.clear() + let completed = 0 + const total = files.length + + const tasks = files.map(file => + this.queue.add(async () => { + if (this.aborted || this.cancelledFiles.has(file.id)) { + callbacks.onStatus(file.id, 'cancelled') + return + } + const success = await this.downloadOne(file, callbacks) + if (success) completed++ + }) + ) + + await Promise.allSettled(tasks) + callbacks.onFinished(completed, total) + } + + private async downloadOne( + file: { id: string; url: string; destPath: string; fileName: string }, + callbacks: DownloadCallbacks, + attempt = 1 + ): Promise { + callbacks.onStatus(file.id, 'downloading') + + return new Promise((resolve) => { + mkdirSync(dirname(file.destPath), { recursive: true }) + + const request = net.request(file.url) + request.on('response', (response) => { + if (response.statusCode && response.statusCode >= 500 && attempt <= 3) { + const delay = Math.pow(2, attempt) * 100 + setTimeout(() => { + this.downloadOne(file, callbacks, attempt + 1).then(resolve) + }, delay) + return + } + + const totalSize = response.headers['content-length'] + ? parseInt(String(response.headers['content-length']), 10) + : 0 + let downloaded = 0 + let lastTime = Date.now() + let lastBytes = 0 + + const stream = createWriteStream(file.destPath) + + response.on('data', (chunk) => { + if (this.aborted || this.cancelledFiles.has(file.id)) { + request.abort() + stream.close() + this.cleanupFile(file.destPath) + callbacks.onStatus(file.id, 'cancelled') + callbacks.onLog(`[Download] Cancelled: ${file.fileName}`) + resolve(false) + return + } + + stream.write(chunk) + downloaded += chunk.length + + const now = Date.now() + const elapsed = (now - lastTime) / 1000 + if (elapsed >= 0.5) { + const speed = (downloaded - lastBytes) / elapsed + lastTime = now + lastBytes = downloaded + const progress = totalSize > 0 ? (downloaded / totalSize) * 100 : 0 + callbacks.onProgress(file.id, progress, speed) + } + }) + + response.on('end', () => { + stream.close() + callbacks.onProgress(file.id, 100, 0) + callbacks.onStatus(file.id, 'completed') + callbacks.onLog(`[Download] Completed: ${file.fileName}`) + resolve(true) + }) + + response.on('error', (err) => { + stream.close() + this.cleanupFile(file.destPath) + callbacks.onStatus(file.id, 'failed') + callbacks.onLog(`[Download] Failed: ${file.fileName} — ${err}`) + resolve(false) + }) + }) + + request.on('error', (err) => { + this.cleanupFile(file.destPath) + callbacks.onStatus(file.id, 'failed') + callbacks.onLog(`[Download] Error: ${file.fileName} — ${err}`) + resolve(false) + }) + + request.end() + }) + } + + private cleanupFile(path: string): void { + try { + if (existsSync(path)) unlinkSync(path) + } catch { /* ignore */ } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/src/main/downloader.ts +git commit -m "feat(electron): implement download manager with retry, cancel, pause/resume" +``` + +--- + +## Task 5: Task Queue & IPC Bridge + +**Files:** +- Create: `electron-app/src/main/task-queue.ts` +- Create: `electron-app/src/main/ipc.ts` +- Modify: `electron-app/src/main/index.ts` +- Modify: `electron-app/src/preload/index.ts` + +- [ ] **Step 1: Implement task queue** + +```typescript +// electron-app/src/main/task-queue.ts +import { randomUUID } from 'crypto' +import type { Task } from '../shared/types' +import { Scanner } from './scanner' +import { Downloader } from './downloader' +import { defaultDownloadFolder } from './utils' + +export class TaskQueue { + tasks = new Map() + + createTask(url: string): Task { + const id = randomUUID() + const task: Task = { + id, + url, + status: 'idle', + nodes: {}, + roots: [], + checkedFiles: [], + downloads: {}, + scanProgress: { scanned: 0, total: 0 }, + downloadPath: defaultDownloadFolder(url), + } + this.tasks.set(id, { task, scanner: new Scanner(), downloader: new Downloader() }) + return task + } + + getTask(taskId: string) { return this.tasks.get(taskId) } + removeTask(taskId: string) { + const entry = this.tasks.get(taskId) + if (entry) { + entry.scanner.abort() + entry.downloader.cancelAll() + this.tasks.delete(taskId) + } + } + allTasks(): Task[] { return [...this.tasks.values()].map(e => e.task) } +} +``` + +- [ ] **Step 2: Implement IPC handler registration** + +Create `electron-app/src/main/ipc.ts` that registers all IPC handlers listed in the spec. Each handler delegates to TaskQueue, Scanner, or Downloader. Main-to-renderer events use `webContents.send()`. + +Key handlers: +- `scan:start` → creates Scanner, calls `scanner.scan()`, pipes callbacks to renderer via IPC events +- `download:start` → prepares file list, calls `downloader.downloadFiles()` +- `task:create` → creates new task, returns task data +- `dialog:selectFolder` → uses `dialog.showOpenDialog()` +- `download:setWorkers` → calls `downloader.setConcurrency()` + +- [ ] **Step 3: Update preload to expose typed API** + +```typescript +// electron-app/src/preload/index.ts +import { contextBridge, ipcRenderer } from 'electron' + +const api = { + // Renderer → Main + scan: { + start: (taskId: string, url: string) => ipcRenderer.invoke('scan:start', taskId, url), + stop: (taskId: string) => ipcRenderer.invoke('scan:stop', taskId), + pause: (taskId: string) => ipcRenderer.invoke('scan:pause', taskId), + resume: (taskId: string) => ipcRenderer.invoke('scan:resume', taskId), + }, + download: { + start: (taskId: string, files: any[], destPath: string) => + ipcRenderer.invoke('download:start', taskId, files, destPath), + pause: (taskId: string) => ipcRenderer.invoke('download:pause', taskId), + resume: (taskId: string) => ipcRenderer.invoke('download:resume', taskId), + cancel: (taskId: string, fileId: string) => ipcRenderer.invoke('download:cancel', taskId, fileId), + cancelAll: (taskId: string) => ipcRenderer.invoke('download:cancelAll', taskId), + retry: (taskId: string, fileId: string) => ipcRenderer.invoke('download:retry', taskId, fileId), + setWorkers: (count: number) => ipcRenderer.invoke('download:setWorkers', count), + }, + task: { + create: (url: string) => ipcRenderer.invoke('task:create', url), + remove: (taskId: string) => ipcRenderer.invoke('task:remove', taskId), + }, + settings: { + get: () => ipcRenderer.invoke('settings:get'), + set: (data: any) => ipcRenderer.invoke('settings:set', data), + }, + dialog: { + selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'), + }, + // Main → Renderer (event subscriptions) + on: (channel: string, callback: (...args: any[]) => void) => { + const listener = (_event: any, ...args: any[]) => callback(...args) + ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) + }, +} + +contextBridge.exposeInMainWorld('api', api) +``` + +- [ ] **Step 4: Update main/index.ts to register IPC handlers on app ready** + +Wire up `registerIpcHandlers(taskQueue)` call in the `app.whenReady()` block. + +- [ ] **Step 5: Verify app still launches** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app +npm run dev +``` + +Expected: Electron window opens without errors + +- [ ] **Step 6: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/src/main/ electron-app/src/preload/ +git commit -m "feat(electron): implement task queue, IPC bridge, and preload API" +``` + +--- + +## Task 6: Zustand Stores + +**Files:** +- Create: `electron-app/src/renderer/stores/task-store.ts` +- Create: `electron-app/src/renderer/stores/tree-store.ts` +- Create: `electron-app/src/renderer/stores/download-store.ts` +- Create: `electron-app/src/renderer/hooks/useIpc.ts` + +- [ ] **Step 1: Create IPC subscription hook** + +```typescript +// electron-app/src/renderer/hooks/useIpc.ts +import { useEffect } from 'react' + +declare global { + interface Window { api: any } +} + +export function useIpcEvent(channel: string, callback: (...args: any[]) => void): void { + useEffect(() => { + const unsubscribe = window.api.on(channel, callback) + return unsubscribe + }, [channel, callback]) +} +``` + +- [ ] **Step 2: Create task store** + +```typescript +// electron-app/src/renderer/stores/task-store.ts +import { create } from 'zustand' +import type { Task } from '../../shared/types' + +interface TaskStore { + tasks: Record + activeTaskId: string | null + createTask: (task: Task) => void + removeTask: (taskId: string) => void + setActiveTask: (taskId: string) => void + updateTask: (taskId: string, partial: Partial) => void +} + +export const useTaskStore = create((set) => ({ + tasks: {}, + activeTaskId: null, + createTask: (task) => set((state) => ({ + tasks: { ...state.tasks, [task.id]: task }, + activeTaskId: state.activeTaskId ?? task.id, + })), + removeTask: (taskId) => set((state) => { + const { [taskId]: _, ...rest } = state.tasks + const ids = Object.keys(rest) + return { + tasks: rest, + activeTaskId: state.activeTaskId === taskId ? (ids[0] ?? null) : state.activeTaskId, + } + }), + setActiveTask: (taskId) => set({ activeTaskId: taskId }), + updateTask: (taskId, partial) => set((state) => ({ + tasks: { + ...state.tasks, + [taskId]: { ...state.tasks[taskId], ...partial }, + }, + })), +})) +``` + +- [ ] **Step 3: Create tree and download stores** + +Similar pattern — `useTreeStore` manages nodes/roots/checked per task, `useDownloadStore` manages download items per task. Both update from IPC events. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/src/renderer/stores/ electron-app/src/renderer/hooks/ +git commit -m "feat(electron): add Zustand stores for tasks, tree, and downloads" +``` + +--- + +## Task 7: UI Shell — TaskTabs + Toolbar + SplitPanel + +**Files:** +- Create: `electron-app/src/renderer/components/TaskTabs.tsx` +- Create: `electron-app/src/renderer/components/Toolbar.tsx` +- Create: `electron-app/src/renderer/components/SplitPanel.tsx` +- Modify: `electron-app/src/renderer/App.tsx` + +- [ ] **Step 1: Build TaskTabs component** + +Tab bar showing all tasks. Each tab displays shortened hostname. `+` button to create new task. Active tab highlighted. + +- [ ] **Step 2: Build Toolbar component** + +URL input, Scan/Stop button, Download button, thread count dropdown, status indicator (dot + text). + +- [ ] **Step 3: Build SplitPanel component** + +Resizable left/right panels using CSS `resize` or a drag handle. Left panel takes ~40% default width. + +- [ ] **Step 4: Assemble in App.tsx** + +```tsx +export default function App() { + return ( +
+ + + File Tree (coming next)
} + right={
Downloads/Logs (coming next)
} + /> + + ) +} +``` + +- [ ] **Step 5: Verify layout renders correctly** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app && npm run dev +``` + +Expected: Window shows task tabs, toolbar with URL input, and split panel layout. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/src/renderer/ +git commit -m "feat(electron): build UI shell with TaskTabs, Toolbar, SplitPanel" +``` + +--- + +## Task 8: FileTree Component + +**Files:** +- Create: `electron-app/src/renderer/components/FileTree.tsx` +- Create: `electron-app/src/renderer/components/FileTreeRow.tsx` +- Create: `electron-app/src/renderer/hooks/useFileTree.ts` +- Create: `electron-app/src/renderer/components/SearchBar.tsx` +- Create: `electron-app/src/renderer/components/TypeFilters.tsx` +- Create: `electron-app/src/renderer/components/StatusBar.tsx` + +- [ ] **Step 1: Create useFileTree hook** + +Logic for: flatten tree to visible list (respecting expanded/hidden), toggle check (with folder cascade), expand/collapse, search filter, type filter. + +- [ ] **Step 2: Create FileTreeRow component** + +Single row: 3px accent bar + indent spacer + emoji icon + name + size (files) or chevron (folders). Hover effect, click to toggle check. + +- [ ] **Step 3: Create FileTree component** + +Uses `@tanstack/react-virtual` to virtualize the flattened visible node list. Right-click context menu using shadcn ContextMenu. + +- [ ] **Step 4: Create SearchBar, TypeFilters, StatusBar** + +- SearchBar: input field, Ctrl+F focuses it, Escape clears +- TypeFilters: horizontal scrollable row of checkbox chips with counts +- StatusBar: "20 files, 3 folders | 2 selected" + +- [ ] **Step 5: Wire into SplitPanel left side** + +- [ ] **Step 6: Verify with mock data** + +Manually add some TreeNode data to the store and verify the tree renders, expands, collapses, and checkboxes work. + +- [ ] **Step 7: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/src/renderer/ +git commit -m "feat(electron): implement FileTree with virtual scrolling, search, type filters" +``` + +--- + +## Task 9: Downloads Panel + Logs Panel + +**Files:** +- Create: `electron-app/src/renderer/components/DownloadsList.tsx` +- Create: `electron-app/src/renderer/components/DownloadCard.tsx` +- Create: `electron-app/src/renderer/components/LogsPanel.tsx` + +- [ ] **Step 1: Build DownloadCard** + +Card showing: file name, progress bar (shadcn Progress), status text (color-coded), speed display, cancel/retry button. + +- [ ] **Step 2: Build DownloadsList** + +Scrollable list of DownloadCards. Overall progress bar + ETA at bottom. + +- [ ] **Step 3: Build LogsPanel** + +Scrollable monospace text area, auto-scrolls to bottom. Receives `log:message` IPC events. + +- [ ] **Step 4: Wire into SplitPanel right side with tab switching** + +Use shadcn Tabs to switch between Downloads and Logs. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/src/renderer/ +git commit -m "feat(electron): implement DownloadsList, DownloadCard, LogsPanel" +``` + +--- + +## Task 10: IPC Integration — Connect UI to Backend + +**Files:** +- Modify: `electron-app/src/renderer/components/Toolbar.tsx` +- Modify: `electron-app/src/renderer/components/TaskTabs.tsx` +- Modify: `electron-app/src/renderer/App.tsx` +- Modify: `electron-app/src/renderer/stores/*.ts` + +- [ ] **Step 1: Wire scan flow** + +Toolbar "Scan" button → `window.api.scan.start(taskId, url)`. Subscribe to `scan:item`, `scan:progress`, `scan:finished`, `scan:error` events → update tree store and task store. + +- [ ] **Step 2: Wire download flow** + +Toolbar "Download" button → collect checked files → `window.api.download.start(taskId, files, destPath)`. Subscribe to `download:progress`, `download:status`, `download:finished` → update download store. + +- [ ] **Step 3: Wire task management** + +TaskTabs `+` button → `window.api.task.create(url)`. Tab close → `window.api.task.remove(taskId)`. Tab click → switch active task in store. + +- [ ] **Step 4: Wire settings persistence** + +On window close → save panel sizes, thread count, last download path via `window.api.settings.set()`. On app start → restore via `window.api.settings.get()`. + +- [ ] **Step 5: Add toast notifications** + +Use shadcn Sonner for: scan complete, download complete, errors. + +- [ ] **Step 6: Full integration test** + +Launch app, enter a real "Index of" URL, scan, select files, download. Verify complete flow. + +- [ ] **Step 7: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/ +git commit -m "feat(electron): integrate IPC between UI and backend, full scan/download flow" +``` + +--- + +## Task 11: Packaging & Build Configuration + +**Files:** +- Modify: `electron-app/electron-builder.yml` +- Modify: `electron-app/package.json` + +- [ ] **Step 1: Configure electron-builder.yml** + +```yaml +appId: com.indexripper.app +productName: Index Ripper +directories: + buildResources: resources + output: dist +files: + - '!**/.vscode/*' + - '!src/*' + - '!electron.vite.config.*' + - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' +win: + target: nsis + icon: resources/icon.ico +mac: + target: dmg + icon: resources/icon.png + category: public.app-category.utilities +linux: + target: AppImage + icon: resources/icon.png + category: Utility +``` + +- [ ] **Step 2: Add build scripts to package.json** + +```json +{ + "scripts": { + "build": "electron-vite build", + "build:win": "npm run build && electron-builder --win", + "build:mac": "npm run build && electron-builder --mac", + "build:linux": "npm run build && electron-builder --linux" + } +} +``` + +- [ ] **Step 3: Test build** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app +npm run build +``` + +Expected: Build succeeds, output in `dist/` + +- [ ] **Step 4: Commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add electron-app/ +git commit -m "build(electron): configure electron-builder for cross-platform packaging" +``` + +--- + +## Task 12: Final Verification & Cleanup + +- [ ] **Step 1: Run all tests** + +```bash +cd /Users/guantou/Desktop/Index-Ripper/electron-app +npx vitest run +``` + +- [ ] **Step 2: Verify complete flow** + +1. `npm run dev` → app launches +2. Enter URL → Scan → file tree populates +3. Check files → Download → progress shows +4. Pause/resume scan and download +5. Add second task tab → switch between tasks +6. Cancel individual download +7. Change thread count + +- [ ] **Step 3: Update .gitignore** + +Add `electron-app/node_modules/`, `electron-app/dist/`, `electron-app/out/` to root `.gitignore`. + +- [ ] **Step 4: Final commit** + +```bash +cd /Users/guantou/Desktop/Index-Ripper +git add -A +git commit -m "feat(electron): complete Electron rewrite with full feature parity" +``` diff --git a/docs/superpowers/plans/2026-03-22-project-restructure.md b/docs/superpowers/plans/2026-03-22-project-restructure.md new file mode 100644 index 0000000..030caab --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-project-restructure.md @@ -0,0 +1,665 @@ +# Index Ripper 專案重構計畫 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 將平放在根目錄的所有 .py 檔案重組為 `src/index_ripper/` 套件結構,移除遺留程式碼,拆分大型模組,統一命名風格,並更新所有相關設定。 + +**Architecture:** 建立 `src/index_ripper/` Python 套件,將核心邏輯、UI 元件、工具函式分離到子模組中。`index_ripper.py` 中的舊版 `WebsiteCopier` 類別(使用 ttk.Treeview)已被 `ui_ctk.py` 中的 `WebsiteCopierCtk`(使用自訂 RowWidget)完全取代,將移除舊版。`ui_ttkb.py`、`pyside_poc.py`、`index_ripper_ttkb.py` 為遺留/PoC 程式碼,一併移除。 + +**Tech Stack:** Python 3.11+, CustomTkinter, requests, BeautifulSoup4, pytest, uv, PyInstaller + +--- + +## 檔案結構規劃 + +### 移除的檔案 +- `index_ripper.py` — 包含舊版 `WebsiteCopier` 類別(1434 行,使用 ttk.Treeview),已被 `ui_ctk.py` 的 `WebsiteCopierCtk` 完全取代 +- `ui_ttkb.py` — 舊版 ttkbootstrap UI(1387 行) +- `index_ripper_ttkb.py` — ttkbootstrap 進入點 +- `pyside_poc.py` — PySide 概念驗證 +- `POC_MATRIX.md` — PoC 比較文件 + +### 新套件結構 +``` +src/ +└── index_ripper/ + ├── __init__.py # 套件初始化,版本號 + ├── __main__.py # python -m index_ripper 進入點(--smoke, --self-test, --ui-smoke) + ├── app.py # WebsiteCopierCtk 主應用類別(初始化、run、on_closing、backend bridge) + ├── backend.py # Backend 類別(掃描、下載邏輯)— 從原 backend.py 移入 + ├── settings.py # load_settings / save_settings — 從原 settings_store.py 移入 + ├── utils.py # 工具函式 — 從原 app_utils.py 移入 + ├── self_test.py # 自測邏輯 — 從原 self_test.py 移入 + └── ui/ + ├── __init__.py # UI 子套件 + ├── theme.py # 主題、顏色 tokens、樣式設定 — 從原 ui_theme.py 移入 + ├── downloads.py # DownloadsPanel — 從原 ui_downloads.py 移入 + ├── filetree.py # TreeNode、RowWidget、FileTree 相關常量 + └── filters.py # 檔案類型篩選邏輯 +tests/ + ├── __init__.py + ├── test_backend.py + ├── test_utils.py # 合併原 test_app_utils.py + test_security_utils.py + ├── test_settings.py # 從原 test_settings_store.py 重新命名 + ├── test_theme.py # 從原 test_ui_theme.py 重新命名 + ├── test_downloads.py # 從原 test_ui_downloads.py 重新命名 + └── test_app_smoke.py # 從原 test_ui_ctk.py 重新命名 +``` + +### 修改的設定檔 +- `pyproject.toml` — 更新 entry point、testpaths +- `macos.spec` — 更新 Analysis 路徑 +- `.github/workflows/ci.yml` — 更新測試指令路徑 +- `.gitignore` — 確認 src/ 結構相容 + +--- + +## Task 1: 建立套件骨架並移動工具模組 + +**Files:** +- Create: `src/index_ripper/__init__.py` +- Create: `src/index_ripper/utils.py` +- Create: `src/index_ripper/settings.py` +- Create: `src/index_ripper/self_test.py` +- Modify: `pyproject.toml` + +- [ ] **Step 1: 建立目錄結構** + +```bash +mkdir -p src/index_ripper/ui +mkdir -p tests +``` + +- [ ] **Step 2: 建立 `src/index_ripper/__init__.py`** + +```python +"""Index Ripper — download files from directory listing pages.""" + +__version__ = "0.1.0" +``` + +- [ ] **Step 3: 移動 `app_utils.py` → `src/index_ripper/utils.py`** + +將 `app_utils.py` 的所有內容複製到 `src/index_ripper/utils.py`,保持所有函式不變。 + +- [ ] **Step 4: 移動 `settings_store.py` → `src/index_ripper/settings.py`** + +將 `settings_store.py` 的所有內容複製到 `src/index_ripper/settings.py`。 + +- [ ] **Step 5: 移動 `self_test.py` → `src/index_ripper/self_test.py`** + +將 `self_test.py` 的所有內容複製到 `src/index_ripper/self_test.py`。 + +- [ ] **Step 6: 更新 `pyproject.toml`** + +新增 build-system 設定(src layout 必需),更新 entry point,移除 ttkbootstrap 依賴: + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/index_ripper"] + +[project.scripts] +index-ripper = "index_ripper.app:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +``` + +移除 `ttkbootstrap` 依賴: +```toml +dependencies = [ + "requests>=2.31.0,<3.0.0", + "beautifulsoup4>=4.12.0,<5.0.0", + "urllib3>=2.0.0,<3.0.0", + "customtkinter>=5.2.0,<6.0.0", +] +``` + +- [ ] **Step 6b: 執行 `uv sync` 安裝新套件結構** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv sync --extra dev` +Expected: 成功安裝,`index_ripper` 套件可被 import + +- [ ] **Step 7: 執行測試確認工具模組可獨立 import** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -c "from index_ripper.utils import sanitize_filename; print('OK')"` +Expected: `OK` + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -c "from index_ripper.settings import load_settings; print('OK')"` +Expected: `OK` + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -c "from index_ripper.self_test import run_self_test; print('OK')"` +Expected: `OK` + +- [ ] **Step 8: Commit** + +```bash +git add src/ pyproject.toml +git commit -m "refactor: create src/index_ripper package skeleton with utils, settings, self_test" +``` + +--- + +## Task 2: 移動 backend 模組 + +**Files:** +- Create: `src/index_ripper/backend.py` +- Modify: `src/index_ripper/utils.py` (確認 import 路徑) + +- [ ] **Step 1: 複製 `backend.py` → `src/index_ripper/backend.py`** + +更新 import 語句: +```python +# 原本 +from app_utils import cleanup_partial_file, is_url_in_scope + +# 改為 +from index_ripper.utils import cleanup_partial_file, is_url_in_scope +``` + +其餘 Backend 類別內容完全不變。 + +- [ ] **Step 2: 驗證 import** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -c "from index_ripper.backend import Backend; print('OK')"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add src/index_ripper/backend.py +git commit -m "refactor: move Backend class to src/index_ripper/backend.py" +``` + +--- + +## Task 3: 移動 UI 子模組(theme、downloads) + +**Files:** +- Create: `src/index_ripper/ui/__init__.py` +- Create: `src/index_ripper/ui/theme.py` +- Create: `src/index_ripper/ui/downloads.py` + +- [ ] **Step 1: 建立 `src/index_ripper/ui/__init__.py`** + +```python +"""Index Ripper UI components.""" +``` + +- [ ] **Step 2: 複製 `ui_theme.py` → `src/index_ripper/ui/theme.py`** + +內容完全不變(沒有 internal import 需要更新)。 + +- [ ] **Step 3: 複製 `ui_downloads.py` → `src/index_ripper/ui/downloads.py`** + +內容完全不變(沒有 internal import 需要更新)。 + +- [ ] **Step 4: 驗證 import** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -c "from index_ripper.ui.theme import ui_tokens; print('OK')"` +Expected: `OK` + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -c "from index_ripper.ui.downloads import DownloadsPanel; print('OK')"` +Expected: `OK` + +- [ ] **Step 5: Commit** + +```bash +git add src/index_ripper/ui/ +git commit -m "refactor: move theme and downloads UI modules to src/index_ripper/ui/" +``` + +--- + +## Task 4: 拆分 ui_ctk.py — 抽取 filetree 模組 + +**Files:** +- Create: `src/index_ripper/ui/filetree.py` + +`ui_ctk.py` 的前 180 行包含 `TreeNode` dataclass、`_EMOJI_ICONS` 常量、顏色常量、`RowWidget` 類別和 `should_skip_file_row` 函式。這些是自成一體的檔案樹資料模型和視覺元件。 + +- [ ] **Step 1: 建立 `src/index_ripper/ui/filetree.py`** + +從 `ui_ctk.py` 第 1-184 行提取以下內容: +- `TreeNode` dataclass +- `_EMOJI_ICONS` 字典 +- `_BG_NORMAL`, `_BG_HOVER`, `_BG_CHECKED`, `_BG_CHECKED_HOVER` 顏色常量 +- `RowWidget` 類別 +- `should_skip_file_row` 函式 + +加上必要的 import: +```python +from __future__ import annotations + +from dataclasses import dataclass, field + +import customtkinter as ctk +``` + +- [ ] **Step 2: 驗證 import** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -c "from index_ripper.ui.filetree import TreeNode, RowWidget; print('OK')"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add src/index_ripper/ui/filetree.py +git commit -m "refactor: extract TreeNode, RowWidget into ui/filetree.py" +``` + +--- + +## Task 5: 拆分 ui_ctk.py — 抽取 filters 模組 + +**Files:** +- Create: `src/index_ripper/ui/filters.py` + +`WebsiteCopierCtk` 中以下方法與檔案類型篩選有關,可獨立為 mixin 或輔助類別: +- `_add_file_type_filter` +- `_on_type_filter_changed` +- `select_all_types` +- `deselect_all_types` +- `_bind_hscroll_wheel` + +- [ ] **Step 1: 建立 `src/index_ripper/ui/filters.py`** + +建立 `FileTypeFilterMixin` 類別,包含上述方法。這些方法依賴 `self.file_types`, `self.file_type_counts`, `self.file_type_widgets`, `self.filters_container`, `self.tree_nodes`, `self.full_tree_backup` 等屬性,作為 mixin 使用時由主應用類別提供。 + +```python +"""File type filter UI logic (mixin for the main app).""" +from __future__ import annotations + +import tkinter as tk +import customtkinter as ctk + +from index_ripper.utils import normalize_extension + + +class FileTypeFilterMixin: + """Mixin providing file-type checkbox filter behaviour.""" + + def _add_file_type_filter(self, ext: str) -> None: + ... # 從 ui_ctk.py 第 1479-1497 行搬入 + + def _on_type_filter_changed(self, ext: str) -> None: + ... # 從 ui_ctk.py 第 1499-1511 行搬入 + # 注意:此方法呼叫 self._rebuild_visible() 和 self._sync_rows() + # 這些方法定義在 WebsiteCopierCtk (app.py) 中,由 mixin 透過 self 動態解析 + + def select_all_types(self) -> None: + ... # 從 ui_ctk.py 第 1449-1451 行搬入 + + def deselect_all_types(self) -> None: + ... # 從 ui_ctk.py 第 1453-1455 行搬入 + + def _bind_hscroll_wheel(self, widget) -> None: + ... # 從 ui_ctk.py 第 1457-1477 行搬入 +``` + +- [ ] **Step 2: 驗證 import** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -c "from index_ripper.ui.filters import FileTypeFilterMixin; print('OK')"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add src/index_ripper/ui/filters.py +git commit -m "refactor: extract FileTypeFilterMixin into ui/filters.py" +``` + +--- + +## Task 6: 建立主應用模組 app.py + +**Files:** +- Create: `src/index_ripper/app.py` + +這是最關鍵的一步。從 `ui_ctk.py` 的 `WebsiteCopierCtk` 類別搬入 `app.py`,同時: +- 從 `ui.filetree` import `TreeNode`, `RowWidget`, `should_skip_file_row` +- 從 `ui.filters` import `FileTypeFilterMixin` 並作為 mixin +- 更新所有 internal import 為套件路徑 + +- [ ] **Step 1: 建立 `src/index_ripper/app.py`** + +```python +"""Main application window.""" +from __future__ import annotations + +import copy +import os +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from queue import Empty, Queue + +import tkinter as tk +from tkinter import filedialog, messagebox, ttk + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +import customtkinter as ctk + +from index_ripper.utils import ( + build_download_path, + default_download_folder, + normalize_extension, + rebuild_executor, + safe_join, + sanitize_filename, +) +from index_ripper.backend import Backend +from index_ripper.ui.downloads import DownloadsPanel +from index_ripper.ui.theme import ( + apply_app_theme, + configure_action_button_styles, + ui_tokens, +) +from index_ripper.ui.filetree import TreeNode, RowWidget, should_skip_file_row +from index_ripper.ui.filters import FileTypeFilterMixin + + +class WebsiteCopierCtk(FileTypeFilterMixin): + ... # 從 ui_ctk.py 第 187-1517 行搬入(排除 TreeNode、RowWidget、should_skip_file_row、 + # FileTypeFilterMixin 已抽出的方法) + + +def main(): + app = WebsiteCopierCtk() + app.run() +``` + +- [ ] **Step 2: 建立 `src/index_ripper/__main__.py`** + +```python +"""Entry point for `python -m index_ripper`.""" +import sys + + +def _main() -> None: + if "--smoke" in sys.argv: + print("SMOKE_OK") + raise SystemExit(0) + + if "--self-test" in sys.argv: + from index_ripper.self_test import run_self_test + + result = run_self_test() + print( + f"SELF_TEST_OK total={result.total} files={result.files} dirs={result.directories}" + ) + raise SystemExit(0) + + from index_ripper.utils import configure_tk_libraries + configure_tk_libraries() + + if "--ui-smoke" in sys.argv: + from index_ripper.app import WebsiteCopierCtk + + app = WebsiteCopierCtk(ui_smoke=True) + app.window.after(0, app.window.destroy) + app.run() + print("UI_SMOKE_OK") + raise SystemExit(0) + + from index_ripper.app import main + main() + + +if __name__ == "__main__": + _main() +``` + +- [ ] **Step 3: 驗證應用可啟動** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -m index_ripper --smoke` +Expected: `SMOKE_OK` + +- [ ] **Step 4: Commit** + +```bash +git add src/index_ripper/app.py src/index_ripper/__main__.py +git commit -m "refactor: create main app module with WebsiteCopierCtk and __main__ entry point" +``` + +--- + +## Task 7: 遷移並合併測試檔案 + +**Files:** +- Create: `tests/__init__.py` +- Create: `tests/test_backend.py` +- Create: `tests/test_utils.py` +- Create: `tests/test_settings.py` +- Create: `tests/test_theme.py` +- Create: `tests/test_downloads.py` +- Create: `tests/test_app_smoke.py` + +- [ ] **Step 1: 建立 `tests/__init__.py`** + +空檔案。 + +- [ ] **Step 2: 遷移 `test_backend.py` → `tests/test_backend.py`** + +更新 import: +```python +# 原本 +from backend import Backend + +# 改為 +from index_ripper.backend import Backend +``` + +- [ ] **Step 3: 合併 `test_app_utils.py` + `test_security_utils.py` → `tests/test_utils.py`** + +更新 import: +```python +# 原本 +from app_utils import ... + +# 改為 +from index_ripper.utils import ... +``` + +將兩個檔案的測試類別合併到同一檔案中。 + +- [ ] **Step 4: 遷移其餘測試** + +- `test_settings_store.py` → `tests/test_settings.py`(import 改為 `from index_ripper.settings import ...`) +- `test_ui_theme.py` → `tests/test_theme.py` + - 原本用 `import ui_theme` 再 `ui_theme.action_button_style_name(...)` 的寫法,改為 `from index_ripper.ui import theme` 再 `theme.action_button_style_name(...)` +- `test_ui_downloads.py` → `tests/test_downloads.py` + - 同上,`import ui_downloads` 改為 `from index_ripper.ui import downloads`,呼叫改為 `downloads.download_status_state(...)` +- `test_ui_ctk.py` → `tests/test_app_smoke.py` + - **重點**:此檔案大量使用 `import ui_ctk`、`importlib.import_module("ui_ctk")` 和 `importlib.reload(mod)` + - 所有 `"ui_ctk"` 字串需改為 `"index_ripper.app"` + - `import ui_ctk` 改為 `from index_ripper import app as ui_ctk` + - `importlib.import_module("ui_ctk")` 改為 `importlib.import_module("index_ripper.app")` + - `import ui_ctk, importlib` 改為 `from index_ripper import app as ui_ctk; import importlib` + +- [ ] **Step 5: 執行所有測試** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -m pytest tests/ -v` +Expected: 所有測試通過 + +- [ ] **Step 6: Commit** + +```bash +git add tests/ +git commit -m "refactor: migrate and consolidate tests to tests/ directory" +``` + +--- + +## Task 8: 刪除舊檔案 + +**Files:** +- Delete: `index_ripper.py` +- Delete: `ui_ctk.py` +- Delete: `ui_ttkb.py` +- Delete: `index_ripper_ttkb.py` +- Delete: `pyside_poc.py` +- Delete: `POC_MATRIX.md` +- Delete: `backend.py` +- Delete: `app_utils.py` +- Delete: `settings_store.py` +- Delete: `self_test.py` +- Delete: `ui_theme.py` +- Delete: `ui_downloads.py` +- Delete: `test_backend.py` +- Delete: `test_app_utils.py` +- Delete: `test_security_utils.py` +- Delete: `test_settings_store.py` +- Delete: `test_ui_theme.py` +- Delete: `test_ui_downloads.py` +- Delete: `test_ui_ttkb.py` +- Delete: `test_ui_ctk.py` + +- [ ] **Step 1: 刪除遺留程式碼(UI 和 PoC)** + +```bash +git rm index_ripper.py ui_ttkb.py index_ripper_ttkb.py pyside_poc.py POC_MATRIX.md +``` + +- [ ] **Step 2: 刪除已遷移到 src/ 的舊模組** + +```bash +git rm ui_ctk.py backend.py app_utils.py settings_store.py self_test.py ui_theme.py ui_downloads.py +``` + +- [ ] **Step 3: 刪除已遷移到 tests/ 的舊測試** + +```bash +git rm test_backend.py test_app_utils.py test_security_utils.py test_settings_store.py test_ui_theme.py test_ui_downloads.py test_ui_ttkb.py test_ui_ctk.py +``` + +- [ ] **Step 4: 執行所有測試確認無殘留依賴** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -m pytest tests/ -v` +Expected: 所有測試通過 + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -m index_ripper --smoke` +Expected: `SMOKE_OK` + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: remove legacy files and old root-level modules" +``` + +--- + +## Task 9: 更新建置與 CI 設定 + +**Files:** +- Modify: `macos.spec` +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/build.yml` (如有需要) + +- [ ] **Step 1: 更新 `macos.spec`** + +```python +a = Analysis( + ["src/index_ripper/__main__.py"], + pathex=["src"], # <-- 必須加上,讓 PyInstaller 找到 index_ripper 套件 + ... + hiddenimports=["tkinter", "tkinter.ttk", "index_ripper"], + ... +) +``` + +- [ ] **Step 2: 更新 `.github/workflows/ci.yml`** + +測試指令改為: +```yaml +- name: Run unit tests + run: uv run python -m pytest tests/ -v +``` + +更新所有三個平台的 PyInstaller build 指令(Windows、macOS、Linux),全部加上 `--paths src`: + +Windows build: +```yaml +run: | + uv run pyinstaller --onefile --windowed --icon=app.png --name=IndexRipper \ + --paths src --collect-all customtkinter src/index_ripper/__main__.py +``` + +macOS build: +```yaml +run: | + uv run pyinstaller -F --windowed --name=IndexRipper \ + --paths src --collect-all customtkinter --icon=app.png src/index_ripper/__main__.py +``` + +Linux build: +```yaml +run: | + uv run pyinstaller --onefile --windowed --name=IndexRipper \ + --paths src --collect-all customtkinter --icon=app.png src/index_ripper/__main__.py +``` + +- [ ] **Step 3: 更新 `pyproject.toml`(如果 Task 1 還沒做完)** + +確認 ttkbootstrap 已從 dependencies 移除。 + +- [ ] **Step 4: 驗證 smoke test** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -m index_ripper --smoke` +Expected: `SMOKE_OK` + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -m index_ripper --self-test` +Expected: `SELF_TEST_OK total=... files=... dirs=...` + +- [ ] **Step 5: Commit** + +```bash +git add macos.spec .github/workflows/ pyproject.toml +git commit -m "build: update CI, PyInstaller, and pyproject for new package structure" +``` + +--- + +## Task 10: 最終驗證與清理 + +**Files:** +- Modify: `.gitignore` (如需要) +- Modify: `README.md` (更新安裝/運行指令) + +- [ ] **Step 1: 執行完整測試套件** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -m pytest tests/ -v` +Expected: 所有測試通過 + +- [ ] **Step 2: 驗證所有進入點** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -m index_ripper --smoke` +Expected: `SMOKE_OK` + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv run python -m index_ripper --self-test` +Expected: `SELF_TEST_OK ...` + +- [ ] **Step 3: 更新 README.md 中的使用說明** + +將 `python index_ripper.py` 改為 `python -m index_ripper` 或 `index-ripper`。 + +- [ ] **Step 4: 確認 `uv.lock` 同步(移除 ttkbootstrap)** + +Run: `cd /Users/guantou/Desktop/Index-Ripper && uv lock` + +- [ ] **Step 5: 最終 Commit** + +```bash +git add -A +git commit -m "docs: update README for new package structure" +``` diff --git a/docs/superpowers/specs/2026-03-22-electron-rewrite-design.md b/docs/superpowers/specs/2026-03-22-electron-rewrite-design.md new file mode 100644 index 0000000..3ab37be --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-electron-rewrite-design.md @@ -0,0 +1,327 @@ +# Index Ripper Electron Rewrite — Design Spec + +## Goal + +Rewrite Index Ripper from Python + CustomTkinter to Electron + React + TypeScript + Node.js. This is a full rewrite — no Python code is retained. The new app delivers all existing features (website scanning, file tree browsing, filtering, multi-threaded download with pause/resume/cancel) plus a new multi-site task queue feature, wrapped in a modern split-panel UI built with shadcn/ui. + +## Architecture + +### Process Model + +**Main Process (Node.js):** +- Scanner service — two-phase: first recursively discover all URLs, then issue HEAD requests per file for metadata (size, MIME type). Scan concurrency: 10 parallel workers. +- Download manager — concurrent streaming downloads with progress tracking, automatic retry (3 attempts with exponential backoff for 5xx errors) +- Task queue — manages multiple scan/download jobs across different sites, each task is independent +- File system — safe path construction, settings persistence + +**Renderer Process (React + TypeScript):** +- Single-window app with split-panel layout, native title bar (not frameless — avoids cross-platform drag/resize issues) +- Communicates with main process exclusively via typed IPC channels +- No direct file system or network access + +**Preload Script:** +- Exposes a typed `window.api` object via `contextBridge` +- All IPC channels explicitly whitelisted — no `remote` module usage + +### IPC Channel Design + +All task-scoped channels include `taskId` as the first parameter. + +``` +renderer → main: + scan:start(taskId, url) → begins recursive crawl + scan:stop(taskId) → aborts in-progress scan + scan:pause(taskId) / scan:resume(taskId) + download:start(taskId, files[], destPath) + download:pause(taskId) / download:resume(taskId) + download:cancel(taskId, fileId) + download:cancelAll(taskId) → cancel all active downloads + download:retry(taskId, fileId) → retry a failed download + download:setWorkers(count) → change concurrency (1-10) + task:create(url) → add new site to task queue + task:remove(taskId) + settings:get() / settings:set(data) + dialog:selectFolder() → open native folder picker + +main → renderer: + scan:item(taskId, node) → discovered file or directory + scan:progress(taskId, scanned, total) + scan:finished(taskId, stopped) + scan:error(taskId, message) → structured scan error + download:progress(taskId, fileId, percent, speed) + download:status(taskId, fileId, status) + download:finished(taskId, completed, total) + log:message(taskId, text) +``` + +## Tech Stack + +| Layer | Technology | Why | +|-------|-----------|-----| +| Framework | Electron 35+ | Desktop app with full OS integration | +| Build tool | electron-vite | Vite-based, fast HMR, TypeScript native | +| Frontend | React 19 + TypeScript | Complex state (tree, downloads, tasks) | +| Styling | Tailwind CSS v4 + shadcn/ui | Modern component library, dark mode built-in | +| State | Zustand | Lightweight, works well with React, no boilerplate | +| HTTP client | Electron `net` module | Respects system proxy, native to Electron | +| HTML parser | cheerio | jQuery-like API for parsing directory listings | +| Concurrency | p-queue | Configurable concurrent download/scan limit | +| Settings | electron-store | JSON persistence with schema validation | +| Tree virtualization | @tanstack/react-virtual | Better variable-height support than react-window | +| Packaging | electron-builder | Cross-platform builds (Windows, macOS, Linux) | + +## UI Design + +### Layout: Split Panel with Native Title Bar + +``` +┌─────────────────────────────────────────────────┐ +│ Index Ripper — □ × │ ← native title bar +├─────────────────────────────────────────────────┤ +│ [example.com] [mirror.site] [+] (task tabs) │ +├─────────────────────────────────────────────────┤ +│ [https://example.com/files/ ] [Scan] [⬇] ● OK │ +├────────────────────┬────────────────────────────┤ +│ 🔍 Search... │ [Downloads] [Logs] │ +│ .pdf(5) .jpg(12) │ │ +│────────────────────│ ┌────────────────────────┐ │ +│ 📁 documents ▼ │ │ report.pdf ✓ Done │ │ +│ ▌📄 report.pdf │ │ ████████████████ 100% │ │ +│ 📄 readme.txt │ ├────────────────────────┤ │ +│ 📁 images ▶ │ │ photo.jpg 67% 1.2MB │ │ +│ 📁 archives ▶ │ │ ████████░░░░░░░ 67% │ │ +│ │ ├────────────────────────┤ │ +│ │ │ backup.zip Queued │ │ +│ │ │ ░░░░░░░░░░░░░░░ 0% │ │ +│ 20 files, 3 dirs │ │ +│ 2 selected │ 1/3 done — ~2 min left │ +└────────────────────┴────────────────────────────┘ +``` + +### Component Hierarchy + +``` +App +├── TaskTabs (multi-site tab bar with + button) +├── Toolbar (URL input, Scan button, Download button, thread count, status) +└── SplitPanel (resizable left/right) + ├── LeftPanel + │ ├── SearchBar (text filter input) + │ ├── TypeFilters (scrollable checkbox chips) + │ ├── FileTree (virtual-scrolled tree with checkboxes) + │ └── StatusBar (file/folder count, selected count) + └── RightPanel + ├── TabSwitcher (Downloads / Logs toggle) + ├── DownloadsList (per-file progress cards with cancel/retry) + └── OverallProgress (aggregate progress bar + ETA) +``` + +### File Tree + +- **Virtual scrolling** via `@tanstack/react-virtual` — flattened list approach, handles 10,000+ nodes +- Each row: accent bar (blue when checked) + icon + name + size + chevron +- Icons: same emoji set as current (📁📄🖼️🗜️💻🎵🎬📝⚙️) +- Checkbox toggling: click row to toggle, folders cascade to children +- Right-click context menu: Select All, Deselect All, Expand All, Collapse All +- Keyboard: Ctrl/Cmd+A select all, Ctrl/Cmd+F focus search, Ctrl/Cmd+L focus logs, Escape clear search +- macOS: standard edit shortcuts (Cmd+V/C/X/Z) work natively in text inputs (Electron handles this with native title bar) + +### Notifications + +- Toast notifications (shadcn/ui `Sonner`) for: scan complete, no files found, download complete, network errors +- Log panel for detailed messages + +### Theme + +- Dark mode only initially (matching current app aesthetic) +- Slate color palette from Tailwind (gray-900 backgrounds, blue-500 accents) +- shadcn/ui dark theme defaults for all interactive components + +## Features + +### Existing Features (preserved) + +1. **Website scanning** — two-phase: recursive URL discovery (10 concurrent), then HEAD requests for file metadata (size, MIME type). Supports pause/resume/stop +2. **File tree** — hierarchical display with expand/collapse, checkbox selection, cascade +3. **File type filtering** — dynamic checkbox chips with counts, select/deselect all +4. **Search filtering** — real-time text search across file names and paths +5. **Configurable concurrent downloads** — 1-10 workers, adjustable at runtime via toolbar dropdown +6. **Per-file progress** — individual progress bars, speed display, status labels, cancel buttons +7. **Pause/resume downloads** — global pause affects all active downloads +8. **Directory structure preservation** — downloads mirror source hierarchy +9. **Partial file cleanup** — removes incomplete files on cancel/error +10. **Settings persistence** — remembers window size, panel proportions, thread count, last download path +11. **Automatic retry** — 3 retries with exponential backoff for transient HTTP errors (5xx) +12. **Default download folder** — auto-derived from site hostname (e.g., `~/Downloads/example.com/`) +13. **URL right-click context menu** — paste support in URL input + +### New Feature: Multi-Site Task Queue + +Each "task" represents one website scan/download session. Users can: +- Add new tasks via the `+` tab button (enters URL, starts scan) +- Switch between tasks — each task has its own file tree, download state, and progress +- Remove completed/cancelled tasks +- Tasks run independently — scanning site A while downloading from site B + +Task lifecycle (state machine): +``` +idle → scanning → scanned → downloading → done + ↓ ↓ + error error + +Any state → cancelled (via task removal) +Scanning can be paused/resumed independently of downloads. +A single task cannot scan and download simultaneously. +``` + +Tasks are **not persisted** across app restarts. Closing the app discards all tasks. + +## Data Models + +All types live in `src/shared/types.ts` — imported by both main and renderer. + +### TreeNode + +```typescript +interface TreeNode { + id: string + parentId: string // "" for roots + name: string + kind: 'folder' | 'file' + url: string // download URL (files only, "" for folders) + fullPath: string // relative path (e.g., "docs/sub" for folders, "docs/sub/file.txt" for files) + size: string // "245 KB" or "Unknown" + fileType: string // MIME type from Content-Type header + iconGroup: string // folder|image|document|archive|code|audio|video|text|binary + checked: boolean + expanded: boolean + hidden: boolean // filtered out by search/type + children: string[] // child node IDs +} +``` + +### Task + +```typescript +interface Task { + id: string + url: string + status: 'idle' | 'scanning' | 'scanned' | 'downloading' | 'done' | 'error' | 'cancelled' + nodes: Record // id → TreeNode (flat map for O(1) lookup) + roots: string[] // top-level node IDs in insertion order + checkedFiles: string[] // fullPath[] of checked files (serializable, derived from nodes) + downloads: Record // fileId → DownloadItem + scanProgress: { scanned: number; total: number } + downloadPath: string // per-task download destination +} +``` + +`checkedFiles` is derived from `nodes` (all file nodes where `checked === true`). It exists as a convenience for `download:start` IPC calls. The source of truth for checked state is `TreeNode.checked`. + +### DownloadItem + +```typescript +interface DownloadItem { + id: string // same as TreeNode.id for correlation + fileName: string + url: string + destPath: string + status: 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'cancelled' + progress: number // 0-100 + speed: number // bytes/sec + totalSize: number + downloadedSize: number +} +``` + +`DownloadItem.id` equals the corresponding `TreeNode.id`, enabling direct correlation between tree selection and download progress. + +### Icon Mapping + +MIME type to `iconGroup` mapping (in `src/shared/icons.ts`): + +```typescript +function getIconGroup(fileName: string, mimeType: string): string { + const ext = extname(fileName).toLowerCase() + if (['.jpg','.jpeg','.png','.gif','.webp','.svg','.bmp','.ico'].includes(ext)) return 'image' + if (['.md','.txt','.pdf','.doc','.docx','.rtf'].includes(ext)) return 'document' + if (['.zip','.rar','.7z','.tar','.gz','.bz2','.xz'].includes(ext)) return 'archive' + if (['.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.c','.cpp','.h','.json','.yaml','.yml','.toml','.xml','.html','.css','.sh'].includes(ext)) return 'code' + if (mimeType.includes('audio/')) return 'audio' + if (mimeType.includes('video/')) return 'video' + if (mimeType.includes('text/')) return 'text' + if (mimeType.includes('image/')) return 'image' + return 'binary' +} +``` + +## Project Structure + +``` +electron-app/ +├── package.json +├── electron.vite.config.ts +├── tsconfig.json +├── src/ +│ ├── shared/ +│ │ ├── types.ts # TreeNode, Task, DownloadItem interfaces +│ │ └── icons.ts # Icon/emoji mapping +│ ├── main/ # Electron main process +│ │ ├── index.ts # App entry, window creation +│ │ ├── ipc.ts # IPC handler registration +│ │ ├── scanner.ts # Two-phase website scanning +│ │ ├── downloader.ts # Download manager with retry +│ │ ├── task-queue.ts # Multi-site task management +│ │ └── utils.ts # Path sanitization, URL validation +│ ├── preload/ +│ │ └── index.ts # contextBridge API exposure +│ └── renderer/ # React app +│ ├── index.html +│ ├── main.tsx # React entry +│ ├── App.tsx # Root layout +│ ├── stores/ +│ │ ├── task-store.ts # Zustand: tasks, active task, settings +│ │ ├── tree-store.ts # Zustand: file tree state per task +│ │ └── download-store.ts # Zustand: download state per task +│ ├── components/ +│ │ ├── TaskTabs.tsx +│ │ ├── Toolbar.tsx +│ │ ├── SplitPanel.tsx +│ │ ├── SearchBar.tsx +│ │ ├── TypeFilters.tsx +│ │ ├── FileTree.tsx # Virtual-scrolled tree +│ │ ├── FileTreeRow.tsx # Single row component +│ │ ├── DownloadsList.tsx +│ │ ├── DownloadCard.tsx +│ │ ├── LogsPanel.tsx +│ │ └── StatusBar.tsx +│ └── hooks/ +│ ├── useIpc.ts # IPC event subscription hook +│ └── useFileTree.ts # Tree traversal/filtering logic +├── resources/ # App icons +│ ├── icon.ico +│ └── icon.png +└── electron-builder.yml # Packaging config +``` + +## Error Handling + +- **Network errors during scan**: Log to UI via `log:message`, report via `scan:error`, skip failed URLs, continue scanning +- **Download failures**: Mark as "Failed", automatic retry (3 attempts, exponential backoff for 5xx). Manual retry via `download:retry` after max retries exhausted +- **Invalid URLs**: Validate before scan, show inline error on URL input +- **Path traversal**: Sanitize all path segments — strip `..`, `/`, `\`, Windows-illegal chars +- **Scope validation**: Ensure discovered URLs stay within base domain/path (same origin + path prefix check) + +## Testing Strategy + +- **Main process unit tests**: Scanner parsing, URL validation, path sanitization, icon mapping (Vitest) +- **Renderer component tests**: React Testing Library for FileTree, DownloadCard, TypeFilters +- **IPC integration tests**: Verify channel contracts between main and renderer +- **E2E consideration**: Playwright + Electron support for full-flow testing (future) + +## Migration Notes + +- This is a **new project** in an `electron-app/` directory alongside the existing `src/` Python code +- The Python code remains untouched — users can choose which version to use +- Feature parity is the primary goal; the Electron version should do everything the Python version does +- The multi-site task queue is the only net-new feature diff --git a/electron-app/.gitignore b/electron-app/.gitignore new file mode 100644 index 0000000..7302b66 --- /dev/null +++ b/electron-app/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +out/ +.DS_Store +*.local diff --git a/electron-app/components.json b/electron-app/components.json new file mode 100644 index 0000000..85d797e --- /dev/null +++ b/electron-app/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/renderer/src/assets/main.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/electron-app/electron-builder.yml b/electron-app/electron-builder.yml new file mode 100644 index 0000000..f450003 --- /dev/null +++ b/electron-app/electron-builder.yml @@ -0,0 +1,21 @@ +appId: com.indexripper.app +productName: Index Ripper +directories: + buildResources: resources + output: dist +files: + - '!**/.vscode/*' + - '!src/*' + - '!electron.vite.config.*' + - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' +win: + target: nsis + icon: resources/icon.ico +mac: + target: dmg + icon: resources/icon.png + category: public.app-category.utilities +linux: + target: AppImage + icon: resources/icon.png + category: Utility diff --git a/electron-app/electron.vite.config.ts b/electron-app/electron.vite.config.ts new file mode 100644 index 0000000..ac51757 --- /dev/null +++ b/electron-app/electron.vite.config.ts @@ -0,0 +1,23 @@ +import { resolve } from 'path' +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()] + }, + preload: { + plugins: [externalizeDepsPlugin()] + }, + renderer: { + resolve: { + alias: { + '@renderer': resolve('src/renderer/src'), + '@': resolve('src/renderer/src'), + '@shared': resolve('src/shared') + } + }, + plugins: [react(), tailwindcss()] + } +}) diff --git a/electron-app/package-lock.json b/electron-app/package-lock.json new file mode 100644 index 0000000..a354a8b --- /dev/null +++ b/electron-app/package-lock.json @@ -0,0 +1,11895 @@ +{ + "name": "index-ripper", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "index-ripper", + "version": "1.0.0", + "hasInstallScript": true, + "dependencies": { + "@electron-toolkit/preload": "^3.0.1", + "@electron-toolkit/utils": "^3.0.0", + "cheerio": "^1.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "electron-store": "^10.1.0", + "lucide-react": "^0.577.0", + "next-themes": "^0.4.6", + "p-queue": "^8.1.1", + "radix-ui": "^1.4.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@electron-toolkit/eslint-config-prettier": "^2.0.0", + "@electron-toolkit/eslint-config-ts": "^2.0.0", + "@electron-toolkit/tsconfig": "^1.0.1", + "@tailwindcss/vite": "^4.0.0", + "@tanstack/react-virtual": "^3.13.23", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "electron": "^33.2.1", + "electron-builder": "^25.1.8", + "electron-vite": "^2.3.0", + "eslint": "^9.9.1", + "prettier": "^3.3.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.2", + "vite": "^5.4.6", + "vitest": "^2.1.9" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron-toolkit/eslint-config-prettier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@electron-toolkit/eslint-config-prettier/-/eslint-config-prettier-2.0.0.tgz", + "integrity": "sha512-L+uG1FvJcAZkPZpSi6B1pmdpyJFyOxWDTjr1Vs47vSryxv/EX1Ch6o4HVsachlDq3fMEkDgojuP2F3ZvVZMoLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.0.1" + }, + "peerDependencies": { + "eslint": ">= 8.0.0", + "prettier": ">= 3.0.0" + } + }, + "node_modules/@electron-toolkit/eslint-config-ts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@electron-toolkit/eslint-config-ts/-/eslint-config-ts-2.0.0.tgz", + "integrity": "sha512-NGXadMyWH9+ZsgYe/u5E0mqK2qTDq01kKKnyo7oiq/7v/dWoMoPhqSkn69NZvt7WmnFNOm57l71fv6128mAx3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "^7.5.0", + "@typescript-eslint/parser": "^7.5.0" + }, + "peerDependencies": { + "eslint": ">=8.56.0", + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@electron-toolkit/eslint-config-ts/node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@electron-toolkit/eslint-config-ts/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@electron-toolkit/eslint-config-ts/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@electron-toolkit/eslint-config-ts/node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@electron-toolkit/preload": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@electron-toolkit/preload/-/preload-3.0.2.tgz", + "integrity": "sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==", + "license": "MIT", + "peerDependencies": { + "electron": ">=13.0.0" + } + }, + "node_modules/@electron-toolkit/tsconfig": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@electron-toolkit/tsconfig/-/tsconfig-1.0.1.tgz", + "integrity": "sha512-M0Mol3odspvtCuheyujLNAW7bXq7KFNYVMRtpjFa4ZfES4MuklXBC7Nli/omvc+PRKlrklgAGx3l4VakjNo8jg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/node": "*" + } + }, + "node_modules/@electron-toolkit/utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@electron-toolkit/utils/-/utils-3.0.0.tgz", + "integrity": "sha512-GaXHDhiT7KCvMJjXdp/QqpYinq69T/Pdl49Z1XLf8mKGf63dnsODMWyrmIjEQ0z/vG7dO8qF3fvmI6Eb2lUNZA==", + "license": "MIT", + "peerDependencies": { + "electron": ">=13.0.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", + "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz", + "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "node-gyp": "^9.0.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/rebuild/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/rebuild/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/rebuild/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/rebuild/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz", + "integrity": "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-25.1.8.tgz", + "integrity": "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.1", + "@electron/rebuild": "3.6.1", + "@electron/universal": "2.0.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chromium-pickle-js": "^0.2.0", + "config-file-ts": "0.2.8-rc1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "25.1.7", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.0", + "resedit": "^1.7.0", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "25.1.8", + "electron-builder-squirrel-windows": "25.1.8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-25.1.7.tgz", + "integrity": "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.10", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/conf": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-14.0.0.tgz", + "integrity": "sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw==", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^9.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/conf/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/config-file-ts": { + "version": "0.2.8-rc1", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", + "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.12", + "typescript": "^5.4.3" + } + }, + "node_modules/config-file-ts/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT", + "optional": true + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dmg-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-25.1.8.tgz", + "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "33.4.11", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", + "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-25.1.8.tgz", + "integrity": "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "dmg-builder": "25.1.8", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", + "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "25.1.8", + "archiver": "^5.3.1", + "builder-util": "25.1.7", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", + "integrity": "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-store": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-10.1.0.tgz", + "integrity": "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w==", + "license": "MIT", + "dependencies": { + "conf": "^14.0.0", + "type-fest": "^4.41.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-vite": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-2.3.0.tgz", + "integrity": "sha512-lsN2FymgJlp4k6MrcsphGqZQ9fKRdJKasoaiwIrAewN1tapYI/KINLdfEL7n10LuF0pPSNf/IqjzZbB5VINctg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.7", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "cac": "^6.7.14", + "esbuild": "^0.21.5", + "magic-string": "^0.30.10", + "picocolors": "^1.0.1" + }, + "bin": { + "electron-vite": "bin/electron-vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@swc/core": "^1.0.0", + "vite": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/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/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/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/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/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/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/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/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/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": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "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": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "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": "2.1.9", + "@vitest/ui": "2.1.9", + "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/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/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/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/electron-app/package.json b/electron-app/package.json new file mode 100644 index 0000000..180c8f2 --- /dev/null +++ b/electron-app/package.json @@ -0,0 +1,60 @@ +{ + "name": "index-ripper", + "version": "1.0.0", + "description": "Index Ripper - Electron App", + "main": "./out/main/index.js", + "author": "Index Ripper", + "homepage": ".", + "scripts": { + "format": "prettier --write .", + "lint": "eslint . --ext .js,.jsx,.ts,.tsx", + "typecheck:node": "tsc --noEmit -p tsconfig.node.json", + "typecheck:web": "tsc --noEmit -p tsconfig.web.json", + "typecheck": "npm run typecheck:node && npm run typecheck:web", + "start": "electron-vite preview", + "dev": "electron-vite dev", + "build": "npm run typecheck && electron-vite build", + "postinstall": "electron-builder install-app-deps", + "build:unpack": "npm run build && electron-builder --dir", + "build:win": "npm run build && electron-builder --win", + "build:mac": "npm run build && electron-builder --mac", + "build:linux": "npm run build && electron-builder --linux", + "test": "vitest run" + }, + "dependencies": { + "@electron-toolkit/preload": "^3.0.1", + "@electron-toolkit/utils": "^3.0.0", + "cheerio": "^1.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "electron-store": "^10.1.0", + "lucide-react": "^0.577.0", + "next-themes": "^0.4.6", + "p-queue": "^8.1.1", + "radix-ui": "^1.4.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@electron-toolkit/eslint-config-prettier": "^2.0.0", + "@electron-toolkit/eslint-config-ts": "^2.0.0", + "@electron-toolkit/tsconfig": "^1.0.1", + "@tailwindcss/vite": "^4.0.0", + "@tanstack/react-virtual": "^3.13.23", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "electron": "^33.2.1", + "electron-builder": "^25.1.8", + "electron-vite": "^2.3.0", + "eslint": "^9.9.1", + "prettier": "^3.3.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.2", + "vite": "^5.4.6", + "vitest": "^2.1.9" + } +} diff --git a/electron-app/resources/icon.png b/electron-app/resources/icon.png new file mode 100644 index 0000000..cd2e0b7 Binary files /dev/null and b/electron-app/resources/icon.png differ diff --git a/electron-app/src/main/__tests__/scanner.test.ts b/electron-app/src/main/__tests__/scanner.test.ts new file mode 100644 index 0000000..8136f71 --- /dev/null +++ b/electron-app/src/main/__tests__/scanner.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest' +import { parseDirectoryListing, isDirectoryHref } from '../scanner' + +// --------------------------------------------------------------------------- +// isDirectoryHref +// --------------------------------------------------------------------------- + +describe('isDirectoryHref', () => { + it('returns true for hrefs ending with /', () => { + expect(isDirectoryHref('subdir/')).toBe(true) + expect(isDirectoryHref('/')).toBe(true) + expect(isDirectoryHref('deep/nested/')).toBe(true) + }) + + it('returns false for hrefs with file extensions', () => { + expect(isDirectoryHref('file.txt')).toBe(false) + expect(isDirectoryHref('archive.tar.gz')).toBe(false) + expect(isDirectoryHref('')).toBe(false) + }) + + it('returns true for hrefs without extension (directory heuristic)', () => { + expect(isDirectoryHref('subdir')).toBe(true) + expect(isDirectoryHref('/docs')).toBe(true) + expect(isDirectoryHref('/electron-app')).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// parseDirectoryListing +// --------------------------------------------------------------------------- + +/** Minimal "Index of" HTML fixture */ +const SAMPLE_HTML = ` + + +Index of /files/ + +

Index of /files/

+ + + + + + + + + + + + + + +
NameSize
Name
Parent Directory
Parent Directory (dotdot)
Current Directory
Dot
Root
subdir/
file.txt
archive.tar.gz
Out of scope
another.mp4 (absolute in scope)
file.txt view link
+ + +` + +describe('parseDirectoryListing', () => { + const pageUrl = 'https://example.com/files/' + + it('extracts in-scope file and directory links', () => { + const results = parseDirectoryListing(SAMPLE_HTML, pageUrl) + const urls = results.map((r) => r.url) + + expect(urls).toContain('https://example.com/files/subdir/') + expect(urls).toContain('https://example.com/files/file.txt') + expect(urls).toContain('https://example.com/files/archive.tar.gz') + expect(urls).toContain('https://example.com/files/another.mp4') + }) + + it('marks directories (trailing-slash hrefs) as isDirectory = true', () => { + const results = parseDirectoryListing(SAMPLE_HTML, pageUrl) + const subdir = results.find((r) => r.url === 'https://example.com/files/subdir/') + expect(subdir).toBeDefined() + expect(subdir!.isDirectory).toBe(true) + }) + + it('marks files (no trailing slash) as isDirectory = false', () => { + const results = parseDirectoryListing(SAMPLE_HTML, pageUrl) + const file = results.find((r) => r.url === 'https://example.com/files/file.txt') + expect(file).toBeDefined() + expect(file!.isDirectory).toBe(false) + }) + + it('skips .. / dotdot links', () => { + const results = parseDirectoryListing(SAMPLE_HTML, pageUrl) + const urls = results.map((r) => r.url) + // None of the parent-directory links should appear + for (const url of urls) { + expect(url).not.toContain('/files/../') + } + // Also href="../" should be skipped — it resolves to the parent, which is out of scope anyway, + // but we want to confirm it doesn't slip through the filter + expect(urls).not.toContain('https://example.com/') + }) + + it('skips links beginning with ? and links with query strings', () => { + const results = parseDirectoryListing(SAMPLE_HTML, pageUrl) + // ?C=N&O=D is a sort query parameter link common in Apache listings + const sortLink = results.find((r) => r.url.includes('C=N')) + expect(sortLink).toBeUndefined() + // file.txt?view should also be filtered + const viewLink = results.find((r) => r.url.includes('?view')) + expect(viewLink).toBeUndefined() + }) + + it('skips the root / link', () => { + const results = parseDirectoryListing(SAMPLE_HTML, pageUrl) + const rootLink = results.find((r) => r.url === 'https://example.com/') + expect(rootLink).toBeUndefined() + }) + + it('skips . and ./ current-directory links', () => { + const results = parseDirectoryListing(SAMPLE_HTML, pageUrl) + // "." resolves to the page URL itself; "./" also resolves to the page URL + // The page URL is the base — it is NOT a child, so isUrlInScope should reject it + // (or the explicit "." / "./" filter catches it first) + const selfLink = results.find((r) => r.url === pageUrl) + expect(selfLink).toBeUndefined() + }) + + it('skips out-of-scope links', () => { + const results = parseDirectoryListing(SAMPLE_HTML, pageUrl) + const outOfScope = results.find((r) => r.url.includes('other.com')) + expect(outOfScope).toBeUndefined() + }) + + it('deduplicates identical links', () => { + const html = ` + link1 + link2 + ` + const results = parseDirectoryListing(html, pageUrl) + const matches = results.filter((r) => r.url === 'https://example.com/files/file.txt') + expect(matches).toHaveLength(1) + }) + + it('returns an empty array for a page with no links', () => { + const results = parseDirectoryListing('No links here', pageUrl) + expect(results).toHaveLength(0) + }) + + it('handles absolute in-scope links correctly', () => { + const results = parseDirectoryListing(SAMPLE_HTML, pageUrl) + const abs = results.find((r) => r.url === 'https://example.com/files/another.mp4') + expect(abs).toBeDefined() + expect(abs!.isDirectory).toBe(false) + }) +}) diff --git a/electron-app/src/main/__tests__/utils.test.ts b/electron-app/src/main/__tests__/utils.test.ts new file mode 100644 index 0000000..e605806 --- /dev/null +++ b/electron-app/src/main/__tests__/utils.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest' +import { join } from 'path' +import { homedir } from 'os' +import { + sanitizePathSegment, + sanitizeFilename, + isUrlInScope, + safeJoin, + defaultDownloadFolder, + normalizeExtension, +} from '../utils' + +describe('sanitizePathSegment', () => { + it('removes path traversal sequences', () => { + expect(sanitizePathSegment('../etc/passwd')).not.toContain('..') + expect(sanitizePathSegment('../../secret')).not.toContain('..') + }) + + it('removes forward slashes', () => { + expect(sanitizePathSegment('foo/bar')).toBe('foobar') + }) + + it('removes backslashes', () => { + expect(sanitizePathSegment('foo\\bar')).toBe('foobar') + }) + + it('removes Windows-illegal characters', () => { + expect(sanitizePathSegment('file.txt')).not.toMatch(/[<>:"|?*]/) + expect(sanitizePathSegment('file:name')).not.toContain(':') + expect(sanitizePathSegment('file|name')).not.toContain('|') + expect(sanitizePathSegment('file?name')).not.toContain('?') + expect(sanitizePathSegment('file*name')).not.toContain('*') + }) + + it('removes control characters', () => { + expect(sanitizePathSegment('file\x00name')).not.toContain('\x00') + expect(sanitizePathSegment('file\x1fname')).not.toContain('\x1f') + }) + + it('passes through normal filenames unchanged', () => { + expect(sanitizePathSegment('hello-world_2024.tar.gz')).toBe('hello-world_2024.tar.gz') + expect(sanitizePathSegment('normalfile.txt')).toBe('normalfile.txt') + }) +}) + +describe('sanitizeFilename', () => { + it('is an alias for sanitizePathSegment', () => { + expect(sanitizeFilename('../foo/bar')).toBe(sanitizePathSegment('../foo/bar')) + expect(sanitizeFilename('normal.txt')).toBe('normal.txt') + }) +}) + +describe('isUrlInScope', () => { + it('returns true when candidate is under base path', () => { + expect(isUrlInScope('https://example.com/files/', 'https://example.com/files/foo.txt')).toBe(true) + expect(isUrlInScope('https://example.com/files/', 'https://example.com/files/sub/bar.txt')).toBe(true) + }) + + it('returns true when base URL has no trailing slash', () => { + expect(isUrlInScope('https://example.com/files', 'https://example.com/files/foo.txt')).toBe(true) + }) + + it('returns false when candidate is at a sibling path', () => { + expect(isUrlInScope('https://example.com/files/', 'https://example.com/other/foo.txt')).toBe(false) + }) + + it('returns false when origins differ', () => { + expect(isUrlInScope('https://example.com/files/', 'https://evil.com/files/foo.txt')).toBe(false) + }) + + it('returns false when protocol differs', () => { + expect(isUrlInScope('https://example.com/files/', 'http://example.com/files/foo.txt')).toBe(false) + }) + + it('returns false for invalid URLs', () => { + expect(isUrlInScope('not-a-url', 'https://example.com/files/')).toBe(false) + expect(isUrlInScope('https://example.com/', 'not-a-url')).toBe(false) + }) + + it('returns false when candidate is a parent of base', () => { + expect(isUrlInScope('https://example.com/files/sub/', 'https://example.com/files/')).toBe(false) + }) +}) + +describe('safeJoin', () => { + it('returns the resolved path for safe inputs', () => { + const root = '/tmp/downloads' + expect(safeJoin(root, ['subdir', 'file.txt'])).toBe('/tmp/downloads/subdir/file.txt') + }) + + it('throws on path traversal attempt', () => { + const root = '/tmp/downloads' + expect(() => safeJoin(root, ['..', 'etc', 'passwd'])).toThrow() + expect(() => safeJoin(root, ['subdir', '..', '..', 'etc'])).toThrow() + }) + + it('handles empty parts array', () => { + const root = '/tmp/downloads' + expect(safeJoin(root, [])).toBe('/tmp/downloads') + }) + + it('handles a single safe part', () => { + const root = '/tmp/downloads' + expect(safeJoin(root, ['myfile.zip'])).toBe('/tmp/downloads/myfile.zip') + }) +}) + +describe('defaultDownloadFolder', () => { + it('derives folder from URL hostname', () => { + const result = defaultDownloadFolder('https://example.com/files/') + expect(result).toBe(join(homedir(), 'Downloads', 'example.com')) + }) + + it('handles URLs without trailing slash', () => { + const result = defaultDownloadFolder('https://example.com') + expect(result).toBe(join(homedir(), 'Downloads', 'example.com')) + }) + + it('uses subdomain in folder name', () => { + const result = defaultDownloadFolder('https://files.example.com/data/') + expect(result).toBe(join(homedir(), 'Downloads', 'files.example.com')) + }) + + it('falls back gracefully on invalid URL', () => { + const result = defaultDownloadFolder('not-a-url') + expect(result).toBe(join(homedir(), 'Downloads', 'index-ripper')) + }) +}) + +describe('normalizeExtension', () => { + it('returns lowercase extension with dot', () => { + expect(normalizeExtension('file.TXT')).toBe('.txt') + expect(normalizeExtension('archive.TAR.GZ')).toBe('.gz') + expect(normalizeExtension('image.PNG')).toBe('.png') + }) + + it('returns empty string for files with no extension', () => { + expect(normalizeExtension('Makefile')).toBe('') + expect(normalizeExtension('README')).toBe('') + }) + + it('returns the extension as-is when already lowercase', () => { + expect(normalizeExtension('script.js')).toBe('.js') + expect(normalizeExtension('data.json')).toBe('.json') + }) + + it('handles dotfiles', () => { + // .gitignore has no extension — extname returns '' + expect(normalizeExtension('.gitignore')).toBe('') + }) + + it('handles filenames with multiple dots', () => { + expect(normalizeExtension('my.archive.tar.gz')).toBe('.gz') + }) +}) diff --git a/electron-app/src/main/downloader.ts b/electron-app/src/main/downloader.ts new file mode 100644 index 0000000..c1e9af5 --- /dev/null +++ b/electron-app/src/main/downloader.ts @@ -0,0 +1,244 @@ +import * as http from 'http' +import * as https from 'https' +import { createWriteStream, mkdirSync, unlinkSync, existsSync } from 'fs' +import { dirname } from 'path' +import { EventEmitter } from 'events' +import type { DownloadItem } from '../shared/types' + +type PQueueType = import('p-queue').default + +interface DownloadCallbacks { + onProgress?: (fileId: string, percent: number, speed: number) => void + onStatus?: (fileId: string, status: DownloadItem['status']) => void + onLog?: (message: string) => void + onFinished?: (completed: number, total: number) => void +} + +interface DownloadFile { + id: string + url: string + destPath: string + fileName: string +} + +const MAX_CONCURRENCY = 10 +const MIN_CONCURRENCY = 1 +const MAX_ATTEMPTS = 3 +const BASE_BACKOFF_MS = 1000 +const PROGRESS_INTERVAL_MS = 500 + +export class Downloader extends EventEmitter { + private queue: PQueueType | null = null + private queueReady: Promise + private concurrency: number + private controllers = new Map() + private _paused = false + private _resumeResolvers: Array<() => void> = [] + + constructor(concurrency = 5) { + super() + this.concurrency = Math.min(MAX_CONCURRENCY, Math.max(MIN_CONCURRENCY, concurrency)) + this.queueReady = this._initQueue() + } + + private async _initQueue(): Promise { + const { default: PQueue } = await import('p-queue') + this.queue = new PQueue({ concurrency: this.concurrency }) + } + + private async getQueue(): Promise { + await this.queueReady + return this.queue! + } + + async setConcurrency(n: number): Promise { + this.concurrency = Math.min(MAX_CONCURRENCY, Math.max(MIN_CONCURRENCY, n)) + const q = await this.getQueue() + q.concurrency = this.concurrency + } + + pause(): void { + if (this._paused) return + this._paused = true + this.getQueue().then((q) => q.pause()).catch(() => {}) + } + + resume(): void { + if (!this._paused) return + this._paused = false + for (const resolve of this._resumeResolvers) resolve() + this._resumeResolvers = [] + this.getQueue().then((q) => q.start()).catch(() => {}) + } + + private waitIfPaused(): Promise { + if (!this._paused) return Promise.resolve() + return new Promise((resolve) => { + this._resumeResolvers.push(resolve) + }) + } + + cancelFile(fileId: string): void { + const ctrl = this.controllers.get(fileId) + if (ctrl) { + ctrl.abort() + this.controllers.delete(fileId) + } + } + + async cancelAll(): Promise { + const q = await this.getQueue() + q.clear() + for (const ctrl of this.controllers.values()) { + ctrl.abort() + } + this.controllers.clear() + } + + async downloadFiles(files: DownloadFile[], callbacks: DownloadCallbacks): Promise { + const q = await this.getQueue() + let completed = 0 + const total = files.length + + const tasks = files.map((file) => + q.add(async () => { + await this.waitIfPaused() + await this.downloadOne(file, callbacks, 1) + completed++ + }) + ) + + await Promise.allSettled(tasks) + callbacks.onFinished?.(completed, total) + } + + async downloadOne(file: DownloadFile, callbacks: DownloadCallbacks, attempt: number): Promise { + const { id, url, destPath, fileName } = file + + callbacks.onStatus?.(id, 'downloading') + callbacks.onLog?.(`[${fileName}] Starting download (attempt ${attempt}/${MAX_ATTEMPTS})`) + + mkdirSync(dirname(destPath), { recursive: true }) + + const controller = new AbortController() + this.controllers.set(id, controller) + + return new Promise((resolve, reject) => { + const isHttps = url.startsWith('https') + const client = isHttps ? https : http + + const req = client.get(url, { signal: controller.signal }, (res) => { + const statusCode = res.statusCode ?? 0 + + // Follow redirects + if (statusCode >= 300 && statusCode < 400 && res.headers.location) { + res.resume() + this.controllers.delete(id) + const redirectedFile = { ...file, url: new URL(res.headers.location, url).href } + this.downloadOne(redirectedFile, callbacks, attempt).then(resolve).catch(reject) + return + } + + // Retry on 5xx + if (statusCode >= 500) { + res.resume() + this.controllers.delete(id) + if (attempt < MAX_ATTEMPTS) { + const backoff = BASE_BACKOFF_MS * Math.pow(2, attempt - 1) + callbacks.onLog?.(`[${fileName}] Server error ${statusCode}, retrying in ${backoff}ms`) + setTimeout(() => { + this.downloadOne(file, callbacks, attempt + 1).then(resolve).catch(reject) + }, backoff) + } else { + callbacks.onStatus?.(id, 'failed') + callbacks.onLog?.(`[${fileName}] Failed after ${MAX_ATTEMPTS} attempts (HTTP ${statusCode})`) + reject(new Error(`HTTP ${statusCode}`)) + } + return + } + + if (statusCode < 200 || statusCode >= 300) { + res.resume() + this.controllers.delete(id) + callbacks.onStatus?.(id, 'failed') + callbacks.onLog?.(`[${fileName}] HTTP error ${statusCode}`) + reject(new Error(`HTTP ${statusCode}`)) + return + } + + const totalSize = parseInt(res.headers['content-length'] ?? '0', 10) || 0 + const writeStream = createWriteStream(destPath) + let bytesDownloaded = 0 + let lastProgressTime = Date.now() + let lastProgressBytes = 0 + + const progressTimer = setInterval(() => { + const now = Date.now() + const elapsed = (now - lastProgressTime) / 1000 + const speed = elapsed > 0 ? (bytesDownloaded - lastProgressBytes) / elapsed : 0 + lastProgressTime = now + lastProgressBytes = bytesDownloaded + const percent = totalSize > 0 ? Math.min(100, (bytesDownloaded / totalSize) * 100) : 0 + callbacks.onProgress?.(id, percent, speed) + }, PROGRESS_INTERVAL_MS) + + const cleanup = (deleteFile: boolean): void => { + clearInterval(progressTimer) + writeStream.destroy() + this.controllers.delete(id) + if (deleteFile && existsSync(destPath)) { + try { unlinkSync(destPath) } catch { /* ignore */ } + } + } + + res.on('data', (chunk: Buffer) => { + bytesDownloaded += chunk.length + writeStream.write(chunk) + }) + + res.on('end', () => { + cleanup(false) + callbacks.onProgress?.(id, 100, 0) + callbacks.onStatus?.(id, 'completed') + callbacks.onLog?.(`[${fileName}] Complete (${bytesDownloaded} bytes)`) + resolve() + }) + + res.on('error', (err) => { + cleanup(true) + callbacks.onStatus?.(id, 'failed') + callbacks.onLog?.(`[${fileName}] Error: ${err.message}`) + reject(err) + }) + + writeStream.on('error', (err) => { + cleanup(true) + callbacks.onStatus?.(id, 'failed') + callbacks.onLog?.(`[${fileName}] Write error: ${err.message}`) + reject(err) + }) + }) + + req.on('error', (err) => { + if (controller.signal.aborted) { + callbacks.onStatus?.(id, 'cancelled') + callbacks.onLog?.(`[${fileName}] Cancelled`) + resolve() + return + } + this.controllers.delete(id) + if (attempt < MAX_ATTEMPTS) { + const backoff = BASE_BACKOFF_MS * Math.pow(2, attempt - 1) + callbacks.onLog?.(`[${fileName}] Network error, retrying in ${backoff}ms: ${err.message}`) + setTimeout(() => { + this.downloadOne(file, callbacks, attempt + 1).then(resolve).catch(reject) + }, backoff) + } else { + callbacks.onStatus?.(id, 'failed') + callbacks.onLog?.(`[${fileName}] Failed: ${err.message}`) + reject(err) + } + }) + }) + } +} diff --git a/electron-app/src/main/index.ts b/electron-app/src/main/index.ts new file mode 100644 index 0000000..dd3775f --- /dev/null +++ b/electron-app/src/main/index.ts @@ -0,0 +1,58 @@ +import { app, shell, BrowserWindow } from 'electron' +import { join } from 'path' +import { electronApp, optimizer, is } from '@electron-toolkit/utils' +import icon from '../../resources/icon.png?asset' +import { TaskQueue } from './task-queue' +import { registerIpcHandlers } from './ipc' + +const taskQueue = new TaskQueue() + +function createWindow(): BrowserWindow { + const mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + show: false, + autoHideMenuBar: true, + ...(process.platform === 'linux' ? { icon } : {}), + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false + } + }) + + mainWindow.on('ready-to-show', () => { + mainWindow.show() + }) + + mainWindow.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } + + return mainWindow +} + +app.whenReady().then(() => { + electronApp.setAppUserModelId('com.index-ripper') + + app.on('browser-window-created', (_, window) => { + optimizer.watchWindowShortcuts(window) + }) + + const mainWindow = createWindow() + registerIpcHandlers(taskQueue, mainWindow) + + app.on('activate', function () { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) +}) + +app.on('window-all-closed', () => { + app.quit() +}) diff --git a/electron-app/src/main/ipc.ts b/electron-app/src/main/ipc.ts new file mode 100644 index 0000000..f760dad --- /dev/null +++ b/electron-app/src/main/ipc.ts @@ -0,0 +1,242 @@ +import { ipcMain, dialog, BrowserWindow } from 'electron' +import type { TaskQueue } from './task-queue' + +// electron-store v10 is ESM-only; use dynamic import for CJS compat. +interface StoreInstance { + store: Record + get(key: string): unknown + set(key: string, value: unknown): void +} + +let storeInstance: StoreInstance | null = null + +async function getStore(): Promise { + if (storeInstance) return storeInstance + const { default: Store } = await import('electron-store') + storeInstance = new Store() as unknown as StoreInstance + return storeInstance +} + +export function registerIpcHandlers( + taskQueue: TaskQueue, + mainWindow: BrowserWindow +): void { + const send = (channel: string, ...args: unknown[]): void => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, ...args) + } + } + + // ------------------------------------------------------------------------- + // Task management + // ------------------------------------------------------------------------- + + ipcMain.handle('task:create', (_event, url: string) => { + const task = taskQueue.createTask(url) + return task + }) + + ipcMain.handle('task:remove', (_event, taskId: string) => { + taskQueue.removeTask(taskId) + }) + + // ------------------------------------------------------------------------- + // Scan + // ------------------------------------------------------------------------- + + ipcMain.handle('scan:start', (_event, taskId: string, url: string) => { + const entry = taskQueue.getTask(taskId) + if (!entry) throw new Error(`Unknown task: ${taskId}`) + + entry.task.status = 'scanning' + + // Fire-and-forget: don't await — let scan run in background + // Results are streamed via IPC events + entry.scanner.scan(url, { + onItem: (node, parentId) => { + send('scan:item', taskId, node, parentId) + }, + onProgress: (scanned, total) => { + entry.task.scanProgress = { scanned, total } + send('scan:progress', taskId, scanned, total) + }, + onError: (errUrl, err) => { + send('scan:error', taskId, errUrl, err.message) + }, + onFinished: () => { + if (entry.task.status === 'scanning') { + entry.task.status = 'scanned' + } + send('scan:finished', taskId) + } + }).catch((err) => { + send('scan:error', taskId, url, String(err)) + send('scan:finished', taskId) + }) + }) + + ipcMain.handle('scan:stop', (_event, taskId: string) => { + const entry = taskQueue.getTask(taskId) + if (!entry) throw new Error(`Unknown task: ${taskId}`) + entry.scanner.abort() + entry.task.status = 'cancelled' + }) + + ipcMain.handle('scan:pause', (_event, taskId: string) => { + const entry = taskQueue.getTask(taskId) + if (!entry) throw new Error(`Unknown task: ${taskId}`) + entry.scanner.pause() + }) + + ipcMain.handle('scan:resume', (_event, taskId: string) => { + const entry = taskQueue.getTask(taskId) + if (!entry) throw new Error(`Unknown task: ${taskId}`) + entry.scanner.resume() + }) + + // ------------------------------------------------------------------------- + // Download + // ------------------------------------------------------------------------- + + ipcMain.handle( + 'download:start', + async ( + _event, + taskId: string, + files: Array<{ id: string; url: string; destPath: string; fileName: string }>, + _destPath: string + ) => { + const entry = taskQueue.getTask(taskId) + if (!entry) throw new Error(`Unknown task: ${taskId}`) + + entry.task.status = 'downloading' + + await entry.downloader.downloadFiles(files, { + onProgress: (fileId, percent, speed) => { + send('download:progress', taskId, fileId, percent, speed) + }, + onStatus: (fileId, status) => { + send('download:status', taskId, fileId, status) + }, + onLog: (message) => { + send('log:message', taskId, message) + }, + onFinished: (completed, total) => { + entry.task.status = 'done' + send('download:finished', taskId, completed, total) + } + }) + } + ) + + ipcMain.handle('download:pause', (_event, taskId: string) => { + const entry = taskQueue.getTask(taskId) + if (!entry) throw new Error(`Unknown task: ${taskId}`) + entry.downloader.pause() + }) + + ipcMain.handle('download:resume', (_event, taskId: string) => { + const entry = taskQueue.getTask(taskId) + if (!entry) throw new Error(`Unknown task: ${taskId}`) + entry.downloader.resume() + }) + + ipcMain.handle('download:cancel', (_event, taskId: string, fileId: string) => { + const entry = taskQueue.getTask(taskId) + if (!entry) throw new Error(`Unknown task: ${taskId}`) + entry.downloader.cancelFile(fileId) + }) + + ipcMain.handle('download:cancelAll', (_event, taskId: string) => { + const entry = taskQueue.getTask(taskId) + if (!entry) throw new Error(`Unknown task: ${taskId}`) + entry.downloader.cancelAll() + }) + + ipcMain.handle('download:retry', async (_event, taskId: string, fileId: string) => { + const entry = taskQueue.getTask(taskId) + if (!entry) throw new Error(`Unknown task: ${taskId}`) + + // Find the download item info from the task + const downloadItem = entry.task.downloads[fileId] + if (!downloadItem) throw new Error(`Unknown download: ${fileId}`) + + await entry.downloader.downloadOne( + { + id: downloadItem.id, + url: downloadItem.url, + destPath: downloadItem.destPath, + fileName: downloadItem.fileName + }, + { + onProgress: (fId, percent, speed) => { + send('download:progress', taskId, fId, percent, speed) + }, + onStatus: (fId, status) => { + send('download:status', taskId, fId, status) + }, + onLog: (message) => { + send('log:message', taskId, message) + } + }, + 1 + ) + }) + + ipcMain.handle('download:setWorkers', async (_event, count: number) => { + for (const entry of taskQueue.tasks.values()) { + await entry.downloader.setConcurrency(count) + } + }) + + // ------------------------------------------------------------------------- + // Settings (electron-store) + // ------------------------------------------------------------------------- + + ipcMain.handle('settings:get', async () => { + const store = await getStore() + return store.store + }) + + ipcMain.handle('settings:set', async (_event, data: Record) => { + const store = await getStore() + for (const [key, value] of Object.entries(data)) { + store.set(key, value) + } + }) + + // ------------------------------------------------------------------------- + // Dialog + // ------------------------------------------------------------------------- + + ipcMain.handle('dialog:selectFolder', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory'] + }) + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] + }) + + // ------------------------------------------------------------------------- + // Preview — fetch file content from remote URL via main process + // ------------------------------------------------------------------------- + + ipcMain.handle('preview:fetch', async (_event, url: string) => { + const response = await globalThis.fetch(url) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + + const contentType = response.headers.get('content-type') ?? '' + const buffer = Buffer.from(await response.arrayBuffer()) + const base64 = buffer.toString('base64') + return { dataUrl: `data:${contentType};base64,${base64}`, contentType } + }) + + ipcMain.handle('preview:fetchText', async (_event, url: string) => { + const response = await globalThis.fetch(url) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const text = await response.text() + return text.slice(0, 200 * 1024) // 200KB limit + }) +} diff --git a/electron-app/src/main/scanner.ts b/electron-app/src/main/scanner.ts new file mode 100644 index 0000000..7415d50 --- /dev/null +++ b/electron-app/src/main/scanner.ts @@ -0,0 +1,384 @@ +import * as cheerio from 'cheerio' +import { isUrlInScope } from './utils' +import { getIconGroup } from '../shared/icons' +import type { TreeNode } from '../shared/types' + +// --------------------------------------------------------------------------- +// Pure helpers (testable without Electron) +// --------------------------------------------------------------------------- + +export interface ParsedLink { + url: string + isDirectory: boolean +} + +/** + * Returns true if the href likely represents a directory. + * Heuristic: ends with '/' OR has no file extension (no '.' in the last segment). + */ +export function isDirectoryHref(href: string): boolean { + if (!href) return false + if (href.endsWith('/')) return true + // No extension = likely a directory (e.g., "/docs", "/electron-app") + const lastSegment = href.split('/').filter(Boolean).pop() ?? '' + if (!lastSegment) return false + return !lastSegment.includes('.') +} + +/** + * Parse an "Index of" HTML page and return links that are in scope. + * + * Filters out: + * - `.` (current directory) + * - `..` and links starting with `..` + * - `/` (root) + * - Links beginning with `?` (sort query params) + * - Links that are out of scope relative to pageUrl + */ +export function parseDirectoryListing(html: string, pageUrl: string): ParsedLink[] { + const $ = cheerio.load(html) + const results: ParsedLink[] = [] + const seen = new Set() + + $('a[href]').each((_i, el) => { + const href = $(el).attr('href') ?? '' + + // Skip navigation / query links + if (href === '.' || href === './' || href === '..' || href === '../') return + if (href.startsWith('..')) return + if (href === '/') return + if (href.startsWith('?')) return + if (href === '') return + + // Resolve to absolute URL + let absoluteUrl: string + try { + const parsed = new URL(href, pageUrl) + // Skip links with query strings (e.g., "?view", "?C=N;O=D") + if (parsed.search) return + absoluteUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}` + } catch { + return + } + + // Scope check + if (!isUrlInScope(pageUrl, absoluteUrl)) return + + // Deduplicate + if (seen.has(absoluteUrl)) return + seen.add(absoluteUrl) + + results.push({ url: absoluteUrl, isDirectory: isDirectoryHref(href) }) + }) + + return results +} + +// --------------------------------------------------------------------------- +// Scanner callbacks interface +// --------------------------------------------------------------------------- + +export interface ScanCallbacks { + onItem: (node: TreeNode, parentId: string) => void + onProgress: (scanned: number, total: number) => void + onError: (url: string, err: Error) => void + onFinished: () => void +} + +// --------------------------------------------------------------------------- +// Scanner class +// --------------------------------------------------------------------------- + +/** + * A fetch function compatible with the native `fetch` API (subset). + * Accepts a URL string and an optional init, returns a Response-like object. + */ +export type FetchFn = ( + url: string, + init?: { method?: string; signal?: AbortSignal } +) => Promise<{ + ok: boolean + status: number + text(): Promise + headers: { get(name: string): string | null } +}> + +/** Generate a simple deterministic node ID from a URL. */ +function urlToId(url: string): string { + // Use a simple hash-like approach; collision risk is negligible for a scan session + return url.replace(/[^a-zA-Z0-9]/g, '_') +} + +/** Format a byte count as a human-readable string. */ +function formatSize(bytes: number | null): string { + if (bytes === null || bytes < 0) return 'Unknown' + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + const val = bytes / Math.pow(1024, i) + return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}` +} + +/** Extract filename from a URL path. */ +function fileNameFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname + const parts = pathname.replace(/\/$/, '').split('/') + return decodeURIComponent(parts[parts.length - 1] || '') + } catch { + return url + } +} + +/** Derive the relative path of a URL under a base URL. */ +function relativePathFromBase(baseUrl: string, targetUrl: string): string { + try { + const base = new URL(baseUrl) + const target = new URL(targetUrl) + const basePath = base.pathname.endsWith('/') ? base.pathname : base.pathname + '/' + const rel = target.pathname.slice(basePath.length) + return rel || '' + } catch { + return '' + } +} + +export class Scanner { + private aborted = false + private paused = false + private resumeResolve: (() => void) | null = null + private fetchFn: FetchFn + + constructor(fetchFn?: FetchFn) { + if (fetchFn) { + this.fetchFn = fetchFn + } else { + // Use Electron net module wrapped as fetch, falling back to global fetch + this.fetchFn = this.electronFetch.bind(this) + } + } + + // --------------------------------------------------------------------------- + // Electron net-based fetch (used at runtime) + // --------------------------------------------------------------------------- + + private async electronFetch( + url: string, + init?: { method?: string; signal?: AbortSignal } + ): Promise<{ + ok: boolean + status: number + text(): Promise + headers: { get(name: string): string | null } + }> { + // Use Node.js built-in fetch (available in Node 18+). + // Electron's net.fetch can force HTTPS upgrades on HTTP URLs, causing + // TLS handshake errors against plain HTTP servers. + const fetchImpl = globalThis.fetch as unknown as FetchFn + return fetchImpl(url, init) + } + + // --------------------------------------------------------------------------- + // Pause / resume support + // --------------------------------------------------------------------------- + + pause(): void { + this.paused = true + } + + resume(): void { + if (!this.paused) return + this.paused = false + if (this.resumeResolve) { + this.resumeResolve() + this.resumeResolve = null + } + } + + private waitIfPaused(): Promise { + if (!this.paused) return Promise.resolve() + return new Promise((resolve) => { + this.resumeResolve = resolve + }) + } + + // --------------------------------------------------------------------------- + // Abort + // --------------------------------------------------------------------------- + + abort(): void { + this.aborted = true + // Unblock any waiting pause + if (this.resumeResolve) { + this.resumeResolve() + this.resumeResolve = null + } + } + + // --------------------------------------------------------------------------- + // Fetch helpers + // --------------------------------------------------------------------------- + + /** Fetch HTML from a directory URL. */ + protected async fetchHtml(url: string): Promise { + const response = await this.fetchFn(url) + if (!response.ok) { + throw new Error(`HTTP ${response.status} fetching ${url}`) + } + return response.text() + } + + /** Issue a HEAD request and return { size, mimeType }. */ + private async headRequest(url: string): Promise<{ size: number | null; mimeType: string }> { + try { + const response = await this.fetchFn(url, { method: 'HEAD' }) + const lengthHeader = response.headers.get('content-length') + const size = lengthHeader !== null ? parseInt(lengthHeader, 10) : null + const mimeType = response.headers.get('content-type') ?? '' + return { size, mimeType: mimeType.split(';')[0].trim() } + } catch { + return { size: null, mimeType: '' } + } + } + + // --------------------------------------------------------------------------- + // Main scan + // --------------------------------------------------------------------------- + + private maxDepth = 20 + + async scan(rootUrl: string, callbacks: ScanCallbacks): Promise { + this.aborted = false + this.paused = false + + // ------------------------------------------------------------------ + // Phase 1: Discovery — recursively collect all URLs + // ------------------------------------------------------------------ + interface DiscoveredEntry { + url: string + isDirectory: boolean + parentId: string + depth: number + } + + const discovered: DiscoveredEntry[] = [] + const visitedDirs = new Set() + + // Iterative BFS discovery + const queue: Array<{ url: string; parentId: string; depth: number }> = [ + { url: rootUrl, parentId: '', depth: 0 }, + ] + + while (queue.length > 0 && !this.aborted) { + await this.waitIfPaused() + if (this.aborted) break + + const { url, parentId, depth } = queue.shift()! + + if (visitedDirs.has(url)) continue + visitedDirs.add(url) + + let html: string + try { + html = await this.fetchHtml(url) + } catch (err) { + callbacks.onError(url, err instanceof Error ? err : new Error(String(err))) + continue + } + + const links = parseDirectoryListing(html, url) + for (const link of links) { + discovered.push({ url: link.url, isDirectory: link.isDirectory, parentId, depth }) + if (link.isDirectory && !visitedDirs.has(link.url) && depth < this.maxDepth) { + queue.push({ url: link.url, parentId: urlToId(link.url), depth: depth + 1 }) + } + } + } + + if (this.aborted) { + callbacks.onFinished() + return + } + + // ------------------------------------------------------------------ + // Phase 2: Process each discovered entry + // ------------------------------------------------------------------ + const total = discovered.length + let scanned = 0 + callbacks.onProgress(scanned, total) + + for (const entry of discovered) { + await this.waitIfPaused() + if (this.aborted) break + + const name = fileNameFromUrl(entry.url) + const nodeId = urlToId(entry.url) + const fullPath = relativePathFromBase(rootUrl, entry.url) + + if (entry.isDirectory) { + const node: TreeNode = { + id: nodeId, + parentId: entry.parentId, + name, + kind: 'folder', + url: '', + fullPath, + size: 'Unknown', + fileType: '', + iconGroup: 'folder', + checked: false, + expanded: false, + hidden: false, + children: [], + } + callbacks.onItem(node, entry.parentId) + } else { + // HEAD request for file metadata + const { size, mimeType } = await this.headRequest(entry.url) + + // Heuristic: if HEAD returns text/html and name has no extension, treat as directory + const isHtmlDir = mimeType.includes('text/html') && !name.includes('.') + if (isHtmlDir) { + const node: TreeNode = { + id: nodeId, + parentId: entry.parentId, + name, + kind: 'folder', + url: '', + fullPath, + size: '', + fileType: '', + iconGroup: 'folder', + checked: false, + expanded: false, + hidden: false, + children: [], + } + callbacks.onItem(node, entry.parentId) + } else { + const node: TreeNode = { + id: nodeId, + parentId: entry.parentId, + name, + kind: 'file', + url: entry.url, + fullPath, + size: formatSize(size), + fileType: mimeType, + iconGroup: getIconGroup(name, mimeType), + checked: false, + expanded: false, + hidden: false, + children: [], + } + callbacks.onItem(node, entry.parentId) + } + } + + scanned++ + callbacks.onProgress(scanned, total) + } + + callbacks.onFinished() + } +} diff --git a/electron-app/src/main/task-queue.ts b/electron-app/src/main/task-queue.ts new file mode 100644 index 0000000..bc7e76d --- /dev/null +++ b/electron-app/src/main/task-queue.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'crypto' +import type { Task } from '../shared/types' +import { Scanner } from './scanner' +import { Downloader } from './downloader' +import { defaultDownloadFolder } from './utils' + +export class TaskQueue { + tasks = new Map() + + createTask(url: string): Task { + const id = randomUUID() + const task: Task = { + id, + url, + status: 'idle', + nodes: {}, + roots: [], + checkedFiles: [], + downloads: {}, + scanProgress: { scanned: 0, total: 0 }, + downloadPath: defaultDownloadFolder(url) + } + this.tasks.set(id, { task, scanner: new Scanner(), downloader: new Downloader() }) + return task + } + + getTask(taskId: string) { + return this.tasks.get(taskId) + } + + removeTask(taskId: string): void { + const entry = this.tasks.get(taskId) + if (entry) { + entry.scanner.abort() + entry.downloader.cancelAll() + this.tasks.delete(taskId) + } + } + + allTasks(): Task[] { + return [...this.tasks.values()].map((e) => e.task) + } +} diff --git a/electron-app/src/main/utils.ts b/electron-app/src/main/utils.ts new file mode 100644 index 0000000..9ff57a8 --- /dev/null +++ b/electron-app/src/main/utils.ts @@ -0,0 +1,82 @@ +import { join, resolve, extname } from 'path' +import { homedir } from 'os' + +/** + * Strip path traversal sequences and illegal characters from a single path segment. + * Removes: `..`, `/`, `\`, and Windows-illegal chars (`<>:"|?*` and control chars). + */ +export function sanitizePathSegment(name: string): string { + // Remove path traversal + let sanitized = name.replace(/\.\./g, '') + // Remove path separators + sanitized = sanitized.replace(/[/\\]/g, '') + // Remove Windows-illegal characters: < > : " | ? * and control chars (0x00-0x1f) + // eslint-disable-next-line no-control-regex + sanitized = sanitized.replace(/[<>:"|?*\x00-\x1f]/g, '') + return sanitized +} + +/** + * Alias for sanitizePathSegment — sanitizes a file name. + */ +export function sanitizeFilename(name: string): string { + return sanitizePathSegment(name) +} + +/** + * Returns true if candidateUrl shares the same origin and has the same + * path prefix as baseUrl. + */ +export function isUrlInScope(baseUrl: string, candidateUrl: string): boolean { + try { + const base = new URL(baseUrl) + const candidate = new URL(candidateUrl) + + if (base.origin !== candidate.origin) { + return false + } + + // Normalize base path to end with '/' so prefix check works correctly + const basePath = base.pathname.endsWith('/') ? base.pathname : base.pathname + '/' + return candidate.pathname.startsWith(basePath) + } catch { + return false + } +} + +/** + * Safely joins root with parts, throwing if the resolved path would escape root. + */ +export function safeJoin(root: string, parts: string[]): string { + const resolvedRoot = resolve(root) + const joined = join(resolvedRoot, ...parts) + const resolvedJoined = resolve(joined) + + if (!resolvedJoined.startsWith(resolvedRoot + '/') && resolvedJoined !== resolvedRoot) { + throw new Error(`Path traversal detected: "${resolvedJoined}" escapes root "${resolvedRoot}"`) + } + + return resolvedJoined +} + +/** + * Derive a default download folder from the URL hostname, placed in ~/Downloads/. + * E.g. https://example.com/files/ → ~/Downloads/example.com + */ +export function defaultDownloadFolder(url: string): string { + try { + const parsed = new URL(url) + const hostname = parsed.hostname + return join(homedir(), 'Downloads', hostname) + } catch { + return join(homedir(), 'Downloads', 'index-ripper') + } +} + +/** + * Extract the lowercase file extension from a filename. + * Returns "" if none. + */ +export function normalizeExtension(fileName: string): string { + return extname(fileName).toLowerCase() +} diff --git a/electron-app/src/preload/index.d.ts b/electron-app/src/preload/index.d.ts new file mode 100644 index 0000000..a153669 --- /dev/null +++ b/electron-app/src/preload/index.d.ts @@ -0,0 +1,8 @@ +import { ElectronAPI } from '@electron-toolkit/preload' + +declare global { + interface Window { + electron: ElectronAPI + api: unknown + } +} diff --git a/electron-app/src/preload/index.ts b/electron-app/src/preload/index.ts new file mode 100644 index 0000000..af11177 --- /dev/null +++ b/electron-app/src/preload/index.ts @@ -0,0 +1,63 @@ +import { contextBridge, ipcRenderer } from 'electron' +import { electronAPI } from '@electron-toolkit/preload' + +const api = { + scan: { + start: (taskId: string, url: string) => ipcRenderer.invoke('scan:start', taskId, url), + stop: (taskId: string) => ipcRenderer.invoke('scan:stop', taskId), + pause: (taskId: string) => ipcRenderer.invoke('scan:pause', taskId), + resume: (taskId: string) => ipcRenderer.invoke('scan:resume', taskId) + }, + download: { + start: (taskId: string, files: unknown[], destPath: string) => + ipcRenderer.invoke('download:start', taskId, files, destPath), + pause: (taskId: string) => ipcRenderer.invoke('download:pause', taskId), + resume: (taskId: string) => ipcRenderer.invoke('download:resume', taskId), + cancel: (taskId: string, fileId: string) => + ipcRenderer.invoke('download:cancel', taskId, fileId), + cancelAll: (taskId: string) => ipcRenderer.invoke('download:cancelAll', taskId), + retry: (taskId: string, fileId: string) => + ipcRenderer.invoke('download:retry', taskId, fileId), + setWorkers: (count: number) => ipcRenderer.invoke('download:setWorkers', count) + }, + task: { + create: (url: string) => ipcRenderer.invoke('task:create', url), + remove: (taskId: string) => ipcRenderer.invoke('task:remove', taskId) + }, + settings: { + get: () => ipcRenderer.invoke('settings:get'), + set: (data: unknown) => ipcRenderer.invoke('settings:set', data) + }, + dialog: { + selectFolder: () => ipcRenderer.invoke('dialog:selectFolder') + }, + preview: { + fetch: (url: string) => ipcRenderer.invoke('preview:fetch', url), + fetchText: (url: string) => ipcRenderer.invoke('preview:fetchText', url) + }, + on: (channel: string, callback: (...args: unknown[]) => void) => { + const listener = (_event: Electron.IpcRendererEvent, ...args: unknown[]): void => + callback(...args) + ipcRenderer.on(channel, listener) + return (): void => { + ipcRenderer.removeListener(channel, listener) + } + } +} + +// Use `contextBridge` APIs to expose Electron APIs to +// renderer only if context isolation is enabled, otherwise +// just add to the DOM global. +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('electron', electronAPI) + contextBridge.exposeInMainWorld('api', api) + } catch (error) { + console.error(error) + } +} else { + // @ts-ignore (define in d.ts) + window.electron = electronAPI + // @ts-ignore (define in d.ts) + window.api = api +} diff --git a/electron-app/src/renderer/index.html b/electron-app/src/renderer/index.html new file mode 100644 index 0000000..f51f293 --- /dev/null +++ b/electron-app/src/renderer/index.html @@ -0,0 +1,16 @@ + + + + + Index Ripper + + + + +
+ + + diff --git a/electron-app/src/renderer/src/App.tsx b/electron-app/src/renderer/src/App.tsx new file mode 100644 index 0000000..f9ae406 --- /dev/null +++ b/electron-app/src/renderer/src/App.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react' +import { Toaster } from 'sonner' +import { TaskTabs } from './components/TaskTabs' +import { Toolbar } from './components/Toolbar' +import { ProgressSection } from './components/ProgressSection' +import { SplitPanel } from './components/SplitPanel' +import { SearchBar } from './components/SearchBar' +import { TypeFilters } from './components/TypeFilters' +import { FileTree } from './components/FileTree' +import { StatusBar } from './components/StatusBar' +import { DownloadsList } from './components/DownloadsList' +import { LogsPanel } from './components/LogsPanel' +import { PreviewPanel } from './components/PreviewPanel' +import { useIpcListeners } from './hooks/useIpcListeners' +import { useTaskStore } from './stores/task-store' + +function FilePanel(): JSX.Element { + return ( +
+ + + + +
+ ) +} + +type RightTab = 'preview' | 'downloads' | 'logs' + +function RightPanel(): JSX.Element { + const [tab, setTab] = useState('preview') + const previewNodeId = useTaskStore((s) => s.previewNodeId) + + // Auto-switch to preview tab when a file is double-clicked + useEffect(() => { + if (previewNodeId) setTab('preview') + }, [previewNodeId]) + + const tabs: { id: RightTab; label: string }[] = [ + { id: 'preview', label: 'Preview' }, + { id: 'downloads', label: 'Downloads' }, + { id: 'logs', label: 'Logs' }, + ] + + return ( +
+
+ {tabs.map((t) => ( + + ))} +
+
+ {tab === 'preview' && } + {tab === 'downloads' && } + {tab === 'logs' && } +
+
+ ) +} + +export default function App(): JSX.Element { + useIpcListeners() + + return ( +
+ + + + + } + right={} + /> +
+ ) +} diff --git a/electron-app/src/renderer/src/assets/main.css b/electron-app/src/renderer/src/assets/main.css new file mode 100644 index 0000000..a3495c3 --- /dev/null +++ b/electron-app/src/renderer/src/assets/main.css @@ -0,0 +1,92 @@ +@import "tailwindcss"; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + border-color: hsl(var(--border)); + } + body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +/* Overlay scrollbars — don't take up space */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(100, 116, 139, 0.3) transparent; +} + +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.3); + border-radius: 3px; +} + +*::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.5); +} diff --git a/electron-app/src/renderer/src/components/DownloadCard.tsx b/electron-app/src/renderer/src/components/DownloadCard.tsx new file mode 100644 index 0000000..02e72c2 --- /dev/null +++ b/electron-app/src/renderer/src/components/DownloadCard.tsx @@ -0,0 +1,84 @@ +import type { DownloadItem } from '../../../shared/types' +import { Progress } from './ui/progress' + +interface DownloadCardProps { + item: DownloadItem + onCancel: (id: string) => void + onRetry: (id: string) => void +} + +const statusColors: Record = { + queued: 'text-slate-400', + downloading: 'text-blue-400', + paused: 'text-slate-400', + completed: 'text-emerald-400', + failed: 'text-red-400', + cancelled: 'text-yellow-400' +} + +const statusLabels: Record = { + queued: 'Queued', + downloading: 'Downloading', + paused: 'Paused', + completed: 'Done', + failed: 'Failed', + cancelled: 'Cancelled' +} + +function formatSpeed(bytesPerSec: number): string { + if (bytesPerSec >= 1024 * 1024) { + return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s` + } + if (bytesPerSec >= 1024) { + return `${(bytesPerSec / 1024).toFixed(1)} KB/s` + } + return `${bytesPerSec} B/s` +} + +export function DownloadCard({ item, onCancel, onRetry }: DownloadCardProps): JSX.Element { + const showCancel = item.status === 'queued' || item.status === 'downloading' || item.status === 'paused' + const showRetry = item.status === 'failed' + const colorClass = statusColors[item.status] + + return ( +
+
+ + {item.fileName} + +
+ {item.status === 'downloading' && item.speed > 0 && ( + {formatSpeed(item.speed)} + )} + {statusLabels[item.status]} +
+
+ + + + {(showCancel || showRetry) && ( +
+ {showCancel && ( + + )} + {showRetry && ( + + )} +
+ )} +
+ ) +} diff --git a/electron-app/src/renderer/src/components/DownloadsList.tsx b/electron-app/src/renderer/src/components/DownloadsList.tsx new file mode 100644 index 0000000..572c402 --- /dev/null +++ b/electron-app/src/renderer/src/components/DownloadsList.tsx @@ -0,0 +1,91 @@ +import { useTaskStore } from '../stores/task-store' +import { DownloadCard } from './DownloadCard' +import { Progress } from './ui/progress' +import type { DownloadItem } from '../../../shared/types' + +function estimateTimeRemaining(items: DownloadItem[]): string | null { + const downloading = items.filter((d) => d.status === 'downloading' && d.speed > 0) + if (downloading.length === 0) return null + + const totalRemaining = downloading.reduce((acc, d) => { + const remaining = d.totalSize > 0 ? d.totalSize - d.downloadedSize : 0 + return acc + remaining + }, 0) + + const totalSpeed = downloading.reduce((acc, d) => acc + d.speed, 0) + if (totalSpeed === 0 || totalRemaining === 0) return null + + const secsRemaining = totalRemaining / totalSpeed + if (secsRemaining >= 3600) { + return `~${Math.ceil(secsRemaining / 3600)} hr remaining` + } + if (secsRemaining >= 60) { + return `~${Math.ceil(secsRemaining / 60)} min remaining` + } + return `~${Math.ceil(secsRemaining)} sec remaining` +} + +export function DownloadsList(): JSX.Element { + const activeTaskId = useTaskStore((s) => s.activeTaskId) + const tasks = useTaskStore((s) => s.tasks) + const updateDownload = useTaskStore((s) => s.updateDownload) + + const activeTask = activeTaskId ? tasks[activeTaskId] : null + const downloads: DownloadItem[] = activeTask ? Object.values(activeTask.downloads) : [] + + const completedCount = downloads.filter((d) => d.status === 'completed').length + const total = downloads.length + + const aggregateProgress = + total === 0 + ? 0 + : downloads.reduce((acc, d) => acc + d.progress, 0) / total + + const timeRemaining = estimateTimeRemaining(downloads) + + const handleCancel = (id: string): void => { + if (!activeTaskId) return + updateDownload(activeTaskId, id, { status: 'cancelled' }) + window.api.download.cancel(activeTaskId, id) + } + + const handleRetry = (id: string): void => { + if (!activeTaskId) return + updateDownload(activeTaskId, id, { status: 'queued', progress: 0 }) + window.api.download.retry(activeTaskId, id) + } + + if (downloads.length === 0) { + return ( +
+ No downloads yet +
+ ) + } + + return ( +
+
+ {downloads.map((item) => ( + + ))} +
+ +
+
+ + {completedCount}/{total} done + {timeRemaining ? ` — ${timeRemaining}` : ''} + + {Math.round(aggregateProgress)}% +
+ +
+
+ ) +} diff --git a/electron-app/src/renderer/src/components/FileTree.tsx b/electron-app/src/renderer/src/components/FileTree.tsx new file mode 100644 index 0000000..5d31683 --- /dev/null +++ b/electron-app/src/renderer/src/components/FileTree.tsx @@ -0,0 +1,197 @@ +import { useRef, useEffect, useState, useCallback } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { useFileTree } from '@/hooks/useFileTree' +import { useTaskStore } from '@/stores/task-store' +import { FileTreeRow } from './FileTreeRow' +import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' + +interface MenuPos { + x: number + y: number +} + +function SortButton({ label, sortKey: key }: { label: string; sortKey: 'name' | 'size' | 'type' }): JSX.Element { + const currentKey = useTaskStore((s) => s.sortKey) + const currentDir = useTaskStore((s) => s.sortDir) + const setSort = useTaskStore((s) => s.setSort) + const active = currentKey === key + + return ( + + ) +} + +export function FileTree(): JSX.Element { + const { + visibleNodes, + handleRowClick, + toggleExpand, + selectAll, + deselectAll, + expandAll, + collapseAll + } = useFileTree() + + const setPreviewNodeId = useTaskStore((s) => s.setPreviewNodeId) + const handlePreview = useCallback((nodeId: string) => { + setPreviewNodeId(nodeId) + }, [setPreviewNodeId]) + + const parentRef = useRef(null) + const [menu, setMenu] = useState(null) + + const rowVirtualizer = useVirtualizer({ + count: visibleNodes.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 36, + overscan: 30 + }) + + // Close menu on left click or scroll + useEffect(() => { + if (!menu) return + const close = (): void => setMenu(null) + window.addEventListener('click', close) + window.addEventListener('scroll', close, true) + return () => { + window.removeEventListener('click', close) + window.removeEventListener('scroll', close, true) + } + }, [menu]) + + // Keyboard shortcut: Ctrl/Cmd+A to select all + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent): void => { + if ((e.metaKey || e.ctrlKey) && e.key === 'a') { + const active = document.activeElement + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return + e.preventDefault() + selectAll() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [selectAll]) + + const menuAction = (fn: () => void): void => { + fn() + setMenu(null) + } + + // Native DOM contextmenu event + useEffect(() => { + const el = parentRef.current + if (!el) return + const handler = (e: MouseEvent): void => { + e.preventDefault() + e.stopPropagation() + setMenu({ x: e.clientX, y: e.clientY }) + } + el.addEventListener('contextmenu', handler) + return () => el.removeEventListener('contextmenu', handler) + }, []) + + return ( + <> + {/* Sort bar */} +
+ Sort: + + + +
+ + {/* Tree */} +
+ {visibleNodes.length === 0 ? ( +
+ No files to display +
+ ) : ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const { node, depth } = visibleNodes[virtualRow.index] + return ( +
+ +
+ ) + })} +
+ )} +
+ + {/* Context menu */} + {menu && ( +
e.stopPropagation()} + > + + +
+ + +
+ )} + + ) +} diff --git a/electron-app/src/renderer/src/components/FileTreeRow.tsx b/electron-app/src/renderer/src/components/FileTreeRow.tsx new file mode 100644 index 0000000..15d3452 --- /dev/null +++ b/electron-app/src/renderer/src/components/FileTreeRow.tsx @@ -0,0 +1,83 @@ +import { memo } from 'react' +import type { TreeNode } from '@shared/types' +import { EMOJI_ICONS } from '@shared/icons' +import { ChevronDown, ChevronRight } from 'lucide-react' + +interface FileTreeRowProps { + node: TreeNode + depth: number + onRowClick: (id: string, shiftKey: boolean) => void + onToggleExpand: (id: string) => void + onPreview?: (id: string) => void +} + +export const FileTreeRow = memo(function FileTreeRow({ + node, + depth, + onRowClick, + onToggleExpand, + onPreview +}: FileTreeRowProps) { + const icon = EMOJI_ICONS[node.iconGroup] ?? EMOJI_ICONS.binary + + const bgClass = node.checked + ? 'bg-blue-500/10 hover:bg-blue-500/20 border-l-blue-500' + : 'hover:bg-slate-800/60 border-l-transparent' + + return ( +
onRowClick(node.id, e.shiftKey)} + onDoubleClick={() => { + if (node.kind === 'file' && onPreview) onPreview(node.id) + }} + > + {/* Indent spacer */} +
+ + {/* Chevron for folders (left of icon) */} + {node.kind === 'folder' ? ( + + ) : ( +
+ )} + + {/* Icon */} + {icon} + + {/* Name */} + + {node.name} + + + {/* Spacer */} +
+ + {/* Size for files */} + {node.kind === 'file' && node.size && ( + + {node.size} + + )} +
+ ) +}) diff --git a/electron-app/src/renderer/src/components/LogsPanel.tsx b/electron-app/src/renderer/src/components/LogsPanel.tsx new file mode 100644 index 0000000..38a6220 --- /dev/null +++ b/electron-app/src/renderer/src/components/LogsPanel.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from 'react' + +export interface LogMessage { + id: string + timestamp: Date + text: string +} + +export function LogsPanel(): JSX.Element { + const [messages, setMessages] = useState([]) + const bottomRef = useRef(null) + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + // Expose append function via window for Task 10 IPC integration + useEffect(() => { + const handler = (event: CustomEvent<{ text: string }>): void => { + const msg: LogMessage = { + id: `${Date.now()}-${Math.random()}`, + timestamp: new Date(), + text: event.detail.text + } + setMessages((prev) => [...prev, msg]) + } + + window.addEventListener('log-message' as never, handler as EventListener) + return () => { + window.removeEventListener('log-message' as never, handler as EventListener) + } + }, []) + + if (messages.length === 0) { + return ( +
+ Waiting for activity... +
+ ) + } + + return ( +
+
+ {messages.map((msg) => ( +
+ + {msg.timestamp.toLocaleTimeString('en-US', { hour12: false })} + + {msg.text} +
+ ))} +
+
+
+ ) +} diff --git a/electron-app/src/renderer/src/components/PreviewPanel.tsx b/electron-app/src/renderer/src/components/PreviewPanel.tsx new file mode 100644 index 0000000..8829c3e --- /dev/null +++ b/electron-app/src/renderer/src/components/PreviewPanel.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from 'react' +import { useTaskStore } from '@/stores/task-store' +import { Eye, FileText, Image, X } from 'lucide-react' + +const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico'] +const TEXT_EXTS = [ + '.txt', '.md', '.html', '.htm', '.css', '.js', '.ts', '.tsx', '.jsx', + '.json', '.yaml', '.yml', '.toml', '.xml', '.py', '.go', '.rs', '.java', + '.c', '.cpp', '.h', '.sh', '.bat', '.log', '.csv', '.ini', '.cfg', + '.env', '.gitignore', '.dockerfile', '.makefile', +] + +function getExt(name: string): string { + const dot = name.lastIndexOf('.') + return dot >= 0 ? name.slice(dot).toLowerCase() : '' +} + +function isImage(name: string): boolean { + return IMAGE_EXTS.includes(getExt(name)) +} + +function isText(name: string): boolean { + const ext = getExt(name) + return TEXT_EXTS.includes(ext) || ext === '' +} + +export function PreviewPanel(): JSX.Element { + const previewNodeId = useTaskStore((s) => s.previewNodeId) + const setPreviewNodeId = useTaskStore((s) => s.setPreviewNodeId) + const task = useTaskStore((s) => (s.activeTaskId ? s.tasks[s.activeTaskId] : null)) + + const node = previewNodeId && task ? task.nodes[previewNodeId] : null + + const [imageDataUrl, setImageDataUrl] = useState(null) + const [textContent, setTextContent] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + setImageDataUrl(null) + setTextContent(null) + setError(null) + + if (!node || node.kind !== 'file' || !node.url) return + + let cancelled = false + setLoading(true) + + if (isImage(node.name)) { + // Fetch image via main process IPC + ;(window.api.preview.fetch(node.url) as Promise<{ dataUrl: string }>) + .then(({ dataUrl }) => { + if (!cancelled) setImageDataUrl(dataUrl) + }) + .catch((err: Error) => { + if (!cancelled) setError(err.message) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + } else if (isText(node.name)) { + // Fetch text via main process IPC + ;(window.api.preview.fetchText(node.url) as Promise) + .then((text) => { + if (!cancelled) setTextContent(text) + }) + .catch((err: Error) => { + if (!cancelled) setError(err.message) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + } else { + setLoading(false) + } + + return () => { cancelled = true } + }, [node?.id, node?.url]) + + if (!node) { + return ( +
+ +

Double-click a file to preview

+
+ ) + } + + const ext = getExt(node.name) + + return ( +
+ {/* Header */} +
+ {isImage(node.name) ? ( + + ) : ( + + )} + {node.name} + {node.size} + +
+ + {/* Content */} +
+ {loading && ( +
+

Loading preview...

+
+ )} + + {error && !loading && ( +
+

{error}

+
+ )} + + {!loading && !error && isImage(node.name) && imageDataUrl && ( +
+ {node.name} +
+ )} + + {!loading && !error && isText(node.name) && textContent !== null && ( +
+            {textContent}
+          
+ )} + + {!loading && !error && !isImage(node.name) && !isText(node.name) && ( +
+ +

Preview not available for {ext || 'this file type'}

+

Supports: images and text files

+
+ )} +
+
+ ) +} diff --git a/electron-app/src/renderer/src/components/ProgressSection.tsx b/electron-app/src/renderer/src/components/ProgressSection.tsx new file mode 100644 index 0000000..7c69e6d --- /dev/null +++ b/electron-app/src/renderer/src/components/ProgressSection.tsx @@ -0,0 +1,86 @@ +import { useTaskStore } from '@/stores/task-store' + +export function ProgressSection(): JSX.Element | null { + const task = useTaskStore((s) => (s.activeTaskId ? s.tasks[s.activeTaskId] : null)) + + if (!task) return null + + const isScanning = task.status === 'scanning' + const isDownloading = task.status === 'downloading' + + if (!isScanning && !isDownloading) return null + + // Scan progress + if (isScanning) { + const { scanned, total } = task.scanProgress + const indeterminate = total === 0 + const pct = total > 0 ? (scanned / total) * 100 : 0 + + return ( +
+
+ {indeterminate ? ( +
+ ) : ( +
+ )} +
+
+ {indeterminate + ? `Discovering URLs… ${scanned > 0 ? `${scanned} found` : ''}` + : `Scanning… ${scanned}/${total} (${pct.toFixed(0)}%)`} +
+ +
+ ) + } + + // Download progress + const downloads = Object.values(task.downloads) + if (downloads.length === 0) return null + + const completed = downloads.filter((d) => d.status === 'completed').length + const total = downloads.length + const pct = total > 0 ? (completed / total) * 100 : 0 + + // Aggregate speed + const activeSpeed = downloads + .filter((d) => d.status === 'downloading') + .reduce((sum, d) => sum + d.speed, 0) + + const formatSpeed = (bytesPerSec: number): string => { + if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s` + if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s` + return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s` + } + + return ( +
+
+
+
+
+ + Downloaded {completed}/{total} ({pct.toFixed(0)}%) + + {activeSpeed > 0 && {formatSpeed(activeSpeed)}} +
+
+ ) +} diff --git a/electron-app/src/renderer/src/components/RightPanel.tsx b/electron-app/src/renderer/src/components/RightPanel.tsx new file mode 100644 index 0000000..f6b64ed --- /dev/null +++ b/electron-app/src/renderer/src/components/RightPanel.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react' +import { DownloadsList } from './DownloadsList' +import { LogsPanel } from './LogsPanel' + +type ActiveTab = 'downloads' | 'logs' + +export function RightPanel(): JSX.Element { + const [activeTab, setActiveTab] = useState('downloads') + + return ( +
+
+ + +
+
+ {activeTab === 'downloads' ? : } +
+
+ ) +} diff --git a/electron-app/src/renderer/src/components/SearchBar.tsx b/electron-app/src/renderer/src/components/SearchBar.tsx new file mode 100644 index 0000000..e422ce2 --- /dev/null +++ b/electron-app/src/renderer/src/components/SearchBar.tsx @@ -0,0 +1,67 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { Search, X } from 'lucide-react' +import { useFileTree } from '@/hooks/useFileTree' + +export function SearchBar(): JSX.Element { + const [query, setQuery] = useState('') + const inputRef = useRef(null) + const debounceRef = useRef | null>(null) + const { searchFilter } = useFileTree() + + const handleChange = useCallback( + (value: string) => { + setQuery(value) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + searchFilter(value) + }, 300) + }, + [searchFilter] + ) + + const handleClear = useCallback(() => { + setQuery('') + if (debounceRef.current) clearTimeout(debounceRef.current) + searchFilter('') + inputRef.current?.focus() + }, [searchFilter]) + + // Ctrl/Cmd+F focuses the search input + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent): void => { + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault() + inputRef.current?.focus() + inputRef.current?.select() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, []) + + return ( +
+ + handleChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') handleClear() + }} + placeholder="Search files..." + className="flex-1 min-w-0 bg-transparent text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none" + /> + {query && ( + + )} +
+ ) +} diff --git a/electron-app/src/renderer/src/components/SplitPanel.tsx b/electron-app/src/renderer/src/components/SplitPanel.tsx new file mode 100644 index 0000000..14fd1ee --- /dev/null +++ b/electron-app/src/renderer/src/components/SplitPanel.tsx @@ -0,0 +1,78 @@ +import { useCallback, useRef, useState } from 'react' + +interface SplitPanelProps { + left: React.ReactNode + right: React.ReactNode + defaultLeftPercent?: number + minLeftPercent?: number + maxLeftPercent?: number +} + +export function SplitPanel({ + left, + right, + defaultLeftPercent = 65, + minLeftPercent = 20, + maxLeftPercent = 80 +}: SplitPanelProps): JSX.Element { + const [leftPercent, setLeftPercent] = useState(defaultLeftPercent) + const containerRef = useRef(null) + const isDragging = useRef(false) + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + isDragging.current = true + + const onMouseMove = (moveEvent: MouseEvent): void => { + if (!isDragging.current || !containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const x = moveEvent.clientX - rect.left + const percent = Math.min( + maxLeftPercent, + Math.max(minLeftPercent, (x / rect.width) * 100) + ) + setLeftPercent(percent) + } + + const onMouseUp = (): void => { + isDragging.current = false + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, + [minLeftPercent, maxLeftPercent] + ) + + return ( +
+ {/* Left panel */} +
+ {left} +
+ + {/* Divider */} +
+ + {/* Right panel */} +
+ {right} +
+
+ ) +} diff --git a/electron-app/src/renderer/src/components/StatusBar.tsx b/electron-app/src/renderer/src/components/StatusBar.tsx new file mode 100644 index 0000000..f00b638 --- /dev/null +++ b/electron-app/src/renderer/src/components/StatusBar.tsx @@ -0,0 +1,25 @@ +import { useFileTree } from '@/hooks/useFileTree' +import { Files, Folder, CheckSquare } from 'lucide-react' + +export function StatusBar(): JSX.Element { + const { stats } = useFileTree() + + return ( +
+ + + {stats.files} files + + + + {stats.folders} folders + + {stats.selected > 0 && ( + + + {stats.selected} selected + + )} +
+ ) +} diff --git a/electron-app/src/renderer/src/components/TaskTabs.tsx b/electron-app/src/renderer/src/components/TaskTabs.tsx new file mode 100644 index 0000000..fe9f613 --- /dev/null +++ b/electron-app/src/renderer/src/components/TaskTabs.tsx @@ -0,0 +1,70 @@ +import { useTaskStore } from '@/stores/task-store' +import { Plus, X } from 'lucide-react' + +function hostnameFromUrl(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return url || 'New Tab' + } +} + +export function TaskTabs(): JSX.Element { + const tasks = useTaskStore((s) => s.tasks) + const activeTaskId = useTaskStore((s) => s.activeTaskId) + const createTask = useTaskStore((s) => s.createTask) + const removeTask = useTaskStore((s) => s.removeTask) + const setActiveTask = useTaskStore((s) => s.setActiveTask) + + const taskList = Object.values(tasks) + + const handleAdd = async (): Promise => { + const task = (await window.api.task.create('')) as { id: string; url: string } + createTask(task.id, task.url) + } + + const handleRemove = (taskId: string): void => { + removeTask(taskId) + window.api.task.remove(taskId) + } + + return ( +
+ {taskList.map((task) => { + const isActive = task.id === activeTaskId + return ( +
setActiveTask(task.id)} + > + + {hostnameFromUrl(task.url) || 'New Tab'} + + +
+ ) + })} + +
+ ) +} diff --git a/electron-app/src/renderer/src/components/Toolbar.tsx b/electron-app/src/renderer/src/components/Toolbar.tsx new file mode 100644 index 0000000..e8be285 --- /dev/null +++ b/electron-app/src/renderer/src/components/Toolbar.tsx @@ -0,0 +1,216 @@ +import { useState } from 'react' +import { useTaskStore } from '@/stores/task-store' +import { Play, Square, Download, Pause, FolderOpen } from 'lucide-react' +import { toast } from 'sonner' +import type { Task } from '../../../shared/types' + +const STATUS_CONFIG: Record = { + idle: { color: 'bg-slate-400', label: 'Ready' }, + scanning: { color: 'bg-yellow-400', label: 'Scanning' }, + scanned: { color: 'bg-green-400', label: 'Scanned' }, + downloading: { color: 'bg-blue-400', label: 'Downloading' }, + done: { color: 'bg-green-400', label: 'Done' }, + error: { color: 'bg-red-400', label: 'Error' }, + cancelled: { color: 'bg-orange-400', label: 'Cancelled' } +} + +export function Toolbar(): JSX.Element { + const activeTaskId = useTaskStore((s) => s.activeTaskId) + const task = useTaskStore((s) => (s.activeTaskId ? s.tasks[s.activeTaskId] : null)) + const updateTask = useTaskStore((s) => s.updateTask) + const [threadCount, setThreadCount] = useState(3) + + const url = task?.url ?? '' + const status = task?.status ?? 'idle' + const isScanning = status === 'scanning' + const isDownloading = status === 'downloading' + const showPause = isScanning || isDownloading + const statusInfo = STATUS_CONFIG[status] + + const handleUrlChange = (e: React.ChangeEvent): void => { + if (activeTaskId) { + updateTask(activeTaskId, { url: e.target.value }) + } + } + + const handleScanToggle = async (): Promise => { + if (!activeTaskId || !url) return + + if (isScanning) { + await window.api.scan.stop(activeTaskId) + updateTask(activeTaskId, { status: 'cancelled' }) + } else { + // Clear previous scan data and start fresh + updateTask(activeTaskId, { + status: 'scanning', + nodes: {}, + roots: [], + checkedFiles: [], + scanProgress: { scanned: 0, total: 0 } + }) + // Fire-and-forget: results come back via IPC events + window.api.scan.start(activeTaskId, url).catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + toast.error(`Scan failed: ${message}`) + updateTask(activeTaskId, { status: 'error' }) + }) + } + } + + const handleDownload = async (): Promise => { + if (!activeTaskId || !task) return + + // Determine download path + let destPath = task.downloadPath + if (!destPath) { + const selected = await window.api.dialog.selectFolder() + if (!selected) return // user cancelled + destPath = selected + updateTask(activeTaskId, { downloadPath: destPath }) + } + + // Build file list from checked files + const files = task.checkedFiles + .map((fileId) => { + const node = task.nodes[fileId] + if (!node || node.kind !== 'file') return null + return { + id: node.id, + url: node.url, + destPath, + fileName: node.name + } + }) + .filter((f): f is NonNullable => f !== null) + + if (files.length === 0) { + toast.error('No files selected for download') + return + } + + // Initialize download items in the store + const downloads: Record = {} + for (const file of files) { + downloads[file.id] = { + id: file.id, + fileName: file.fileName, + url: file.url, + destPath: file.destPath, + status: 'queued', + progress: 0, + speed: 0, + totalSize: 0, + downloadedSize: 0 + } + } + updateTask(activeTaskId, { status: 'downloading', downloads }) + + try { + await window.api.download.start(activeTaskId, files, destPath) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + toast.error(`Download failed: ${message}`) + updateTask(activeTaskId, { status: 'error' }) + } + } + + const handlePause = (): void => { + if (!activeTaskId) return + if (isScanning) { + window.api.scan.pause(activeTaskId) + } else if (isDownloading) { + window.api.download.pause(activeTaskId) + } + } + + const handleThreadChange = (e: React.ChangeEvent): void => { + const count = Number(e.target.value) + setThreadCount(count) + window.api.download.setWorkers(count) + } + + const handleSelectFolder = async (): Promise => { + if (!activeTaskId) return + const selected = await window.api.dialog.selectFolder() + if (selected) { + updateTask(activeTaskId, { downloadPath: selected }) + } + } + + return ( +
+ {/* URL input */} + { + if (e.key === 'Enter') handleScanToggle() + }} + /> + + {/* Scan / Stop button */} + + + {/* Download button */} + + + {/* Pause button */} + {showPause && ( + + )} + + {/* Folder button */} + + + {/* Thread count */} + + + {/* Status indicator */} +
+ + {statusInfo.label} +
+
+ ) +} diff --git a/electron-app/src/renderer/src/components/TypeFilters.tsx b/electron-app/src/renderer/src/components/TypeFilters.tsx new file mode 100644 index 0000000..cc7f4c0 --- /dev/null +++ b/electron-app/src/renderer/src/components/TypeFilters.tsx @@ -0,0 +1,83 @@ +import { useState, useCallback } from 'react' +import { useFileTree } from '@/hooks/useFileTree' +import { Check, X } from 'lucide-react' + +export function TypeFilters(): JSX.Element { + const { extensionCounts, typeFilter, setAllTypesVisible } = useFileTree() + const [hiddenExts, setHiddenExts] = useState>(new Set()) + + const extensions = Object.entries(extensionCounts).sort(([a], [b]) => a.localeCompare(b)) + + const toggleExt = useCallback( + (ext: string) => { + setHiddenExts((prev) => { + const next = new Set(prev) + if (next.has(ext)) { + next.delete(ext) + typeFilter(ext, true) + } else { + next.add(ext) + typeFilter(ext, false) + } + return next + }) + }, + [typeFilter] + ) + + const showAll = useCallback(() => { + setAllTypesVisible(true) + setHiddenExts(new Set()) + }, [setAllTypesVisible]) + + const hideAll = useCallback(() => { + setAllTypesVisible(false) + setHiddenExts(new Set(Object.keys(extensionCounts))) + }, [setAllTypesVisible, extensionCounts]) + + if (extensions.length === 0) return <> + + return ( +
+ {/* Fixed ALL buttons */} +
+ + +
+ + {/* Scrollable extension chips */} +
+ {extensions.map(([ext, count]) => { + const isHidden = hiddenExts.has(ext) + return ( + + ) + })} +
+
+ ) +} diff --git a/electron-app/src/renderer/src/components/ui/button.tsx b/electron-app/src/renderer/src/components/ui/button.tsx new file mode 100644 index 0000000..4d38506 --- /dev/null +++ b/electron-app/src/renderer/src/components/ui/button.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/electron-app/src/renderer/src/components/ui/checkbox.tsx b/electron-app/src/renderer/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..f5a7e43 --- /dev/null +++ b/electron-app/src/renderer/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import { CheckIcon } from "lucide-react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/electron-app/src/renderer/src/components/ui/context-menu.tsx b/electron-app/src/renderer/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..5aaca76 --- /dev/null +++ b/electron-app/src/renderer/src/components/ui/context-menu.tsx @@ -0,0 +1,252 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { ContextMenu as ContextMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/electron-app/src/renderer/src/components/ui/progress.tsx b/electron-app/src/renderer/src/components/ui/progress.tsx new file mode 100644 index 0000000..af2ccf5 --- /dev/null +++ b/electron-app/src/renderer/src/components/ui/progress.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import { Progress as ProgressPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/electron-app/src/renderer/src/components/ui/scroll-area.tsx b/electron-app/src/renderer/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..e38a492 --- /dev/null +++ b/electron-app/src/renderer/src/components/ui/scroll-area.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/electron-app/src/renderer/src/components/ui/sonner.tsx b/electron-app/src/renderer/src/components/ui/sonner.tsx new file mode 100644 index 0000000..1cd7d2a --- /dev/null +++ b/electron-app/src/renderer/src/components/ui/sonner.tsx @@ -0,0 +1,8 @@ +import { Toaster as Sonner } from 'sonner' + +// Re-export sonner's Toaster for use in the app. +// This replaces the auto-generated shadcn wrapper which had circular imports +// and a next-themes dependency that doesn't apply to Electron. +const Toaster = Sonner + +export { Toaster } diff --git a/electron-app/src/renderer/src/components/ui/tabs.tsx b/electron-app/src/renderer/src/components/ui/tabs.tsx new file mode 100644 index 0000000..7f2c4f2 --- /dev/null +++ b/electron-app/src/renderer/src/components/ui/tabs.tsx @@ -0,0 +1,89 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/electron-app/src/renderer/src/hooks/useFileTree.ts b/electron-app/src/renderer/src/hooks/useFileTree.ts new file mode 100644 index 0000000..f4e7576 --- /dev/null +++ b/electron-app/src/renderer/src/hooks/useFileTree.ts @@ -0,0 +1,326 @@ +import { useMemo, useCallback, useRef } from 'react' +import { useTaskStore } from '@/stores/task-store' +import type { TreeNode } from '@shared/types' + +export interface VisibleNode { + node: TreeNode + depth: number +} + +export type SortKey = 'name' | 'size' | 'type' | 'none' +export type SortDir = 'asc' | 'desc' + +function parseSizeBytes(size: string): number { + if (!size || size === 'Unknown') return -1 + const match = size.match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i) + if (!match) return -1 + const val = parseFloat(match[1]) + const unit = match[2].toUpperCase() + const multipliers: Record = { B: 1, KB: 1024, MB: 1048576, GB: 1073741824, TB: 1099511627776 } + return val * (multipliers[unit] ?? 1) +} + +function sortChildren( + children: string[], + nodes: Record, + key: SortKey, + dir: SortDir +): string[] { + if (key === 'none' || children.length === 0) return children + + const sorted = [...children].sort((a, b) => { + const na = nodes[a] + const nb = nodes[b] + if (!na || !nb) return 0 + + // Folders always before files + if (na.kind !== nb.kind) return na.kind === 'folder' ? -1 : 1 + + let cmp = 0 + switch (key) { + case 'name': + cmp = na.name.localeCompare(nb.name, undefined, { numeric: true, sensitivity: 'base' }) + break + case 'size': + cmp = parseSizeBytes(na.size) - parseSizeBytes(nb.size) + break + case 'type': { + const extA = na.name.lastIndexOf('.') >= 0 ? na.name.slice(na.name.lastIndexOf('.')) : '' + const extB = nb.name.lastIndexOf('.') >= 0 ? nb.name.slice(nb.name.lastIndexOf('.')) : '' + cmp = extA.localeCompare(extB) + if (cmp === 0) cmp = na.name.localeCompare(nb.name, undefined, { numeric: true }) + break + } + } + return dir === 'desc' ? -cmp : cmp + }) + return sorted +} + +function flattenTree( + nodes: Record, + roots: string[], + sortKey: SortKey, + sortDir: SortDir +): VisibleNode[] { + const result: VisibleNode[] = [] + function walk(nodeId: string, depth: number): void { + const node = nodes[nodeId] + if (!node || node.hidden) return + result.push({ node, depth }) + if (node.kind === 'folder' && node.expanded) { + const sorted = sortChildren(node.children, nodes, sortKey, sortDir) + for (const childId of sorted) walk(childId, depth + 1) + } + } + const sortedRoots = sortChildren(roots, nodes, sortKey, sortDir) + for (const rootId of sortedRoots) walk(rootId, 0) + return result +} + +function updateFolderVisibility(nodes: Record): void { + const hasVisibleDescendant = (nodeId: string): boolean => { + const node = nodes[nodeId] + if (!node) return false + if (node.kind === 'file') return !node.hidden + return node.children.some((childId) => hasVisibleDescendant(childId)) + } + for (const [id, node] of Object.entries(nodes)) { + if (node.kind === 'folder') { + nodes[id] = { ...node, hidden: !hasVisibleDescendant(id) } + } + } +} + +export function useFileTree() { + const activeTaskId = useTaskStore((s) => s.activeTaskId) + const task = useTaskStore((s) => (s.activeTaskId ? s.tasks[s.activeTaskId] : null)) + const toggleCheckStore = useTaskStore((s) => s.toggleCheck) + const toggleExpandStore = useTaskStore((s) => s.toggleExpand) + const updateTask = useTaskStore((s) => s.updateTask) + const sortKey = useTaskStore((s) => s.sortKey) + const sortDir = useTaskStore((s) => s.sortDir) + + const nodes = task?.nodes ?? {} + const roots = task?.roots ?? [] + + // Track last clicked index for shift-select + const lastClickedIndex = useRef(-1) + + const visibleNodes = useMemo( + () => flattenTree(nodes, roots, sortKey, sortDir), + [nodes, roots, sortKey, sortDir] + ) + + const toggleCheck = useCallback( + (nodeId: string) => { + if (activeTaskId) toggleCheckStore(activeTaskId, nodeId) + }, + [activeTaskId, toggleCheckStore] + ) + + /** Handle click with shift support for range selection. */ + const handleRowClick = useCallback( + (nodeId: string, shiftKey: boolean) => { + if (!activeTaskId) return + + // Read fresh state to avoid stale closures + const currentTask = useTaskStore.getState().tasks[activeTaskId] + if (!currentTask) return + + // Recompute visible list from fresh state + const currentVisible = flattenTree(currentTask.nodes, currentTask.roots, sortKey, sortDir) + const currentIndex = currentVisible.findIndex((v) => v.node.id === nodeId) + if (currentIndex < 0) return + + if (shiftKey && lastClickedIndex.current >= 0) { + const start = Math.min(lastClickedIndex.current, currentIndex) + const end = Math.max(lastClickedIndex.current, currentIndex) + + const newNodes: Record = {} + for (const [id, node] of Object.entries(currentTask.nodes)) { + newNodes[id] = { ...node } + } + + for (let i = start; i <= end; i++) { + const nid = currentVisible[i].node.id + if (newNodes[nid]) { + newNodes[nid] = { ...newNodes[nid], checked: true } + if (newNodes[nid].kind === 'folder') { + const cascade = (fid: string): void => { + for (const cid of (newNodes[fid]?.children ?? [])) { + if (newNodes[cid]) { + newNodes[cid] = { ...newNodes[cid], checked: true } + if (newNodes[cid].kind === 'folder') cascade(cid) + } + } + } + cascade(nid) + } + } + } + + const checkedFiles = Object.values(newNodes) + .filter((n) => n.kind === 'file' && n.checked) + .map((n) => n.id) + useTaskStore.getState().updateTask(activeTaskId, { nodes: newNodes, checkedFiles }) + } else { + toggleCheckStore(activeTaskId, nodeId) + } + + lastClickedIndex.current = currentIndex + }, + [activeTaskId, sortKey, sortDir, toggleCheckStore] + ) + + const toggleExpand = useCallback( + (nodeId: string) => { + if (activeTaskId) toggleExpandStore(activeTaskId, nodeId) + }, + [activeTaskId, toggleExpandStore] + ) + + const selectAll = useCallback(() => { + if (!activeTaskId || !task) return + const newNodes: Record = {} + for (const [id, node] of Object.entries(task.nodes)) { + newNodes[id] = { ...node, checked: true } + } + const checkedFiles = Object.values(newNodes) + .filter((n) => n.kind === 'file') + .map((n) => n.id) + updateTask(activeTaskId, { nodes: newNodes, checkedFiles }) + }, [activeTaskId, task, updateTask]) + + const deselectAll = useCallback(() => { + if (!activeTaskId || !task) return + const newNodes: Record = {} + for (const [id, node] of Object.entries(task.nodes)) { + newNodes[id] = { ...node, checked: false } + } + updateTask(activeTaskId, { nodes: newNodes, checkedFiles: [] }) + }, [activeTaskId, task, updateTask]) + + const expandAll = useCallback(() => { + if (!activeTaskId || !task) return + const newNodes: Record = {} + for (const [id, node] of Object.entries(task.nodes)) { + newNodes[id] = { ...node, expanded: node.kind === 'folder' ? true : node.expanded } + } + updateTask(activeTaskId, { nodes: newNodes }) + }, [activeTaskId, task, updateTask]) + + const collapseAll = useCallback(() => { + if (!activeTaskId || !task) return + const newNodes: Record = {} + for (const [id, node] of Object.entries(task.nodes)) { + newNodes[id] = { ...node, expanded: false } + } + updateTask(activeTaskId, { nodes: newNodes }) + }, [activeTaskId, task, updateTask]) + + const searchFilter = useCallback( + (query: string) => { + if (!activeTaskId || !task) return + const lowerQuery = query.toLowerCase() + const newNodes: Record = {} + for (const [id, node] of Object.entries(task.nodes)) { + if (!query) { + newNodes[id] = { ...node, hidden: false } + } else { + const matches = node.name.toLowerCase().includes(lowerQuery) + newNodes[id] = { ...node, hidden: !matches && node.kind !== 'folder' } + } + } + if (query) { + updateFolderVisibility(newNodes) + } + updateTask(activeTaskId, { nodes: newNodes }) + }, + [activeTaskId, task, updateTask] + ) + + const typeFilter = useCallback( + (ext: string, visible: boolean) => { + if (!activeTaskId) return + const currentTask = useTaskStore.getState().tasks[activeTaskId] + if (!currentTask) return + + const lowerExt = ext.toLowerCase() + const newNodes: Record = {} + for (const [id, node] of Object.entries(currentTask.nodes)) { + if (node.kind === 'file' && node.name.toLowerCase().endsWith(lowerExt)) { + newNodes[id] = { ...node, hidden: !visible } + } else { + newNodes[id] = { ...node } + } + } + updateFolderVisibility(newNodes) + useTaskStore.getState().updateTask(activeTaskId, { nodes: newNodes }) + }, + [activeTaskId] + ) + + const setAllTypesVisible = useCallback( + (visible: boolean) => { + if (!activeTaskId) return + const currentTask = useTaskStore.getState().tasks[activeTaskId] + if (!currentTask) return + + const newNodes: Record = {} + for (const [id, node] of Object.entries(currentTask.nodes)) { + if (node.kind === 'file') { + newNodes[id] = { ...node, hidden: !visible } + } else { + newNodes[id] = { ...node } + } + } + updateFolderVisibility(newNodes) + useTaskStore.getState().updateTask(activeTaskId, { nodes: newNodes }) + }, + [activeTaskId] + ) + + const extensionCounts = useMemo(() => { + const counts: Record = {} + for (const node of Object.values(nodes)) { + if (node.kind === 'file') { + const dotIndex = node.name.lastIndexOf('.') + const ext = dotIndex >= 0 ? node.name.slice(dotIndex).toLowerCase() : '(no ext)' + counts[ext] = (counts[ext] ?? 0) + 1 + } + } + return counts + }, [nodes]) + + const stats = useMemo(() => { + let files = 0 + let folders = 0 + let selected = 0 + for (const node of Object.values(nodes)) { + if (node.kind === 'file') { + files++ + if (node.checked) selected++ + } else { + folders++ + } + } + return { files, folders, selected } + }, [nodes]) + + return { + visibleNodes, + toggleCheck, + handleRowClick, + toggleExpand, + selectAll, + deselectAll, + expandAll, + collapseAll, + searchFilter, + typeFilter, + setAllTypesVisible, + extensionCounts, + stats + } +} diff --git a/electron-app/src/renderer/src/hooks/useIpc.ts b/electron-app/src/renderer/src/hooks/useIpc.ts new file mode 100644 index 0000000..f3e809d --- /dev/null +++ b/electron-app/src/renderer/src/hooks/useIpc.ts @@ -0,0 +1,48 @@ +import { useEffect } from 'react' + +export interface Api { + scan: { + start: (taskId: string, url: string) => Promise + stop: (taskId: string) => Promise + pause: (taskId: string) => Promise + resume: (taskId: string) => Promise + } + download: { + start: (taskId: string, files: unknown[], destPath: string) => Promise + pause: (taskId: string) => Promise + resume: (taskId: string) => Promise + cancel: (taskId: string, fileId: string) => Promise + cancelAll: (taskId: string) => Promise + retry: (taskId: string, fileId: string) => Promise + setWorkers: (count: number) => Promise + } + task: { + create: (url: string) => Promise + remove: (taskId: string) => Promise + } + settings: { + get: () => Promise + set: (data: unknown) => Promise + } + dialog: { + selectFolder: () => Promise + } + preview: { + fetch: (url: string) => Promise<{ dataUrl: string; contentType: string }> + fetchText: (url: string) => Promise + } + on: (channel: string, callback: (...args: unknown[]) => void) => () => void +} + +declare global { + interface Window { + api: Api + } +} + +export function useIpcEvent(channel: string, callback: (...args: unknown[]) => void): void { + useEffect(() => { + const unsubscribe = window.api.on(channel, callback) + return unsubscribe + }, [channel, callback]) +} diff --git a/electron-app/src/renderer/src/hooks/useIpcListeners.ts b/electron-app/src/renderer/src/hooks/useIpcListeners.ts new file mode 100644 index 0000000..c9f4658 --- /dev/null +++ b/electron-app/src/renderer/src/hooks/useIpcListeners.ts @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useTaskStore } from '@/stores/task-store' +import { useIpcEvent } from './useIpc' +import { toast } from 'sonner' +import type { Task, TreeNode } from '../../../shared/types' + +/** + * Subscribes to all IPC events from the main process and updates the Zustand store. + * Batches scan:item events to avoid O(n²) store updates. + * Should be called once at the App root level. + */ +export function useIpcListeners(): void { + const updateTask = useTaskStore((s) => s.updateTask) + const updateDownload = useTaskStore((s) => s.updateDownload) + + // --- Batch buffer for scan:item events --- + const pendingNodes = useRef>(new Map()) + const flushScheduled = useRef(false) + + const flushNodes = useCallback(() => { + flushScheduled.current = false + const batches = pendingNodes.current + if (batches.size === 0) return + + // Process all pending batches at once + for (const [taskId, nodes] of batches) { + const state = useTaskStore.getState() + const task = state.tasks[taskId] + if (!task) continue + + // Build updated nodes map in one pass (no per-node spread) + const updatedNodes = { ...task.nodes } + const updatedRoots = [...task.roots] + + for (const node of nodes) { + const nodeToAdd = node.kind === 'folder' ? { ...node, expanded: true } : node + updatedNodes[nodeToAdd.id] = nodeToAdd + + // Link to parent + if (nodeToAdd.parentId && updatedNodes[nodeToAdd.parentId]) { + const parent = updatedNodes[nodeToAdd.parentId] + if (!parent.children.includes(nodeToAdd.id)) { + updatedNodes[nodeToAdd.parentId] = { + ...parent, + children: [...parent.children, nodeToAdd.id] + } + } + } + + // Track roots + if (!nodeToAdd.parentId && !updatedRoots.includes(nodeToAdd.id)) { + updatedRoots.push(nodeToAdd.id) + } + } + + useTaskStore.getState().updateTask(taskId, { nodes: updatedNodes, roots: updatedRoots }) + } + + batches.clear() + }, []) + + const scheduleFlush = useCallback(() => { + if (!flushScheduled.current) { + flushScheduled.current = true + requestAnimationFrame(flushNodes) + } + }, [flushNodes]) + + // --- Scan events --- + + const onScanItem = useCallback( + (...args: unknown[]) => { + const [taskId, nodeData] = args as [string, TreeNode] + const batch = pendingNodes.current.get(taskId) ?? [] + batch.push(nodeData) + pendingNodes.current.set(taskId, batch) + scheduleFlush() + }, + [scheduleFlush] + ) + + const onScanProgress = useCallback( + (...args: unknown[]) => { + const [taskId, scanned, total] = args as [string, number, number] + updateTask(taskId, { scanProgress: { scanned, total } }) + }, + [updateTask] + ) + + const onScanFinished = useCallback( + (...args: unknown[]) => { + // Flush any remaining nodes first + flushNodes() + + const [taskId] = args as [string] + const task = useTaskStore.getState().tasks[taskId] + const status = task?.status === 'scanning' ? 'scanned' : 'cancelled' + updateTask(taskId, { status }) + + if (status === 'scanned' && task) { + const fileCount = Object.values(task.nodes).filter((n) => n.kind === 'file').length + toast.success(`Scan finished — ${fileCount} files found`) + } + }, + [updateTask, flushNodes] + ) + + const onScanError = useCallback((...args: unknown[]) => { + const [_taskId, errUrl, message] = args as [string, string, string] + const text = `[Error] ${errUrl}: ${message}` + window.dispatchEvent(new CustomEvent('log-message', { detail: { text } })) + }, []) + + // --- Download events --- + + const onDownloadProgress = useCallback( + (...args: unknown[]) => { + const [taskId, fileId, percent, speed] = args as [string, string, number, number] + updateDownload(taskId, fileId, { progress: percent, speed, status: 'downloading' }) + }, + [updateDownload] + ) + + const onDownloadStatus = useCallback( + (...args: unknown[]) => { + const [taskId, fileId, status] = args as [string, string, string] + updateDownload(taskId, fileId, { + status: status as 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'cancelled' + }) + }, + [updateDownload] + ) + + const onDownloadFinished = useCallback( + (...args: unknown[]) => { + const [taskId, completed, total] = args as [string, number, number] + updateTask(taskId, { status: 'done' }) + toast.success(`Downloaded ${completed}/${total} files`) + }, + [updateTask] + ) + + const onLogMessage = useCallback((...args: unknown[]) => { + const [_taskId, text] = args as [string, string] + window.dispatchEvent(new CustomEvent('log-message', { detail: { text } })) + }, []) + + // --- Register all listeners --- + + useIpcEvent('scan:item', onScanItem) + useIpcEvent('scan:progress', onScanProgress) + useIpcEvent('scan:finished', onScanFinished) + useIpcEvent('scan:error', onScanError) + useIpcEvent('download:progress', onDownloadProgress) + useIpcEvent('download:status', onDownloadStatus) + useIpcEvent('download:finished', onDownloadFinished) + useIpcEvent('log:message', onLogMessage) + + // Create a default task on mount if none exists + useEffect(() => { + const state = useTaskStore.getState() + if (Object.keys(state.tasks).length === 0) { + ;(window.api.task.create('') as Promise).then((task) => { + useTaskStore.getState().createTask(task.id, task.url) + }) + } + }, []) +} diff --git a/electron-app/src/renderer/src/lib/utils.ts b/electron-app/src/renderer/src/lib/utils.ts new file mode 100644 index 0000000..d32b0fe --- /dev/null +++ b/electron-app/src/renderer/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/electron-app/src/renderer/src/main.tsx b/electron-app/src/renderer/src/main.tsx new file mode 100644 index 0000000..f4d40c7 --- /dev/null +++ b/electron-app/src/renderer/src/main.tsx @@ -0,0 +1,11 @@ +import './assets/main.css' + +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +) diff --git a/electron-app/src/renderer/src/stores/task-store.ts b/electron-app/src/renderer/src/stores/task-store.ts new file mode 100644 index 0000000..0437fbc --- /dev/null +++ b/electron-app/src/renderer/src/stores/task-store.ts @@ -0,0 +1,232 @@ +import { create } from 'zustand' +import type { Task, TreeNode, DownloadItem } from '../../../shared/types' + +function createEmptyTask(id: string, url: string): Task { + return { + id, + url, + status: 'idle', + nodes: {}, + roots: [], + checkedFiles: [], + downloads: {}, + scanProgress: { scanned: 0, total: 0 }, + downloadPath: '' + } +} + +type SortKey = 'name' | 'size' | 'type' | 'none' +type SortDir = 'asc' | 'desc' + +interface TaskStore { + tasks: Record + activeTaskId: string | null + previewNodeId: string | null + sortKey: SortKey + sortDir: SortDir + + // Preview & Sort + setPreviewNodeId: (nodeId: string | null) => void + setSort: (key: SortKey) => void + + // Task management + createTask: (id: string, url: string) => void + removeTask: (taskId: string) => void + setActiveTask: (taskId: string) => void + updateTask: (taskId: string, partial: Partial) => void + + // Tree operations + getNodes: (taskId: string) => Record + addNode: (taskId: string, node: TreeNode) => void + toggleCheck: (taskId: string, nodeId: string) => void + toggleExpand: (taskId: string, nodeId: string) => void + setHidden: (taskId: string, nodeId: string, hidden: boolean) => void + + // Download operations + updateDownload: (taskId: string, downloadId: string, partial: Partial) => void + setDownloads: (taskId: string, downloads: Record) => void +} + +function cascadeCheck(nodes: Record, nodeId: string, checked: boolean): void { + const node = nodes[nodeId] + if (!node) return + node.checked = checked + for (const childId of node.children) { + cascadeCheck(nodes, childId, checked) + } +} + +export const useTaskStore = create((set, get) => ({ + tasks: {}, + activeTaskId: null, + previewNodeId: null, + sortKey: 'name' as SortKey, + sortDir: 'asc' as SortDir, + + setPreviewNodeId: (nodeId) => set({ previewNodeId: nodeId }), + setSort: (key) => set((state) => ({ + sortKey: key, + sortDir: state.sortKey === key && state.sortDir === 'asc' ? 'desc' : 'asc', + })), + + createTask: (id, url) => { + set((state) => ({ + tasks: { ...state.tasks, [id]: createEmptyTask(id, url) }, + activeTaskId: id + })) + }, + + removeTask: (taskId) => { + set((state) => { + const { [taskId]: _, ...remaining } = state.tasks + const ids = Object.keys(remaining) + const newActive = + state.activeTaskId === taskId ? (ids.length > 0 ? ids[ids.length - 1] : null) : state.activeTaskId + return { tasks: remaining, activeTaskId: newActive } + }) + }, + + setActiveTask: (taskId) => { + set({ activeTaskId: taskId }) + }, + + updateTask: (taskId, partial) => { + set((state) => { + const task = state.tasks[taskId] + if (!task) return state + return { + tasks: { ...state.tasks, [taskId]: { ...task, ...partial } } + } + }) + }, + + getNodes: (taskId) => { + const task = get().tasks[taskId] + return task ? task.nodes : {} + }, + + addNode: (taskId, node) => { + set((state) => { + const task = state.tasks[taskId] + if (!task) return state + + // Folders should be expanded by default so the tree is visible + const nodeToAdd = node.kind === 'folder' ? { ...node, expanded: true } : node + + const updatedNodes = { ...task.nodes, [nodeToAdd.id]: nodeToAdd } + + // Add to parent's children array if it has a parent + if (nodeToAdd.parentId && updatedNodes[nodeToAdd.parentId]) { + const parent = updatedNodes[nodeToAdd.parentId] + if (!parent.children.includes(nodeToAdd.id)) { + updatedNodes[nodeToAdd.parentId] = { + ...parent, + children: [...parent.children, nodeToAdd.id] + } + } + } + + const updatedRoots = nodeToAdd.parentId === '' && !task.roots.includes(nodeToAdd.id) + ? [...task.roots, nodeToAdd.id] + : task.roots + + return { + tasks: { + ...state.tasks, + [taskId]: { ...task, nodes: updatedNodes, roots: updatedRoots } + } + } + }) + }, + + toggleCheck: (taskId, nodeId) => { + set((state) => { + const task = state.tasks[taskId] + if (!task) return state + const node = task.nodes[nodeId] + if (!node) return state + + const newNodes = { ...task.nodes } + // Deep copy nodes that will be modified + for (const id of Object.keys(newNodes)) { + newNodes[id] = { ...newNodes[id] } + } + cascadeCheck(newNodes, nodeId, !node.checked) + + // Recalculate checkedFiles + const checkedFiles = Object.values(newNodes) + .filter((n) => n.kind === 'file' && n.checked) + .map((n) => n.id) + + return { + tasks: { + ...state.tasks, + [taskId]: { ...task, nodes: newNodes, checkedFiles } + } + } + }) + }, + + toggleExpand: (taskId, nodeId) => { + set((state) => { + const task = state.tasks[taskId] + if (!task) return state + const node = task.nodes[nodeId] + if (!node) return state + return { + tasks: { + ...state.tasks, + [taskId]: { + ...task, + nodes: { ...task.nodes, [nodeId]: { ...node, expanded: !node.expanded } } + } + } + } + }) + }, + + setHidden: (taskId, nodeId, hidden) => { + set((state) => { + const task = state.tasks[taskId] + if (!task) return state + const node = task.nodes[nodeId] + if (!node) return state + return { + tasks: { + ...state.tasks, + [taskId]: { + ...task, + nodes: { ...task.nodes, [nodeId]: { ...node, hidden } } + } + } + } + }) + }, + + updateDownload: (taskId, downloadId, partial) => { + set((state) => { + const task = state.tasks[taskId] + if (!task) return state + const existing = task.downloads[downloadId] ?? ({} as DownloadItem) + return { + tasks: { + ...state.tasks, + [taskId]: { + ...task, + downloads: { ...task.downloads, [downloadId]: { ...existing, ...partial } } + } + } + } + }) + }, + + setDownloads: (taskId, downloads) => { + set((state) => { + const task = state.tasks[taskId] + if (!task) return state + return { + tasks: { ...state.tasks, [taskId]: { ...task, downloads } } + } + }) + } +})) diff --git a/electron-app/src/shared/icons.ts b/electron-app/src/shared/icons.ts new file mode 100644 index 0000000..e7e0bb2 --- /dev/null +++ b/electron-app/src/shared/icons.ts @@ -0,0 +1,31 @@ +// Pure implementation to avoid Node.js 'path' in browser context +function extname(filename: string): string { + const dot = filename.lastIndexOf('.') + return dot > 0 && dot < filename.length - 1 ? filename.slice(dot) : '' +} + +const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico'] +const DOC_EXTS = ['.md', '.txt', '.pdf', '.doc', '.docx', '.rtf'] +const ARCHIVE_EXTS = ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz'] +const CODE_EXTS = [ + '.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.rs', '.java', '.c', '.cpp', + '.h', '.hpp', '.json', '.yaml', '.yml', '.toml', '.xml', '.html', '.css', '.sh', +] + +export function getIconGroup(fileName: string, mimeType: string): string { + const ext = extname(fileName).toLowerCase() + if (IMAGE_EXTS.includes(ext)) return 'image' + if (DOC_EXTS.includes(ext)) return 'document' + if (ARCHIVE_EXTS.includes(ext)) return 'archive' + if (CODE_EXTS.includes(ext)) return 'code' + if (mimeType.includes('audio/')) return 'audio' + if (mimeType.includes('video/')) return 'video' + if (mimeType.includes('text/')) return 'text' + if (mimeType.includes('image/')) return 'image' + return 'binary' +} + +export const EMOJI_ICONS: Record = { + folder: '📁', image: '🖼️', document: '📄', archive: '🗜️', + code: '💻', audio: '🎵', video: '🎬', text: '📝', binary: '⚙️', +} diff --git a/electron-app/src/shared/types.ts b/electron-app/src/shared/types.ts new file mode 100644 index 0000000..992a554 --- /dev/null +++ b/electron-app/src/shared/types.ts @@ -0,0 +1,42 @@ +export interface TreeNode { + id: string + parentId: string // "" for roots + name: string + kind: 'folder' | 'file' + url: string // download URL (files only, "" for folders) + fullPath: string // relative path + size: string // "245 KB" or "Unknown" + fileType: string // MIME type + iconGroup: string // folder|image|document|archive|code|audio|video|text|binary + checked: boolean + expanded: boolean + hidden: boolean // filtered out by search/type + children: string[] // child node IDs +} + +export interface Task { + id: string + url: string + status: 'idle' | 'scanning' | 'scanned' | 'downloading' | 'done' | 'error' | 'cancelled' + nodes: Record + roots: string[] + checkedFiles: string[] + downloads: Record + scanProgress: { scanned: number; total: number } + downloadPath: string +} + +export interface DownloadItem { + id: string + fileName: string + url: string + destPath: string + status: 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'cancelled' + progress: number + speed: number + totalSize: number + downloadedSize: number +} + +export type ScanStatus = Task['status'] +export type DownloadStatus = DownloadItem['status'] diff --git a/electron-app/tsconfig.json b/electron-app/tsconfig.json new file mode 100644 index 0000000..b1d115b --- /dev/null +++ b/electron-app/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.web.json" + } + ] +} diff --git a/electron-app/tsconfig.node.json b/electron-app/tsconfig.node.json new file mode 100644 index 0000000..b6dd582 --- /dev/null +++ b/electron-app/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", + "include": [ + "electron.vite.config.*", + "src/main/**/*", + "src/preload/**/*", + "src/shared/**/*" + ], + "compilerOptions": { + "composite": true, + "moduleResolution": "bundler", + "types": [ + "electron-vite/node" + ] + } +} diff --git a/electron-app/tsconfig.web.json b/electron-app/tsconfig.web.json new file mode 100644 index 0000000..71b491d --- /dev/null +++ b/electron-app/tsconfig.web.json @@ -0,0 +1,23 @@ +{ + "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", + "include": [ + "src/renderer/src/**/*", + "src/renderer/src/**/*.d.ts", + "src/shared/**/*" + ], + "compilerOptions": { + "composite": true, + "baseUrl": ".", + "paths": { + "@renderer/*": [ + "src/renderer/src/*" + ], + "@/*": [ + "src/renderer/src/*" + ], + "@shared/*": [ + "src/shared/*" + ] + } + } +} diff --git a/electron-app/vitest.config.ts b/electron-app/vitest.config.ts new file mode 100644 index 0000000..e646338 --- /dev/null +++ b/electron-app/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/__tests__/**/*.test.ts', 'src/**/*.test.ts'], + }, +}) diff --git a/index_ripper.py b/index_ripper.py deleted file mode 100644 index e673672..0000000 --- a/index_ripper.py +++ /dev/null @@ -1,1319 +0,0 @@ -"""Index Ripper UI using customtkinter with search, logs, and per-file downloads.""" - -# pylint: disable=too-many-lines -import os -import posixpath -import sys -import threading -from concurrent.futures import ThreadPoolExecutor -from queue import Empty, Queue -from threading import Event, Thread -from urllib.parse import unquote, urljoin, urlparse - -if "--smoke" in sys.argv: - print("SMOKE_OK") - raise SystemExit(0) - -if "--self-test" in sys.argv: - from self_test import run_self_test - - result = run_self_test() - print( - f"SELF_TEST_OK total={result.total} files={result.files} dirs={result.directories}" - ) - raise SystemExit(0) - -import tkinter as tk -from tkinter import filedialog, messagebox, ttk - -import customtkinter as ctk # type: ignore -import requests # type: ignore -from requests.adapters import HTTPAdapter # type: ignore -from urllib3.util.retry import Retry # type: ignore - -from backend import Backend -from ui_downloads import DownloadsPanel -from app_utils import ( - default_download_folder, - normalize_extension, - safe_join, - sanitize_filename, - sanitize_path_segment, - shorten_path, -) -from settings_store import load_settings, save_settings -from ui_theme import apply_app_theme, configure_treeview_style, treeview_tag_colors - - -class WebsiteCopier: - """A GUI application for downloading files from a website directory listing.""" - - USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/91.0.4472.124 Safari/537.36" - ) - - def __init__(self): - """Initializes the main window and UI components.""" - apply_app_theme(ctk) - - self.window = ctk.CTk() - self.window.title("Index Ripper") - self.window.geometry("1200x900") - self.window.minsize(900, 650) - - self.backend = Backend(self) - - # Download control - self.pause_event = Event() - self.current_downloads = [] - - # Add scan control - self.scan_pause_event = Event() - self.scan_pause_event.set() # Initially set to not paused - self.is_scanning = False - - # 添加线程锁,用于保护共享数据 - self.files_dict_lock = threading.Lock() - self.folders_dict_lock = threading.Lock() - - # 初始化队列和处理状态 - self.dir_queue = Queue() - self.file_queue = Queue() - self.scan_item_buffer = Queue() - self.is_processing_dirs = False - self.is_processing_files = False - self.scan_flush_interval_ms = 16 - self.scan_flush_batch_size = 200 - self.scan_flush_job = None - - # --- Main Layout --- - self.window.grid_columnconfigure(0, weight=1) - self.window.grid_rowconfigure(1, weight=1) - - # --- URL and filter area --- - self.top_frame = ctk.CTkFrame(self.window) - self.top_frame.grid(row=0, column=0, padx=10, pady=(10, 5), sticky="ew") - self.top_frame.grid_columnconfigure(0, weight=1) - - # URL input - url_input_frame = ctk.CTkFrame(self.top_frame, fg_color="transparent") - url_input_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew") - url_input_frame.grid_columnconfigure(1, weight=1) - ctk.CTkLabel( - url_input_frame, text="URL:", font=ctk.CTkFont(weight="bold") - ).grid(row=0, column=0, padx=(0, 5)) - self.url_entry = ctk.CTkEntry( - url_input_frame, placeholder_text="https://example.com" - ) - self.url_entry.grid(row=0, column=1, sticky="ew") - - # Scan buttons - scan_buttons_frame = ctk.CTkFrame(self.top_frame, fg_color="transparent") - scan_buttons_frame.grid(row=0, column=1, padx=10, pady=5) - - self.status_chip = ctk.CTkLabel( - self.top_frame, - text="Ready", - fg_color=("#DCFCE7", "#14532D"), - text_color=("#14532D", "#DCFCE7"), - corner_radius=12, - padx=10, - pady=2, - ) - self.status_chip.grid(row=0, column=2, padx=(0, 10), pady=5, sticky="e") - - self.scan_btn = ctk.CTkButton( - scan_buttons_frame, text="Scan", command=self.start_scan - ) - self.scan_btn.pack(side="left", padx=5) - - self.scan_pause_btn = ctk.CTkButton( - scan_buttons_frame, - text="Pause Scan", - command=self.toggle_scan_pause, - state="disabled", - ) - self.scan_pause_btn.pack(side="left", padx=5) - - self.clear_scan_btn = ctk.CTkButton( - scan_buttons_frame, text="Clear", command=self.clear_scan_results - ) - self.clear_scan_btn.pack(side="left", padx=5) - - # --- File type filter area --- - filter_frame = ctk.CTkFrame(self.top_frame) - filter_frame.grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky="ew") - filter_frame.grid_columnconfigure(0, weight=1) - - ctk.CTkLabel( - filter_frame, text="File Type Selection", font=ctk.CTkFont(weight="bold") - ).grid(row=0, column=0, padx=10, pady=(5, 0), sticky="w") - - # File type checkbox container - self.filter_checkboxes_frame = ctk.CTkScrollableFrame( - filter_frame, label_text="", fg_color="transparent" - ) - self.filter_checkboxes_frame.grid(row=1, column=0, padx=5, pady=5, sticky="ew") - - # Variable to store file types - self.file_types = {} # {'.pdf': BooleanVar(), '.jpg': BooleanVar(), ...} - self.file_type_counts = {} # {'.pdf': 0, '.jpg': 0, ...} - - # Select/deselect all buttons - select_buttons_frame = ctk.CTkFrame(filter_frame, fg_color="transparent") - select_buttons_frame.grid(row=2, column=0, padx=10, pady=5, sticky="w") - ctk.CTkButton( - select_buttons_frame, text="Select All", command=self.select_all_types - ).pack(side="left", padx=2) - ctk.CTkButton( - select_buttons_frame, text="Deselect All", command=self.deselect_all_types - ).pack(side="left", padx=2) - - # --- File list area --- - self.tree_frame = ctk.CTkFrame(self.window) - self.tree_frame.grid(row=1, column=0, padx=10, pady=5, sticky="nsew") - self.tree_frame.grid_rowconfigure(1, weight=1) - self.tree_frame.grid_columnconfigure(0, weight=1) - - # Search bar - search_frame = ctk.CTkFrame(self.tree_frame) - search_frame.grid(row=0, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - search_frame.grid_columnconfigure(1, weight=1) - - ctk.CTkLabel(search_frame, text="Search:").grid(row=0, column=0, padx=(5, 2)) - self.search_var = ctk.StringVar() - self.search_var.trace_add("write", self.on_search_filter_changed) - self.search_entry = ctk.CTkEntry( - search_frame, textvariable=self.search_var, state="disabled" - ) - self.search_entry.grid(row=0, column=1, sticky="ew") - self.full_tree_backup = {} - - configure_treeview_style(self.window, ctk, ttk) - - self.tree = ttk.Treeview( - self.tree_frame, - selectmode="none", - show=("tree", "headings"), - style="Treeview", - ) - - colors = treeview_tag_colors(self.window) - self.tree.tag_configure("oddrow", background=colors["oddrow"]) - self.tree.tag_configure("evenrow", background=colors["evenrow"]) - self.tree.tag_configure("checked", foreground=colors["checked"]) - - # Set column configuration - self.tree["columns"] = ("size", "type") - self.tree.heading("#0", text="Name", command=lambda: self.sort_tree("name")) - self.tree.heading("size", text="Size", command=lambda: self.sort_tree("size")) - self.tree.heading("type", text="Type", command=lambda: self.sort_tree("type")) - - self.tree.column("#0", minwidth=400, width=600) - self.tree.column("size", width=120, anchor="center") - self.tree.column("type", width=200, anchor="center") - - # Bind right-click menu - self.tree.bind("", self.show_context_menu) - - # Create right-click menu (using tk.Menu as customtkinter doesn't have one) - self.context_menu = tk.Menu(self.window, tearoff=0) - self.context_menu.add_command(label="Select All", command=self.select_all) - self.context_menu.add_command(label="Deselect All", command=self.deselect_all) - self.context_menu.add_separator() - self.context_menu.add_command(label="Expand All", command=self.expand_all) - self.context_menu.add_command(label="Collapse All", command=self.collapse_all) - - # Sort status - self.sort_column = None - self.sort_reverse = False - - self.checked_items = set() - self.checkbox_checked = "✔" - self.folder_icon = "📁" - self.file_icon = "📄" - self.folders = {} - - self.scrollbar = ctk.CTkScrollbar(self.tree_frame, command=self.tree.yview) - self.tree.configure(yscrollcommand=self.scrollbar.set) - - self.tree.grid(row=1, column=0, sticky="nsew") - self.scrollbar.grid(row=1, column=1, sticky="ns") - - self.tree.bind("", self.on_tree_click) - - # --- Download control area --- - control_frame = ctk.CTkFrame(self.window) - control_frame.grid(row=2, column=0, padx=10, pady=5, sticky="ew") - - self.download_btn = ctk.CTkButton( - control_frame, - text="Download Selected Files", - command=self.download_selected, - ) - self.download_btn.pack(side="left", padx=5, pady=5) - - self.pause_btn = ctk.CTkButton( - control_frame, - text="Pause Download", - command=self.toggle_pause, - state="disabled", - ) - self.pause_btn.pack(side="left", padx=5, pady=5) - - self.path_btn = ctk.CTkButton( - control_frame, - text="Choose Download Location", - command=self.choose_download_path, - ) - self.path_btn.pack(side="left", padx=5, pady=5) - - # Thread download count control - threads_frame = ctk.CTkFrame(control_frame, fg_color="transparent") - threads_frame.pack(side="right", padx=5, pady=5) - ctk.CTkLabel(threads_frame, text="Concurrent Downloads:").pack(side="left") - self.threads_var = ctk.StringVar(value="5") - threads_option_menu = ctk.CTkOptionMenu( - threads_frame, - values=[str(i) for i in range(1, 11)], - variable=self.threads_var, - command=self.update_thread_count, - ) - threads_option_menu.pack(side="left", padx=5) - - # Toggle button to show/hide bottom panels (Downloads/Logs) - self.panels_visible = True - self.toggle_panels_btn = ctk.CTkButton( - control_frame, - text="Hide Panels", - width=110, - command=self.toggle_panels, - ) - self.toggle_panels_btn.pack(side="right", padx=5) - - # --- Progress bar --- - self.progress_frame = ctk.CTkFrame(self.window) - self.progress_frame.grid(row=3, column=0, padx=10, pady=(5, 10), sticky="ew") - self.progress_frame.grid_columnconfigure(0, weight=1) - - self.progress_var = ctk.DoubleVar() - self.progress_bar = ctk.CTkProgressBar( - self.progress_frame, variable=self.progress_var - ) - self.progress_bar.set(0) - self.progress_bar.grid(row=0, column=0, padx=5, pady=2, sticky="ew") - - self.progress_label = ctk.CTkLabel(self.progress_frame, text="") - self.progress_label.grid(row=1, column=0, padx=5, pady=2, sticky="w") - - # --- Panels (Downloads + Logs) inside a Tabview --- - self.panels_tabview = ctk.CTkTabview(self.window) - self.panels_tabview.grid(row=4, column=0, padx=10, pady=(0, 10), sticky="ew") - - self.panels_tabview.add("Downloads") - self.panels_tabview.add("Logs") - - downloads_tab = self.panels_tabview.tab("Downloads") - self.downloads_frame = ctk.CTkScrollableFrame(downloads_tab, label_text="") - self.downloads_frame.pack(fill="x", expand=False) - - logs_tab = self.panels_tabview.tab("Logs") - self.log_text = ctk.CTkTextbox(logs_tab, height=80) - self.log_text.pack(fill="x", expand=False) - self.log_text.configure(state="disabled") - - try: - self.panels_tabview.set("Logs") - except tk.TclError: - pass - - # Track per-file download UI elements and cancel events - self.downloads_panel = DownloadsPanel(self.downloads_frame, ctk, tk, threading) - - # Load and apply saved settings (panels visibility and active tab) - self._settings_path = os.path.join( - os.path.expanduser("~"), - ".index_ripper_settings.json", - ) - saved = load_settings(self._settings_path) if os.path.exists(self._settings_path) else {} - # Active tab - active = saved.get("active_tab") - if active in ("Downloads", "Logs"): - try: - self.panels_tabview.set(active) - except tk.TclError: - pass - visible = bool(saved.get("panels_visible", True)) - if visible: - self.panels_tabview.grid() - self.panels_visible = True - self.toggle_panels_btn.configure(text="Hide Panels") - else: - self.panels_tabview.grid_remove() - self.panels_visible = False - self.toggle_panels_btn.configure(text="Show Panels") - - # Initialize session with retry - retry_strategy = Retry( - total=3, - backoff_factor=0.1, - status_forcelist=[500, 502, 503, 504], - ) - - self.files_dict = {} - self.download_path = "" - self.is_paused = False - self.download_queue = Queue() - self.max_workers = 5 - self.executor = ThreadPoolExecutor(max_workers=self.max_workers) - self.active_downloads = [] - - self.session = requests.Session() - adapter = HTTPAdapter( - max_retries=retry_strategy, - pool_connections=10, - pool_maxsize=10, - pool_block=False, - ) - self.session.mount("http://", adapter) - self.session.mount("https://", adapter) - self.timeout = (10, 30) - - self.total_urls = 0 - self.scanned_urls = 0 - self.window.protocol("WM_DELETE_WINDOW", self.on_closing) - self.should_stop = False - self.window.bind("", self.focus_search) - self.window.bind("", self.focus_logs) - self.window.bind("", self.clear_search) - - def notify_info(self, title: str, message: str) -> None: - try: - messagebox.showinfo(title, message) - except Exception: - self.log_message(f"[INFO] {title}: {message}") - - def notify_warning(self, title: str, message: str) -> None: - try: - messagebox.showwarning(title, message) - except Exception: - self.log_message(f"[WARN] {title}: {message}") - - def notify_error(self, title: str, message: str) -> None: - try: - messagebox.showerror(title, message) - except Exception: - self.log_message(f"[ERROR] {title}: {message}") - - def toggle_panels(self): - """Show or hide the bottom panels (Downloads/Logs).""" - if self.panels_visible: - try: - self.panels_tabview.grid_remove() - except tk.TclError: - pass - self.panels_visible = False - self.toggle_panels_btn.configure(text="Show Panels") - else: - try: - self.panels_tabview.grid() - except tk.TclError: - pass - self.panels_visible = True - self.toggle_panels_btn.configure(text="Hide Panels") - # Save setting - # Persist setting (best-effort) - data = { - "panels_visible": bool(self.panels_visible), - "active_tab": str(self.panels_tabview.get()), - } - save_settings(self._settings_path, data) - - def choose_download_path(self): - """Opens a dialog to choose a download directory.""" - path = filedialog.askdirectory(title="Choose Download Location") - if path: - self.download_path = path - self.path_btn.configure(text=f"Location: {shorten_path(path)}") - - def toggle_pause(self): - """Pauses or resumes the download process.""" - if self.is_paused: - self.pause_event.set() - self.pause_btn.configure(text="Pause Download") - self.is_paused = False - else: - self.pause_event.clear() - self.pause_btn.configure(text="Resume Download") - self.is_paused = True - - def toggle_scan_pause(self): - """Toggle scan pause state""" - if self.scan_pause_event.is_set(): - self.scan_pause_event.clear() - self.scan_pause_btn.configure(text="Resume Scan") - self.progress_label.configure(text="Scan paused") - self.status_chip.configure(text="Scan Paused") - else: - self.scan_pause_event.set() - self.scan_pause_btn.configure(text="Pause Scan") - self.status_chip.configure(text="Scanning") - - def filter_file_type(self, file_name): - """Check if file should be displayed""" - ext = normalize_extension(file_name) - - existing_ext = next( - (e for e in self.file_types if e.lower() == ext.lower()), None - ) - - return existing_ext is not None and self.file_types[existing_ext].get() - - def create_folder_structure(self, path, url): - """Create folder structure and return ID of last folder""" - path = unquote(path.replace("\\", "/")) - path = posixpath.normpath(path).strip("/") - - if not path: - return "" - - parts = path.split("/") - current_path = "" - parent = "" - - for part in parts: - if not part: - continue - - current_path = posixpath.join(current_path, part) if current_path else part - - with self.folders_dict_lock: - if current_path in self.folders: - parent = self.folders[current_path]["id"] - continue - - # Check for existing folder by name under the same parent - found = False - for child in self.tree.get_children(parent): - child_text = self.tree.item(child)["text"] - child_tags = self.tree.item(child)["tags"] - if "folder" in child_tags: - # Extract folder name (remove icon) - folder_name = child_text.replace(f"{self.folder_icon} ", "") - if folder_name == part: - parent = child - self.folders[current_path] = { - "id": child, - "url": urljoin(url, current_path), - "full_path": current_path, - } - found = True - break - if found: - continue - - folder_id = self.tree.insert( - parent, - "end", - text=f"{self.folder_icon} {part}", - values=("", "Directory"), - tags=("folder",), - ) - self.folders[current_path] = { - "id": folder_id, - "url": urljoin(url, current_path), - "full_path": current_path, - } - parent = folder_id - return parent - - def _get_ancestors(self, item): - """Get all ancestor nodes of an item""" - ancestors = [] - parent = self.tree.parent(item) - while parent: - ancestors.append(parent) - parent = self.tree.parent(parent) - return ancestors - - def on_tree_click(self, event): - """Handle tree item click event.""" - item = self.tree.identify("item", event.x, event.y) - if not item: - return - - # Identify the specific element clicked - element = self.tree.identify_element(event.x, event.y) - - # If the click is on the expand/collapse indicator, let the default - # binding handle it and do nothing more. - if "indicator" in element: - return - - # If the click is on the cell (text/icon), toggle the check state. - column = self.tree.identify_column(event.x) - if column == "#0": # Only toggle check if name column is clicked - self.toggle_check(item) - - def toggle_check(self, item, force_check=None): - """Toggle item checked status""" - if not self.tree.exists(item): - return - - tags = list(self.tree.item(item, "tags")) - is_checked = "checked" in tags - - if force_check is not None: - new_check_state = force_check - else: - new_check_state = not is_checked - - if new_check_state: - if "checked" not in tags: - tags.append("checked") - self.checked_items.add(item) - else: - if "checked" in tags: - tags.remove("checked") - self.checked_items.discard(item) - - self.tree.item(item, tags=tuple(tags)) - - if "folder" in self.tree.item(item, "tags"): - for child in self.tree.get_children(item): - self.toggle_check(child, force_check=new_check_state) - - def update_scan_progress(self): - """Update scan progress""" - if self.total_urls > 0: - progress = self.scanned_urls / self.total_urls - self.progress_bar.set(progress) - self.progress_label.configure( - text=f"Scanning... ({self.scanned_urls}/{self.total_urls}) {progress:.1%}" - ) - self.window.update_idletasks() - - def _run_on_ui_thread(self, callback, *args, wait=False): - """Run a callback on Tk main thread.""" - if threading.current_thread() is threading.main_thread(): - callback(*args) - return - - done = threading.Event() - error_box = {} - - def wrapped(): - try: - callback(*args) - except Exception as ex: - error_box["error"] = ex - finally: - done.set() - - self.window.after(0, wrapped) - if wait: - done.wait() - if "error" in error_box: - raise error_box["error"] - - def on_scan_started(self, url): - """Backend hook: initialize UI state before scan starts.""" - self._run_on_ui_thread(self._on_scan_started_ui, url, wait=True) - - def _on_scan_started_ui(self, _url): - self.is_scanning = True - self.scan_pause_btn.configure(state="normal", text="Pause Scan") - self.progress_label.configure(text="Scanning website...") - self.scan_btn.configure(state="normal", text="Stop Scan") - self.status_chip.configure(text="Scanning") - - self._cancel_scan_item_flush() - while not self.scan_item_buffer.empty(): - self.scan_item_buffer.get() - - while not self.dir_queue.empty(): - self.dir_queue.get() - while not self.file_queue.empty(): - self.file_queue.get() - self.is_processing_dirs = False - self.is_processing_files = False - - with self.files_dict_lock: - self.files_dict.clear() - with self.folders_dict_lock: - self.folders.clear() - - self.tree.delete(*self.tree.get_children()) - self.checked_items.clear() - self.file_types.clear() - self.file_type_counts.clear() - for widget in self.filter_checkboxes_frame.winfo_children(): - widget.destroy() - - self.total_urls = 0 - self.scanned_urls = 0 - self.progress_bar.set(0) - self.search_entry.configure(state="disabled") - self.search_var.set("") - - def on_scan_progress(self, scanned_urls, total_urls): - """Backend hook: update scan counters/progress.""" - self._run_on_ui_thread(self._on_scan_progress_ui, scanned_urls, total_urls) - - def _on_scan_progress_ui(self, scanned_urls, total_urls): - self.scanned_urls = scanned_urls - self.total_urls = total_urls - self.update_scan_progress() - - def on_scan_item( - self, - is_directory, - path, - url, - file_name=None, - size=None, - file_type=None, - full_path=None, - ): - """Backend hook: enqueue a discovered directory/file for UI processing.""" - self._run_on_ui_thread( - self._on_scan_item_ui, - is_directory, - path, - url, - file_name, - size, - file_type, - full_path, - ) - - def _on_scan_item_ui( - self, - is_directory, - path, - url, - file_name, - size, - file_type, - full_path, - ): - if not self.is_scanning: - return - self.scan_item_buffer.put( - (is_directory, path, url, file_name, size, file_type, full_path) - ) - self._schedule_scan_item_flush() - - def _schedule_scan_item_flush(self): - if self.scan_flush_job is not None: - return - if not self.is_scanning: - return - self.scan_flush_job = self.window.after( - self.scan_flush_interval_ms, self._flush_scan_item_buffer - ) - - def _cancel_scan_item_flush(self): - if self.scan_flush_job is None: - return - try: - self.window.after_cancel(self.scan_flush_job) - except tk.TclError: - pass - finally: - self.scan_flush_job = None - - def _flush_scan_item_buffer(self, max_items=None, reschedule=True): - self.scan_flush_job = None - if max_items is None: - max_items = self.scan_flush_batch_size - - processed = 0 - added_dir = False - added_file = False - - while processed < max_items: - try: - ( - is_directory, - path, - url, - file_name, - size, - file_type, - full_path, - ) = self.scan_item_buffer.get_nowait() - except Empty: - break - - if is_directory: - with self.folders_dict_lock: - self.dir_queue.put((path, url)) - added_dir = True - else: - self.file_queue.put((path, url, file_name, size, file_type, full_path)) - added_file = True - processed += 1 - - if added_dir and not self.is_processing_dirs: - self.window.after(0, self.process_dir_queue) - if added_file and not self.is_processing_files: - self.window.after(0, self.process_file_queue) - - if reschedule and self.is_scanning and not self.scan_item_buffer.empty(): - self._schedule_scan_item_flush() - - def on_scan_finished(self, stopped): - """Backend hook: finalize UI state after scan ends.""" - self._run_on_ui_thread(self._on_scan_finished_ui, stopped) - - def _on_scan_finished_ui(self, stopped): - self.is_scanning = False - self._cancel_scan_item_flush() - while not self.scan_item_buffer.empty(): - self._flush_scan_item_buffer( - max_items=self.scan_flush_batch_size, - reschedule=False, - ) - - self.scan_btn.configure(state="normal", text="Scan") - self.scan_pause_btn.configure(state="disabled", text="Pause Scan") - self.scan_pause_event.set() - if not stopped: - self.progress_label.configure(text="Scan finished.") - self.log_message("[Scan] Finished.") - else: - self.progress_label.configure(text="Scan stopped.") - self.log_message("[Scan] Stopped by user.") - self.scan_completed() - - def on_downloads_finished(self, completed, total): - """Backend hook: finalize download summary on main thread.""" - self._run_on_ui_thread(self.downloads_completed, completed, total) - - def process_dir_queue(self): - """Processes the directory creation queue to avoid UI blocking.""" - try: - if not hasattr(self, "dir_queue"): - return - self.is_processing_dirs = True - if not self.dir_queue.empty(): - dir_path, url = self.dir_queue.get() - self.create_folder_structure(dir_path, url) - self.window.after(10, self.process_dir_queue) - else: - self.is_processing_dirs = False - except tk.TclError as ex: - self.is_processing_dirs = False - print(f"Error processing directory queue (UI): {str(ex)}") - - def process_file_queue(self): - """Processes the file queue to avoid UI blocking.""" - try: - if not hasattr(self, "file_queue"): - return - self.is_processing_files = True - if not self.file_queue.empty(): - ( - dir_path, - url, - file_name, - size, - file_type, - full_path, - ) = self.file_queue.get() - self.update_file_types(file_name) - self._safe_create_folder_and_add_file( - dir_path, url, file_name, size, file_type, full_path - ) - self.window.after(5, self.process_file_queue) - else: - self.is_processing_files = False - except tk.TclError as ex: - self.is_processing_files = False - print(f"Error processing file queue (UI): {str(ex)}") - - def _safe_create_folder_and_add_file( - self, dir_path, url, file_name, size, file_type, full_path - ): - """Create folder structure and add file in main thread""" - try: - with self.files_dict_lock: - if ( - full_path in self.files_dict - and self.files_dict[full_path] is not None - ): - return - - parent_id = self.create_folder_structure(dir_path, url) - - for child in self.tree.get_children(parent_id): - if "file" in self.tree.item(child)["tags"]: - child_text = self.tree.item(child)["text"] - child_name = child_text.replace(f"{self.file_icon} ", "") - if child_name == file_name: - with self.files_dict_lock: - if full_path in self.files_dict: - del self.files_dict[full_path] - return - - self._add_file_to_tree( - parent_id, file_name, size, file_type, full_path, url - ) - except (tk.TclError, OSError) as ex: - with self.files_dict_lock: - if full_path in self.files_dict: - del self.files_dict[full_path] - print(f"Error adding file to tree structure: {str(ex)}") - - def _add_file_to_tree(self, parent_id, file_name, size, file_type, full_path, url): - """Add file to tree structure in main thread""" - try: - items_count = len(self.tree.get_children(parent_id)) - row_tag = "evenrow" if items_count % 2 == 0 else "oddrow" - - self.tree.insert( - parent_id, - "end", - text=f"{self.file_icon} {file_name}", - values=(size, file_type), - tags=("file", row_tag), - ) - with self.files_dict_lock: - self.files_dict[full_path] = url - except tk.TclError as ex: - print(f"Error in _add_file_to_tree (TclError): {ex}") - except RuntimeError as ex: - print(f"Error in _add_file_to_tree (General): {ex}") - - def update_file_types(self, file_name): - """Updates the file type filter UI.""" - ext = normalize_extension(file_name) - - if ext not in self.file_types: - self.file_types[ext] = ctk.BooleanVar(value=False) - self.file_type_counts[ext] = 0 - # Re-draw checkboxes - self.redraw_file_type_filters() - self.file_type_counts[ext] += 1 - # Update count on the checkbox - for child in self.filter_checkboxes_frame.winfo_children(): - if child.cget("text").startswith(ext): - # Use dict expansion to avoid type-checker complaints about unknown kwargs - child.configure(**{"text": f"{ext} ({self.file_type_counts[ext]})"}) - break - - def redraw_file_type_filters(self): - """Clears and redraws the file type filter checkboxes.""" - for widget in self.filter_checkboxes_frame.winfo_children(): - widget.destroy() - - sorted_types = sorted(self.file_types.keys()) - for i, file_type in enumerate(sorted_types): - var = self.file_types[file_type] - count = self.file_type_counts.get(file_type, 0) - chk = ctk.CTkCheckBox( - self.filter_checkboxes_frame, - text=f"{file_type} ({count})", - variable=var, - command=self.apply_filters, - ) - chk.grid(row=i // 4, column=i % 4, padx=5, pady=2, sticky="w") - - def apply_filters(self): - """Applies selected file type filters by checking/unchecking items in the tree.""" - all_items = self.get_all_tree_items() - items_to_expand = set() - - for item in all_items: - tags = self.tree.item(item, "tags") - if "file" in tags: - text = self.tree.item(item, "text") - file_name = text.replace(f"{self.file_icon} ", "") - - ext = normalize_extension(file_name) - - if ext in self.file_types: - should_be_checked = self.file_types[ext].get() - self.toggle_check(item, force_check=should_be_checked) - if should_be_checked: - # If a file is checked, we need to expand its ancestors - parent = self.tree.parent(item) - while parent: - items_to_expand.add(parent) - parent = self.tree.parent(parent) - - for item_id in items_to_expand: - self.tree.item(item_id, open=True) - - def get_all_tree_items(self, parent=""): - """Recursively gets all item IDs from the treeview.""" - items = [] - for child in self.tree.get_children(parent): - items.append(child) - items.extend(self.get_all_tree_items(child)) - return items - - def select_all_types(self): - """Selects all file type checkboxes.""" - for var in self.file_types.values(): - var.set(True) - self.apply_filters() - - def deselect_all_types(self): - """Deselects all file type checkboxes.""" - for var in self.file_types.values(): - var.set(False) - self.apply_filters() - - def clear_scan_results(self): - """Clears the treeview and all scan-related data.""" - if self.is_scanning: - messagebox.showwarning( - "Warning", "Cannot clear results while a scan is in progress." - ) - return - - # Clear Treeview - for item in self.tree.get_children(): - self.tree.delete(item) - - # Clear data structures - with self.files_dict_lock: - self.files_dict.clear() - with self.folders_dict_lock: - self.folders.clear() - - self.checked_items.clear() - self.file_types.clear() - self.file_type_counts.clear() - - # Clear file type filter UI - for widget in self.filter_checkboxes_frame.winfo_children(): - widget.destroy() - - # Reset scan progress - self.total_urls = 0 - self.scanned_urls = 0 - self.progress_bar.set(0) - self.progress_label.configure(text="Scan results cleared.") - self.search_var.set("") - self.search_entry.configure(state="disabled") - self.full_tree_backup.clear() - - def on_search_filter_changed(self, *_): - """Callback when the search entry text changes.""" - self.apply_search_filter() - - def apply_search_filter(self): - """Filters the treeview based on the search query.""" - query = self.search_var.get().lower() - - # Detach all items first - for item_id in self.full_tree_backup: - if self.tree.exists(item_id): - self.tree.detach(item_id) - - if not query: - # If query is empty, reattach all items in their original order - for item_id, data in self.full_tree_backup.items(): - if self.tree.exists(item_id): - self.tree.move(item_id, data["parent"], data["index"]) - return - - items_to_show = set() - # Find items that match the query - for item_id, data in self.full_tree_backup.items(): - if query in data["text"].lower(): - items_to_show.add(item_id) - # Add all parents to ensure the path is visible - parent = data["parent"] - while parent: - items_to_show.add(parent) - parent = self.full_tree_backup[parent]["parent"] - - # Re-attach only the items that should be visible - for item_id, data in self.full_tree_backup.items(): - if item_id in items_to_show: - if self.tree.exists(item_id): - self.tree.move(item_id, data["parent"], data["index"]) - if "folder" in data["tags"]: - self.tree.item(item_id, open=True) - - def update_thread_count(self, new_count_str: str): - """Updates the number of concurrent download threads.""" - try: - new_count = int(new_count_str) - if 1 <= new_count <= 10: - self.max_workers = new_count - # Re-create the executor with the new worker count - # Shut down the old one to release resources - self.executor.shutdown(wait=False, cancel_futures=True) - self.executor = ThreadPoolExecutor(max_workers=self.max_workers) - except (ValueError, TypeError): - pass - - def update_progress(self, file_path, file_name, progress): - """Updates per-file or global progress from the main thread.""" - self.window.after( - 0, lambda: self._update_progress_ui(file_path, file_name, progress) - ) - - def _update_progress_ui(self, file_path, file_name, progress): - """Update per-file progress if present; fallback to global bar.""" - item = self.downloads_panel._items.get(file_path) - if item: - self.downloads_panel.set_progress(file_path, progress) - else: - self.progress_bar.set(progress / 100) - self.progress_label.configure( - text=f"Downloading: {file_name} ({progress:.1f}%)" - ) - - def update_download_status(self, file_path, status_text): - """Update status label for a specific download item.""" - item = self.downloads_panel._items.get(file_path) - if item: - self.downloads_panel.set_status(file_path, status_text) - - def log_message(self, message: str): - """Append a message to the log panel.""" - try: - self.log_text.configure(state="normal") - self.log_text.insert("end", message + "\n") - self.log_text.see("end") - self.log_text.configure(state="disabled") - except tk.TclError: - pass - - def download_selected(self): - """Download checked files""" - if not self.checked_items: - messagebox.showwarning("Warning", "Please select files to download") - return - - files_to_download = [ - item - for item in self.checked_items - if "file" in self.tree.item(item)["tags"] - ] - - if not files_to_download: - messagebox.showwarning("Warning", "No files selected to download") - return - - if not self.download_path: - self.choose_download_path() - if not self.download_path: # User cancelled - return - - self.pause_event.set() - self.pause_btn.configure(state="normal") - self.progress_label.configure(text="Preparing to download...") - - futures = [] - for item in files_to_download: - text = self.tree.item(item)["text"] - file_name = text.replace(f"{self.file_icon} ", "") - - path_parts = [] - sanitized_path_parts = [] - current = item - while current: - parent = self.tree.parent(current) - if not parent: - break - parent_text = self.tree.item(parent)["text"] - folder_name = parent_text.replace(f"{self.folder_icon} ", "") - path_parts.append(folder_name) - sanitized_path_parts.append(sanitize_path_segment(folder_name)) - current = parent - - path_parts.reverse() - sanitized_path_parts.reverse() - relative_path = os.path.join(*path_parts) if path_parts else "" - - full_path_key = os.path.join(relative_path, file_name).replace("\\", "/") - if full_path_key.startswith("/"): - full_path_key = full_path_key[1:] - - safe_file_name = sanitize_filename(file_name) - - try: - target_dir = safe_join(self.download_path, sanitized_path_parts) - file_path = safe_join(self.download_path, sanitized_path_parts + [safe_file_name]) - except ValueError: - self.log_message(f"[Download] Skipped unsafe path: {full_path_key}") - continue - - os.makedirs(target_dir, exist_ok=True) - - url = self.files_dict.get(full_path_key) - - if url: - cancel_event = self.downloads_panel.ensure(file_path, safe_file_name) - # Submit with cancel support and per-file progress - future = self.executor.submit( - self.backend.download_file, - url, - file_path, - safe_file_name, - cancel_event, - ) - futures.append(future) - - threading.Thread(target=self.backend.monitor_downloads, args=(futures,)).start() - - def downloads_completed(self, completed, total): - """Finalizes the download process and shows a summary.""" - self.progress_label.configure( - text=f"Download completed: {completed}/{total} files." - ) - self.pause_btn.configure(state="disabled") - messagebox.showinfo( - "Completed", - f"{completed} of {total} selected files downloaded successfully.", - ) - - def start_scan(self): - """Starts the website scanning process.""" - url = self.url_entry.get() - if not url: - messagebox.showerror("Error", "Please enter URL") - return - - if self.is_scanning: - self.backend.should_stop = True - self.scan_btn.configure(text="Stopping...") - self.scan_btn.configure(state="disabled") - self.status_chip.configure(text="Stopping") - return - - self.backend.should_stop = False - try: - self.download_path = default_download_folder(url, os.getcwd()) - except (OSError, TypeError) as ex: - print(f"Error setting download path: {str(ex)}") - self.download_path = os.path.join(os.getcwd(), "downloads") - - self.progress_label.configure(text="Preparing to scan...") - self.status_chip.configure(text="Scanning") - self.scan_btn.configure(text="Stop Scan") - self.search_entry.configure(state="disabled") - self.search_var.set("") - Thread(target=self.backend.scan_website, args=(url,)).start() - - def scan_completed(self): - """Called by the backend when scanning is finished.""" - self.search_entry.configure(state="normal") - self.full_tree_backup.clear() - for item in self.get_all_tree_items(): - self.full_tree_backup[item] = { - "parent": self.tree.parent(item), - "index": self.tree.index(item), - "text": self.tree.item(item, "text"), - "tags": self.tree.item(item, "tags"), - } - self.status_chip.configure(text="Ready") - - def focus_search(self, _event=None): - if self.search_entry.cget("state") != "disabled": - self.search_entry.focus_set() - self.search_entry.icursor("end") - - def focus_logs(self, _event=None): - try: - if not self.panels_visible: - self.panels_tabview.grid() - self.panels_visible = True - self.toggle_panels_btn.configure(text="Hide Panels") - self.panels_tabview.set("Logs") - self.log_text.focus_set() - except (AttributeError, tk.TclError): - pass - - def clear_search(self, _event=None): - if self.search_entry.cget("state") != "disabled": - self.search_var.set("") - - def run(self): - """Starts the Tkinter main loop.""" - self.window.mainloop() - - def show_context_menu(self, event): - """Show right-click menu""" - self.context_menu.post(event.x_root, event.y_root) - - def select_all(self): - """Select all items""" - for item in self.get_all_tree_items(): - self.toggle_check(item, force_check=True) - - def deselect_all(self): - """Deselect all items""" - for item in self.get_all_tree_items(): - self.toggle_check(item, force_check=False) - - def expand_all(self, parent=""): - """Expand all nodes in the treeview.""" - for item in self.tree.get_children(parent): - self.tree.item(item, open=True) - self.expand_all(item) - - def collapse_all(self, parent=""): - """Collapse all nodes in the treeview.""" - for item in self.tree.get_children(parent): - self.tree.item(item, open=False) - self.collapse_all(item) - - def on_closing(self): - """Handles the window closing event.""" - # Persist settings on close (best-effort) - data = { - "panels_visible": bool(getattr(self, "panels_visible", False)), - "active_tab": str(self.panels_tabview.get()), - } - settings_path = getattr( - self, - "_settings_path", - os.path.join(os.path.expanduser("~"), ".index_ripper_settings.json"), - ) - save_settings(settings_path, data) - if messagebox.askokcancel("Quit", "Do you want to quit?"): - self.backend.should_stop = True - self.executor.shutdown(wait=False, cancel_futures=True) - self.window.destroy() - - def sort_tree(self, col): - """Sorts the treeview columns.""" - items = [(self.tree.set(i, col), i) for i in self.tree.get_children("")] - - # A simple string sort - items.sort(reverse=self.sort_reverse) - - for index, (_, i) in enumerate(items): - self.tree.move(i, "", index) - - self.sort_reverse = not self.sort_reverse - - -if __name__ == "__main__": - if "--ui-smoke" in sys.argv: - app = WebsiteCopier() - try: - for _ in range(3): - app.window.update_idletasks() - app.window.update() - app.window.destroy() - except Exception: - try: - app.window.destroy() - except Exception: - pass - print("UI_SMOKE_OK") - raise SystemExit(0) - - app = WebsiteCopier() - app.run() diff --git a/index_ripper_ttkb.py b/index_ripper_ttkb.py deleted file mode 100644 index 82e6c5e..0000000 --- a/index_ripper_ttkb.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -import sys - - -if "--smoke" in sys.argv: - print("SMOKE_OK") - raise SystemExit(0) - -if "--self-test" in sys.argv: - from self_test import run_self_test - - result = run_self_test() - print( - f"SELF_TEST_OK total={result.total} files={result.files} dirs={result.directories}" - ) - raise SystemExit(0) - - -def main() -> int: - if "--ui-smoke" in sys.argv: - from ui_ttkb import WebsiteCopierTtkb - - app = WebsiteCopierTtkb(ui_smoke=True) - app.run() - print("UI_SMOKE_OK") - return 0 - - from ui_ttkb import WebsiteCopierTtkb - - app = WebsiteCopierTtkb() - app.run() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/macos.spec b/macos.spec index 1404f3b..4c4fed7 100644 --- a/macos.spec +++ b/macos.spec @@ -4,11 +4,11 @@ from PyInstaller.utils.hooks import collect_data_files block_cipher = None a = Analysis( - ["index_ripper.py"], - pathex=[], + ["src/index_ripper/__main__.py"], + pathex=["src"], binaries=[], datas=[], - hiddenimports=["tkinter", "tkinter.ttk"], + hiddenimports=["tkinter", "tkinter.ttk", "index_ripper"], hookspath=[], hooksconfig={}, runtime_hooks=[], diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4692e50 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "index-ripper" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "requests>=2.31.0,<3.0.0", + "beautifulsoup4>=4.12.0,<5.0.0", + "urllib3>=2.0.0,<3.0.0", + "customtkinter>=5.2.0,<6.0.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0.0"] + +[project.scripts] +index-ripper = "index_ripper.app:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/index_ripper"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] diff --git a/pyside_poc.py b/pyside_poc.py deleted file mode 100644 index 376028f..0000000 --- a/pyside_poc.py +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import importlib -import os -import sys -import time - - -def _parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser( - prog="pyside_poc.py", - description="Minimal PySide6 POC UI (Tree / Logs / Downloads / Status).", - ) - parser.add_argument( - "--qt-platform", - default=None, - help=( - "Optional QT_QPA_PLATFORM override (e.g. offscreen, minimal). " - "Applied before importing PySide6." - ), - ) - parser.add_argument( - "--quit-after", - type=float, - default=None, - help="Auto-quit after N seconds (useful for manual measurements).", - ) - parser.add_argument( - "--print-pid", - action="store_true", - help="Print process PID on startup.", - ) - parser.add_argument( - "--measure-startup", - action="store_true", - help="Print rough startup timing checkpoints to stdout.", - ) - return parser.parse_args(argv) - - -def _print_missing_pyside6_instructions() -> None: - msg = """PySide6 is not installed. - -Install: - python3 -m pip install PySide6 - # or: python -m pip install PySide6 - -Run: - python3 pyside_poc.py - # or: python pyside_poc.py - -Notes: - - This is a discardable POC and exits with code 0 when PySide6 is missing. - - For headless experiments you may try: python3 pyside_poc.py --qt-platform offscreen -""" - sys.stdout.write(msg) - - -def main(argv: list[str] | None = None) -> int: - argv = list(sys.argv[1:] if argv is None else argv) - args = _parse_args(argv) - - if args.qt_platform: - os.environ.setdefault("QT_QPA_PLATFORM", str(args.qt_platform)) - - t0 = time.perf_counter() - try: - QtCore = importlib.import_module("PySide6.QtCore") - QtWidgets = importlib.import_module("PySide6.QtWidgets") - - Qt = QtCore.Qt - QTimer = QtCore.QTimer - - QApplication = QtWidgets.QApplication - QFrame = QtWidgets.QFrame - QGroupBox = QtWidgets.QGroupBox - QHBoxLayout = QtWidgets.QHBoxLayout - QLabel = QtWidgets.QLabel - QListWidget = QtWidgets.QListWidget - QMainWindow = QtWidgets.QMainWindow - QPlainTextEdit = QtWidgets.QPlainTextEdit - QProgressBar = QtWidgets.QProgressBar - QSplitter = QtWidgets.QSplitter - QTreeWidget = QtWidgets.QTreeWidget - QTreeWidgetItem = QtWidgets.QTreeWidgetItem - QVBoxLayout = QtWidgets.QVBoxLayout - QWidget = QtWidgets.QWidget - except Exception: - _print_missing_pyside6_instructions() - return 0 - - t_import = time.perf_counter() - - app = QApplication(["pyside_poc"]) - app.setApplicationName("Index-Ripper PySide6 POC") - - if args.print_pid: - sys.stdout.write(f"PID: {os.getpid()}\n") - sys.stdout.flush() - - window = QMainWindow() - window.setWindowTitle("Index-Ripper - PySide6 POC") - window.resize(1080, 720) - - central = QWidget() - root_layout = QVBoxLayout(central) - root_layout.setContentsMargins(12, 12, 12, 12) - root_layout.setSpacing(10) - - def boxed(title: str, child): - box = QGroupBox(title) - layout = QVBoxLayout(box) - layout.setContentsMargins(10, 10, 10, 10) - layout.addWidget(child) - return box - - tree = QTreeWidget() - tree.setHeaderHidden(True) - root_item = QTreeWidgetItem(["index-ripper"]) - sources_item = QTreeWidgetItem(["sources"]) - sources_item.addChild(QTreeWidgetItem(["example.torrent"])) - root_item.addChild(sources_item) - root_item.addChild(QTreeWidgetItem(["outputs"])) - tree.addTopLevelItem(root_item) - tree.expandAll() - tree_box = boxed("Tree", tree) - - logs = QPlainTextEdit() - logs.setReadOnly(True) - logs.setPlaceholderText("Logs will appear here.") - logs.appendPlainText("[info] PySide6 POC started") - logs.appendPlainText("[info] This UI is intentionally minimal") - logs_box = boxed("Logs", logs) - - downloads = QListWidget() - downloads.addItem("example_01.zip (queued)") - downloads.addItem("example_02.zip (waiting)") - downloads_box = boxed("Downloads", downloads) - - right_split = QSplitter() - right_split.setOrientation(Qt.Orientation.Vertical) - right_split.addWidget(logs_box) - right_split.addWidget(downloads_box) - right_split.setStretchFactor(0, 3) - right_split.setStretchFactor(1, 2) - - main_split = QSplitter() - main_split.addWidget(tree_box) - main_split.addWidget(right_split) - main_split.setStretchFactor(0, 2) - main_split.setStretchFactor(1, 5) - - status_frame = QFrame() - status_frame.setFrameShape(QFrame.StyledPanel) - status_layout = QHBoxLayout(status_frame) - status_layout.setContentsMargins(10, 8, 10, 8) - status_layout.setSpacing(10) - status_label = QLabel("Status: idle") - status_progress = QProgressBar() - status_progress.setRange(0, 100) - status_progress.setValue(0) - status_progress.setTextVisible(False) - status_layout.addWidget(status_label, 1) - status_layout.addWidget(status_progress, 2) - status_box = boxed("Status", status_frame) - - root_layout.addWidget(main_split, 1) - root_layout.addWidget(status_box, 0) - window.setCentralWidget(central) - - t_ui = time.perf_counter() - - if args.quit_after is not None and args.quit_after >= 0: - QTimer.singleShot(int(args.quit_after * 1000), app.quit) - - window.show() - t_show = time.perf_counter() - - if args.measure_startup: - sys.stdout.write( - "startup_seconds " - f"import={t_import - t0:.4f} " - f"ui={t_ui - t_import:.4f} " - f"show={t_show - t0:.4f}\n" - ) - sys.stdout.flush() - - return int(app.exec()) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/index_ripper/__init__.py b/src/index_ripper/__init__.py new file mode 100644 index 0000000..3b6fb18 --- /dev/null +++ b/src/index_ripper/__init__.py @@ -0,0 +1,3 @@ +"""Index Ripper — download files from directory listing pages.""" + +__version__ = "0.1.0" diff --git a/src/index_ripper/__main__.py b/src/index_ripper/__main__.py new file mode 100644 index 0000000..3ebe16f --- /dev/null +++ b/src/index_ripper/__main__.py @@ -0,0 +1,36 @@ +"""Entry point for `python -m index_ripper`.""" +import sys + + +def _main() -> None: + if "--smoke" in sys.argv: + print("SMOKE_OK") + raise SystemExit(0) + + if "--self-test" in sys.argv: + from index_ripper.self_test import run_self_test + + result = run_self_test() + print( + f"SELF_TEST_OK total={result.total} files={result.files} dirs={result.directories}" + ) + raise SystemExit(0) + + from index_ripper.utils import configure_tk_libraries + configure_tk_libraries() + + if "--ui-smoke" in sys.argv: + from index_ripper.app import WebsiteCopierCtk + + app = WebsiteCopierCtk(ui_smoke=True) + app.window.after(0, app.window.destroy) + app.run() + print("UI_SMOKE_OK") + raise SystemExit(0) + + from index_ripper.app import main + main() + + +if __name__ == "__main__": + _main() diff --git a/src/index_ripper/app.py b/src/index_ripper/app.py new file mode 100644 index 0000000..17fad68 --- /dev/null +++ b/src/index_ripper/app.py @@ -0,0 +1,1301 @@ +"""Main application window.""" +from __future__ import annotations + +import copy +import os +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from queue import Empty, Queue + +import tkinter as tk +from tkinter import filedialog, messagebox, ttk + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +import customtkinter as ctk + +from index_ripper.utils import ( + build_download_path, + default_download_folder, + normalize_extension, + rebuild_executor, + safe_join, + sanitize_filename, +) +from index_ripper.backend import Backend +from index_ripper.settings import load_settings, save_settings +from index_ripper.ui.downloads import DownloadsPanel +from index_ripper.ui.theme import ( + apply_app_theme, + configure_action_button_styles, + ui_tokens, +) +from index_ripper.ui.filetree import TreeNode, RowWidget, should_skip_file_row +from index_ripper.ui.filters import FileTypeFilterMixin + + +class WebsiteCopierCtk(FileTypeFilterMixin): + USER_AGENT = "IndexRipper/2.0" + + def __init__(self, ui_smoke: bool = False): + self._ui_smoke = bool(ui_smoke) + self.debug_enabled = os.environ.get("INDEX_RIPPER_DEBUG", "0") != "0" + self.use_modal_dialogs = os.environ.get("INDEX_RIPPER_MODAL_DIALOGS", "0") == "1" + + apply_app_theme(ctk) + + self.window = ctk.CTk() + self.window.title("Index Ripper") + self.window.geometry("1200x900") + self.window.minsize(900, 650) + + configure_action_button_styles(self.window, ctk, ttk) + + self.ui_tokens = ui_tokens() + + self.backend = Backend(self) + + self.pause_event = threading.Event() + self.pause_event.set() + self.scan_pause_event = threading.Event() + self.scan_pause_event.set() + + self.is_scanning = False + self.scanned_urls = 0 + self.total_urls = 0 + + self.files_dict_lock = threading.Lock() + self.folders_dict_lock = threading.Lock() + self.files_dict: dict[str, dict] = {} + self.folders: dict[str, str] = {} + self.checked_items: set[str] = set() + + # FileTree data model + self.tree_nodes: dict[str, TreeNode] = {} + self.tree_roots: list[str] = [] # top-level node_ids in insertion order + self._node_counter: int = 0 # monotonic counter for unique node_ids + self._last_toggle_time: float = 0.0 # debounce timestamp for row clicks + self._tree_update_pending: bool = False # debounce flag for _schedule_tree_update + + self.dir_queue = Queue() + self.file_queue = Queue() + self.scan_item_buffer = Queue() + self.is_processing_dirs = False + self.is_processing_files = False + self.scan_flush_interval_ms = 16 + self.scan_flush_batch_size = 200 + self.scan_flush_job = None + self._last_logged_queue_size = None + + self.file_types: dict[str, tk.BooleanVar] = {} + self.file_type_counts: dict[str, int] = {} + self.file_type_widgets: dict = {} + + self.download_path = "" + self.download_queue = Queue() + self.max_workers = 5 + self.executor = ThreadPoolExecutor(max_workers=self.max_workers) + self.active_downloads = [] + + retry_strategy = Retry( + total=3, + backoff_factor=0.1, + status_forcelist=[500, 502, 503, 504], + ) + self.session = requests.Session() + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + self.timeout = (10, 20) + + self._build_ui() + + if not self._ui_smoke: + self.window.after(100, self._poll_scan_queue) + self.window.after(100, self._poll_file_queue) + + self.window.protocol("WM_DELETE_WINDOW", self.on_closing) + if not self._ui_smoke: + self.window.bind("", self.focus_search) + self.window.bind("", self.focus_logs) + self.window.bind("", self.clear_search) + # macOS: forward Cmd+V / Cmd+C / Cmd+X / Cmd+A to the focused widget + # so native clipboard shortcuts work in every CTkEntry / CTkTextbox. + for seq, virt in ( + ("", "<>"), + ("", "<>"), + ("", "<>"), + ("", "<>"), + ("", "<>"), + ): + virt_local = virt # capture for lambda + + def _fwd(e, _v=virt_local): + w = e.widget + try: + w.event_generate(_v) + except Exception: + pass + return "break" + + self.window.bind(seq, _fwd, add="+") + + def _build_ui(self) -> None: + if self._ui_smoke: + self._build_ui_smoke_only() + else: + self._build_full_ui() + + def _build_ui_smoke_only(self) -> None: + self.url_var = tk.StringVar(value="https://example.com/") + self.search_var = tk.StringVar() + self.log_text = ctk.CTkTextbox(self.window, height=80) + self.log_text.pack(fill="both", expand=True, padx=10, pady=10) + self.log_text.insert("end", "UI smoke minimal view initialized.\n") + self.panels_notebook = None + self.logs_tab = None + self._visible_nodes = [] + self._row_widgets = {} + self.tree_scroll_frame = None + self.full_tree_backup = {} + self.sort_reverse = False + self._last_toggle_time = 0.0 + self._search_after_id = None + self.tree_nodes = {} + self.tree_roots = [] + self._node_counter = 0 + self.scan_pause_btn = type("_Stub", (), { + "configure": lambda s, **kw: None, + "grid": lambda s: None, + "grid_remove": lambda s: None, + })() + self._status_dot = type("_Stub", (), {"configure": lambda s, **kw: None})() + self.context_menu = tk.Menu(self.window, tearoff=0) + + def _build_full_ui(self) -> None: + self.window.grid_columnconfigure(0, weight=1) + self.window.grid_rowconfigure(3, weight=1) # filetree expands (row shifted by types row) + self.window.grid_rowconfigure(5, weight=0) # panels fixed + + self._build_toolbar() + self._build_filters_row() + self._build_filetree() + self._build_progress_section() + self._build_panels() + self._build_download_controls() + + self.sort_reverse = False + self.full_tree_backup = {} + + self.context_menu = tk.Menu(self.window, tearoff=0) + self.context_menu.add_command(label="Select All", command=self.select_all) + self.context_menu.add_command(label="Deselect All", command=self.deselect_all) + self.context_menu.add_separator() + self.context_menu.add_command(label="Expand All", command=self.expand_all) + self.context_menu.add_command(label="Collapse All", command=self.collapse_all) + + def _build_toolbar(self) -> None: + toolbar = ctk.CTkFrame(self.window, fg_color="transparent") + toolbar.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 0)) + toolbar.grid_columnconfigure(0, weight=1) # URL entry expands + toolbar.grid_columnconfigure(2, minsize=96) # reserve space for pause btn + + self.url_var = tk.StringVar() + self.url_entry = ctk.CTkEntry( + toolbar, textvariable=self.url_var, + placeholder_text="https://", + font=ctk.CTkFont(size=14), + height=36, + ) + self.url_entry.grid(row=0, column=0, sticky="ew", padx=(0, 8)) + + # Scan button (toggles "Scan" / "Stop Scan") + self.scan_btn = ctk.CTkButton(toolbar, text="Scan", command=self.start_scan, width=90) + self.scan_btn.grid(row=0, column=1, padx=(0, 4)) + + # Pause Scan button — hidden by default, shown only while scanning + self.scan_pause_btn = ctk.CTkButton( + toolbar, text="Pause", + fg_color=("gray70", "gray30"), + hover_color=("gray60", "gray40"), + command=self.toggle_scan_pause, + width=80, + state="disabled", + ) + self.scan_pause_btn.grid(row=0, column=2, padx=(0, 8)) + self.scan_pause_btn.grid_remove() # hidden until scan starts + + # Status: dot + text + status_frame = ctk.CTkFrame(toolbar, fg_color="transparent") + status_frame.grid(row=0, column=3, sticky="e") + + self._status_dot = ctk.CTkFrame( + status_frame, width=8, height=8, corner_radius=4, + fg_color="#059669", + ) + self._status_dot.pack(side="left", padx=(0, 5)) + self._status_dot.pack_propagate(False) + + self.status_label = ctk.CTkLabel( + status_frame, text="Ready", + text_color="#059669", font=ctk.CTkFont(size=13), + ) + self.status_label.pack(side="left") + + # URL context menu (right-click paste) + self.url_context_menu = tk.Menu(self.window, tearoff=0) + self.url_context_menu.add_command(label="Paste", command=self._paste_into_url_entry) + self.url_entry.bind("", self._show_url_context_menu) + self.url_entry.bind("", self._show_url_context_menu) + + def run(self) -> None: + self.window.mainloop() + + def on_closing(self) -> None: + self.executor.shutdown(wait=False) + self.window.destroy() + + def log_message(self, message: str) -> None: + try: + self.log_text.configure(state="normal") + self.log_text.insert("end", message + "\n") + self.log_text.see("end") + except Exception: + pass + + def _debug(self, message: str) -> None: + if self.debug_enabled: + print(f"[DEBUG] {message}") + try: + self.log_text.configure(state="normal") + self.log_text.insert("end", f"[DEBUG] {message}\n") + self.log_text.see("end") + except Exception: + pass + + def notify_info(self, title: str, message: str) -> None: + if self.use_modal_dialogs: + messagebox.showinfo(title, message) + else: + self.log_message(f"[INFO] {title}: {message}") + + def notify_warning(self, title: str, message: str) -> None: + if self.use_modal_dialogs: + messagebox.showwarning(title, message) + else: + self.log_message(f"[WARNING] {title}: {message}") + + def notify_error(self, title: str, message: str) -> None: + if self.use_modal_dialogs: + messagebox.showerror(title, message) + else: + self.log_message(f"[ERROR] {title}: {message}") + + # --- Queue polling (thread-safe) --- + + def on_scan_item( + self, + *, + is_directory: bool, + path: str, + url: str, + file_name: str = "", + size: str = "", + file_type: str = "", + full_path: str = "", + ) -> None: + """Backend hook — called from background thread when an item is found.""" + if not self.is_scanning: + return + self.scan_item_buffer.put( + (is_directory, path, url, file_name, size, file_type, full_path) + ) + self.window.after(0, self._schedule_flush) + + def _schedule_flush(self) -> None: + """Schedule a deferred flush of scan_item_buffer if not already pending.""" + if self.scan_flush_job is not None: + return + if not self.is_scanning and self.scan_item_buffer.empty(): + return + self.scan_flush_job = self.window.after( + self.scan_flush_interval_ms, self._flush_scan_buffer + ) + + def _flush_scan_buffer(self, max_items: int | None = None, reschedule: bool = True) -> None: + """Drain scan_item_buffer in batches and dispatch to dir/file queues.""" + self.scan_flush_job = None + if max_items is None: + max_items = self.scan_flush_batch_size + + processed = 0 + added_dir = False + added_file = False + + while processed < max_items: + try: + ( + is_directory, + path, + url, + file_name, + size, + file_type, + full_path, + ) = self.scan_item_buffer.get_nowait() + except Empty: + break + + if is_directory: + self.dir_queue.put((path, url)) + added_dir = True + else: + self.file_queue.put((path, url, file_name, size, file_type, full_path)) + added_file = True + processed += 1 + + if added_dir and not self.is_processing_dirs: + self.window.after(0, self._poll_scan_queue) + if added_file and not self.is_processing_files: + self.window.after(0, self._poll_file_queue) + + if reschedule and not self.scan_item_buffer.empty(): + self._schedule_flush() + + def _poll_scan_queue(self) -> None: + """Process one item from dir_queue and reschedule until empty.""" + try: + self.is_processing_dirs = True + if not self.dir_queue.empty(): + dir_path, url = self.dir_queue.get() + self.add_folder(dir_path, url) + self.window.after(10, self._poll_scan_queue) + else: + self.is_processing_dirs = False + except tk.TclError: + self.is_processing_dirs = False + + def _poll_file_queue(self) -> None: + """Process one item from file_queue and reschedule until empty.""" + try: + self.is_processing_files = True + qsize = self.file_queue.qsize() + if qsize != self._last_logged_queue_size and (qsize <= 5 or qsize % 100 == 0): + self._debug(f"_poll_file_queue queue_size={qsize}") + self._last_logged_queue_size = qsize + if not self.file_queue.empty(): + ( + dir_path, + url, + file_name, + size, + file_type, + full_path, + ) = self.file_queue.get() + self.add_file(dir_path, url, file_name, size, file_type, full_path) + self.window.after(5, self._poll_file_queue) + else: + self.is_processing_files = False + except tk.TclError: + self.is_processing_files = False + + def _build_filters_row(self) -> None: + # ── Row 1: action buttons ────────────────────────────────────────── + self._ctrl_row_frame = ctk.CTkFrame(self.window, fg_color="transparent") + self._ctrl_row_frame.grid(row=1, column=0, sticky="ew", padx=10, pady=(6, 0)) + self._ctrl_row_frame.grid_columnconfigure(0, weight=1) + + # ── Row 2: Types label + checkboxes + type-action buttons ────────── + types_row = ctk.CTkFrame(self.window, fg_color="transparent") + types_row.grid(row=2, column=0, sticky="ew", padx=10, pady=(4, 0)) + types_row.grid_columnconfigure(2, weight=1) # checkboxes column expands + + _s = dict( # shared small-button style + fg_color=("gray80", "gray25"), + text_color=("gray10", "gray90"), + hover_color=("gray70", "gray35"), + height=28, + font=ctk.CTkFont(size=12), + ) + + ctk.CTkLabel( + types_row, text="Types:", + font=ctk.CTkFont(size=13, weight="bold"), + ).grid(row=0, column=0, sticky="w", padx=(0, 6)) + + type_btns = ctk.CTkFrame(types_row, fg_color="transparent") + type_btns.grid(row=0, column=1, sticky="w", padx=(0, 8)) + ctk.CTkButton(type_btns, text="\u2713 All", command=self.select_all_types, width=68, **_s).pack(side="left", padx=(0, 4)) + ctk.CTkButton(type_btns, text="\u2717 All", command=self.deselect_all_types, width=68, **_s).pack(side="left") + + self.filters_container = ctk.CTkScrollableFrame( + types_row, + height=38, + orientation="horizontal", + fg_color="transparent", + ) + self.filters_container.grid(row=0, column=2, sticky="ew") + self._bind_hscroll_wheel(self.filters_container) + + def _build_filetree(self) -> None: + outer = ctk.CTkFrame(self.window, fg_color="transparent") + outer.grid(row=3, column=0, sticky="nsew", padx=10, pady=(4, 0)) + outer.grid_columnconfigure(0, weight=1) + outer.grid_rowconfigure(1, weight=1) + + # Search bar + search_bar = ctk.CTkFrame(outer, fg_color="transparent") + search_bar.grid(row=0, column=0, sticky="ew", pady=(0, 4)) + search_bar.grid_columnconfigure(1, weight=1) + ctk.CTkLabel(search_bar, text="Search").grid(row=0, column=0, padx=(0, 6)) + self.search_var = tk.StringVar() + self.search_entry = ctk.CTkEntry(search_bar, textvariable=self.search_var) + self.search_entry.grid(row=0, column=1, sticky="ew") + self.search_var.trace_add("write", self.on_search_filter_changed) + + # Scrollable tree container + self.tree_scroll_frame = ctk.CTkScrollableFrame( + outer, + fg_color=("gray95", "gray17"), + corner_radius=8, + ) + self.tree_scroll_frame.grid(row=1, column=0, sticky="nsew") + self.tree_scroll_frame.bind("", self.show_context_menu) + self.tree_scroll_frame.bind("", self.show_context_menu) + self.window.bind("", lambda _e: self.select_all()) + self.window.bind("", lambda _e: self.select_all()) + + # Runtime state for view layer + self._visible_nodes: list[str] = [] + self._row_widgets: dict[str, RowWidget] = {} + self._last_visible: list[str] = [] + + def _rebuild_visible(self) -> None: + """Recompute self._visible_nodes from data model (DFS, respects expanded/hidden).""" + result: list[str] = [] + + def _walk(node_id: str) -> None: + node = self.tree_nodes.get(node_id) + if node is None or node.hidden: + return + result.append(node_id) + if node.kind == "folder" and node.expanded: + for child_id in node.children: + _walk(child_id) + + for root_id in self.tree_roots: + _walk(root_id) + + self._visible_nodes = result + + def _sync_rows(self) -> None: + """Create/destroy RowWidgets to match _visible_nodes.""" + if self.tree_scroll_frame is None: + return + + new_ids = set(self._visible_nodes) + # Remove rows no longer visible + for node_id in list(self._row_widgets): + if node_id not in new_ids: + self._row_widgets.pop(node_id).destroy() + + prev = self._last_visible + # Append-only optimisation: if the existing rows are still a prefix of + # the new list (no reordering), just pack the new rows at the end. + # This prevents the pack_forget->repack flash during scanning. + if ( + len(self._visible_nodes) >= len(prev) + and self._visible_nodes[: len(prev)] == prev + and all(nid in self._row_widgets for nid in prev) + ): + for node_id in self._visible_nodes[len(prev) :]: + node = self.tree_nodes[node_id] + depth = self._node_depth(node_id) + self._row_widgets[node_id] = RowWidget( + self.tree_scroll_frame, self, node, depth + ) + else: + # Full repack needed (expand/collapse, filter, sort, restore) + for row in list(self._row_widgets.values()): + row.frame.pack_forget() + for node_id in self._visible_nodes: + node = self.tree_nodes[node_id] + depth = self._node_depth(node_id) + if node_id not in self._row_widgets: + self._row_widgets[node_id] = RowWidget( + self.tree_scroll_frame, self, node, depth + ) + else: + self._row_widgets[node_id].frame.pack(fill="x", padx=4, pady=0) + + self._last_visible = list(self._visible_nodes) + + def _schedule_tree_update(self) -> None: + """Debounce _rebuild_visible + _sync_rows to avoid O(n^2) during bulk add.""" + if not self._tree_update_pending: + self._tree_update_pending = True + self.window.after(50, self._do_tree_update) + + def _do_tree_update(self) -> None: + self._tree_update_pending = False + self._rebuild_visible() + self._sync_rows() + + def _node_depth(self, node_id: str) -> int: + depth = 0 + node = self.tree_nodes.get(node_id) + while node and node.parent_id: + depth += 1 + node = self.tree_nodes.get(node.parent_id) + return depth + + def _next_node_id(self) -> str: + self._node_counter += 1 + return f"n{self._node_counter}" + + def _on_row_click(self, node_id: str, event=None) -> None: + now = time.monotonic() + if now - self._last_toggle_time < 0.25: + return + self._last_toggle_time = now + node = self.tree_nodes.get(node_id) + if node is None: + return + self.toggle_check(node_id) + + def _on_chevron_click(self, node_id: str) -> None: + node = self.tree_nodes.get(node_id) + if node is None or node.kind != "folder": + return + node.expanded = not node.expanded + row = self._row_widgets.get(node_id) + if row: + row.set_chevron(node.expanded) + self._rebuild_visible() + self._sync_rows() + + def _build_progress_section(self) -> None: + progress_frame = ctk.CTkFrame(self.window, fg_color="transparent") + progress_frame.grid(row=4, column=0, sticky="ew", padx=10, pady=(6, 2)) + progress_frame.grid_columnconfigure(0, weight=1) + + self.progress_bar = ctk.CTkProgressBar(progress_frame, height=14, corner_radius=6) + self.progress_bar.set(0) + self.progress_bar.grid(row=0, column=0, sticky="ew", pady=(0, 4)) + + self.progress_label = ctk.CTkLabel( + progress_frame, text="", + font=ctk.CTkFont(size=13), + anchor="w", + ) + self.progress_label.grid(row=1, column=0, sticky="ew") + + def _build_panels(self) -> None: + self.panels_notebook = ctk.CTkTabview(self.window, height=180) + self.panels_notebook.grid(row=5, column=0, sticky="ew", padx=10, pady=(4, 10)) + self._panels_widget = self.panels_notebook # toggle_panels uses this + + downloads_tab = self.panels_notebook.add("Downloads") + logs_tab = self.panels_notebook.add("Logs") + self.panels_notebook.set("Logs") # default to Logs + + # Downloads tab: scrollable container + DownloadsPanel + downloads_scroll = ctk.CTkScrollableFrame(downloads_tab, height=120) + downloads_scroll.pack(fill="both", expand=True) + self.downloads_panel = DownloadsPanel( + parent_frame=downloads_scroll, + ctk=ctk, + tk=tk, + tokens=self.ui_tokens, + ) + + # Logs tab: CTkTextbox + self.log_text = ctk.CTkTextbox(logs_tab, height=120, wrap="word") + self.log_text.pack(fill="both", expand=True) + + def _build_download_controls(self) -> None: + _s = dict( + fg_color=("gray80", "gray25"), + text_color=("gray10", "gray90"), + hover_color=("gray70", "gray35"), + height=30, + ) + + # Left side of row 1: Select All / Deselect All (file tree) + sel_frame = ctk.CTkFrame(self._ctrl_row_frame, fg_color="transparent") + sel_frame.grid(row=0, column=0, sticky="w") + ctk.CTkButton(sel_frame, text="Select All", command=self.select_all, width=100, **_s).pack(side="left", padx=(0, 4)) + ctk.CTkButton(sel_frame, text="Deselect All", command=self.deselect_all, width=110, **_s).pack(side="left") + + # Right side of row 1: download controls + controls = ctk.CTkFrame(self._ctrl_row_frame, fg_color="transparent") + controls.grid(row=0, column=1, sticky="e", padx=(10, 0)) + + self.download_btn = ctk.CTkButton( + controls, text="\u2b07 Download Selected", command=self.download_selected, + height=30, + ) + self.download_btn.grid(row=0, column=0, padx=(0, 4)) + + self.pause_btn = ctk.CTkButton( + controls, text="\u23f8 Pause", + fg_color=("gray70", "gray30"), + hover_color=("gray60", "gray40"), + command=self.toggle_pause, + state="disabled", + height=30, + ) + self.pause_btn.grid(row=0, column=1, padx=(0, 4)) + + self.path_btn = ctk.CTkButton( + controls, text="\U0001f4c1 Folder", + fg_color=("gray70", "gray30"), + hover_color=("gray60", "gray40"), + command=self.choose_download_path, + height=30, + ) + self.path_btn.grid(row=0, column=2, padx=(0, 8)) + + ctk.CTkLabel(controls, text="Threads", font=ctk.CTkFont(size=13)).grid(row=0, column=3, padx=(0, 4)) + + self.threads_var = tk.StringVar(value="5") + self.threads_combo = ctk.CTkOptionMenu( + controls, + values=[str(i) for i in range(1, 11)], + variable=self.threads_var, + command=self.update_thread_count, + width=70, + height=30, + ) + self.threads_combo.grid(row=0, column=4, padx=(0, 8)) + + self.panels_visible = True + self.toggle_panels_btn = ctk.CTkButton( + controls, text="Hide Panels", + command=self.toggle_panels, + width=100, + **_s, + ) + self.toggle_panels_btn.grid(row=0, column=5) + + def focus_search(self, event=None) -> None: + try: + self.search_entry.focus_set() + except tk.TclError: + pass + + def focus_logs(self, event=None) -> None: + try: + if hasattr(self, "panels_notebook") and self.panels_notebook is not None: + self.panels_notebook.set("Logs") + self.log_text.focus_set() + except (AttributeError, tk.TclError): + pass + + def clear_search(self, event=None) -> None: + if hasattr(self, "search_var"): + self.search_var.set("") + + def _on_url_paste(self, _event=None): + self._paste_into_url_entry() + return "break" + + def _on_global_url_paste(self, _event=None): + if not hasattr(self, "url_entry"): + return None + try: + focused = self.window.focus_get() + except tk.TclError: + return None + try: + if focused is self.url_entry._entry: + self._paste_into_url_entry() + return "break" + except AttributeError: + pass + return None + + def _paste_into_url_entry(self): + try: + text = self.window.clipboard_get() + except tk.TclError: + return + if not text: + return + self.url_entry.delete(0, "end") + self.url_entry.insert(0, text) + + def _show_url_context_menu(self, event): + try: + self.url_context_menu.tk_popup(event.x_root, event.y_root) + finally: + self.url_context_menu.grab_release() + + def _set_status(self, text: str, color: str = "#059669") -> None: + try: + self.status_label.configure(text=text, text_color=color) + self._status_dot.configure(fg_color=color) + except Exception: + pass + + # --- Treeview interaction helpers --- + + def _all_tree_items(self) -> list[str]: + """Return all node_ids in DFS order.""" + out: list[str] = [] + def _walk(node_id: str) -> None: + out.append(node_id) + node = self.tree_nodes.get(node_id) + if node: + for child_id in node.children: + _walk(child_id) + for root_id in self.tree_roots: + _walk(root_id) + return out + + def toggle_check(self, node_id: str, force_check=None, _skip_children: bool = False) -> None: + node = self.tree_nodes.get(node_id) + if node is None: + return + new_checked = (not node.checked) if force_check is None else bool(force_check) + node.checked = new_checked + + if node.kind == "file" and node.full_path: + if new_checked: + self.checked_items.add(node.full_path) + else: + self.checked_items.discard(node.full_path) + + row = self._row_widgets.get(node_id) + if row: + row.set_checked(new_checked) + + if node.kind == "folder" and not _skip_children: + for child_id in node.children: + self.toggle_check(child_id, force_check=new_checked) + + def show_context_menu(self, event) -> None: + try: + self.context_menu.tk_popup(event.x_root, event.y_root) + finally: + self.context_menu.grab_release() + + def select_all(self) -> None: + for node_id in list(self.tree_nodes): + self.toggle_check(node_id, force_check=True, _skip_children=True) + + def deselect_all(self) -> None: + for node_id in list(self.tree_nodes): + self.toggle_check(node_id, force_check=False, _skip_children=True) + + def expand_all(self, parent: str = "") -> None: + targets = self.tree_roots if not parent else ( + self.tree_nodes[parent].children if parent in self.tree_nodes else [] + ) + for node_id in targets: + node = self.tree_nodes.get(node_id) + if node and node.kind == "folder": + node.expanded = True + self.expand_all(node_id) + if not parent: # Only rebuild once at top-level call + self._rebuild_visible() + self._sync_rows() + + def collapse_all(self, parent: str = "") -> None: + targets = self.tree_roots if not parent else ( + self.tree_nodes[parent].children if parent in self.tree_nodes else [] + ) + for node_id in targets: + node = self.tree_nodes.get(node_id) + if node and node.kind == "folder": + node.expanded = False + self.collapse_all(node_id) + if not parent: # Only rebuild once at top-level call + self._rebuild_visible() + self._sync_rows() + + def sort_tree(self, col: str = "name") -> None: + def sort_children(children: list[str]) -> None: + if not children: + return + def key(node_id: str) -> str: + node = self.tree_nodes.get(node_id) + if node is None: + return "" + if col == "size": + return node.size.lower() + if col == "type": + return node.icon_group.lower() + return node.name.lower() + children.sort(key=key, reverse=self.sort_reverse) + for node_id in children: + node = self.tree_nodes.get(node_id) + if node: + sort_children(node.children) + + sort_children(self.tree_roots) + self.sort_reverse = not self.sort_reverse + self._rebuild_visible() + self._sync_rows() + + # --- Status color mapping --- + + _STATUS_COLORS = { + "success": "#059669", + "danger": "#DC2626", + "warning": "#B45309", + "secondary": "#64748B", + "info": "#0284C7", + } + + # --- Backend bridge: scan --- + + def _run_on_ui_thread(self, callback, *args, wait: bool = False): + if not wait: + self.window.after(0, lambda: callback(*args)) + return + done = threading.Event() + error_box = {} + + def wrapped(): + try: + callback(*args) + except Exception as ex: + error_box["error"] = ex + finally: + done.set() + + self.window.after(0, wrapped) + done.wait() + if "error" in error_box: + raise error_box["error"] + + def start_scan(self) -> None: + url = self.url_var.get().strip() + if self.is_scanning: + self.backend.should_stop = True + self._set_status("Stopping", "#B45309") + return + if not url: + self.notify_error("Error", "Please enter a URL") + return + + self.backend.should_stop = False + self.search_var.set("") + self.full_tree_backup.clear() + try: + self.download_path = default_download_folder(url, os.getcwd()) + except Exception: + self.download_path = os.path.join(os.getcwd(), "downloads") + + t = threading.Thread(target=self.backend.scan_website, args=(url,), daemon=True) + t.start() + + def toggle_scan_pause(self) -> None: + if self.scan_pause_event.is_set(): + self.scan_pause_event.clear() + self.scan_pause_btn.configure(text="Resume Scan", state="normal") + self._set_status("Scan Paused", "#B45309") + else: + self.scan_pause_event.set() + self.scan_pause_btn.configure(text="Pause Scan", state="normal") + self._set_status("Scanning", "#0284C7") + + def _drain_queues(self) -> None: + """Discard all pending items in scan queues and cancel any scheduled flush.""" + if self.scan_flush_job is not None: + try: + self.window.after_cancel(self.scan_flush_job) + except Exception: + pass + self.scan_flush_job = None + for q in (self.scan_item_buffer, self.dir_queue, self.file_queue): + while True: + try: + q.get_nowait() + except Exception: + break + + def clear_scan_results(self) -> None: + if self.is_scanning: + return + self._drain_queues() + self._tree_update_pending = False + self._last_visible = [] + + # Clear data model + self.tree_nodes.clear() + self.tree_roots.clear() + self._node_counter = 0 + with self.folders_dict_lock: + self.folders.clear() + + # Destroy row widgets + for row in self._row_widgets.values(): + row.destroy() + self._row_widgets.clear() + self._visible_nodes.clear() + + # Clear other state + with self.files_dict_lock: + self.files_dict.clear() + if hasattr(self, "filters_container"): + for widget in self.filters_container.winfo_children(): + widget.destroy() + self.file_types.clear() + self.file_type_counts.clear() + self.file_type_widgets.clear() + self.checked_items.clear() + self.full_tree_backup = {} + self.scanned_urls = 0 + self.total_urls = 0 + self.progress_bar.set(0) + self.progress_label.configure(text="") + self._set_status("Ready", "#059669") + + def on_scan_started(self, *, url: str = "") -> None: + def _start(): + self.scan_btn.configure(text="Stop Scan") + self.scan_pause_btn.grid() # show + self.scan_pause_btn.configure(state="normal") + self.progress_bar.set(0) + self.progress_label.configure(text="Scanning\u2026") + self._set_status("Scanning", "#B45309") + self.window.after(0, _start) + + def on_scan_progress(self, *, scanned_urls: int = 0, total_urls: int = 0) -> None: + self.window.after(0, lambda: self._update_scan_progress(scanned_urls, total_urls)) + + def on_scan_finished(self, *, stopped: bool = False) -> None: + def _finish(): + self.scan_btn.configure(text="Scan") + self.scan_pause_btn.grid_remove() # hide + self.scan_pause_btn.configure(text="Pause", state="disabled") + if stopped: + self.progress_bar.set(0) + self.progress_label.configure(text="Scan stopped") + self._set_status("Stopped", "#B91C1C") + else: + self.progress_bar.set(1) + n = len(self.files_dict) + self.progress_label.configure(text=f"Done \u2014 {n} files found") + self._set_status("Ready", "#059669") + self.window.after(0, _finish) + + def update_progress(self, file_path: str, file_name: str, progress: float) -> None: + """Backend hook — called from download thread with per-file progress (0-100).""" + self.window.after(0, lambda: self._update_download_progress(file_path, progress)) + + def update_download_status(self, file_path: str, status: str) -> None: + """Backend hook — called from download thread with file status.""" + self.window.after(0, lambda: self._set_download_status(file_path, status)) + + def _update_scan_progress(self, scanned: int, total: int) -> None: + """Update the scan progress bar (0-100%).""" + if total <= 0: + self.progress_bar.set(0) + return + pct = scanned / total + self.progress_bar.set(pct) + self.progress_label.configure(text=f"Scanning\u2026 {scanned}/{total} ({pct:.0%})") + + def add_folder(self, dir_path: str, url: str) -> str: + """Ensure all path segments exist as folder nodes; return leaf node_id.""" + if not dir_path: + dir_path = "/" + parts = [p for p in dir_path.split("/") if p] + + parent_id = "" + current_path = "" + for part in parts: + current_path = current_path + "/" + part + with self.folders_dict_lock: + existing_id = self.folders.get(current_path) + if not existing_id: + node_id = self._next_node_id() + node = TreeNode( + node_id=node_id, + parent_id=parent_id, + name=part, + kind="folder", + full_path="", + size="", + file_type="", + icon_group="folder", + expanded=True, + ) + self.tree_nodes[node_id] = node + if parent_id: + self.tree_nodes[parent_id].children.append(node_id) + else: + self.tree_roots.append(node_id) + self.folders[current_path] = node_id + existing_id = node_id + parent_id = existing_id + + self._schedule_tree_update() + return parent_id + + def add_file(self, dir_path: str, url: str, file_name: str, size, file_type: str, full_path: str) -> None: + """Add a file node to the tree.""" + if not file_name: + return + is_html_dir_like = ( + isinstance(file_type, str) + and "text/html" in file_type.lower() + and "." not in (file_name or "") + ) + parent_id = self.add_folder(dir_path, url) + + with self.files_dict_lock: + existing_entry = self.files_dict.get(full_path) + if should_skip_file_row(existing_entry): + return + if is_html_dir_like: + self.files_dict.pop(full_path, None) + else: + self.files_dict[full_path] = { + "url": url, + "file_name": file_name, + "size": size, + "file_type": file_type, + "path": dir_path, + } + + if is_html_dir_like: + folder_path = f"{dir_path.rstrip('/')}/{file_name}".replace("//", "/") + self.add_folder(folder_path, url) + return + + ext = normalize_extension(file_name) + self._add_file_type_filter(ext) + self.file_type_counts[ext] = self.file_type_counts.get(ext, 0) + 1 + cb = self.file_type_widgets.get(ext) + if cb: + label = ext if ext else "(no ext)" + cb.configure(text=f"{label} ({self.file_type_counts[ext]})") + var = self.file_types.get(ext) + filtered_out = var is not None and not var.get() + + _icon, group = self._file_icon_and_group(file_name, file_type) + node_id = self._next_node_id() + node = TreeNode( + node_id=node_id, + parent_id=parent_id, + name=file_name, + kind="file", + full_path=full_path or "", + size=size or "", + file_type=file_type or "", + icon_group=group, + checked=full_path in self.checked_items, + hidden=filtered_out, + ) + self.tree_nodes[node_id] = node + if parent_id: + self.tree_nodes[parent_id].children.append(node_id) + else: + self.tree_roots.append(node_id) + + self._schedule_tree_update() + + def _file_icon_and_group(self, file_name: str, file_type: str | None): + ext = normalize_extension(file_name) + mime = (file_type or "").lower() + if ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".ico"): + return "\U0001f5bc", "image" + if ext in (".md", ".txt", ".pdf", ".doc", ".docx", ".rtf"): + return "\U0001f4c4", "document" + if ext in (".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz"): + return "\U0001f5dc", "archive" + if ext in ( + ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java", ".c", ".cpp", + ".h", ".hpp", ".json", ".yaml", ".yml", ".toml", ".xml", ".html", ".css", ".sh", + ): + return "\U0001f4bb", "code" + if "text/" in mime: + return "\U0001f4c4", "text" + if "image/" in mime: + return "\U0001f5bc", "image" + if "audio/" in mime: + return "\U0001f3b5", "audio" + if "video/" in mime: + return "\U0001f3ac", "video" + return "\U0001f4e6", "binary" + + # --- Backend bridge: downloads --- + + def download_selected(self) -> None: + if not self.download_path: + url = self.url_var.get().strip() + if url: + self.download_path = default_download_folder(url, os.getcwd()) + if not self.download_path: + self.notify_warning("Warning", "Please choose a download location first.") + return + + selected_paths = list(self.checked_items) + if not selected_paths: + self.notify_info("Info", "No files selected for download.") + return + + futures = [] + for full_path in selected_paths: + info = self.files_dict.get(full_path) + if not info: + continue + url = info.get("url") + file_name = info.get("file_name") + if not url or not file_name: + continue + + dir_path = info.get("path", "") + path_segments = [seg for seg in dir_path.strip("/").split("/") if seg] + try: + target_dir, file_path = build_download_path(self.download_path, path_segments, file_name) + except ValueError: + self.log_message(f"[Download] Skipped unsafe path: {full_path}") + continue + os.makedirs(target_dir, exist_ok=True) + + cancel_event = self.downloads_panel.ensure(file_path, safe_name) + futures.append(self.executor.submit(self.backend.download_file, url, file_path, safe_name, cancel_event)) + + if not futures: + return + try: + self.pause_btn.configure(state="normal") + except Exception: + pass + monitor = threading.Thread(target=self.backend.monitor_downloads, args=(futures,), daemon=True) + monitor.start() + + def toggle_pause(self) -> None: + if self.pause_event.is_set(): + self.pause_event.clear() + self.pause_btn.configure(text="Resume") + self.progress_label.configure(text="Downloads paused") + else: + self.pause_event.set() + self.pause_btn.configure(text="Pause") + self.progress_label.configure(text="") + + def _update_download_progress(self, file_path: str, progress: float) -> None: + self.downloads_panel.set_progress(file_path, progress) + + def _set_download_status(self, file_path: str, text: str) -> None: + self.downloads_panel.set_status(file_path, text) + + # --- Backend bridge: search --- + + def on_search_filter_changed(self, *args): + query = self.search_var.get() if hasattr(self, "search_var") else "" + self._apply_search_filter(query) + + def _apply_search_filter(self, query: str) -> None: + term = (query or "").strip().lower() + if not term: + if self.full_tree_backup: + self._restore_full_tree() + return + if not self.full_tree_backup: + self._backup_full_tree() + self._filter_tree_by_term(term) + + def _backup_full_tree(self) -> None: + """Snapshot tree_nodes and tree_roots for search filter restore.""" + self.full_tree_backup = { + "nodes": copy.deepcopy(self.tree_nodes), + "roots": list(self.tree_roots), + "folders": dict(self.folders), + } + + def _restore_full_tree(self) -> None: + """Restore tree_nodes from backup (undo search filter).""" + if not self.full_tree_backup: + return + self.tree_nodes = self.full_tree_backup["nodes"] + self.tree_roots = self.full_tree_backup["roots"] + with self.folders_dict_lock: + self.folders = self.full_tree_backup["folders"] + self.full_tree_backup = {} + + # Destroy all existing row widgets and rebuild from scratch + for row in self._row_widgets.values(): + row.destroy() + self._row_widgets.clear() + self._rebuild_visible() + self._sync_rows() + + def _filter_tree_by_term(self, term: str) -> None: + """Mark nodes hidden if they (and their descendants) don't match term.""" + term = term.lower() + + cache: dict[str, bool] = {} + + def matches(node_id: str) -> bool: + if node_id in cache: + return cache[node_id] + node = self.tree_nodes.get(node_id) + if node is None: + cache[node_id] = False + elif term in node.name.lower() or term in node.full_path.lower(): + cache[node_id] = True + else: + cache[node_id] = any(matches(child_id) for child_id in node.children) + return cache[node_id] + + def apply_visibility(node_id: str) -> None: + node = self.tree_nodes.get(node_id) + if node is None: + return + if matches(node_id): + node.hidden = False + if node.kind == "folder": + node.expanded = True + else: + node.hidden = True + for child_id in node.children: + apply_visibility(child_id) + + for root_id in self.tree_roots: + apply_visibility(root_id) + + self._rebuild_visible() + self._sync_rows() + + def choose_download_path(self) -> None: + path = filedialog.askdirectory(title="Choose Download Location") + if path: + self.download_path = path + + def update_thread_count(self, value=None) -> None: + try: + n = int(self.threads_var.get()) + except (ValueError, AttributeError): + return + new_count = max(1, min(10, n)) + if new_count == self.max_workers: + return + self.max_workers = new_count + self.executor = rebuild_executor(self.executor, self.max_workers) + + def toggle_panels(self) -> None: + if self.panels_visible: + if hasattr(self, "_panels_widget"): + self._panels_widget.grid_remove() + self.panels_visible = False + self.toggle_panels_btn.configure(text="Show Panels") + else: + if hasattr(self, "_panels_widget"): + self._panels_widget.grid() + self.panels_visible = True + self.toggle_panels_btn.configure(text="Hide Panels") + + +def main(): + app = WebsiteCopierCtk() + app.run() diff --git a/backend.py b/src/index_ripper/backend.py similarity index 90% rename from backend.py rename to src/index_ripper/backend.py index 8918a7b..246714b 100644 --- a/backend.py +++ b/src/index_ripper/backend.py @@ -12,7 +12,8 @@ import requests from bs4 import BeautifulSoup -from app_utils import is_url_in_scope +from index_ripper.utils import cleanup_partial_file, is_url_in_scope + class Backend: @@ -184,7 +185,6 @@ def _get_all_urls(self, url, scanned_urls=None, base_url=None): href = link.get("href") if not href or href in [".", "..", "/"] or href.startswith("?"): continue - full_url = urljoin(url, href) if not full_url or not is_url_in_scope(base_url, full_url): continue @@ -196,7 +196,8 @@ def _get_all_urls(self, url, scanned_urls=None, base_url=None): final_url = f"{parsed_full.scheme}://{parsed_full.netloc}{path}" - if href.endswith("/"): + is_directory_hint = href.endswith("/") + if is_directory_hint: if not final_url.endswith("/"): final_url += "/" else: @@ -301,18 +302,8 @@ def download_file(self, url, file_path, file_name, cancel_event=None): with open(file_path, "wb") as file_handle: for data in response.iter_content(block_size): self.ui_manager.pause_event.wait() - if cancel_event is not None and cancel_event.is_set(): - self.ui_manager.log_message(f"[Download] Canceled: {file_name}") - try: - self.ui_manager.update_download_status( - file_path, "Canceled" - ) - except AttributeError: - pass - return False - if self.should_stop: - self._log(f"[Download] Stopping download for {file_name}") - return False + if (cancel_event is not None and cancel_event.is_set()) or self.should_stop: + break if not data: break downloaded += len(data) @@ -322,6 +313,21 @@ def download_file(self, url, file_path, file_name, cancel_event=None): progress = (downloaded / total_size) * 100 self.ui_manager.update_progress(file_path, file_name, progress) + # Re-check abort flags after file handle is closed + canceled = cancel_event is not None and cancel_event.is_set() + stopped = self.should_stop + if canceled or stopped: + cleanup_partial_file(file_path) + if canceled: + self.ui_manager.log_message(f"[Download] Canceled: {file_name}") + try: + self.ui_manager.update_download_status(file_path, "Canceled") + except AttributeError: + pass + else: + self._log(f"[Download] Stopping download for {file_name}") + return False + self.ui_manager.update_progress(file_path, file_name, 100) try: self.ui_manager.update_download_status(file_path, "Completed") @@ -329,21 +335,13 @@ def download_file(self, url, file_path, file_name, cancel_event=None): pass return True - except requests.exceptions.RequestException as ex: - self._log(f"[Download] Error downloading {file_name}: {str(ex)}") + except (requests.exceptions.RequestException, IOError) as ex: + label = "File error" if isinstance(ex, IOError) else "Error downloading" + self._log(f"[Download] {label} {file_name}: {ex}") + cleanup_partial_file(file_path) try: self.ui_manager.update_download_status(file_path, "Failed") - self.ui_manager.log_message(f"[Download] Error: {file_name} - {ex}") - except AttributeError: - pass - return False - except IOError as ex: - self._log(f"[Download] File error for {file_name}: {str(ex)}") - try: - self.ui_manager.update_download_status(file_path, "Failed") - self.ui_manager.log_message( - f"[Download] File error: {file_name} - {ex}" - ) + self.ui_manager.log_message(f"[Download] {label}: {file_name} - {ex}") except AttributeError: pass return False diff --git a/self_test.py b/src/index_ripper/self_test.py similarity index 100% rename from self_test.py rename to src/index_ripper/self_test.py diff --git a/settings_store.py b/src/index_ripper/settings.py similarity index 100% rename from settings_store.py rename to src/index_ripper/settings.py diff --git a/src/index_ripper/ui/__init__.py b/src/index_ripper/ui/__init__.py new file mode 100644 index 0000000..08e7b92 --- /dev/null +++ b/src/index_ripper/ui/__init__.py @@ -0,0 +1 @@ +"""Index Ripper UI components.""" diff --git a/src/index_ripper/ui/downloads.py b/src/index_ripper/ui/downloads.py new file mode 100644 index 0000000..78a0806 --- /dev/null +++ b/src/index_ripper/ui/downloads.py @@ -0,0 +1,115 @@ +import threading + + +def download_status_state(text: str) -> str: + lower = (text or "").lower() + if "fail" in lower: + return "error" + if "complete" in lower: + return "success" + if "cancel" in lower: + return "warning" + if "download" in lower: + return "active" + return "queued" + + +class DownloadsPanel: + def __init__(self, parent_frame, ctk, tk, threading_module=None, tokens=None): + self.parent_frame = parent_frame + self.ctk = ctk + self.tk = tk + self.threading = threading_module or threading + self._items = {} + self.tokens = tokens or {} + downloads_tokens = self.tokens.get("downloads", {}) + self.status_colors = downloads_tokens.get("status_colors", {}) + + def _set_status_text(self, item, text: str) -> None: + state = download_status_state(text) + status_kwargs = {"text": text} + color = self.status_colors.get(state) + if color is not None: + status_kwargs["text_color"] = color + item["status"].configure(**status_kwargs) + + def ensure(self, file_path: str, display_name: str): + try: + item = self._items.get(file_path) + if item: + return item["cancel_event"] + + downloads_tokens = self.tokens.get("downloads", {}) + row = self.ctk.CTkFrame( + self.parent_frame, + fg_color=downloads_tokens.get("row_fg_color"), + corner_radius=downloads_tokens.get("row_corner_radius", 8), + border_width=downloads_tokens.get("row_border_width", 0), + ) + row.pack(fill="x", padx=5, pady=4) + + name_label = self.ctk.CTkLabel( + row, + text=display_name, + text_color=downloads_tokens.get("name_text_color"), + ) + name_label.pack(side="left", padx=5) + + progress_bar_widget = self.ctk.CTkProgressBar(row) + progress_bar_widget.set(0) + progress_bar_widget.pack(side="left", expand=True, fill="x", padx=8) + + status = self.ctk.CTkLabel(row, text="Queued") + status.pack(side="left", padx=5) + + cancel_event = self.threading.Event() + + def do_cancel(): + cancel_event.set() + try: + self._set_status_text(self._items[file_path], "Canceling...") + except self.tk.TclError: + pass + + cancel_btn_tokens = downloads_tokens.get("cancel_button", {}) + cancel_btn = self.ctk.CTkButton( + row, + text="Cancel", + command=do_cancel, + width=cancel_btn_tokens.get("width", 80), + height=cancel_btn_tokens.get("height", 28), + fg_color=cancel_btn_tokens.get("fg_color"), + hover_color=cancel_btn_tokens.get("hover_color"), + ) + cancel_btn.pack(side="right", padx=5) + + self._items[file_path] = { + "frame": row, + "bar": progress_bar_widget, + "label": name_label, + "status": status, + "cancel_event": cancel_event, + } + self._set_status_text(self._items[file_path], "Queued") + return cancel_event + except self.tk.TclError: + return self.threading.Event() + + def set_progress(self, file_path: str, progress: float) -> None: + try: + item = self._items.get(file_path) + if not item: + return + item["bar"].set(progress / 100) + self._set_status_text(item, f"Downloading {progress:.1f}%") + except self.tk.TclError: + pass + + def set_status(self, file_path: str, text: str) -> None: + try: + item = self._items.get(file_path) + if not item: + return + self._set_status_text(item, text) + except self.tk.TclError: + pass diff --git a/src/index_ripper/ui/filetree.py b/src/index_ripper/ui/filetree.py new file mode 100644 index 0000000..46f5e24 --- /dev/null +++ b/src/index_ripper/ui/filetree.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +import customtkinter as ctk + + +@dataclass +class TreeNode: + node_id: str + parent_id: str # "" for root-level nodes + name: str + kind: str # "folder" | "file" + full_path: str # "" for folders + size: str + file_type: str + icon_group: str # "folder"|"image"|"document"|"archive"|"code"|"audio"|"video"|"text"|"binary" + checked: bool = False + expanded: bool = False + hidden: bool = False # True when filtered out by search + children: list[str] = field(default_factory=list) # ordered list of child node_ids + + +_EMOJI_ICONS = { + "folder": "📁", + "image": "🖼️", + "document": "📄", + "archive": "🗜️", + "code": "💻", + "audio": "🎵", + "video": "🎬", + "text": "📝", + "binary": "⚙️", +} + +_BG_NORMAL = "transparent" +_BG_HOVER = ("#F1F5F9", "#1E293B") +_BG_CHECKED = ("#EFF6FF", "#172554") +_BG_CHECKED_HOVER = ("#DBEAFE", "#1E3A5F") + + +class RowWidget: + """One visible row in the FileTree.""" + + INDENT_PX = 20 + ROW_HEIGHT = 36 + + def __init__(self, parent, app, node: TreeNode, depth: int): + self.app = app + self.node_id = node.node_id + self._checked = node.checked + self._hovered = False + + self.frame = ctk.CTkFrame(parent, height=self.ROW_HEIGHT, corner_radius=4) + self.frame.pack(fill="x", padx=4, pady=0) + self.frame.pack_propagate(False) + + # 3-px accent bar (left edge, blue when checked) + self._accent = ctk.CTkFrame( + self.frame, width=3, corner_radius=0, + fg_color="#2563EB" if node.checked else "transparent", + ) + self._accent.pack(side="left", fill="y") + + # Indent spacer + if depth > 0: + ctk.CTkFrame( + self.frame, width=depth * self.INDENT_PX, + fg_color="transparent", height=self.ROW_HEIGHT, + ).pack(side="left") + + # Emoji icon + ctk.CTkLabel( + self.frame, + text=_EMOJI_ICONS.get(node.icon_group, "📄"), + font=ctk.CTkFont(size=18), + width=28, + ).pack(side="left", padx=(4, 4)) + + # Name label + self.name_label = ctk.CTkLabel( + self.frame, + text=node.name, + anchor="w", + font=ctk.CTkFont(size=14, weight="bold" if node.kind == "folder" else "normal"), + ) + self.name_label.pack(side="left", fill="x", expand=True) + + # Chevron on RIGHT (folders only) + if node.kind == "folder": + self.chevron = ctk.CTkButton( + self.frame, text="▼" if node.expanded else "▶", + width=22, height=22, fg_color="transparent", + hover_color=("gray85", "gray30"), text_color=("gray40", "gray60"), + font=ctk.CTkFont(size=10), + command=lambda: app._on_chevron_click(self.node_id), + ) + self.chevron.pack(side="right", padx=(4, 4)) + elif node.kind == "file" and node.size: + # Size label (right side, files only) + ctk.CTkLabel( + self.frame, + text=node.size, + font=ctk.CTkFont(size=12), + text_color=("gray50", "gray60"), + width=80, + anchor="e", + ).pack(side="right", padx=(0, 8)) + + # Bind hover only on outer frame (avoids flicker from child entry) + self.frame.bind("", self._on_enter) + self.frame.bind("", self._on_leave) + # Bind click on frame and all non-chevron children + self._bind_clicks(self.frame) + + def _bind_clicks(self, widget) -> None: + """Bind click handler on widget and all children except the chevron.""" + if hasattr(self, "chevron") and widget is self.chevron: + return # chevron has its own command; don't intercept clicks + widget.bind("", self._on_click) + for child in widget.winfo_children(): + self._bind_clicks(child) + + def _on_enter(self, _event=None) -> None: + self._hovered = True + self._update_bg() + + def _on_leave(self, _event=None) -> None: + self._hovered = False + self._update_bg() + + def _on_click(self, event) -> None: + self.app._on_row_click(self.node_id, event) + + def set_checked(self, checked: bool) -> None: + self._checked = checked + self._accent.configure( + fg_color="#2563EB" if checked else "transparent" + ) + self._update_bg() + + def set_chevron(self, expanded: bool) -> None: + if hasattr(self, "chevron"): + self.chevron.configure(text="▼" if expanded else "▶") + + def _update_bg(self) -> None: + if self._checked and self._hovered: + color = _BG_CHECKED_HOVER + elif self._checked: + color = _BG_CHECKED + elif self._hovered: + color = _BG_HOVER + else: + color = _BG_NORMAL + self.frame.configure(fg_color=color) + + def destroy(self) -> None: + self.frame.destroy() + + +def should_skip_file_row(existing_entry) -> bool: + """Return True when a file row is already fully registered.""" + return existing_entry is not None diff --git a/src/index_ripper/ui/filters.py b/src/index_ripper/ui/filters.py new file mode 100644 index 0000000..8196e45 --- /dev/null +++ b/src/index_ripper/ui/filters.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import tkinter as tk + +import customtkinter as ctk + +from index_ripper.utils import normalize_extension + + +class FileTypeFilterMixin: + """Mixin providing file-type filter checkbox management for the main app class.""" + + def select_all_types(self) -> None: + for var in self.file_types.values(): + var.set(True) + + def deselect_all_types(self) -> None: + for var in self.file_types.values(): + var.set(False) + + def _bind_hscroll_wheel(self, widget) -> None: + """讓 widget 的滾輪事件橫向捲動 filters_container(平滑版)。""" + try: + canvas = self.filters_container._parent_canvas + except AttributeError: + return + + # 每個 scroll unit = 20px,讓滾動距離合理 + canvas.configure(xscrollincrement=20) + + def _scroll(event): + delta = getattr(event, "delta", 0) + if not delta: + return + # macOS trackpad delta 很小 (1–5);滑鼠滾輪約 120 + # 統一換算為 unit 數,最少 1 + units = max(1, abs(delta) // 3) + canvas.xview_scroll(-units if delta > 0 else units, "units") + + widget.bind("", _scroll, add="+") + widget.bind("", _scroll, add="+") + + def _add_file_type_filter(self, ext: str) -> None: + if not hasattr(self, "filters_container"): + return + if ext in self.file_types: + return + var = tk.BooleanVar(value=True) + self.file_types[ext] = var + self.file_type_counts[ext] = 0 + cb = ctk.CTkCheckBox( + self.filters_container, + text=ext if ext else "(no ext)", + variable=var, + onvalue=True, + offvalue=False, + ) + cb.pack(side="left", padx=4, pady=4) + self._bind_hscroll_wheel(cb) + var.trace_add("write", lambda *_: self._on_type_filter_changed(ext)) + self.file_type_widgets[ext] = cb + + def _on_type_filter_changed(self, ext: str) -> None: + """Show or hide existing tree rows when a file-type checkbox is toggled.""" + if self.full_tree_backup: + return # search filter active; handled by _filter_tree_by_term + var = self.file_types.get(ext) + if var is None: + return + visible = var.get() + for node in self.tree_nodes.values(): + if node.kind == "file" and normalize_extension(node.name) == ext: + node.hidden = not visible + self._rebuild_visible() + self._sync_rows() diff --git a/src/index_ripper/ui/theme.py b/src/index_ripper/ui/theme.py new file mode 100644 index 0000000..a839601 --- /dev/null +++ b/src/index_ripper/ui/theme.py @@ -0,0 +1,164 @@ +from __future__ import annotations + + +def apply_app_theme(ctk): + ctk.set_appearance_mode("system") + ctk.set_default_color_theme("blue") + + +def ui_tokens(): + return { + "option_menu": { + "height": 34, + "fg_color": ("#E2E8F0", "#334155"), + "button_color": ("#CBD5E1", "#475569"), + "button_hover_color": ("#94A3B8", "#64748B"), + "text_color": ("#0F172A", "#F8FAFC"), + "dropdown_fg_color": ("#FFFFFF", "#1E293B"), + "dropdown_text_color": ("#0F172A", "#F1F5F9"), + "dropdown_hover_color": ("#E2E8F0", "#334155"), + }, + "tabview": { + "segmented_button_fg_color": ("#E2E8F0", "#334155"), + "segmented_button_selected_color": ("#2563EB", "#3B82F6"), + "segmented_button_selected_hover_color": ("#1D4ED8", "#2563EB"), + "segmented_button_unselected_color": ("#CBD5E1", "#475569"), + "segmented_button_unselected_hover_color": ("#94A3B8", "#64748B"), + "text_color": ("#0F172A", "#F8FAFC"), + }, + "checkbox": { + "checkbox_width": 20, + "checkbox_height": 20, + "corner_radius": 6, + "border_width": 2, + "border_color": ("#94A3B8", "#64748B"), + "fg_color": ("#2563EB", "#3B82F6"), + "hover_color": ("#1D4ED8", "#2563EB"), + "checkmark_color": ("#FFFFFF", "#FFFFFF"), + "text_color": ("#0F172A", "#E2E8F0"), + }, + "logs": { + "height": 100, + "font": ("SF Mono", 12), + "text_color": ("#0F172A", "#E2E8F0"), + "fg_color": ("#F8FAFC", "#111827"), + }, + "downloads": { + "row_fg_color": ("#FFFFFF", "#1F2937"), + "row_border_width": 1, + "row_corner_radius": 10, + "name_text_color": ("#0F172A", "#E2E8F0"), + "status_colors": { + "queued": ("#475569", "#94A3B8"), + "active": ("#1D4ED8", "#60A5FA"), + "warning": ("#B45309", "#F59E0B"), + "success": ("#047857", "#34D399"), + "error": ("#B91C1C", "#F87171"), + }, + "cancel_button": { + "width": 88, + "height": 30, + "fg_color": "#DC2626", + "hover_color": "#B91C1C", + }, + }, + } + + +def action_button_style_name(kind: str) -> str: + mapping = { + "primary": "Primary.TButton", + "secondary": "Secondary.TButton", + "danger": "Danger.TButton", + "success": "Success.TButton", + } + return mapping.get(kind, "Secondary.TButton") + + +def configure_action_button_styles(window, ctk, ttk): + style = ttk.Style() + text_light = "#F8FAFC" + fg = { + "primary": window._apply_appearance_mode(("#2563EB", "#3B82F6")), + "secondary": window._apply_appearance_mode(("#334155", "#475569")), + "danger": window._apply_appearance_mode(("#DC2626", "#EF4444")), + "success": window._apply_appearance_mode(("#059669", "#10B981")), + } + active = { + "primary": window._apply_appearance_mode(("#1D4ED8", "#2563EB")), + "secondary": window._apply_appearance_mode(("#1E293B", "#334155")), + "danger": window._apply_appearance_mode(("#B91C1C", "#DC2626")), + "success": window._apply_appearance_mode(("#047857", "#059669")), + } + for kind in ("primary", "secondary", "danger", "success"): + style_name = action_button_style_name(kind) + style.configure( + style_name, + padding=(12, 7), + foreground=text_light, + background=fg[kind], + borderwidth=0, + focusthickness=1, + focuscolor=active[kind], + font=("SF Pro Text", 11, "bold"), + ) + style.map( + style_name, + background=[("active", active[kind]), ("disabled", "#6B7280")], + foreground=[("disabled", "#E5E7EB")], + ) + + +def configure_treeview_style(window, ctk, ttk): + style = ttk.Style() + bg_color = window._apply_appearance_mode(ctk.ThemeManager.theme["CTkFrame"]["fg_color"]) + text_color = window._apply_appearance_mode( + ctk.ThemeManager.theme["CTkLabel"]["text_color"] + ) + selected_color = window._apply_appearance_mode( + ctk.ThemeManager.theme["CTkButton"]["fg_color"] + ) + + style.theme_use("default") + style.configure( + "Treeview", + background=bg_color, + foreground=text_color, + fieldbackground=bg_color, + borderwidth=0, + rowheight=28, + font=("SF Pro Text", 12), + ) + style.map( + "Treeview", + background=[("selected", selected_color)], + foreground=[("selected", "#FFFFFF")], + ) + style.configure( + "Treeview.Heading", + background=window._apply_appearance_mode( + ctk.ThemeManager.theme["CTkFrame"]["top_fg_color"] + ), + foreground=text_color, + relief="flat", + font=("SF Pro Text", 11, "bold"), + ) + style.map( + "Treeview.Heading", + background=[ + ( + "active", + window._apply_appearance_mode( + ctk.ThemeManager.theme["CTkButton"]["hover_color"] + ), + ) + ], + ) + + +def treeview_tag_colors(window): + return { + "oddrow": window._apply_appearance_mode(("#FFFFFF", "#2A2D35")), + "evenrow": window._apply_appearance_mode(("#F8FAFC", "#242831")), + "checked": window._apply_appearance_mode(("#0F766E", "#2DD4BF")), + } diff --git a/app_utils.py b/src/index_ripper/utils.py similarity index 58% rename from app_utils.py rename to src/index_ripper/utils.py index f321077..36a1f60 100644 --- a/app_utils.py +++ b/src/index_ripper/utils.py @@ -2,6 +2,9 @@ import os import posixpath +import sys +import threading +from concurrent.futures import ThreadPoolExecutor from urllib.parse import urlparse from urllib.parse import unquote @@ -105,3 +108,50 @@ def default_download_folder(url: str, fallback_root: str) -> str: if not site_name: site_name = "downloads" return os.path.join(fallback_root, site_name) + + +def cleanup_partial_file(file_path: str) -> None: + """Best-effort removal of a partial download file.""" + try: + os.remove(file_path) + except OSError: + pass + + +def configure_tk_libraries() -> None: + """Set Tcl/Tk library env vars for uv-managed Python when missing.""" + if os.environ.get("TCL_LIBRARY") and os.environ.get("TK_LIBRARY"): + return + for prefix in (sys.base_prefix, sys.prefix): + lib_root = os.path.join(prefix, "lib") + if not os.path.isdir(lib_root): + continue + tcl_dir = tk_dir = None + try: + for entry in os.listdir(lib_root): + if entry.startswith("tcl") and os.path.isfile(os.path.join(lib_root, entry, "init.tcl")): + tcl_dir = os.path.join(lib_root, entry) + if entry.startswith("tk") and os.path.isfile(os.path.join(lib_root, entry, "tk.tcl")): + tk_dir = os.path.join(lib_root, entry) + except OSError: + continue + if tcl_dir and tk_dir: + os.environ.setdefault("TCL_LIBRARY", tcl_dir) + os.environ.setdefault("TK_LIBRARY", tk_dir) + return + + +def rebuild_executor(old_executor: ThreadPoolExecutor, max_workers: int) -> ThreadPoolExecutor: + """Create a new executor and shut down the old one in a background thread.""" + new_executor = ThreadPoolExecutor(max_workers=max_workers) + threading.Thread(target=old_executor.shutdown, kwargs={"wait": True, "cancel_futures": False}, daemon=True).start() + return new_executor + + +def build_download_path(download_root: str, path_segments: list[str], file_name: str) -> tuple[str, str]: + """Return (target_dir, file_path) for a download. Raises ValueError on path escape.""" + safe_segments = [sanitize_path_segment(s) for s in path_segments if s] + safe_file = sanitize_filename(file_name) + target_dir = safe_join(download_root, safe_segments) if safe_segments else download_root + file_path = safe_join(download_root, safe_segments + [safe_file]) + return target_dir, file_path diff --git a/test_app_utils.py b/test_app_utils.py deleted file mode 100644 index 0d22df4..0000000 --- a/test_app_utils.py +++ /dev/null @@ -1,23 +0,0 @@ -import unittest - -from app_utils import default_download_folder, normalize_extension, shorten_path - - -class TestAppUtils(unittest.TestCase): - def test_normalize_extension_normal(self): - self.assertEqual(normalize_extension("a.TXT"), ".txt") - - def test_normalize_extension_none(self): - self.assertEqual(normalize_extension("README"), ".(No Extension)") - - def test_shorten_path(self): - self.assertEqual(shorten_path("/a/b", keep=10), "/a/b") - self.assertEqual(shorten_path("/" + "x" * 50, keep=10), "..." + "x" * 10) - - def test_default_download_folder(self): - out = default_download_folder("https://example.com/a/b", "/tmp") - self.assertTrue(out.endswith("example.com")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py new file mode 100644 index 0000000..7819b00 --- /dev/null +++ b/tests/test_app_smoke.py @@ -0,0 +1,154 @@ +"""Smoke tests for index_ripper.app (WebsiteCopierCtk).""" +import os +import sys +import unittest + +os.environ.setdefault("INDEX_RIPPER_MODAL_DIALOGS", "0") +# 無頭環境 +os.environ.setdefault("DISPLAY", ":99") + +# Ensure Tcl/Tk can find its library when running under uv's isolated Python. +# TCL_LIBRARY must be set before _tkinter is first imported. +import sys as _sys +_tcl_dir = os.path.join(_sys.base_prefix, "lib", "tcl8.6") +if os.path.isdir(_tcl_dir) and "TCL_LIBRARY" not in os.environ: + os.environ["TCL_LIBRARY"] = _tcl_dir + + +class TestWebsiteCopierCtkSmoke(unittest.TestCase): + def _make_smoke(self): + import importlib + mod = importlib.import_module("index_ripper.app") + importlib.reload(mod) + return mod.WebsiteCopierCtk(ui_smoke=True) + + def test_import(self): + from index_ripper import app as ui_ctk # noqa: F401 + + def test_smoke_init_and_destroy(self): + app = self._make_smoke() + app.window.after(0, app.window.destroy) + app.run() + + def test_has_url_var(self): + app = self._make_smoke() + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "url_var")) + + def test_has_log_text(self): + app = self._make_smoke() + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "log_text")) + + def test_full_ui_has_scan_btn(self): + from index_ripper import app as ui_ctk + import importlib + importlib.reload(ui_ctk) + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "scan_btn")) + self.assertTrue(hasattr(app, "url_entry")) + self.assertTrue(hasattr(app, "status_label")) + + def test_has_filters_container(self): + from index_ripper import app as ui_ctk + import importlib + importlib.reload(ui_ctk) + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "filters_container")) + + def test_add_file_type_filter(self): + from index_ripper import app as ui_ctk + import importlib + importlib.reload(ui_ctk) + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app._add_file_type_filter(".mp4") + app.window.after(0, app.window.destroy) + app.run() + self.assertIn(".mp4", app.file_types) + self.assertIn(".mp4", app.file_type_widgets) + + def test_has_download_controls(self): + from index_ripper import app as ui_ctk + import importlib + importlib.reload(ui_ctk) + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "download_btn")) + self.assertTrue(hasattr(app, "pause_btn")) + self.assertTrue(hasattr(app, "threads_var")) + self.assertTrue(hasattr(app, "toggle_panels_btn")) + + def test_has_file_tree(self): + from index_ripper import app as ui_ctk + import importlib + importlib.reload(ui_ctk) + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "tree_nodes")) + self.assertTrue(hasattr(app, "tree_roots")) + self.assertTrue(hasattr(app, "tree_scroll_frame")) + self.assertTrue(hasattr(app, "search_var")) + + def test_search_var_traces(self): + from index_ripper import app as ui_ctk + import importlib + importlib.reload(ui_ctk) + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + # search_var should have trace (on_search_filter_changed) + self.assertTrue(len(app.search_var.trace_info()) > 0) + + def test_has_progress_bar(self): + from index_ripper import app as ui_ctk + import importlib + importlib.reload(ui_ctk) + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "progress_bar")) + self.assertTrue(hasattr(app, "progress_label")) + + def test_has_panels_notebook(self): + from index_ripper import app as ui_ctk + import importlib + importlib.reload(ui_ctk) + app = ui_ctk.WebsiteCopierCtk(ui_smoke=False) + app.window.after(0, app.window.destroy) + app.run() + self.assertTrue(hasattr(app, "panels_notebook")) + self.assertIsNotNone(app.panels_notebook) + self.assertTrue(hasattr(app, "log_text")) + self.assertTrue(hasattr(app, "downloads_panel")) + + def test_poll_methods_exist(self): + from index_ripper import app as ui_ctk + import importlib + importlib.reload(ui_ctk) + app = ui_ctk.WebsiteCopierCtk(ui_smoke=True) + for method in ("_poll_scan_queue", "_poll_file_queue", + "_flush_scan_buffer", "_schedule_flush"): + self.assertTrue(hasattr(app, method), f"Missing: {method}") + app.window.after(0, app.window.destroy) + app.run() + + def test_scan_methods_exist(self): + from index_ripper import app as ui_ctk + for method in ("start_scan", "toggle_scan_pause", "clear_scan_results", + "add_folder", "add_file", "update_progress", + "download_selected", "toggle_pause", + "on_search_filter_changed", "_apply_search_filter", + "focus_search", "focus_logs", "clear_search"): + self.assertTrue(hasattr(ui_ctk.WebsiteCopierCtk, method), f"Missing: {method}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_backend.py b/tests/test_backend.py new file mode 100644 index 0000000..27bce72 --- /dev/null +++ b/tests/test_backend.py @@ -0,0 +1,188 @@ +"""Tests for backend module - scanning and downloading logic.""" +import os +import tempfile +import threading +import unittest +from unittest.mock import MagicMock, patch + +from index_ripper.backend import Backend + + +class MockUIManager: + """Mock UI manager for testing backend.""" + + def __init__(self): + self.timeout = (10, 30) + self.USER_AGENT = "TestAgent/1.0" + self.is_scanning = False + self.scanned_urls = 0 + self.total_urls = 0 + self.files_dict = {} + self.folders = {} + self.files_dict_lock = threading.Lock() + self.folders_dict_lock = threading.Lock() + self.scan_pause_event = threading.Event() + self.scan_pause_event.set() + self.should_stop = False + self.pause_event = threading.Event() + self.pause_event.set() + self.log_messages = [] + self.session = MagicMock() + + def log_message(self, message): + self.log_messages.append(message) + + def notify_info(self, title, message): + pass + + def notify_warning(self, title, message): + pass + + def notify_error(self, title, message): + pass + + def on_scan_started(self, url): + self.is_scanning = True + + def on_scan_progress(self, scanned_urls, total_urls): + self.scanned_urls = scanned_urls + self.total_urls = total_urls + + def on_scan_item(self, **payload): + is_directory = payload.get("is_directory") + if is_directory: + with self.folders_dict_lock: + self.folders[payload.get("path")] = payload.get("url") + else: + with self.files_dict_lock: + self.files_dict[payload.get("full_path")] = payload.get("url") + + def on_scan_finished(self, stopped): + self.is_scanning = False + + +class TestBackendScan(unittest.TestCase): + """Tests for website scanning functionality.""" + + def setUp(self): + self.ui = MockUIManager() + self.backend = Backend(self.ui) + self.backend.should_stop = False + + def test_backend_initialization(self): + """Test Backend initializes with correct defaults.""" + self.assertFalse(self.backend.should_stop) + self.assertEqual(self.backend.ui_manager, self.ui) + + def test_log_message(self): + """Test _log method calls ui_manager.log_message.""" + self.backend._log("Test message") + self.assertIn("Test message", self.ui.log_messages) + + def test_log_message_fallback(self): + """Test _log falls back to print when ui_manager has no log_message.""" + self.backend.ui_manager = MagicMock() + del self.backend.ui_manager.log_message + # Should not raise + self.backend._log("Test") + + def test_notify_info(self): + """Test _notify calls correct handler for info.""" + with patch.object(self.ui, 'notify_info') as mock_notify: + self.backend._notify("info", "Title", "Message") + mock_notify.assert_called_once_with("Title", "Message") + + def test_notify_error(self): + """Test _notify calls correct handler for error.""" + with patch.object(self.ui, 'notify_error') as mock_notify: + self.backend._notify("error", "Title", "Message") + mock_notify.assert_called_once_with("Title", "Message") + + def test_notify_warning(self): + """Test _notify calls correct handler for warning.""" + with patch.object(self.ui, 'notify_warning') as mock_notify: + self.backend._notify("warning", "Title", "Message") + mock_notify.assert_called_once_with("Title", "Message") + + def test_call_ui_hook_exists(self): + """Test _call_ui_hook calls existing hook.""" + self.ui.test_hook = MagicMock(return_value=True) + result = self.backend._call_ui_hook("test_hook", foo="bar") + self.assertTrue(result) + self.ui.test_hook.assert_called_once_with(foo="bar") + + def test_call_ui_hook_missing(self): + """Test _call_ui_hook handles missing hook gracefully.""" + result = self.backend._call_ui_hook("nonexistent_hook") + self.assertFalse(result) + + def test_call_ui_hook_exception(self): + """Test _call_ui_hook handles hook exception gracefully.""" + def failing_hook(): + raise Exception("Test error") + + self.ui.failing_hook = failing_hook + result = self.backend._call_ui_hook("failing_hook") + self.assertFalse(result) + + +class TestBackendDownload(unittest.TestCase): + """Tests for file download functionality.""" + + def setUp(self): + self.ui = MockUIManager() + self.backend = Backend(self.ui) + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + import shutil + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @unittest.skip("Requires complex network mocking") + def test_download_file_creates_directory(self): + """Test download_file creates target directory if needed.""" + subdir = os.path.join(self.temp_dir, "subdir", "nested") + test_file = os.path.join(subdir, "test.txt") + + # Directory shouldn't exist yet + self.assertFalse(os.path.exists(subdir)) + + # Mock session.get to avoid actual network call + mock_response = MagicMock() + mock_response.headers = {"content-length": "10"} + mock_response.iter_content = lambda size: [b"test data"] + mock_response.raise_for_status = MagicMock() + + self.ui.session.get = MagicMock(return_value=mock_response) + + # Mock the pause event to not block + self.ui.pause_event = threading.Event() + self.ui.pause_event.set() + + result = self.backend.download_file( + "http://example.com/test.txt", + test_file, + "test.txt" + ) + + # File should be created + self.assertTrue(os.path.exists(test_file)) + + +class TestBackendProcessFile(unittest.TestCase): + """Tests for file processing in scan.""" + + def setUp(self): + self.ui = MockUIManager() + self.backend = Backend(self.ui) + + def test_process_file_extracts_filename(self): + """Test _process_file correctly extracts file info from URL.""" + # This tests the parsing logic without network calls + url = "http://example.com/path/to/file.txt" + # The method should handle this without error + # Full test would require mocking session.head + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..fb0c9f5 --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,17 @@ +import unittest + +from index_ripper.ui import downloads + + +class TestUIDownloadsSemantics(unittest.TestCase): + def test_download_status_state_mapping(self): + self.assertEqual(downloads.download_status_state("Queued"), "queued") + self.assertEqual(downloads.download_status_state("Downloading 12.0%"), "active") + self.assertEqual(downloads.download_status_state("Completed"), "success") + self.assertEqual(downloads.download_status_state("Failed"), "error") + self.assertEqual(downloads.download_status_state("Canceling..."), "warning") + self.assertEqual(downloads.download_status_state("something else"), "queued") + + +if __name__ == "__main__": + unittest.main() diff --git a/test_settings_store.py b/tests/test_settings.py similarity index 93% rename from test_settings_store.py rename to tests/test_settings.py index 7ca95e8..44b24ac 100644 --- a/test_settings_store.py +++ b/tests/test_settings.py @@ -2,7 +2,7 @@ import tempfile import unittest -from settings_store import load_settings, save_settings +from index_ripper.settings import load_settings, save_settings class TestSettingsStore(unittest.TestCase): diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 0000000..80cfba2 --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,32 @@ +import unittest + +from index_ripper.ui import theme + + +class TestUITheme(unittest.TestCase): + def test_action_button_style_name(self): + self.assertEqual(theme.action_button_style_name("primary"), "Primary.TButton") + self.assertEqual( + theme.action_button_style_name("secondary"), "Secondary.TButton" + ) + self.assertEqual(theme.action_button_style_name("danger"), "Danger.TButton") + self.assertEqual( + theme.action_button_style_name("success"), "Success.TButton" + ) + self.assertEqual(theme.action_button_style_name("unknown"), "Secondary.TButton") + + def test_ui_tokens_contains_modern_widget_styles(self): + tokens = theme.ui_tokens() + self.assertIn("option_menu", tokens) + self.assertIn("tabview", tokens) + self.assertIn("checkbox", tokens) + self.assertIn("fg_color", tokens["option_menu"]) + self.assertIn("segmented_button_fg_color", tokens["tabview"]) + self.assertIn("border_color", tokens["checkbox"]) + + def test_apply_bootstrap_theme_not_present(self): + self.assertFalse(hasattr(theme, "apply_bootstrap_theme")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_security_utils.py b/tests/test_utils.py similarity index 71% rename from test_security_utils.py rename to tests/test_utils.py index 01b4364..f06a85f 100644 --- a/test_security_utils.py +++ b/tests/test_utils.py @@ -2,14 +2,33 @@ import tempfile import unittest -from app_utils import ( +from index_ripper.utils import ( + default_download_folder, is_url_in_scope, + normalize_extension, safe_join, sanitize_filename, sanitize_path_segment, + shorten_path, ) +class TestAppUtils(unittest.TestCase): + def test_normalize_extension_normal(self): + self.assertEqual(normalize_extension("a.TXT"), ".txt") + + def test_normalize_extension_none(self): + self.assertEqual(normalize_extension("README"), ".(No Extension)") + + def test_shorten_path(self): + self.assertEqual(shorten_path("/a/b", keep=10), "/a/b") + self.assertEqual(shorten_path("/" + "x" * 50, keep=10), "..." + "x" * 10) + + def test_default_download_folder(self): + out = default_download_folder("https://example.com/a/b", "/tmp") + self.assertTrue(out.endswith("example.com")) + + class TestSecurityUtils(unittest.TestCase): def test_sanitize_path_segment_blocks_traversal_patterns(self): self.assertEqual(sanitize_path_segment(".."), "_") diff --git a/ui_downloads.py b/ui_downloads.py deleted file mode 100644 index 103c989..0000000 --- a/ui_downloads.py +++ /dev/null @@ -1,73 +0,0 @@ -import threading - - -class DownloadsPanel: - def __init__(self, parent_frame, ctk, tk, threading_module=None): - self.parent_frame = parent_frame - self.ctk = ctk - self.tk = tk - self.threading = threading_module or threading - self._items = {} - - def ensure(self, file_path: str, display_name: str): - try: - item = self._items.get(file_path) - if item: - return item["cancel_event"] - - row = self.ctk.CTkFrame(self.parent_frame) - row.pack(fill="x", padx=5, pady=4) - - name_label = self.ctk.CTkLabel(row, text=display_name) - name_label.pack(side="left", padx=5) - - progress_bar_widget = self.ctk.CTkProgressBar(row) - progress_bar_widget.set(0) - progress_bar_widget.pack(side="left", expand=True, fill="x", padx=8) - - status = self.ctk.CTkLabel(row, text="Queued") - status.pack(side="left", padx=5) - - cancel_event = self.threading.Event() - - def do_cancel(): - cancel_event.set() - try: - status.configure(text="Canceling...") - except self.tk.TclError: - pass - - cancel_btn = self.ctk.CTkButton( - row, text="Cancel", width=80, command=do_cancel - ) - cancel_btn.pack(side="right", padx=5) - - self._items[file_path] = { - "frame": row, - "bar": progress_bar_widget, - "label": name_label, - "status": status, - "cancel_event": cancel_event, - } - return cancel_event - except self.tk.TclError: - return self.threading.Event() - - def set_progress(self, file_path: str, progress: float) -> None: - try: - item = self._items.get(file_path) - if not item: - return - item["bar"].set(progress / 100) - item["status"].configure(text=f"Downloading {progress:.1f}%") - except self.tk.TclError: - pass - - def set_status(self, file_path: str, text: str) -> None: - try: - item = self._items.get(file_path) - if not item: - return - item["status"].configure(text=text) - except self.tk.TclError: - pass diff --git a/ui_theme.py b/ui_theme.py deleted file mode 100644 index 11cc825..0000000 --- a/ui_theme.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - - -def apply_app_theme(ctk): - ctk.set_appearance_mode("system") - ctk.set_default_color_theme("green") - - -def configure_treeview_style(window, ctk, ttk): - style = ttk.Style() - bg_color = window._apply_appearance_mode(ctk.ThemeManager.theme["CTkFrame"]["fg_color"]) - text_color = window._apply_appearance_mode( - ctk.ThemeManager.theme["CTkLabel"]["text_color"] - ) - selected_color = window._apply_appearance_mode( - ctk.ThemeManager.theme["CTkButton"]["fg_color"] - ) - - style.theme_use("default") - style.configure( - "Treeview", - background=bg_color, - foreground=text_color, - fieldbackground=bg_color, - borderwidth=0, - rowheight=22, - ) - style.map("Treeview", background=[("selected", selected_color)]) - style.configure( - "Treeview.Heading", - background=window._apply_appearance_mode( - ctk.ThemeManager.theme["CTkFrame"]["top_fg_color"] - ), - foreground=text_color, - relief="flat", - font=("TkDefaultFont", 10, "bold"), - ) - style.map( - "Treeview.Heading", - background=[ - ( - "active", - window._apply_appearance_mode( - ctk.ThemeManager.theme["CTkButton"]["hover_color"] - ), - ) - ], - ) - - -def treeview_tag_colors(window): - return { - "oddrow": window._apply_appearance_mode(("#F8FAFC", "#2B2B2B")), - "evenrow": window._apply_appearance_mode(("#F1F5F9", "#2E2E2E")), - "checked": window._apply_appearance_mode(("#166534", "#50C878")), - } diff --git a/ui_ttkb.py b/ui_ttkb.py deleted file mode 100644 index 11b55e7..0000000 --- a/ui_ttkb.py +++ /dev/null @@ -1,915 +0,0 @@ -from __future__ import annotations - -import os -import threading -from concurrent.futures import ThreadPoolExecutor -from queue import Empty, Queue - -import tkinter as tk -from tkinter import filedialog, messagebox - -import requests -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -import ttkbootstrap as ttkb -from ttkbootstrap.constants import BOTH, END, LEFT, RIGHT, TOP, X, Y - -from app_utils import default_download_folder, normalize_extension, safe_join, sanitize_filename -from backend import Backend - - -class WebsiteCopierTtkb: - USER_AGENT = "IndexRipper/2.0" - - def __init__(self, ui_smoke: bool = False): - self._ui_smoke = bool(ui_smoke) - - self.window = ttkb.Window(title="Index Ripper", themename="flatly") - self.window.geometry("1200x900") - self.window.minsize(900, 650) - - self.backend = Backend(self) - - self.pause_event = threading.Event() - self.pause_event.set() - self.scan_pause_event = threading.Event() - self.scan_pause_event.set() - - self.is_scanning = False - self.scanned_urls = 0 - self.total_urls = 0 - - self.files_dict_lock = threading.Lock() - self.folders_dict_lock = threading.Lock() - self.files_dict: dict[str, dict] = {} - self.folders: dict[str, str] = {} - self.checked_items: set[str] = set() - - self.dir_queue = Queue() - self.file_queue = Queue() - self.scan_item_buffer = Queue() - self.is_processing_dirs = False - self.is_processing_files = False - self.scan_flush_interval_ms = 16 - self.scan_flush_batch_size = 200 - self.scan_flush_job = None - - self.file_types: dict[str, tk.BooleanVar] = {} - self.file_type_counts: dict[str, int] = {} - - self.download_path = "" - self.download_queue = Queue() - self.max_workers = 5 - self.executor = ThreadPoolExecutor(max_workers=self.max_workers) - self.active_downloads = [] - - retry_strategy = Retry( - total=3, - backoff_factor=0.1, - status_forcelist=[500, 502, 503, 504], - ) - self.session = requests.Session() - adapter = HTTPAdapter( - max_retries=retry_strategy, - pool_connections=10, - pool_maxsize=10, - pool_block=False, - ) - self.session.mount("http://", adapter) - self.session.mount("https://", adapter) - self.timeout = (10, 20) - - self._build_ui() - - self.window.protocol("WM_DELETE_WINDOW", self.on_closing) - if not self._ui_smoke: - self.window.bind("", self.focus_search) - self.window.bind("", self.focus_logs) - self.window.bind("", self.clear_search) - - def _build_ui(self) -> None: - if self._ui_smoke: - self._build_ui_smoke_only() - return - - self.window.columnconfigure(0, weight=1) - self.window.rowconfigure(2, weight=1) - - header = ttkb.Frame(self.window, padding=10) - header.grid(row=0, column=0, sticky="ew") - header.columnconfigure(1, weight=1) - - ttkb.Label(header, text="URL").grid(row=0, column=0, sticky="w") - self.url_var = tk.StringVar() - self.url_entry = ttkb.Entry(header, textvariable=self.url_var) - self.url_entry.grid(row=0, column=1, sticky="ew", padx=(8, 8)) - - self.status_label = ttkb.Label(header, text="Ready", bootstyle="success") - self.status_label.grid(row=0, column=2, sticky="e", padx=(0, 8)) - - actions = ttkb.Frame(header) - actions.grid(row=0, column=3, sticky="e") - self.scan_btn = ttkb.Button(actions, text="Scan", bootstyle="primary", command=self.start_scan) - self.scan_btn.pack(side=LEFT, padx=3) - self.scan_pause_btn = ttkb.Button( - actions, - text="Pause Scan", - bootstyle="secondary", - command=self.toggle_scan_pause, - state="disabled", - ) - self.scan_pause_btn.pack(side=LEFT, padx=3) - self.clear_scan_btn = ttkb.Button(actions, text="Clear", command=self.clear_scan_results) - self.clear_scan_btn.pack(side=LEFT, padx=3) - - filters_and_controls = ttkb.Frame(self.window, padding=(10, 0, 10, 6)) - filters_and_controls.grid(row=1, column=0, sticky="ew") - filters_and_controls.columnconfigure(0, weight=1) - filters_and_controls.columnconfigure(1, weight=0) - - self.filters_frame = ttkb.Frame(filters_and_controls) - self.filters_frame.grid(row=0, column=0, sticky="ew") - self.filters_frame.columnconfigure(0, weight=1) - self.filters_canvas, self.filters_container = self._create_scrollable_container( - self.filters_frame, - height=85, - ) - - controls = ttkb.Frame(filters_and_controls) - controls.grid(row=0, column=1, sticky="e", padx=(10, 0)) - self.download_btn = ttkb.Button( - controls, - text="Download Selected", - bootstyle="success", - command=self.download_selected, - ) - self.download_btn.grid(row=0, column=0, padx=4) - self.pause_btn = ttkb.Button( - controls, - text="Pause", - bootstyle="secondary", - command=self.toggle_pause, - state="disabled", - ) - self.pause_btn.grid(row=0, column=1, padx=4) - self.path_btn = ttkb.Button( - controls, - text="Choose Folder", - bootstyle="secondary", - command=self.choose_download_path, - ) - self.path_btn.grid(row=0, column=2, padx=4) - - ttkb.Label(controls, text="Threads").grid(row=0, column=3, padx=(12, 4)) - self.threads_var = tk.StringVar(value="5") - self.threads_combo = ttkb.Combobox( - controls, - values=[str(i) for i in range(1, 11)], - textvariable=self.threads_var, - width=4, - state="readonly", - ) - self.threads_combo.grid(row=0, column=4) - self.threads_combo.bind("<>", self.update_thread_count) - - self.tree_frame = ttkb.Frame(self.window, padding=(10, 0, 10, 8)) - self.tree_frame.grid(row=2, column=0, sticky="nsew") - self.tree_frame.columnconfigure(0, weight=1) - self.tree_frame.rowconfigure(1, weight=1) - - search_bar = ttkb.Frame(self.tree_frame) - search_bar.grid(row=0, column=0, sticky="ew") - search_bar.columnconfigure(1, weight=1) - ttkb.Label(search_bar, text="Search").grid(row=0, column=0, padx=(0, 6)) - self.search_var = tk.StringVar() - self.search_entry = ttkb.Entry(search_bar, textvariable=self.search_var) - self.search_entry.grid(row=0, column=1, sticky="ew") - self.search_var.trace_add("write", lambda *_: self.apply_search_filter()) - - self.tree = ttkb.Treeview( - self.tree_frame, - columns=("size", "type"), - show="tree headings", - bootstyle="info", - ) - self.tree.heading("#0", text="Path", command=lambda: self.sort_tree("#0")) - self.tree.heading("size", text="Size", command=lambda: self.sort_tree("size")) - self.tree.heading("type", text="Type", command=lambda: self.sort_tree("type")) - self.tree.column("#0", width=600, stretch=True) - self.tree.column("size", width=120, stretch=False, anchor="e") - self.tree.column("type", width=240, stretch=False) - self.tree.grid(row=1, column=0, sticky="nsew") - - self.tree_scroll = tk.Scrollbar(self.tree_frame, orient="vertical", command=self.tree.yview) - self.tree.configure(yscrollcommand=self.tree_scroll.set) - self.tree_scroll.grid(row=1, column=1, sticky="ns") - - self.progress_frame = ttkb.Frame(self.window, padding=(10, 0, 10, 8)) - self.progress_frame.grid(row=3, column=0, sticky="ew") - self.progress_frame.columnconfigure(0, weight=1) - self.progress_var = tk.DoubleVar(value=0) - self.progress_bar = ttkb.Progressbar( - self.progress_frame, - variable=self.progress_var, - maximum=100, - bootstyle="success", - ) - self.progress_bar.grid(row=0, column=0, sticky="ew") - self.progress_label = ttkb.Label(self.progress_frame, text="") - self.progress_label.grid(row=1, column=0, sticky="w") - - self.panels_notebook = ttkb.Notebook(self.window, bootstyle="secondary") - self.panels_notebook.grid(row=4, column=0, sticky="ew", padx=10, pady=(0, 10)) - - self.downloads_tab = ttkb.Frame(self.panels_notebook, padding=10) - self.logs_tab = ttkb.Frame(self.panels_notebook, padding=10) - self.panels_notebook.add(self.downloads_tab, text="Downloads") - self.panels_notebook.add(self.logs_tab, text="Logs") - self.panels_notebook.select(self.logs_tab) - - downloads_frame = ttkb.Frame(self.downloads_tab) - downloads_frame.pack(fill=X) - self.downloads_canvas, self.downloads_container = self._create_scrollable_container( - downloads_frame, - height=140, - ) - - logs_wrap = ttkb.Frame(self.logs_tab) - logs_wrap.pack(fill=X) - self.log_text = tk.Text(logs_wrap, height=8, wrap="word") - self.log_text.pack(side=LEFT, fill=X, expand=True) - self.log_scroll = tk.Scrollbar(logs_wrap, orient="vertical", command=self.log_text.yview) - self.log_scroll.pack(side=RIGHT, fill=Y) - self.log_text.configure(yscrollcommand=self.log_scroll.set) - - self.download_items: dict[str, dict] = {} - self.sort_reverse = False - self.full_tree_backup = {} - - def _build_ui_smoke_only(self) -> None: - self.window.columnconfigure(0, weight=1) - self.window.rowconfigure(1, weight=1) - - header = tk.Frame(self.window) - header.grid(row=0, column=0, sticky="ew", padx=10, pady=10) - tk.Label(header, text="Index Ripper ttkbootstrap UI Smoke").pack(anchor="w") - - self.url_var = tk.StringVar(value="https://example.com/") - tk.Entry(header, textvariable=self.url_var).pack(fill=X, pady=(6, 0)) - - body = tk.Frame(self.window) - body.grid(row=1, column=0, sticky="nsew", padx=10, pady=(0, 10)) - body.columnconfigure(0, weight=1) - body.rowconfigure(1, weight=1) - - self.search_var = tk.StringVar() - self.search_entry = tk.Entry(body, textvariable=self.search_var) - self.search_entry.grid(row=0, column=0, sticky="ew") - - self.log_text = tk.Text(body, height=8, wrap="word") - self.log_text.grid(row=1, column=0, sticky="nsew", pady=(6, 0)) - self.log_text.insert("end", "UI smoke minimal view initialized.\n") - - self.panels_notebook = None - self.logs_tab = None - - def notify_info(self, title: str, message: str) -> None: - try: - messagebox.showinfo(title, message) - except Exception: - self.log_message(f"[INFO] {title}: {message}") - - def notify_warning(self, title: str, message: str) -> None: - try: - messagebox.showwarning(title, message) - except Exception: - self.log_message(f"[WARN] {title}: {message}") - - def notify_error(self, title: str, message: str) -> None: - try: - messagebox.showerror(title, message) - except Exception: - self.log_message(f"[ERROR] {title}: {message}") - - def log_message(self, message: str) -> None: - try: - self.log_text.insert(END, message + "\n") - self.log_text.see(END) - except tk.TclError: - pass - - def _set_status(self, text: str, style: str = "secondary") -> None: - try: - self.status_label.configure(text=text, bootstyle=style) - except tk.TclError: - pass - - def choose_download_path(self) -> None: - path = filedialog.askdirectory(title="Choose Download Location") - if path: - self.download_path = path - - def update_thread_count(self, _event=None) -> None: - try: - self.max_workers = int(self.threads_var.get()) - except ValueError: - self.max_workers = 5 - self.executor.shutdown(wait=False, cancel_futures=True) - self.executor = ThreadPoolExecutor(max_workers=self.max_workers) - - def toggle_pause(self) -> None: - if self.pause_event.is_set(): - self.pause_event.clear() - self.pause_btn.configure(text="Resume") - self.progress_label.configure(text="Downloads paused") - else: - self.pause_event.set() - self.pause_btn.configure(text="Pause") - - def toggle_scan_pause(self) -> None: - if self.scan_pause_event.is_set(): - self.scan_pause_event.clear() - self.scan_pause_btn.configure(text="Resume Scan") - self._set_status("Scan Paused", "warning") - else: - self.scan_pause_event.set() - self.scan_pause_btn.configure(text="Pause Scan") - self._set_status("Scanning", "info") - - def _run_on_ui_thread(self, callback, *args, wait: bool = False): - if not wait: - self.window.after(0, lambda: callback(*args)) - return - done = threading.Event() - error_box = {} - - def wrapped(): - try: - callback(*args) - except Exception as ex: - error_box["error"] = ex - finally: - done.set() - - self.window.after(0, wrapped) - done.wait() - if "error" in error_box: - raise error_box["error"] - - def _create_scrollable_container(self, parent, height: int): - canvas = tk.Canvas(parent, height=height, highlightthickness=0, borderwidth=0) - scrollbar = tk.Scrollbar(parent, orient="vertical", command=canvas.yview) - container = ttkb.Frame(canvas) - - window_id = canvas.create_window((0, 0), window=container, anchor="nw") - - def _on_container_configure(_event): - canvas.configure(scrollregion=canvas.bbox("all")) - - def _on_canvas_configure(event): - canvas.itemconfigure(window_id, width=event.width) - - container.bind("", _on_container_configure) - canvas.bind("", _on_canvas_configure) - canvas.configure(yscrollcommand=scrollbar.set) - - canvas.pack(side=LEFT, fill=X, expand=True) - scrollbar.pack(side=RIGHT, fill=Y) - return canvas, container - - def on_scan_started(self, url): - self._run_on_ui_thread(self._on_scan_started_ui, url, wait=True) - - def _on_scan_started_ui(self, _url): - self.is_scanning = True - self.scanned_urls = 0 - self.total_urls = 0 - self.scan_pause_btn.configure(state="normal", text="Pause Scan") - self.scan_btn.configure(text="Stop Scan") - self._set_status("Scanning", "info") - - self._cancel_scan_item_flush() - while not self.scan_item_buffer.empty(): - self.scan_item_buffer.get() - while not self.dir_queue.empty(): - self.dir_queue.get() - while not self.file_queue.empty(): - self.file_queue.get() - self.is_processing_dirs = False - self.is_processing_files = False - - with self.files_dict_lock: - self.files_dict.clear() - with self.folders_dict_lock: - self.folders.clear() - - for item in self.tree.get_children(""): - self.tree.delete(item) - self.checked_items.clear() - - self.file_types.clear() - self.file_type_counts.clear() - for widget in self.filters_container.winfo_children(): - widget.destroy() - - self.progress_var.set(0) - self.progress_label.configure(text="Preparing...") - - def on_scan_progress(self, scanned_urls, total_urls): - self._run_on_ui_thread(self._on_scan_progress_ui, scanned_urls, total_urls) - - def _on_scan_progress_ui(self, scanned_urls, total_urls): - self.scanned_urls = scanned_urls - self.total_urls = total_urls - self.update_scan_progress() - - def on_scan_item(self, **payload): - self._run_on_ui_thread(self._on_scan_item_ui, payload) - - def _on_scan_item_ui(self, payload: dict): - if not self.is_scanning: - return - self.scan_item_buffer.put( - ( - bool(payload.get("is_directory")), - payload.get("path"), - payload.get("url"), - payload.get("file_name"), - payload.get("size"), - payload.get("file_type"), - payload.get("full_path"), - ) - ) - self._schedule_scan_item_flush() - - def _schedule_scan_item_flush(self): - if self.scan_flush_job is not None: - return - if not self.is_scanning: - return - self.scan_flush_job = self.window.after( - self.scan_flush_interval_ms, self._flush_scan_item_buffer - ) - - def _cancel_scan_item_flush(self): - if self.scan_flush_job is None: - return - try: - self.window.after_cancel(self.scan_flush_job) - except tk.TclError: - pass - finally: - self.scan_flush_job = None - - def _flush_scan_item_buffer(self, max_items=None, reschedule=True): - self.scan_flush_job = None - if max_items is None: - max_items = self.scan_flush_batch_size - - processed = 0 - added_dir = False - added_file = False - - while processed < max_items: - try: - ( - is_directory, - path, - url, - file_name, - size, - file_type, - full_path, - ) = self.scan_item_buffer.get_nowait() - except Empty: - break - - if is_directory: - with self.folders_dict_lock: - self.dir_queue.put((path, url)) - added_dir = True - else: - self.file_queue.put((path, url, file_name, size, file_type, full_path)) - added_file = True - processed += 1 - - if added_dir and not self.is_processing_dirs: - self.window.after(0, self.process_dir_queue) - if added_file and not self.is_processing_files: - self.window.after(0, self.process_file_queue) - - if reschedule and self.is_scanning and not self.scan_item_buffer.empty(): - self._schedule_scan_item_flush() - - def on_scan_finished(self, stopped): - self._run_on_ui_thread(self._on_scan_finished_ui, stopped) - - def _on_scan_finished_ui(self, stopped): - self.is_scanning = False - self._cancel_scan_item_flush() - while not self.scan_item_buffer.empty(): - self._flush_scan_item_buffer(max_items=self.scan_flush_batch_size, reschedule=False) - - self.scan_btn.configure(text="Scan") - self.scan_pause_btn.configure(state="disabled", text="Pause Scan") - self.scan_pause_event.set() - - if stopped: - self.progress_label.configure(text="Scan stopped.") - self._set_status("Ready", "success") - self.log_message("[Scan] Stopped by user.") - else: - self.progress_label.configure(text="Scan finished.") - self._set_status("Ready", "success") - self.log_message("[Scan] Finished.") - - def update_scan_progress(self): - if self.total_urls <= 0: - self.progress_var.set(0) - return - pct = (self.scanned_urls / self.total_urls) * 100 - self.progress_var.set(pct) - self.progress_label.configure(text=f"Scan: {self.scanned_urls}/{self.total_urls}") - - def create_folder_structure(self, dir_path: str, url: str) -> str: - if not dir_path: - dir_path = "/" - parts = [p for p in dir_path.split("/") if p] - - current = "" - parent_id = "" - for part in parts: - current = current + "/" + part - with self.folders_dict_lock: - existing = self.folders.get(current) - if existing: - parent_id = existing - continue - - node_id = self.tree.insert(parent_id, "end", text=part, values=("", "dir"), tags=("folder",)) - with self.folders_dict_lock: - self.folders[current] = node_id - parent_id = node_id - - return parent_id - - def process_dir_queue(self): - try: - self.is_processing_dirs = True - if not self.dir_queue.empty(): - dir_path, url = self.dir_queue.get() - self.create_folder_structure(dir_path, url) - self.window.after(10, self.process_dir_queue) - else: - self.is_processing_dirs = False - except tk.TclError: - self.is_processing_dirs = False - - def process_file_queue(self): - try: - self.is_processing_files = True - if not self.file_queue.empty(): - ( - dir_path, - url, - file_name, - size, - file_type, - full_path, - ) = self.file_queue.get() - self.update_file_types(file_name) - self._add_file_to_tree(dir_path, url, file_name, size, file_type, full_path) - self.window.after(5, self.process_file_queue) - else: - self.is_processing_files = False - except tk.TclError: - self.is_processing_files = False - - def _add_file_to_tree(self, dir_path, url, file_name, size, file_type, full_path): - if not file_name: - return - parent_id = self.create_folder_structure(dir_path, url) - with self.files_dict_lock: - if full_path in self.files_dict: - return - self.files_dict[full_path] = { - "url": url, - "file_name": file_name, - "size": size, - "file_type": file_type, - "path": dir_path, - } - - ext = normalize_extension(file_name) - if not self.filter_file_type(file_name): - return - - node_id = self.tree.insert( - parent_id, - "end", - text=file_name, - values=(size or "", file_type or ""), - tags=("file", ext), - ) - if full_path: - self.tree.set(node_id, "#0", file_name) - - def filter_file_type(self, file_name: str) -> bool: - ext = normalize_extension(file_name) - var = self.file_types.get(ext) - if var is None: - return True - return bool(var.get()) - - def update_file_types(self, file_name: str) -> None: - ext = normalize_extension(file_name) - self.file_type_counts[ext] = self.file_type_counts.get(ext, 0) + 1 - if ext in self.file_types: - return - - var = tk.BooleanVar(value=True) - self.file_types[ext] = var - cb = ttkb.Checkbutton( - self.filters_container, - text=f"{ext} ({self.file_type_counts[ext]})", - variable=var, - bootstyle="round-toggle", - command=self.apply_filters, - ) - cb.pack(side=LEFT, padx=6) - - def apply_filters(self) -> None: - for item in self.tree.get_children(""): - self.tree.delete(item) - with self.folders_dict_lock: - self.folders.clear() - - with self.files_dict_lock: - items = list(self.files_dict.items()) - for full_path, info in items: - file_name = info.get("file_name") - if not file_name: - continue - if not self.filter_file_type(file_name): - continue - self._add_file_to_tree(info.get("path") or "", info.get("url") or "", file_name, info.get("size"), info.get("file_type"), full_path) - - def clear_scan_results(self) -> None: - if self.is_scanning: - return - for item in self.tree.get_children(""): - self.tree.delete(item) - with self.files_dict_lock: - self.files_dict.clear() - with self.folders_dict_lock: - self.folders.clear() - for widget in self.filters_container.winfo_children(): - widget.destroy() - self.file_types.clear() - self.file_type_counts.clear() - self.progress_var.set(0) - self.progress_label.configure(text="") - self._set_status("Ready", "success") - - def start_scan(self) -> None: - url = self.url_var.get().strip() - if self.is_scanning: - self.backend.should_stop = True - self.backend.should_stop = True - self._set_status("Stopping", "warning") - return - if not url: - self.notify_error("Error", "Please enter a URL") - return - - self.backend.should_stop = False - self.backend.should_stop = False - try: - self.download_path = default_download_folder(url, os.getcwd()) - except Exception: - self.download_path = os.path.join(os.getcwd(), "downloads") - - t = threading.Thread(target=self.backend.scan_website, args=(url,), daemon=True) - t.start() - - def _ensure_download_item(self, file_path: str, display_name: str): - item = self.download_items.get(file_path) - if item: - return item["cancel_event"] - - row = ttkb.Frame(self.downloads_container) - row.pack(fill=X, pady=3) - name = ttkb.Label(row, text=display_name) - name.pack(side=LEFT, padx=(0, 8)) - bar_var = tk.DoubleVar(value=0) - bar = ttkb.Progressbar(row, variable=bar_var, maximum=100, bootstyle="info") - bar.pack(side=LEFT, fill=X, expand=True) - status = ttkb.Label(row, text="Queued") - status.pack(side=LEFT, padx=8) - cancel_event = threading.Event() - - def do_cancel(): - cancel_event.set() - try: - status.configure(text="Canceling...") - except tk.TclError: - pass - - cancel_btn = ttkb.Button(row, text="Cancel", bootstyle="danger", width=8, command=do_cancel) - cancel_btn.pack(side=RIGHT, padx=(8, 0)) - - self.download_items[file_path] = { - "frame": row, - "bar_var": bar_var, - "status": status, - "cancel_event": cancel_event, - } - return cancel_event - - def update_progress(self, file_path, file_name, progress): - self._run_on_ui_thread(self._update_progress_ui, file_path, file_name, progress) - - def _update_progress_ui(self, file_path, file_name, progress): - item = self.download_items.get(file_path) - if item: - try: - item["bar_var"].set(progress) - item["status"].configure(text=f"Downloading {progress:.1f}%") - except tk.TclError: - pass - return - - self.progress_var.set(progress) - self.progress_label.configure(text=f"Downloading: {file_name} ({progress:.1f}%)") - - def update_download_status(self, file_path, status_text): - self._run_on_ui_thread(self._update_download_status_ui, file_path, status_text) - - def _update_download_status_ui(self, file_path, status_text): - item = self.download_items.get(file_path) - if not item: - return - try: - item["status"].configure(text=status_text) - except tk.TclError: - pass - - def on_downloads_finished(self, completed, total): - self._run_on_ui_thread(self.downloads_completed, completed, total) - - def downloads_completed(self, completed, total): - self.pause_btn.configure(state="disabled") - self.progress_var.set(0) - self.progress_label.configure(text=f"Downloads: {completed}/{total}") - self._set_status("Ready", "success") - - def download_selected(self) -> None: - if not self.download_path: - url = self.url_var.get().strip() - if url: - try: - self.download_path = default_download_folder(url, os.getcwd()) - except Exception: - self.download_path = os.path.join(os.getcwd(), "downloads") - else: - self.download_path = os.path.join(os.getcwd(), "downloads") - - os.makedirs(self.download_path, exist_ok=True) - - selected = [] - with self.files_dict_lock: - for full_path, info in self.files_dict.items(): - selected.append((full_path, info)) - - if not selected: - self.notify_info("Info", "No files to download") - return - - self.pause_btn.configure(state="normal") - futures = [] - for full_path, info in selected: - url = info.get("url") - file_name = info.get("file_name") - if not url or not file_name: - continue - - safe_name = sanitize_filename(file_name) - file_path = safe_join(self.download_path, [safe_name]) - - cancel_event = self._ensure_download_item(file_path, safe_name) - futures.append(self.executor.submit(self.backend.download_file, url, file_path, safe_name, cancel_event)) - - monitor = threading.Thread(target=self.backend.monitor_downloads, args=(futures,), daemon=True) - monitor.start() - - def apply_search_filter(self) -> None: - term = (self.search_var.get() or "").strip().lower() - if not term: - if self.full_tree_backup: - self._restore_full_tree() - return - - if not self.full_tree_backup: - self._backup_full_tree() - self._filter_tree_by_term(term) - - def _backup_full_tree(self) -> None: - self.full_tree_backup.clear() - for item in self._all_tree_items(): - self.full_tree_backup[item] = { - "parent": self.tree.parent(item), - "index": self.tree.index(item), - "text": self.tree.item(item, "text"), - "values": self.tree.item(item, "values"), - "tags": self.tree.item(item, "tags"), - "open": self.tree.item(item, "open"), - } - - def _restore_full_tree(self) -> None: - backup = dict(self.full_tree_backup) - self.full_tree_backup.clear() - for item in self.tree.get_children(""): - self.tree.delete(item) - with self.folders_dict_lock: - self.folders.clear() - - items_sorted = sorted(backup.items(), key=lambda kv: (kv[1]["parent"], kv[1]["index"])) - id_map = {} - for old_id, data in items_sorted: - parent_old = data["parent"] - parent_new = id_map.get(parent_old, "") if parent_old else "" - new_id = self.tree.insert(parent_new, "end", text=data["text"], values=data["values"], tags=data["tags"]) - self.tree.item(new_id, open=data["open"]) - id_map[old_id] = new_id - - def _filter_tree_by_term(self, term: str) -> None: - for item in self._all_tree_items(): - text = (self.tree.item(item, "text") or "").lower() - if term in text: - continue - try: - self.tree.detach(item) - except tk.TclError: - pass - - def _all_tree_items(self): - out = [] - stack = list(self.tree.get_children("")) - while stack: - item = stack.pop() - out.append(item) - stack.extend(self.tree.get_children(item)) - return out - - def focus_search(self, _event=None): - try: - self.search_entry.focus_set() - except tk.TclError: - pass - - def focus_logs(self, _event=None): - try: - if self.panels_notebook is not None and self.logs_tab is not None: - self.panels_notebook.select(self.logs_tab) - self.log_text.focus_set() - except (AttributeError, tk.TclError): - pass - - def clear_search(self, _event=None): - self.search_var.set("") - - def sort_tree(self, col): - try: - items = [(self.tree.set(i, col), i) for i in self.tree.get_children("")] - items.sort(reverse=self.sort_reverse) - for index, (_, i) in enumerate(items): - self.tree.move(i, "", index) - self.sort_reverse = not self.sort_reverse - except tk.TclError: - pass - - def on_closing(self): - try: - self.backend.should_stop = True - self.executor.shutdown(wait=False, cancel_futures=True) - except Exception: - pass - try: - self.window.destroy() - except tk.TclError: - pass - - def run(self) -> None: - if self._ui_smoke: - try: - for _ in range(3): - self.window.update_idletasks() - self.window.update() - finally: - try: - self.window.destroy() - except tk.TclError: - pass - return - self.window.mainloop() diff --git a/uv.lock b/uv.lock index 9d878e4..bad854e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,2 +1,258 @@ -# This lock file is optional when using uv with requirements.txt. -# Present here as a placeholder; uv will resolve from requirements.txt. +version = 1 +revision = 1 +requires-python = ">=3.11" + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721 }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531 }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006 }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085 }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545 }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863 }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827 }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085 }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688 }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077 }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706 }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665 }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950 }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830 }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029 }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404 }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796 }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976 }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356 }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369 }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285 }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274 }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715 }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426 }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780 }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805 }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342 }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661 }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819 }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080 }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630 }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856 }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982 }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788 }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890 }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136 }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551 }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572 }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438 }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035 }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340 }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464 }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014 }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297 }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321 }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509 }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284 }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630 }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254 }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232 }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688 }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833 }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879 }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764 }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728 }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937 }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040 }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107 }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310 }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918 }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615 }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784 }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009 }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511 }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775 }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "customtkinter" +version = "5.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "darkdetect" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/48/c5a9d44188c44702e1e3db493c741e9c779596835a761b819fe15431d163/customtkinter-5.2.2.tar.gz", hash = "sha256:fd8db3bafa961c982ee6030dba80b4c2e25858630756b513986db19113d8d207", size = 261999 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/b1/b43b33001a77256b335511e75f257d001082350b8506c8807f30c98db052/customtkinter-5.2.2-py3-none-any.whl", hash = "sha256:14ad3e7cd3cb3b9eb642b9d4e8711ae80d3f79fb82545ad11258eeffb2e6b37c", size = 296062 }, +] + +[[package]] +name = "darkdetect" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/77/7575be73bf12dee231d0c6e60ce7fb7a7be4fcd58823374fc59a6e48262e/darkdetect-0.8.0.tar.gz", hash = "sha256:b5428e1170263eb5dea44c25dc3895edd75e6f52300986353cd63533fe7df8b1", size = 7681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl", hash = "sha256:a7509ccf517eaad92b31c214f593dbcf138ea8a43b2935406bbd565e15527a85", size = 8955 }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "index-ripper" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "customtkinter" }, + { name = "requests" }, + { name = "urllib3" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.12.0,<5.0.0" }, + { name = "customtkinter", specifier = ">=5.2.0,<6.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "requests", specifier = ">=2.31.0,<3.0.0" }, + { name = "urllib3", specifier = ">=2.0.0,<3.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, +]