diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..db07a616 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "npx pretty-quick" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..1844a897 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,34 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install:*)", + "Bash(npm uninstall:*)", + "Bash(yarn build)", + "Bash(rg:*)", + "Bash(npm run lint)", + "WebFetch(domain:github.com)", + "WebFetch(domain:docs.dndkit.com)", + "WebFetch(domain:stackoverflow.com)", + "Bash(yarn lint)", + "Bash(yarn dev)", + "Bash(pkill:*)", + "Bash(node:*)", + "Bash(rm:*)", + "Bash(grep:*)", + "WebFetch(domain:react-hook-form.com)", + "Bash(cat:*)", + "Bash(yarn add:*)", + "Bash(mv:*)", + "Bash(mkdir:*)", + "Bash(sed:*)", + "Bash(npx tsc:*)", + "Bash(find:*)", + "Bash(yarn lint:*)", + "Bash(yarn eslint:*)", + "Bash(npx eslint:*)", + "WebFetch(domain:www.npmjs.com)", + "Bash(ls:*)" + ], + "deny": [] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..266d9572 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +- `yarn dev` - Start development mode with Vite +- `yarn build` - Build the extension (runs TypeScript compilation + Vite build) +- `yarn lint` - Run ESLint to check code quality +- `yarn zip` - Create a distributable extension zip file from the built dist folder + +## Architecture Overview + +This is a Chrome Extension (Manifest V3) called **Selection Command** that allows users to perform various actions on selected text on web pages. + +### Key Components + +**Chrome Extension Structure:** + +- `manifest.json` - Extension manifest defining permissions, content scripts, and background workers +- `src/background_script.ts` - Service worker handling extension lifecycle and background operations +- `src/content_script.tsx` - Main content script injected into web pages +- `src/options_page.tsx` - Extension options/settings page + +**Core Architecture:** + +- **Actions** (`src/action/`) - Core functionality modules including background operations, popup handling, page actions, and command execution +- **Components** (`src/components/`) - React components organized by feature: + - `menu/` - Context menu and menu item components + - `option/` - Settings and configuration UI + - `pageAction/` - Page automation and recording components + - `result/` - Result display and popup components + - `ui/` - Reusable UI components (uses Radix UI) +- **Services** (`src/services/`) - Business logic and utilities including settings management, storage, analytics, and page action handling +- **Hooks** (`src/hooks/`) - Custom React hooks for state management and Chrome extension APIs + +**Key Features:** + +- **Page Actions** - Record and replay browser automation sequences +- **Command Hub** - Web interface for sharing and discovering commands (separate Next.js app in `pages/`) +- **Context Menus** - Right-click actions on selected text +- **Settings Management** - Import/export configurations and user preferences + +### Technical Stack + +- **Frontend**: React 18 with TypeScript +- **Build System**: Vite with `@crxjs/vite-plugin` for Chrome extension development +- **UI Components**: Shadcn +- **Form and Validation**: react-hook-form and zod +- **Styling**: CSS Modules + Tailwind CSS(ver.3) +- **State Management**: React hooks with Chrome extension storage APIs +- **Testing**: ESLint for code quality + +### Project Structure Notes + +- The main extension code is in `src/` +- The command hub website is a separate Next.js application in `pages/` +- Extension supports internationalization with locale files in `public/_locales/` +- Uses Shadow DOM for content script styling isolation +- Implements Robula+ algorithm for robust XPath selector generation (`src/lib/robula-plus/`) diff --git a/package.json b/package.json index 0f449b61..a0c10b5b 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,11 @@ "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.2", - "@radix-ui/react-menubar": "^1.1.2", + "@radix-ui/react-menubar": "^1.1.15", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-toggle": "^1.1.6", @@ -40,6 +42,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.58.1", + "react-multi-progress": "^1.3.0", "react-textarea-autosize": "^8.5.3", "react-transition-group": "^4.4.5", "sonner": "github:ujiro99/sonner", @@ -72,8 +75,8 @@ "husky": "^9.1.7", "npm-build-zip": "^1.0.4", "postcss": "^8.4.49", - "prettier": "^3.3.3", - "pretty-quick": "^4.0.0", + "prettier": "^3.6.2", + "pretty-quick": "^4.2.2", "tailwindcss": "^3.4.17", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", @@ -91,7 +94,7 @@ }, "prettier": { "semi": false, - "singleQuote": true, + "singleQuote": false, "tabWidth": 2, "trailingComma": "all" }, diff --git a/public/_locales/de/messages.json b/public/_locales/de/messages.json index 1d824a1f..3fcf4d6e 100644 --- a/public/_locales/de/messages.json +++ b/public/_locales/de/messages.json @@ -781,5 +781,62 @@ }, "clipboard_error_action": { "message": "OK" + }, + "Option_RestoreFromBackup": { + "message": "Aus Backup wiederherstellen" + }, + "Option_RestoreFromBackup_checking": { + "message": "Backups werden überprüft..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "Kein Backup verfügbar" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "Befehle aus Backup wiederherstellen" + }, + "Option_RestoreFromBackup_no_data": { + "message": "Keine Backup-Daten gefunden." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "Aus Backup wiederherstellen" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "Keine Backup-Daten verfügbar." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "Wählen Sie ein Backup zum Wiederherstellen:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "Wiederherstellen" + }, + "Option_RestoreFromBackup_legacy": { + "message": "Legacy-Migration" + }, + "Option_RestoreFromBackup_daily": { + "message": "Tägliches Backup" + }, + "Option_RestoreFromBackup_weekly": { + "message": "Wöchentliches Backup" + }, + "Option_RestoreFromBackup_created": { + "message": "Erstellt:" + }, + "Option_RestoreFromBackup_commands": { + "message": "Befehle:" + }, + "Option_RestoreFromBackup_folders": { + "message": "Ordner:" + }, + "Option_RestoreFromBackup_items": { + "message": "Elemente" + }, + "Option_RestoreFromBackup_warning": { + "message": "Warnung:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "Dies ersetzt alle aktuellen Befehle durch die Backup-Daten." + }, + "Option_RestoreFromBackup_failed": { + "message": "Wiederherstellung aus Backup fehlgeschlagen." } } diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 328a1282..7ece6283 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -302,6 +302,15 @@ "Option_parentFolderId": { "message": "Folder" }, + "Option_parentFolder": { + "message": "Parent Folder" + }, + "Option_parentFolder_desc": { + "message": "Select the parent folder" + }, + "Option_rootFolder": { + "message": "Root (no parent)" + }, "Option_copyOption": { "message": "Copy format" }, @@ -784,5 +793,62 @@ "clipboard_error_action": { "message": "OK", "description": "Action button for clipboard error" + }, + "Option_RestoreFromBackup": { + "message": "Restore from Backup" + }, + "Option_RestoreFromBackup_checking": { + "message": "Checking backups..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "No backup available" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "Restore commands from backup" + }, + "Option_RestoreFromBackup_no_data": { + "message": "No backup data found." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "Restore from Backup" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "No backup data available." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "Select a backup to restore:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "Restore" + }, + "Option_RestoreFromBackup_legacy": { + "message": "Legacy Migration" + }, + "Option_RestoreFromBackup_daily": { + "message": "Daily Backup" + }, + "Option_RestoreFromBackup_weekly": { + "message": "Weekly Backup" + }, + "Option_RestoreFromBackup_created": { + "message": "Created:" + }, + "Option_RestoreFromBackup_commands": { + "message": "Commands:" + }, + "Option_RestoreFromBackup_folders": { + "message": "Folders:" + }, + "Option_RestoreFromBackup_items": { + "message": "items" + }, + "Option_RestoreFromBackup_warning": { + "message": "Warning:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "This will replace all current commands with the backup data." + }, + "Option_RestoreFromBackup_failed": { + "message": "Failed to restore from backup." } } diff --git a/public/_locales/es/messages.json b/public/_locales/es/messages.json index bd3f7a16..8b546eda 100644 --- a/public/_locales/es/messages.json +++ b/public/_locales/es/messages.json @@ -781,5 +781,62 @@ }, "clipboard_error_action": { "message": "OK" + }, + "Option_RestoreFromBackup": { + "message": "Restaurar desde Respaldo" + }, + "Option_RestoreFromBackup_checking": { + "message": "Verificando respaldos..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "Sin respaldo disponible" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "Restaurar comandos desde respaldo" + }, + "Option_RestoreFromBackup_no_data": { + "message": "No se encontraron datos de respaldo." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "Restaurar desde Respaldo" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "No hay datos de respaldo disponibles." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "Seleccione un respaldo para restaurar:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "Restaurar" + }, + "Option_RestoreFromBackup_legacy": { + "message": "Migración Heredada" + }, + "Option_RestoreFromBackup_daily": { + "message": "Respaldo Diario" + }, + "Option_RestoreFromBackup_weekly": { + "message": "Respaldo Semanal" + }, + "Option_RestoreFromBackup_created": { + "message": "Creado:" + }, + "Option_RestoreFromBackup_commands": { + "message": "Comandos:" + }, + "Option_RestoreFromBackup_folders": { + "message": "Carpetas:" + }, + "Option_RestoreFromBackup_items": { + "message": "elementos" + }, + "Option_RestoreFromBackup_warning": { + "message": "Advertencia:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "Esto reemplazará todos los comandos actuales con los datos del respaldo." + }, + "Option_RestoreFromBackup_failed": { + "message": "Error al restaurar desde respaldo." } } diff --git a/public/_locales/fr/messages.json b/public/_locales/fr/messages.json index 191f5526..3698edcf 100644 --- a/public/_locales/fr/messages.json +++ b/public/_locales/fr/messages.json @@ -781,5 +781,62 @@ }, "clipboard_error_action": { "message": "OK" + }, + "Option_RestoreFromBackup": { + "message": "Restaurer depuis Sauvegarde" + }, + "Option_RestoreFromBackup_checking": { + "message": "Vérification des sauvegardes..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "Aucune sauvegarde disponible" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "Restaurer les commandes depuis la sauvegarde" + }, + "Option_RestoreFromBackup_no_data": { + "message": "Aucune donnée de sauvegarde trouvée." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "Restaurer depuis Sauvegarde" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "Aucune donnée de sauvegarde disponible." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "Sélectionnez une sauvegarde à restaurer :" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "Restaurer" + }, + "Option_RestoreFromBackup_legacy": { + "message": "Migration Héritée" + }, + "Option_RestoreFromBackup_daily": { + "message": "Sauvegarde Quotidienne" + }, + "Option_RestoreFromBackup_weekly": { + "message": "Sauvegarde Hebdomadaire" + }, + "Option_RestoreFromBackup_created": { + "message": "Créé :" + }, + "Option_RestoreFromBackup_commands": { + "message": "Commandes :" + }, + "Option_RestoreFromBackup_folders": { + "message": "Dossiers :" + }, + "Option_RestoreFromBackup_items": { + "message": "éléments" + }, + "Option_RestoreFromBackup_warning": { + "message": "Attention :" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "Ceci remplacera toutes les commandes actuelles par les données de sauvegarde." + }, + "Option_RestoreFromBackup_failed": { + "message": "Échec de la restauration depuis la sauvegarde." } } diff --git a/public/_locales/hi/messages.json b/public/_locales/hi/messages.json index 5b033ed0..fbdd412b 100644 --- a/public/_locales/hi/messages.json +++ b/public/_locales/hi/messages.json @@ -781,5 +781,62 @@ }, "clipboard_error_action": { "message": "ठीक है" + }, + "Option_RestoreFromBackup": { + "message": "बैकअप से पुनर्स्थापना" + }, + "Option_RestoreFromBackup_checking": { + "message": "बैकअप जांच रहे हैं..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "कोई बैकअप उपलब्ध नहीं" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "बैकअप से कमांड पुनर्स्थापित करें" + }, + "Option_RestoreFromBackup_no_data": { + "message": "कोई बैकअप डेटा नहीं मिला।" + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "बैकअप से पुनर्स्थापना" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "कोई बैकअप डेटा उपलब्ध नहीं।" + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "पुनर्स्थापित करने के लिए बैकअप चुनें:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "पुनर्स्थापना" + }, + "Option_RestoreFromBackup_legacy": { + "message": "लेगेसी माइग्रेशन" + }, + "Option_RestoreFromBackup_daily": { + "message": "दैनिक बैकअप" + }, + "Option_RestoreFromBackup_weekly": { + "message": "साप्ताहिक बैकअप" + }, + "Option_RestoreFromBackup_created": { + "message": "बनाया गया:" + }, + "Option_RestoreFromBackup_commands": { + "message": "कमांड:" + }, + "Option_RestoreFromBackup_folders": { + "message": "फ़ोल्डर:" + }, + "Option_RestoreFromBackup_items": { + "message": "आइटम" + }, + "Option_RestoreFromBackup_warning": { + "message": "चेतावनी:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "यह सभी वर्तमान कमांड को बैकअप डेटा से बदल देगा।" + }, + "Option_RestoreFromBackup_failed": { + "message": "बैकअप से पुनर्स्थापना विफल।" } } diff --git a/public/_locales/id/messages.json b/public/_locales/id/messages.json index 35eb128f..0f74e261 100644 --- a/public/_locales/id/messages.json +++ b/public/_locales/id/messages.json @@ -784,5 +784,62 @@ "clipboard_error_action": { "message": "OK", "description": "Action button for clipboard error" + }, + "Option_RestoreFromBackup": { + "message": "Pulihkan dari Cadangan" + }, + "Option_RestoreFromBackup_checking": { + "message": "Memeriksa cadangan..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "Tidak ada cadangan tersedia" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "Pulihkan perintah dari cadangan" + }, + "Option_RestoreFromBackup_no_data": { + "message": "Data cadangan tidak ditemukan." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "Pulihkan dari Cadangan" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "Tidak ada data cadangan tersedia." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "Pilih cadangan untuk dipulihkan:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "Pulihkan" + }, + "Option_RestoreFromBackup_legacy": { + "message": "Migrasi Warisan" + }, + "Option_RestoreFromBackup_daily": { + "message": "Cadangan Harian" + }, + "Option_RestoreFromBackup_weekly": { + "message": "Cadangan Mingguan" + }, + "Option_RestoreFromBackup_created": { + "message": "Dibuat:" + }, + "Option_RestoreFromBackup_commands": { + "message": "Perintah:" + }, + "Option_RestoreFromBackup_folders": { + "message": "Folder:" + }, + "Option_RestoreFromBackup_items": { + "message": "item" + }, + "Option_RestoreFromBackup_warning": { + "message": "Peringatan:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "Ini akan mengganti semua perintah saat ini dengan data cadangan." + }, + "Option_RestoreFromBackup_failed": { + "message": "Gagal memulihkan dari cadangan." } } diff --git a/public/_locales/it/messages.json b/public/_locales/it/messages.json index 43e74ae6..94363fc4 100644 --- a/public/_locales/it/messages.json +++ b/public/_locales/it/messages.json @@ -781,5 +781,62 @@ }, "clipboard_error_action": { "message": "OK" + }, + "Option_RestoreFromBackup": { + "message": "Ripristina da Backup" + }, + "Option_RestoreFromBackup_checking": { + "message": "Controllo backup in corso..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "Nessun backup disponibile" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "Ripristina comandi da backup" + }, + "Option_RestoreFromBackup_no_data": { + "message": "Nessun dato di backup trovato." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "Ripristina da Backup" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "Nessun dato di backup disponibile." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "Seleziona un backup da ripristinare:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "Ripristina" + }, + "Option_RestoreFromBackup_legacy": { + "message": "Migrazione Legacy" + }, + "Option_RestoreFromBackup_daily": { + "message": "Backup Giornaliero" + }, + "Option_RestoreFromBackup_weekly": { + "message": "Backup Settimanale" + }, + "Option_RestoreFromBackup_created": { + "message": "Creato:" + }, + "Option_RestoreFromBackup_commands": { + "message": "Comandi:" + }, + "Option_RestoreFromBackup_folders": { + "message": "Cartelle:" + }, + "Option_RestoreFromBackup_items": { + "message": "elementi" + }, + "Option_RestoreFromBackup_warning": { + "message": "Attenzione:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "Questo sostituirà tutti i comandi attuali con i dati del backup." + }, + "Option_RestoreFromBackup_failed": { + "message": "Ripristino da backup fallito." } } diff --git a/public/_locales/ja/messages.json b/public/_locales/ja/messages.json index 863e323b..f31492ac 100644 --- a/public/_locales/ja/messages.json +++ b/public/_locales/ja/messages.json @@ -302,6 +302,15 @@ "Option_parentFolderId": { "message": "フォルダ" }, + "Option_parentFolder": { + "message": "親フォルダ" + }, + "Option_parentFolder_desc": { + "message": "親フォルダを選択してください" + }, + "Option_rootFolder": { + "message": "ルート(親なし)" + }, "Option_copyOption": { "message": "コピーフォーマット" }, @@ -778,5 +787,62 @@ }, "clipboard_error_action": { "message": "OK" + }, + "Option_RestoreFromBackup": { + "message": "バックアップから復元" + }, + "Option_RestoreFromBackup_checking": { + "message": "バックアップを確認中..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "バックアップなし" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "バックアップからコマンドを復元" + }, + "Option_RestoreFromBackup_no_data": { + "message": "バックアップデータが見つかりません。" + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "バックアップから復元" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "バックアップデータがありません。" + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "復元するバックアップを選択:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "復元" + }, + "Option_RestoreFromBackup_legacy": { + "message": "レガシー移行" + }, + "Option_RestoreFromBackup_daily": { + "message": "日次バックアップ" + }, + "Option_RestoreFromBackup_weekly": { + "message": "週次バックアップ" + }, + "Option_RestoreFromBackup_created": { + "message": "作成日時:" + }, + "Option_RestoreFromBackup_commands": { + "message": "コマンド:" + }, + "Option_RestoreFromBackup_folders": { + "message": "フォルダ:" + }, + "Option_RestoreFromBackup_items": { + "message": "件" + }, + "Option_RestoreFromBackup_warning": { + "message": "警告:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "現在のコマンドがすべてバックアップデータに置き換えられます。" + }, + "Option_RestoreFromBackup_failed": { + "message": "バックアップからの復元に失敗しました。" } } diff --git a/public/_locales/ko/messages.json b/public/_locales/ko/messages.json index cf16d85a..9997794f 100644 --- a/public/_locales/ko/messages.json +++ b/public/_locales/ko/messages.json @@ -781,5 +781,62 @@ }, "clipboard_error_action": { "message": "확인" + }, + "Option_RestoreFromBackup": { + "message": "백업에서 복원" + }, + "Option_RestoreFromBackup_checking": { + "message": "백업 확인 중..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "사용 가능한 백업 없음" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "백업에서 명령어 복원" + }, + "Option_RestoreFromBackup_no_data": { + "message": "백업 데이터를 찾을 수 없습니다." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "백업에서 복원" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "사용 가능한 백업 데이터가 없습니다." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "복원할 백업을 선택하세요:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "복원" + }, + "Option_RestoreFromBackup_legacy": { + "message": "레거시 마이그레이션" + }, + "Option_RestoreFromBackup_daily": { + "message": "일간 백업" + }, + "Option_RestoreFromBackup_weekly": { + "message": "주간 백업" + }, + "Option_RestoreFromBackup_created": { + "message": "생성일:" + }, + "Option_RestoreFromBackup_commands": { + "message": "명령어:" + }, + "Option_RestoreFromBackup_folders": { + "message": "폴더:" + }, + "Option_RestoreFromBackup_items": { + "message": "개" + }, + "Option_RestoreFromBackup_warning": { + "message": "경고:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "현재 모든 명령어가 백업 데이터로 교체됩니다." + }, + "Option_RestoreFromBackup_failed": { + "message": "백업에서 복원하지 못했습니다." } } diff --git a/public/_locales/ms/messages.json b/public/_locales/ms/messages.json index 984d2c84..c8b1640b 100644 --- a/public/_locales/ms/messages.json +++ b/public/_locales/ms/messages.json @@ -784,5 +784,62 @@ "clipboard_error_action": { "message": "OK", "description": "Action button for clipboard error" + }, + "Option_RestoreFromBackup": { + "message": "Pulihkan dari Sandaran" + }, + "Option_RestoreFromBackup_checking": { + "message": "Memeriksa sandaran..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "Tiada sandaran tersedia" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "Pulihkan arahan dari sandaran" + }, + "Option_RestoreFromBackup_no_data": { + "message": "Data sandaran tidak dijumpai." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "Pulihkan dari Sandaran" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "Tiada data sandaran tersedia." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "Pilih sandaran untuk dipulihkan:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "Pulihkan" + }, + "Option_RestoreFromBackup_legacy": { + "message": "Migrasi Legasi" + }, + "Option_RestoreFromBackup_daily": { + "message": "Sandaran Harian" + }, + "Option_RestoreFromBackup_weekly": { + "message": "Sandaran Mingguan" + }, + "Option_RestoreFromBackup_created": { + "message": "Dicipta:" + }, + "Option_RestoreFromBackup_commands": { + "message": "Arahan:" + }, + "Option_RestoreFromBackup_folders": { + "message": "Folder:" + }, + "Option_RestoreFromBackup_items": { + "message": "item" + }, + "Option_RestoreFromBackup_warning": { + "message": "Amaran:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "Ini akan menggantikan semua arahan semasa dengan data sandaran." + }, + "Option_RestoreFromBackup_failed": { + "message": "Gagal memulihkan dari sandaran." } } diff --git a/public/_locales/pt_BR/messages.json b/public/_locales/pt_BR/messages.json index b7dd5acf..c9b1aeb7 100644 --- a/public/_locales/pt_BR/messages.json +++ b/public/_locales/pt_BR/messages.json @@ -784,5 +784,62 @@ "clipboard_error_action": { "message": "OK", "description": "Action button for clipboard error" + }, + "Option_RestoreFromBackup": { + "message": "Restaurar do Backup" + }, + "Option_RestoreFromBackup_checking": { + "message": "Verificando backups..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "Nenhum backup disponível" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "Restaurar comandos do backup" + }, + "Option_RestoreFromBackup_no_data": { + "message": "Nenhum dado de backup encontrado." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "Restaurar do Backup" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "Nenhum dado de backup disponível." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "Selecione um backup para restaurar:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "Restaurar" + }, + "Option_RestoreFromBackup_legacy": { + "message": "Migração Legada" + }, + "Option_RestoreFromBackup_daily": { + "message": "Backup Diário" + }, + "Option_RestoreFromBackup_weekly": { + "message": "Backup Semanal" + }, + "Option_RestoreFromBackup_created": { + "message": "Criado:" + }, + "Option_RestoreFromBackup_commands": { + "message": "Comandos:" + }, + "Option_RestoreFromBackup_folders": { + "message": "Pastas:" + }, + "Option_RestoreFromBackup_items": { + "message": "itens" + }, + "Option_RestoreFromBackup_warning": { + "message": "Aviso:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "Isso substituirá todos os comandos atuais pelos dados do backup." + }, + "Option_RestoreFromBackup_failed": { + "message": "Falha ao restaurar do backup." } } diff --git a/public/_locales/pt_PT/messages.json b/public/_locales/pt_PT/messages.json index afd8b61a..918ab745 100644 --- a/public/_locales/pt_PT/messages.json +++ b/public/_locales/pt_PT/messages.json @@ -784,5 +784,62 @@ "clipboard_error_action": { "message": "OK", "description": "Action button for clipboard error" + }, + "Option_RestoreFromBackup": { + "message": "Restaurar da Cópia de Segurança" + }, + "Option_RestoreFromBackup_checking": { + "message": "A verificar cópias de segurança..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "Nenhuma cópia de segurança disponível" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "Restaurar comandos da cópia de segurança" + }, + "Option_RestoreFromBackup_no_data": { + "message": "Nenhum dado de cópia de segurança encontrado." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "Restaurar da Cópia de Segurança" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "Nenhum dado de cópia de segurança disponível." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "Selecione uma cópia de segurança para restaurar:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "Restaurar" + }, + "Option_RestoreFromBackup_legacy": { + "message": "Migração Legada" + }, + "Option_RestoreFromBackup_daily": { + "message": "Cópia de Segurança Diária" + }, + "Option_RestoreFromBackup_weekly": { + "message": "Cópia de Segurança Semanal" + }, + "Option_RestoreFromBackup_created": { + "message": "Criado:" + }, + "Option_RestoreFromBackup_commands": { + "message": "Comandos:" + }, + "Option_RestoreFromBackup_folders": { + "message": "Pastas:" + }, + "Option_RestoreFromBackup_items": { + "message": "itens" + }, + "Option_RestoreFromBackup_warning": { + "message": "Aviso:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "Isto substituirá todos os comandos actuais pelos dados da cópia de segurança." + }, + "Option_RestoreFromBackup_failed": { + "message": "Falha ao restaurar da cópia de segurança." } } diff --git a/public/_locales/ru/messages.json b/public/_locales/ru/messages.json index a7c17266..2f5c167a 100644 --- a/public/_locales/ru/messages.json +++ b/public/_locales/ru/messages.json @@ -781,5 +781,62 @@ }, "clipboard_error_action": { "message": "OK" + }, + "Option_RestoreFromBackup": { + "message": "Восстановить из резервной копии" + }, + "Option_RestoreFromBackup_checking": { + "message": "Проверка резервных копий..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "Нет доступных резервных копий" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "Восстановить команды из резервной копии" + }, + "Option_RestoreFromBackup_no_data": { + "message": "Данные резервной копии не найдены." + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "Восстановить из резервной копии" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "Нет доступных данных резервной копии." + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "Выберите резервную копию для восстановления:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "Восстановить" + }, + "Option_RestoreFromBackup_legacy": { + "message": "Унаследованная миграция" + }, + "Option_RestoreFromBackup_daily": { + "message": "Ежедневная резервная копия" + }, + "Option_RestoreFromBackup_weekly": { + "message": "Еженедельная резервная копия" + }, + "Option_RestoreFromBackup_created": { + "message": "Создано:" + }, + "Option_RestoreFromBackup_commands": { + "message": "Команды:" + }, + "Option_RestoreFromBackup_folders": { + "message": "Папки:" + }, + "Option_RestoreFromBackup_items": { + "message": "элементов" + }, + "Option_RestoreFromBackup_warning": { + "message": "Предупреждение:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "Это заменит все текущие команды данными из резервной копии." + }, + "Option_RestoreFromBackup_failed": { + "message": "Не удалось восстановить из резервной копии." } } diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index 4531b878..13db41c1 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -781,5 +781,62 @@ }, "clipboard_error_action": { "message": "确定" + }, + "Option_RestoreFromBackup": { + "message": "从备份恢复" + }, + "Option_RestoreFromBackup_checking": { + "message": "正在检查备份..." + }, + "Option_RestoreFromBackup_no_backup": { + "message": "无可用备份" + }, + "Option_RestoreFromBackup_tooltip": { + "message": "从备份恢复命令" + }, + "Option_RestoreFromBackup_no_data": { + "message": "未找到备份数据。" + }, + "Option_RestoreFromBackup_dialog_title": { + "message": "从备份恢复" + }, + "Option_RestoreFromBackup_dialog_no_data": { + "message": "无可用备份数据。" + }, + "Option_RestoreFromBackup_dialog_select": { + "message": "选择要恢复的备份:" + }, + "Option_RestoreFromBackup_dialog_restore": { + "message": "恢复" + }, + "Option_RestoreFromBackup_legacy": { + "message": "传统迁移" + }, + "Option_RestoreFromBackup_daily": { + "message": "每日备份" + }, + "Option_RestoreFromBackup_weekly": { + "message": "每周备份" + }, + "Option_RestoreFromBackup_created": { + "message": "创建时间:" + }, + "Option_RestoreFromBackup_commands": { + "message": "命令:" + }, + "Option_RestoreFromBackup_folders": { + "message": "文件夹:" + }, + "Option_RestoreFromBackup_items": { + "message": "项" + }, + "Option_RestoreFromBackup_warning": { + "message": "警告:" + }, + "Option_RestoreFromBackup_warning_message": { + "message": "这将用备份数据替换所有当前命令。" + }, + "Option_RestoreFromBackup_failed": { + "message": "从备份恢复失败。" } } diff --git a/renovate.json b/renovate.json index 3c80f928..b07b5798 100644 --- a/renovate.json +++ b/renovate.json @@ -1,7 +1,5 @@ { - "extends": [ - "config:base" - ], + "extends": ["config:base"], "automerge": true, "major": { "automerge": false diff --git a/src/action/pageAction.ts b/src/action/pageAction.ts index cd820e41..105535b6 100644 --- a/src/action/pageAction.ts +++ b/src/action/pageAction.ts @@ -2,7 +2,7 @@ import { Ipc, BgCommand } from '@/services/ipc' import { getScreenSize, getWindowPosition } from '@/services/screen' import { isValidString, isPageActionCommand } from '@/lib/utils' import { PAGE_ACTION_OPEN_MODE } from '@/const' -import { PopupOption } from '@/services/defaultSettings' +import { PopupOption } from '@/services/option/defaultSettings' import type { ExecuteCommandParams, UrlParam } from '@/types' import type { OpenAndRunProps } from '@/services/pageAction/background' diff --git a/src/action/popup.ts b/src/action/popup.ts index 25616c91..7de75849 100644 --- a/src/action/popup.ts +++ b/src/action/popup.ts @@ -2,7 +2,7 @@ import { Ipc, BgCommand } from '@/services/ipc' import { isValidString } from '@/lib/utils' import { getScreenSize, getWindowPosition } from '@/services/screen' import { POPUP_TYPE, SPACE_ENCODING } from '@/const' -import { PopupOption } from '@/services/defaultSettings' +import { PopupOption } from '@/services/option/defaultSettings' import type { ExecuteCommandParams } from '@/types' import type { OpenPopupProps } from '@/services/chrome' diff --git a/src/action/window.ts b/src/action/window.ts index 846bdf59..d7a56e12 100644 --- a/src/action/window.ts +++ b/src/action/window.ts @@ -2,7 +2,7 @@ import { Ipc, BgCommand } from '@/services/ipc' import { isValidString } from '@/lib/utils' import { getScreenSize, getWindowPosition } from '@/services/screen' import { POPUP_TYPE, SPACE_ENCODING } from '@/const' -import { PopupOption } from '@/services/defaultSettings' +import { PopupOption } from '@/services/option/defaultSettings' import type { OpenPopupProps } from '@/services/chrome' import type { ExecuteCommandParams } from '@/types' diff --git a/src/background_script.ts b/src/background_script.ts index 8450a2cd..54f5bf88 100644 --- a/src/background_script.ts +++ b/src/background_script.ts @@ -10,7 +10,7 @@ import { import { executeActionProps } from '@/services/contextMenus' import { Ipc, BgCommand, TabCommand } from '@/services/ipc' import { Settings } from '@/services/settings' -import { PopupOption, PopupPlacement } from '@/services/defaultSettings' +import { PopupOption, PopupPlacement } from '@/services/option/defaultSettings' import * as PageActionBackground from '@/services/pageAction/background' import { BgData } from '@/services/backgroundData' import { ContextMenu } from '@/services/contextMenus' @@ -496,8 +496,46 @@ chrome.runtime.onInstalled.addListener((details) => { // Set uninstall survey URL chrome.runtime.setUninstallURL(`${HUB_URL}/uninstall`) } + + // Check for daily backup on startup + checkAndPerformDailyBackup() + + // Check for weekly backup on startup + checkAndPerformWeeklyBackup() +}) + +chrome.runtime.onStartup.addListener(() => { + // Check for daily backup on browser startup + checkAndPerformDailyBackup() + + // Check for weekly backup on browser startup + checkAndPerformWeeklyBackup() }) +// Daily backup check function +const checkAndPerformDailyBackup = async () => { + try { + const dailyBackupManager = Storage.dailyBackupManager + if (await dailyBackupManager.shouldBackup()) { + await dailyBackupManager.performDailyBackup() + } + } catch (error) { + console.error('Failed to perform daily backup check:', error) + } +} + +// Weekly backup check function +const checkAndPerformWeeklyBackup = async () => { + try { + const weeklyBackupManager = Storage.weeklyBackupManager + if (await weeklyBackupManager.shouldBackup()) { + await weeklyBackupManager.performWeeklyBackup() + } + } catch (error) { + console.error('Failed to perform weekly backup check:', error) + } +} + Settings.addChangedListener(() => ContextMenu.init()) // for debug @@ -529,12 +567,12 @@ chrome.commands.onCommand.addListener(async (commandName) => { return } - let enableSendTab = + const enableSendTab = tab?.id && !tab.url?.startsWith('chrome') && !tab.url?.includes('chromewebstore.google.com') - let selectionText = await Storage.get( + const selectionText = await Storage.get( SESSION_STORAGE_KEY.SELECTION_TEXT, ) diff --git a/src/command_hub.tsx b/src/command_hub.tsx index 9dd3a0c7..ce590f4f 100644 --- a/src/command_hub.tsx +++ b/src/command_hub.tsx @@ -11,7 +11,7 @@ const insertCss = (elm: ShadowRoot) => { fetch(url) .then((res) => res.text()) .then((css) => { - let style = document.createElement('style') + const style = document.createElement('style') style.append(document.createTextNode(css)) elm.insertBefore(style, elm.firstChild) }) diff --git a/src/components/Popup.module.css b/src/components/Popup.module.css index 50677169..4aed3138 100644 --- a/src/components/Popup.module.css +++ b/src/components/Popup.module.css @@ -19,7 +19,8 @@ --background: var(--sc-bg-color-h) var(--sc-bg-color-s) var(--sc-bg-color-l); --foreground: 222.2 47.4% 11.2%; --border: var(--sc-bd-color-h) var(--sc-bd-color-s) var(--sc-bd-color-l); - --accent: var(--sc-bg-color-h) var(--sc-bg-color-s) calc(var(--sc-bg-color-l) - 6%); + --accent: var(--sc-bg-color-h) var(--sc-bg-color-s) + calc(var(--sc-bg-color-l) - 6%); --accent-foreground: 224 71% 4%; } @@ -34,7 +35,7 @@ .previewContainer { position: relative; - &>div { + & > div { transform: translate(0, 0) !important; } } diff --git a/src/components/Popup.tsx b/src/components/Popup.tsx index f1fcb83a..80b2ef19 100644 --- a/src/components/Popup.tsx +++ b/src/components/Popup.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, createContext, forwardRef } from 'react' import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover' import { Menu } from '@/components/menu/Menu' -import { useSetting } from '@/hooks/useSetting' +import { useUserSettings } from '@/hooks/useSetting' import { useDetectStartup } from '@/hooks/useDetectStartup' import { useTabCommandReceiver } from '@/hooks/useTabCommandReceiver' import { hexToHsl, isMac, onHover, cn } from '@/lib/utils' @@ -27,7 +27,7 @@ export const popupContext = createContext({} as ContextType) export const Popup = forwardRef( (props: PopupProps, ref) => { useTabCommandReceiver() - const { settings } = useSetting() + const { userSettings } = useUserSettings() const [inTransition, setInTransition] = useState(false) const [shouldRender, setShouldRender] = useState(false) const [isHover, setIsHover] = useState(false) @@ -36,15 +36,15 @@ export const Popup = forwardRef( isHover, }) const isPreview = props.isPreview === true - const placement = settings.popupPlacement - const side = isPreview ? SIDE.bottom : (placement.side ?? SIDE.top) - const align = isPreview ? ALIGN.center : (placement.align ?? ALIGN.start) - const sideOffset = isPreview ? 0 : (placement.sideOffset ?? 0) - const alignOffset = isPreview ? 0 : (placement.alignOffset ?? 0) + const placement = userSettings?.popupPlacement + const side = isPreview ? SIDE.bottom : (placement?.side ?? SIDE.top) + const align = isPreview ? ALIGN.center : (placement?.align ?? ALIGN.start) + const sideOffset = isPreview ? 0 : (placement?.sideOffset ?? 0) + const alignOffset = isPreview ? 0 : (placement?.alignOffset ?? 0) const userStyles = - settings.userStyles && - settings.userStyles.reduce((acc, cur) => { + userSettings?.userStyles && + userSettings.userStyles.reduce((acc: any, cur: any) => { if (cur.value == null) return acc if (cur.name === 'background-color' || cur.name === 'border-color') { const hsl = hexToHsl(cur.value) @@ -73,11 +73,11 @@ export const Popup = forwardRef( }, EXIT_DURATION) } else { // Enter transition - const popupDuration = settings.userStyles?.find( - (s) => s.name === STYLE_VARIABLE.POPUP_DURATION, + const popupDuration = userSettings?.userStyles?.find( + (s: any) => s.name === STYLE_VARIABLE.POPUP_DURATION, ) - const popupDelay = settings.userStyles?.find( - (s) => s.name === STYLE_VARIABLE.POPUP_DELAY, + const popupDelay = userSettings?.userStyles?.find( + (s: any) => s.name === STYLE_VARIABLE.POPUP_DELAY, ) const duration = popupDuration?.value != null ? parseInt(popupDuration.value) : 150 @@ -133,10 +133,10 @@ export const Popup = forwardRef( export function PreviewDesc(props: PopupProps) { const { visible, isContextMenu, isKeyboard, isLeftClickHold } = useDetectStartup(props) - const { settings } = useSetting() - const key = settings.startupMethod.keyboardParam + const { userSettings } = useUserSettings() + const key = userSettings?.startupMethod?.keyboardParam - let os = isMac() ? 'mac' : 'windows' + const os = isMac() ? 'mac' : 'windows' const keyLabel = t(`Option_keyboardParam_${key}_${os}`) return ( diff --git a/src/components/SelectAnchor.tsx b/src/components/SelectAnchor.tsx index d2c189b2..0cf9c1ea 100644 --- a/src/components/SelectAnchor.tsx +++ b/src/components/SelectAnchor.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, forwardRef, useCallback } from 'react' import { LinkClickGuard } from '@/components/LinkClickGuard' -import { useSetting } from '@/hooks/useSetting' +import { useUserSettings } from '@/hooks/useSetting' import { useSelectContext } from '@/hooks/useSelectContext' import { useLeftClickHold } from '@/hooks/useLeftClickHold' import { MOUSE, EXIT_DURATION, STARTUP_METHOD } from '@/const' @@ -17,8 +17,8 @@ export const SelectAnchor = forwardRef((_props, ref) => { const [offset, setOffset] = useState({} as Point) const [delayTO, setDelayTO] = useState() - const { settings } = useSetting() - const { method, leftClickHoldParam } = settings.startupMethod + const { userSettings } = useUserSettings() + const { method, leftClickHoldParam } = userSettings?.startupMethod || {} const selected = !isEmpty(selectionText) const { detectHold, detectHoldLink, position } = useLeftClickHold({ enable: method === STARTUP_METHOD.LEFT_CLICK_HOLD && selected, diff --git a/src/components/commandHub/DownloadButton.tsx b/src/components/commandHub/DownloadButton.tsx index 5cf7bfe0..d2e6135e 100644 --- a/src/components/commandHub/DownloadButton.tsx +++ b/src/components/commandHub/DownloadButton.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from 'react' import clsx from 'clsx' import { Ipc, BgCommand } from '@/services/ipc' -import { useSetting } from '@/hooks/useSetting' +import { useSection } from '@/hooks/useSetting' +import { useDetectUrlChanged } from '@/hooks/useDetectUrlChanged' +import { CACHE_SECTIONS } from '@/services/settingsCache' import { sendEvent, ANALYTICS_EVENTS } from '@/services/analytics' import { Popover, @@ -10,16 +12,14 @@ import { PopoverArrow, } from '@/components/ui/popover' import { SCREEN } from '@/const' -import { useDetectUrlChanged } from '@/hooks/useDetectUrlChanged' const TooltipDuration = 2000 export const DownloadButton = (): JSX.Element => { const [position, setPosition] = useState(null) - const { settings } = useSetting() + const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS) const { addUrlChangeListener, removeUrlChangeListener } = useDetectUrlChanged() - const commands = settings.commands const [shouldRender, setShouldRender] = useState(false) const open = position != null @@ -46,7 +46,7 @@ export const DownloadButton = (): JSX.Element => { } const updateButtonVisibility = () => { - const ids = commands.map((c) => c.id) + const ids = commands?.map((c) => c.id) ?? [] ids.forEach((id) => { // hide installed buttons const installed = document.querySelector( @@ -65,7 +65,7 @@ export const DownloadButton = (): JSX.Element => { const count = Number(span.dataset.downloadCount) if (count == null || isNaN(count)) return let reviced = 0 - const cmd = commands.find((c) => c.id === span.dataset.id) + const cmd = commands?.find((c) => c.id === span.dataset.id) if (cmd != null) { // There is a command. reviced++ diff --git a/src/components/commandHub/MyCommands.tsx b/src/components/commandHub/MyCommands.tsx index 68a7f772..d077258f 100644 --- a/src/components/commandHub/MyCommands.tsx +++ b/src/components/commandHub/MyCommands.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, forwardRef } from 'react' import { ChevronLeft, ChevronRight } from 'lucide-react' import ColorThief from 'colorthief' -import { useSetting } from '@/hooks/useSetting' +import { useSettingsWithImageCache } from '@/hooks/useSetting' import { sendEvent, ANALYTICS_EVENTS } from '@/services/analytics' import { t } from '@/services/i18n' import { cn, isSearchCommand, isPageActionCommand } from '@/lib/utils' @@ -13,14 +13,18 @@ export const MyCommands = (): JSX.Element => { const [pageActionIds, setPageActionIds] = useState([]) const listRef = useRef(null) const list2Ref = useRef(null) - const { settings, iconUrls } = useSetting() - const commands = settings.commands + const { commands: allCommands, iconUrls } = useSettingsWithImageCache() + const commands = allCommands .filter( (c) => (isSearchCommand(c) && !urls.includes(c.searchUrl as string)) || (isPageActionCommand(c) && !pageActionIds.includes(c.id)), ) - .map((c) => ({ ...c, iconDataUrl: c.iconUrl, iconUrl: iconUrls[c.id] })) + .map((c) => ({ + ...c, + iconDataUrl: iconUrls[c.id] || c.iconUrl, + iconUrl: iconUrls[c.id], + })) const loaded = urls.length > 0 const enableMarquee = commands.length > 3 diff --git a/src/components/commandHub/StarButton.tsx b/src/components/commandHub/StarButton.tsx index 2243f185..91512d73 100644 --- a/src/components/commandHub/StarButton.tsx +++ b/src/components/commandHub/StarButton.tsx @@ -1,5 +1,6 @@ import { useEffect, useCallback } from 'react' -import { useSetting } from '@/hooks/useSetting' +import { useSection } from '@/hooks/useSetting' +import { CACHE_SECTIONS } from '@/services/settingsCache' import { sendEvent, ANALYTICS_EVENTS } from '@/services/analytics' import { SCREEN } from '@/const' import { useDetectUrlChanged } from '@/hooks/useDetectUrlChanged' @@ -17,8 +18,7 @@ function findButtonElement(elm: Element): HTMLButtonElement | undefined { } export const StarButton = (): JSX.Element => { - const { settings } = useSetting() - const stars = settings.stars + const { data: stars } = useSection(CACHE_SECTIONS.STARS) const { addUrlChangeListener, removeUrlChangeListener } = useDetectUrlChanged() @@ -27,7 +27,7 @@ export const StarButton = (): JSX.Element => { const button = findButtonElement(e.target as Element) const id = button?.dataset.starId if (id == null) return - const found = stars.some((s) => s.id === id) + const found = stars?.some((s: any) => s.id === id) ?? false sendEvent( found ? ANALYTICS_EVENTS.COMMAND_HUB_STAR_REMOVE @@ -47,7 +47,7 @@ export const StarButton = (): JSX.Element => { if (id == null) return button.addEventListener('click', updateStar) button.dataset.clickable = 'true' - if (stars.some((s) => s.id === id)) { + if (stars?.some((s: any) => s.id === id)) { button.dataset.starred = 'true' } else { button.dataset.starred = 'false' @@ -61,7 +61,7 @@ export const StarButton = (): JSX.Element => { const count = Number(span.dataset.starCount) if (count == null || isNaN(count)) return let reviced = 0 - const star = stars.find((s) => s.id === span.dataset.starId) + const star = stars?.find((s: any) => s.id === span.dataset.starId) if (star != null) { // There is a new star. reviced++ diff --git a/src/components/menu/HoverArea.tsx b/src/components/menu/HoverArea.tsx index 291cec98..bfdd2cd6 100644 --- a/src/components/menu/HoverArea.tsx +++ b/src/components/menu/HoverArea.tsx @@ -4,6 +4,8 @@ type Props = { isHorizontal: boolean } +type Placement = "top" | "bottom" | "left" | "right" + export const HoverArea = (props: Props) => { const { anchor, content, isHorizontal } = props @@ -21,55 +23,66 @@ export const HoverArea = (props: Props) => { const isTop = isHorizontal && anchor.y <= content.y const isBottom = isHorizontal && anchor.bottom >= content.bottom const isLeft = !isHorizontal && anchor.x <= content.x - const isRight = !isHorizontal && anchor.right >= content.right + const placement: Placement = isTop + ? "top" + : isBottom + ? "bottom" + : isLeft + ? "left" + : "right" let top = 0 let left = 0 let d - if (isTop) { - d = `M ${anchor.right} ${anchor.top} + switch (placement) { + case "top": + d = `M ${anchor.right} ${anchor.top} + v ${anchor.height / 4} Q ${anchor.right} ${content.top}, ${content.right} ${content.top} h ${-content.width} Q ${anchor.x} ${content.top}, ${anchor.x} ${anchor.top} - v ${anchor.height} h ${anchor.width} z` - top = anchor.top - content.top - } else if (isBottom) { - d = `M ${anchor.left} ${anchor.bottom} + top = anchor.top - content.top + break + case "bottom": + d = `M ${anchor.left} ${anchor.bottom} Q ${anchor.left} ${content.bottom}, ${content.left} ${content.bottom} h ${content.width} Q ${anchor.right} ${content.bottom}, - ${anchor.right} ${anchor.bottom} - v ${-anchor.height} + ${anchor.right} ${anchor.bottom - anchor.height / 4} + v ${anchor.height / 4} h ${-anchor.width} z` - } else if (isLeft) { - d = `M ${anchor.x} ${anchor.y} + break + case "left": + d = `M ${anchor.x} ${anchor.y} Q ${content.x} ${anchor.y}, ${content.x} ${content.y} v ${content.height} Q ${content.x} ${anchor.bottom}, - ${anchor.x} ${anchor.bottom} - h ${anchor.width} + ${anchor.right - (anchor.width * 3) / 4} ${anchor.bottom} + h ${-(anchor.width / 4)} v ${-anchor.height} z` - left = -anchor.width + 2 - } else if (isRight) { - d = `M ${anchor.right} ${anchor.top} + left = -anchor.width + 2 + break + case "right": + d = `M ${anchor.right} ${anchor.top} Q ${content.right} ${anchor.top}, ${content.right} ${content.top} v ${content.height} Q ${content.right} ${anchor.bottom}, - ${anchor.right} ${anchor.bottom} - h ${-anchor.width} + ${anchor.right - anchor.width / 4} ${anchor.bottom} + h ${anchor.width / 4} v ${-anchor.height} z` - left = -2 + left = -2 + break } return ( @@ -78,17 +91,17 @@ export const HoverArea = (props: Props) => { height={height} viewBox={`${x} ${y} ${width} ${height}`} style={{ - pointerEvents: 'none', - position: 'absolute', + pointerEvents: "none", + position: "absolute", top, left, }} > ) diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index edfc1fb8..93a94052 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -1,66 +1,48 @@ -import React, { useState, useRef } from 'react' -import clsx from 'clsx' +import React, { useState, useRef } from "react" +import clsx from "clsx" import { Menubar, - MenubarContent, - MenubarItem, MenubarMenu, MenubarTrigger, -} from '@/components/ui/menubar' - -import { STYLE, ROOT_FOLDER, SIDE } from '@/const' -import { MenuItem } from './MenuItem' -import { Icon } from '@/components/Icon' -import { HoverArea } from '@/components/menu/HoverArea' -import { MenuImage } from '@/components/menu/MenuImage' -import css from './Menu.module.css' -import type { Command, CommandFolder } from '@/types' -import { useSetting } from '@/hooks/useSetting' -import { onHover, isMenuCommand } from '@/lib/utils' + MenubarContent, +} from "@/components/ui/menubar" +import { ScrollArea } from "@/components/ui/scroll-area" -type ItemObj = { - folder: CommandFolder - commands: Command[] -} +import { STYLE, SIDE } from "@/const" +import { MenuItem } from "./MenuItem" +import { Icon } from "@/components/Icon" +import { HoverArea } from "@/components/menu/HoverArea" +import { MenuImage } from "@/components/menu/MenuImage" +import css from "./Menu.module.css" +import type { Command, CommandFolder } from "@/types" +import { useSettingsWithImageCache, useUserSettings } from "@/hooks/useSetting" +import { onHover, isMenuCommand } from "@/lib/utils" +import { + toCommandTree, + type CommandTreeNode, +} from "@/services/option/commandTree" -function isRoot(folder: CommandFolder): boolean { - return folder.id === ROOT_FOLDER +type MenuTreeNodeProps = { + node: CommandTreeNode + isHorizontal: boolean + side: SIDE + menuRef: React.RefObject + onHoverTrigger: (enterVal: any) => void + onHoverContent: (enterVal: any) => void + depth?: number } export function Menu(): JSX.Element { const menuRef = useRef(null) - const [hoverTrigger, setHoverTrigger] = useState('') - const [hoverContent, setHoverContent] = useState('') - const { settings } = useSetting() - const commands = settings.commands.filter(isMenuCommand) - const folders = settings.folders - const isHorizontal = settings.style === STYLE.HORIZONTAL - const side = settings.popupPlacement.side - - const items = commands.reduce((pre, cur, idx) => { - const folder = folders.find((obj) => obj.id === cur.parentFolderId) - if (folder) { - const f = pre.find((obj) => obj.folder.id === cur.parentFolderId) - if (f) { - f.commands.push(cur) - } else { - pre.push({ folder, commands: [cur] }) - } - } else { - // insert the command to the root folder - const preitem = pre[idx - 1] - if (preitem && isRoot(preitem.folder)) { - preitem.commands.push(cur) - } else { - pre.push({ - folder: { id: ROOT_FOLDER, title: '' }, - commands: [cur], - }) - } - } - return pre - }, [] as ItemObj[]) + const [hoverTrigger, setHoverTrigger] = useState("") + const [hoverContent, setHoverContent] = useState("") + const { commands: allCommands, folders } = useSettingsWithImageCache() + const commands = allCommands.filter(isMenuCommand) + const { userSettings } = useUserSettings() + const isHorizontal = userSettings.style === STYLE.HORIZONTAL + const side = userSettings.popupPlacement?.side ?? SIDE.top + const commandTree = toCommandTree(commands, folders) const activeFolder = hoverTrigger || hoverContent return ( @@ -71,43 +53,71 @@ export function Menu(): JSX.Element { })} ref={menuRef} > - {items.map(({ folder, commands }) => - folder.id === ROOT_FOLDER ? ( - commands.map((command) => ( - - )) - ) : ( - - ), - )} + {commandTree.map((node) => ( + + ))} ) } +const MenuTreeNode = (props: MenuTreeNodeProps): JSX.Element => { + const { + node, + isHorizontal, + side, + menuRef, + onHoverTrigger, + onHoverContent, + depth = 0, + } = props + + if (node.type === "command") { + return ( + + ) + } else { + return ( + + ) + } +} + const MenuFolder = (props: { folder: CommandFolder - commands: Command[] + children?: CommandTreeNode[] isHorizontal: boolean side: SIDE menuRef: React.RefObject onHoverTrigger: (enterVal: any) => void onHoverContent: (enterVal: any) => void + depth?: number }) => { - const { folder, isHorizontal } = props + const { folder, children, isHorizontal, depth = 0 } = props + const [hoverTrigger, setHoverTrigger] = useState("") + const [hoverContent, setHoverContent] = useState("") + const activeFolder = hoverTrigger || hoverContent + const menuSide = isHorizontal ? props.side === SIDE.bottom ? SIDE.bottom @@ -131,6 +141,24 @@ const MenuFolder = (props: { }, 200) } + const baseSize = anchorRef.current?.clientHeight ?? 0 + const menubarStyle = isHorizontal + ? { + maxWidth: + baseSize * 10 /* buttons */ + + 1 * 9 /* gap */ + + 2 * 2 /* padding */ + + 1 * 2 /* border */ + + 5, + } + : { + maxHeight: + baseSize * 11.4 /* buttons */ + + 2 * 11 /* gap */ + + 2 * 2 /* padding */ + + 1 * 2 /* border */, + } + return ( - {props.commands.map((command) => ( - - - - ))} + {!isHorizontal ? ( + + + {children?.map((child) => ( + + ))} + + + ) : ( + + {children?.map((child) => ( + + ))} + + )} @@ -31,7 +31,7 @@ export function MenuItem(props: MenuItemProps): React.ReactNode { if (openMode === OPEN_MODE.LINK_POPUP) { const links = linksInSelection() - console.debug('links', links) + console.debug("links", links) enable = links.length > 0 message = `${links.length} links` } @@ -67,9 +67,9 @@ export function MenuItem(props: MenuItemProps): React.ReactNode { css.button, { [css.itemHorizontal]: onlyIcon, - ['hover:bg-accent']: !inTransition, + ["hover:bg-accent"]: !inTransition, }, - 'rounded-sm ', + "rounded-sm ", )} ref={buttonRef} onClick={handleClick} diff --git a/src/components/option/ImportExport.tsx b/src/components/option/ImportExport.tsx index 086d3ca6..8baac1a7 100644 --- a/src/components/option/ImportExport.tsx +++ b/src/components/option/ImportExport.tsx @@ -1,32 +1,208 @@ -import { useState, useRef } from 'react' -import { Dialog } from './Dialog' -import type { UserSettings } from '@/types' +import { useState, useRef, useEffect } from "react" +import { Dialog } from "./Dialog" +import type { UserSettings } from "@/types" -import { Storage, STORAGE_KEY } from '@/services/storage' -import { Settings, migrate } from '@/services/settings' -import { isBase64, isUrl } from '@/lib/utils' -import { APP_ID } from '@/const' -import { t } from '@/services/i18n' -import { Download, Upload, Undo2 } from 'lucide-react' +import { + Storage, + STORAGE_KEY, + LOCAL_STORAGE_KEY, + CommandMigrationManager, +} from "@/services/storage" +import { + DailyBackupManager, + WeeklyBackupManager, +} from "@/services/storage/backupManager" +import { Settings, migrate } from "@/services/settings" +import { isBase64, isUrl } from "@/lib/utils" +import { APP_ID } from "@/const" +import { t } from "@/services/i18n" +import { Download, Upload, Undo2, RotateCcw } from "lucide-react" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import type { BackupData } from "@/services/storage/backupManager" -import css from './Option.module.css' +import css from "./Option.module.css" + +// Backup type constants +const BACKUP_TYPES = { + LEGACY: "legacy", + DAILY: "daily", + WEEKLY: "weekly", +} as const + +// Backup status constants +const BACKUP_STATUS = { + CHECKING: "checking", + AVAILABLE: "available", + NONE: "none", +} as const + +type BackupType = (typeof BACKUP_TYPES)[keyof typeof BACKUP_TYPES] +type BackupStatus = (typeof BACKUP_STATUS)[keyof typeof BACKUP_STATUS] function getTimestamp() { const date = new Date() const year = date.getFullYear() - const month = (date.getMonth() + 1).toString().padStart(2, '0') - const day = date.getDate().toString().padStart(2, '0') - const hours = date.getHours().toString().padStart(2, '0') - const minutes = date.getMinutes().toString().padStart(2, '0') + const month = (date.getMonth() + 1).toString().padStart(2, "0") + const day = date.getDate().toString().padStart(2, "0") + const hours = date.getHours().toString().padStart(2, "0") + const minutes = date.getMinutes().toString().padStart(2, "0") return `${year}${month}${day}_${hours}${minutes}` } export function ImportExport() { const [resetDialog, setResetDialog] = useState(false) const [importDialog, setImportDialog] = useState(false) + const [restoreDialog, setRestoreDialog] = useState(false) const [importJson, setImportJson] = useState() + const [backupData, setBackupData] = useState<{ + [BACKUP_TYPES.LEGACY]: { + status: BackupStatus + info: { + timestamp: number + commandCount: number + folderCount?: number + } | null + } + [BACKUP_TYPES.DAILY]: { + status: BackupStatus + info: { + timestamp: number + commandCount: number + folderCount?: number + } | null + } + [BACKUP_TYPES.WEEKLY]: { + status: BackupStatus + info: { + timestamp: number + commandCount: number + folderCount?: number + } | null + } + }>({ + [BACKUP_TYPES.LEGACY]: { status: BACKUP_STATUS.CHECKING, info: null }, + [BACKUP_TYPES.DAILY]: { status: BACKUP_STATUS.CHECKING, info: null }, + [BACKUP_TYPES.WEEKLY]: { status: BACKUP_STATUS.CHECKING, info: null }, + }) + const [selectedBackupType, setSelectedBackupType] = useState( + BACKUP_TYPES.LEGACY, + ) const inputFile = useRef(null) + // Check backup status on initialization + useEffect(() => { + checkBackupStatus() + }, []) + + const checkBackupStatus = async () => { + try { + const newBackupData = { ...backupData } + + // 1. Check legacy backup + const legacyBackup = await Storage.get( + LOCAL_STORAGE_KEY.COMMANDS_BACKUP, + ) + + if ( + legacyBackup && + legacyBackup.commands && + Array.isArray(legacyBackup.commands) + ) { + newBackupData[BACKUP_TYPES.LEGACY] = { + status: BACKUP_STATUS.AVAILABLE, + info: { + timestamp: legacyBackup.timestamp, + commandCount: legacyBackup.commands.length, + folderCount: Array.isArray(legacyBackup.folders) + ? legacyBackup.folders.length + : 0, + }, + } + } else { + newBackupData[BACKUP_TYPES.LEGACY] = { + status: BACKUP_STATUS.NONE, + info: null, + } + } + + // 2. Check daily backup + const dailyBackupManager = new DailyBackupManager() + const dailyBackup = await dailyBackupManager.getLastBackupData() + + if ( + dailyBackup && + dailyBackup.commands && + Array.isArray(dailyBackup.commands) + ) { + newBackupData[BACKUP_TYPES.DAILY] = { + status: BACKUP_STATUS.AVAILABLE, + info: { + timestamp: dailyBackup.timestamp, + commandCount: dailyBackup.commands.length, + folderCount: Array.isArray(dailyBackup.folders) + ? dailyBackup.folders.length + : 0, + }, + } + } else { + newBackupData[BACKUP_TYPES.DAILY] = { + status: BACKUP_STATUS.NONE, + info: null, + } + } + + // 3. Check weekly backup + const weeklyBackupManager = new WeeklyBackupManager() + const weeklyBackup = await weeklyBackupManager.getLastBackupData() + + if ( + weeklyBackup && + weeklyBackup.commands && + Array.isArray(weeklyBackup.commands) + ) { + newBackupData[BACKUP_TYPES.WEEKLY] = { + status: BACKUP_STATUS.AVAILABLE, + info: { + timestamp: weeklyBackup.timestamp, + commandCount: weeklyBackup.commands.length, + folderCount: Array.isArray(weeklyBackup.folders) + ? weeklyBackup.folders.length + : 0, + }, + } + } else { + newBackupData[BACKUP_TYPES.WEEKLY] = { + status: BACKUP_STATUS.NONE, + info: null, + } + } + + setBackupData(newBackupData) + + // Set default selection to first available backup + if ( + newBackupData[BACKUP_TYPES.LEGACY].status === BACKUP_STATUS.AVAILABLE + ) { + setSelectedBackupType(BACKUP_TYPES.LEGACY) + } else if ( + newBackupData[BACKUP_TYPES.DAILY].status === BACKUP_STATUS.AVAILABLE + ) { + setSelectedBackupType(BACKUP_TYPES.DAILY) + } else if ( + newBackupData[BACKUP_TYPES.WEEKLY].status === BACKUP_STATUS.AVAILABLE + ) { + setSelectedBackupType(BACKUP_TYPES.WEEKLY) + } + } catch (error) { + console.error("Failed to check backup status:", error) + setBackupData({ + [BACKUP_TYPES.LEGACY]: { status: BACKUP_STATUS.NONE, info: null }, + [BACKUP_TYPES.DAILY]: { status: BACKUP_STATUS.NONE, info: null }, + [BACKUP_TYPES.WEEKLY]: { status: BACKUP_STATUS.NONE, info: null }, + }) + } + } + const handleReset = () => { setResetDialog(true) } @@ -52,9 +228,9 @@ export function ImportExport() { } const text = JSON.stringify(data, null, 2) - const blob = new Blob([text], { type: 'text/plain' }) + const blob = new Blob([text], { type: "text/plain" }) const url = URL.createObjectURL(blob) - const a = document.createElement('a') + const a = document.createElement("a") document.body.appendChild(a) a.download = `${APP_ID}_${getTimestamp()}.json` a.href = url @@ -83,13 +259,14 @@ export function ImportExport() { const handleImportClose = (ret: boolean) => { if (ret && importJson != null) { - ; (async () => { - const { commandExecutionCount = 0, hasShownReviewRequest = false } = await Settings.get() + ;(async () => { + const { commandExecutionCount = 0, hasShownReviewRequest = false } = + await Settings.get() const data = await migrate({ ...importJson, commandExecutionCount, hasShownReviewRequest, - stars: [] + stars: [], }) await Settings.set(data) location.reload() @@ -98,6 +275,86 @@ export function ImportExport() { setImportDialog(false) } + const handleRestore = async () => { + const hasAnyBackup = Object.values(backupData).some( + (backup) => backup.status === BACKUP_STATUS.AVAILABLE, + ) + + if (!hasAnyBackup) { + alert(t("Option_RestoreFromBackup_no_data")) + return + } + + setRestoreDialog(true) + } + + const handleRestoreClose = (ret: boolean) => { + if (ret) { + ;(async () => { + try { + let backupCommands: any[] = [] + + if (selectedBackupType === BACKUP_TYPES.LEGACY) { + // Restore from legacy backup + const migrationManager = new CommandMigrationManager() + const legacyData = await migrationManager.restoreFromBackup() + backupCommands = legacyData.commands + + if (legacyData.folders && legacyData.folders.length > 0) { + // Restore folders to settings + const currentSettings = await Settings.get() + await Settings.set({ + ...currentSettings, + folders: legacyData.folders, + }) + } + } else if (selectedBackupType === BACKUP_TYPES.DAILY) { + // Restore from daily backup + const dailyBackupManager = new DailyBackupManager() + const dailyData = await dailyBackupManager.restoreFromDailyBackup() + backupCommands = dailyData.commands + + if (dailyData.folders && dailyData.folders.length > 0) { + // Restore folders to settings + const currentSettings = await Settings.get() + await Settings.set({ + ...currentSettings, + folders: dailyData.folders, + }) + } + } else if (selectedBackupType === BACKUP_TYPES.WEEKLY) { + // Restore from weekly backup + const weeklyBackupManager = new WeeklyBackupManager() + const weeklyData = + await weeklyBackupManager.restoreFromWeeklyBackup() + backupCommands = weeklyData.commands + + if (weeklyData.folders && weeklyData.folders.length > 0) { + // Restore folders to settings + const currentSettings = await Settings.get() + await Settings.set({ + ...currentSettings, + folders: weeklyData.folders, + }) + } + } + + if (backupCommands.length > 0) { + // Save restored commands + await Storage.setCommands(backupCommands) + location.reload() + } else { + alert(t("Option_RestoreFromBackup_failed")) + } + } catch (error) { + console.error("Failed to restore from backup:", error) + alert("Failed to restore from backup.") + } + })() + } + setRestoreDialog(false) + } + return ( <>
@@ -112,38 +369,62 @@ export function ImportExport() { type="button" > - {t('Option_Import')} + {t("Option_Import")} +
( )} - okText={t('Option_Reset')} + okText={t("Option_Reset")} /> ( )} - okText={t('Option_Import')} + okText={t("Option_Import")} > + { + const availableBackups = Object.entries(backupData).filter( + ([, backup]) => backup.status === BACKUP_STATUS.AVAILABLE, + ) + + if (availableBackups.length === 0) { + return {t("Option_RestoreFromBackup_dialog_no_data")} + } + + return {t("Option_RestoreFromBackup_dialog_select")} + }} + okText={t("Option_RestoreFromBackup_dialog_restore")} + > + {(() => { + const availableBackups = Object.entries(backupData).filter( + ([, backup]) => backup.status === BACKUP_STATUS.AVAILABLE, + ) + + if (availableBackups.length === 0) { + return null + } + + const getBackupTypeLabel = (type: string) => { + switch (type) { + case BACKUP_TYPES.LEGACY: + return t("Option_RestoreFromBackup_legacy") + case BACKUP_TYPES.DAILY: + return t("Option_RestoreFromBackup_daily") + case BACKUP_TYPES.WEEKLY: + return t("Option_RestoreFromBackup_weekly") + default: + return type + } + } + + return ( +
+ + setSelectedBackupType(value as typeof selectedBackupType) + } + > + {availableBackups.map(([type, backup]) => ( +
+ +
+ + {backup.info && ( +
+
+ {t("Option_RestoreFromBackup_created")}{" "} + {new Date(backup.info.timestamp).toLocaleString()} +
+
+ {t("Option_RestoreFromBackup_commands")}{" "} + {backup.info.commandCount}{" "} + {t("Option_RestoreFromBackup_items")} +
+ {backup.info.folderCount !== undefined && ( +
+ {t("Option_RestoreFromBackup_folders")}{" "} + {backup.info.folderCount}{" "} + {t("Option_RestoreFromBackup_items")} +
+ )} +
+ )} +
+
+ ))} +
+

+ {t("Option_RestoreFromBackup_warning")}{" "} + {t("Option_RestoreFromBackup_warning_message")} +

+
+ ) + })()} +
) } diff --git a/src/components/option/Option.module.css b/src/components/option/Option.module.css index 88b820fd..2db517c5 100644 --- a/src/components/option/Option.module.css +++ b/src/components/option/Option.module.css @@ -1,6 +1,7 @@ :root { - --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - 'Helvetica Neue', Arial, sans-serif; + --font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', + Arial, sans-serif; --font-color: #333; --menu-top: 150px; } @@ -21,7 +22,7 @@ margin: 0; } -.titleSpan+.titleSpan { +.titleSpan + .titleSpan { margin-left: 4px; } @@ -40,7 +41,7 @@ display: flex; flex-direction: column; align-items: flex-start; - margin-top: 30px; + margin-top: 40px; } .menuLabel { @@ -89,3 +90,14 @@ flex: 1; border: none; } + +.bgHatching { + background-color: #fff; + background-image: repeating-linear-gradient( + 45deg, + #6b7280, + #6b7280 1px, + transparent 1px, + transparent 4px + ); +} diff --git a/src/components/option/Option.tsx b/src/components/option/Option.tsx index 9c5d04d1..72cab0fd 100644 --- a/src/components/option/Option.tsx +++ b/src/components/option/Option.tsx @@ -9,6 +9,7 @@ import { TableOfContents } from '@/components/option/TableOfContents' import { ImportExport } from '@/components/option/ImportExport' import { HubBanner } from '@/components/option/HubBanner' import { SettingForm } from '@/components/option/SettingForm' +import StorageUsage from '@/components/option/StorageUsage' import css from './Option.module.css' @@ -92,6 +93,9 @@ export function Option() {
+
+ +
diff --git a/src/components/option/SettingForm.tsx b/src/components/option/SettingForm.tsx index 052f7eb2..c2013555 100644 --- a/src/components/option/SettingForm.tsx +++ b/src/components/option/SettingForm.tsx @@ -17,15 +17,10 @@ import { InputField } from '@/components/option/field/InputField' import { SelectField } from '@/components/option/field/SelectField' import { SwitchField } from '@/components/option/field/SwitchField' import { PopupPlacementField } from '@/components/option/field/PopupPlacementField' -import { folderSchema } from '@/components/option/editor/FolderEditDialog' -import { commandSchema } from '@/components/option/editor/CommandEditDialog' -import { - CommandList, - toCommandTree, - toFlatten, - isCommand, - removeUnstoredParam, -} from '@/components/option/editor/CommandList' +import { commandSchema, folderSchema } from '@/types/schema' +import { CommandList } from '@/components/option/editor/CommandList' +import { toCommandTree, toFlatten } from '@/services/option/commandTree' +import { isCommand, removeUnstoredParam } from '@/services/option/commandUtils' import { PageRuleList, pageRuleSchema, @@ -61,7 +56,7 @@ import { cn, } from '@/lib/utils' import { Settings } from '@/services/settings' -import DefaultSettings from '@/services/defaultSettings' +import DefaultSettings from '@/services/option/defaultSettings' const formSchema = z .object({ @@ -112,7 +107,7 @@ const formSchema = z .strict() type FormValues = z.infer -type SettingsFormType = Omit +export type SettingsFormType = Omit export function SettingForm({ className }: { className?: string }) { const [isSaving, setIsSaving] = useState(false) @@ -205,7 +200,7 @@ export function SettingForm({ className }: { className?: string }) { // sort commands const commandTree = toCommandTree(settings.commands, settings.folders) const commands = toFlatten(commandTree) - .map((f) => f.content) + .map((f: any) => f.content) .filter(isCommand) .map(removeUnstoredParam) diff --git a/src/components/option/SortableItem.tsx b/src/components/option/SortableItem.tsx index f7bdee14..b8da2cc5 100644 --- a/src/components/option/SortableItem.tsx +++ b/src/components/option/SortableItem.tsx @@ -2,6 +2,7 @@ import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { GripVertical } from 'lucide-react' import { cn } from '@/lib/utils' +import type { Command, CommandFolder } from '@/types' type SrotabelItemProps = { id: string @@ -10,6 +11,8 @@ type SrotabelItemProps = { level: number className?: string droppable?: boolean + content: Command | CommandFolder + folders?: CommandFolder[] } export function SortableItem(props: SrotabelItemProps) { @@ -25,6 +28,10 @@ export function SortableItem(props: SrotabelItemProps) { isDragging, } = useSortable({ id: props.id, + data: { + content: props.content, + folders: props.folders, + }, transition: { duration: 150, easing: 'cubic-bezier(0.25, 1, 0.5, 1)', @@ -34,6 +41,7 @@ export function SortableItem(props: SrotabelItemProps) { const style = { transform: CSS.Transform.toString(transform), transition, + marginLeft: `${props.level * 32}px`, } return ( @@ -46,7 +54,6 @@ export function SortableItem(props: SrotabelItemProps) { props.index === activeIndex ? 'border-y bg-gray-100/80 shadow-lg relative z-10' : '', - props.level > 0 && 'ml-8', props.className, )} {...attributes} diff --git a/src/components/option/StorageUsage.tsx b/src/components/option/StorageUsage.tsx new file mode 100644 index 00000000..43b384ea --- /dev/null +++ b/src/components/option/StorageUsage.tsx @@ -0,0 +1,173 @@ +import React, { useState, useEffect } from "react" +import MultiProgress from "react-multi-progress" +import { Cloud, Monitor } from "lucide-react" +import { + subscribeStorageUsage, + StorageUsageData, +} from "@/services/storage/storageUsage" +import { cn } from "@/lib/utils" + +import s from "./Option.module.css" + +const StorageUsage: React.FC = () => { + const [storageData, setStorageData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const loadStorageUsage = async (data: StorageUsageData) => { + try { + setLoading(true) + setStorageData(data) + setError(null) + } catch (err) { + console.error("Failed to load storage usage:", err) + setError("Failed to load storage usage") + } finally { + setLoading(false) + } + } + return subscribeStorageUsage(loadStorageUsage) + }, []) + + if (loading) { + return ( +
+

Storage Usage

+

+ Loading... +

+
+ ) + } + + if (error || !storageData) { + return ( +
+

Storage Usage

+

{error || "No data available"}

+
+ ) + } + + const syncElements = [ + { + value: storageData.sync.systemPercent, + color: "#4b5563", // gray-600 + name: "System", + }, + { + value: storageData.sync.commandsPercent, + color: "#9ca3af", // gray-400 + name: "Commands", + }, + { + value: storageData.sync.reservedPercent, + color: "#fff", + name: "Reserved", + className: s.bgHatching, + }, + { + value: storageData.sync.freePercent, + color: "#e5e7eb", // gray-200 + name: "Free", + }, + ] + + const localElements = [ + { + value: storageData.local.systemPercent, + color: "#4b5563", // gray-600 + name: "System", + }, + { + value: storageData.local.commandsPercent, + color: "#9ca3af", // gray-400 + name: "Commands", + }, + { + value: storageData.local.backupPercent, + color: "#fff", + name: "Backup", + className: s.bgHatching, + }, + { + value: storageData.local.freePercent, + color: "#e5e7eb", // gray-200 + name: "Free", + }, + ] + + return ( +
+

Storage Usage

+
+

+ + Sync Area +

+ +
+
+
+ System: {storageData.sync.systemPercent}% +
+
+
+ Commands: {storageData.sync.commandsPercent}% +
+
+
+ Reserved: {storageData.sync.reservedPercent}% +
+
+
+ Free: {storageData.sync.freePercent}% +
+
+
+ +
+

+ + Local Area +

+ +
+
+
+ System: {storageData.local.systemPercent}% +
+
+
+ Commands: {storageData.local.commandsPercent}% +
+
+
+ Backup: {storageData.local.backupPercent}% +
+
+
+ Free: {storageData.local.freePercent}% +
+
+
+
+ ) +} + +export default StorageUsage diff --git a/src/components/option/editor/CommandEditDialog.tsx b/src/components/option/editor/CommandEditDialog.tsx index 1ca05cc3..3e5e0cbb 100644 --- a/src/components/option/editor/CommandEditDialog.tsx +++ b/src/components/option/editor/CommandEditDialog.tsx @@ -43,12 +43,8 @@ import { InputField } from '@/components/option/field/InputField' import { IconField } from '@/components/option/field/IconField' import { SelectField } from '@/components/option/field/SelectField' import { TextareaField } from '@/components/option/field/TextareaField' -import { - pageActionSchema, - PageActionSection, -} from '@/components/option/editor/PageActionSection' +import { PageActionSection } from '@/components/option/editor/PageActionSection' import { PaeActionHelp } from '@/components/help/PageActionHelp' - import { PageActionStep } from '@/types/schema' import { @@ -66,7 +62,7 @@ import { FaviconContextProvider, useFavicon, FaviconEvent, -} from '@/hooks/useFavicon' +} from '@/hooks/option/useFavicon' import { Ipc, BgCommand } from '@/services/ipc' import { getScreenSize } from '@/services/screen' @@ -76,6 +72,8 @@ import { ANALYTICS_EVENTS, sendEvent } from '@/services/analytics' import { isEmpty, e2a, cn } from '@/lib/utils' import { t as _t } from '@/services/i18n' const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) + +import { SEARCH_OPEN_MODE, isSearchType, commandSchema } from '@/types/schema' import type { SelectionCommand, CommandFolder, @@ -83,129 +81,7 @@ import type { } from '@/types' import css from './CommandEditDialog.module.css' - -const SearchOpenMode = [ - OPEN_MODE.POPUP, - OPEN_MODE.TAB, - OPEN_MODE.WINDOW, -] as const - -const searchSchema = z.object({ - openMode: z.enum(SearchOpenMode), - id: z.string(), - revision: z.number().optional(), - title: z.string().min(1, { message: t('zod_string_min', ['1']) }), - iconUrl: z - .string() - .url({ message: t('zod_url') }) - .max(1000, { message: t('zod_string_max', ['1000']) }), - searchUrl: z.string().url({ message: t('zod_url') }), - parentFolderId: z.string().optional(), - openModeSecondary: z.enum(SearchOpenMode), - spaceEncoding: z.nativeEnum(SPACE_ENCODING), - popupOption: z - .object({ - width: z.number().min(1), - height: z.number().min(1), - }) - .optional(), -}) - -type SearchType = z.infer - -const isSearchType = (data: any): data is SearchType => { - return SearchOpenMode.includes(data.openMode) -} - -const apiSchema = z.object({ - openMode: z.literal(OPEN_MODE.API), - id: z.string(), - revision: z.number().optional(), - title: z.string().min(1, { message: t('zod_string_min', ['1']) }), - iconUrl: z - .string() - .url({ message: t('zod_url') }) - .max(1000, { message: t('zod_string_max', ['1000']) }), - searchUrl: z.string().url({ message: t('zod_url') }), - parentFolderId: z.string().optional(), - fetchOptions: z.string().optional(), - variables: z - .array( - z.object({ - name: z.string({ message: t('zod_string_min', ['1']) }), - value: z.string({ message: t('zod_string_min', ['1']) }), - }), - ) - .optional(), -}) - -const linkPopupSchema = z.object({ - openMode: z.enum([OPEN_MODE.LINK_POPUP]), - id: z.string(), - revision: z.number().optional(), - parentFolderId: z.string().optional(), - title: z - .string() - .min(1, { message: t('zod_string_min', ['1']) }) - .default('Link Popup'), - iconUrl: z - .string() - .url({ message: t('zod_url') }) - .max(1000, { message: t('zod_string_max', ['1000']) }) - .default( - 'https://cdn4.iconfinder.com/data/icons/basic-ui-2-line/32/folder-archive-document-archives-fold-1024.png', - ), - popupOption: z.object({ - width: z.number().min(1), - height: z.number().min(1), - }), -}) - -const copySchema = z.object({ - openMode: z.enum([OPEN_MODE.COPY]), - id: z.string(), - revision: z.number().optional(), - parentFolderId: z.string().optional(), - title: z - .string() - .min(1, { message: t('zod_string_min', ['1']) }) - .default('Copy text'), - iconUrl: z - .string() - .url({ message: t('zod_url') }) - .max(1000, { message: t('zod_string_max', ['1000']) }) - .default( - 'https://cdn0.iconfinder.com/data/icons/phosphor-light-vol-2/256/copy-light-1024.png', - ), - copyOption: z.nativeEnum(COPY_OPTION).default(COPY_OPTION.DEFAULT), -}) - -const textStyleSchema = z.object({ - openMode: z.enum([OPEN_MODE.GET_TEXT_STYLES]), - id: z.string(), - revision: z.number().optional(), - parentFolderId: z.string().optional(), - title: z - .string() - .min(1, { message: t('zod_string_min', ['1']) }) - .default('Get Text Styles'), - iconUrl: z - .string() - .url({ message: t('zod_url') }) - .max(1000, { message: t('zod_string_max', ['1000']) }) - .default( - 'https://cdn0.iconfinder.com/data/icons/phosphor-light-vol-3/256/paint-brush-light-1024.png', - ), -}) - -export const commandSchema = z.discriminatedUnion('openMode', [ - searchSchema, - apiSchema, - pageActionSchema, - linkPopupSchema, - copySchema, - textStyleSchema, -]) +import { calcLevel } from '@/services/option/commandTree' const EmptyFolder = { id: ROOT_FOLDER, @@ -213,7 +89,7 @@ const EmptyFolder = { } as CommandFolder const defaultValue = (openMode: OPEN_MODE) => { - if (SearchOpenMode.includes(openMode as any)) { + if (SEARCH_OPEN_MODE.includes(openMode as any)) { return { id: '', searchUrl: '', @@ -409,8 +285,8 @@ const CommandEditDialogInner = ({ useEffect(() => { if ( - SearchOpenMode.includes(openMode as any) && - SearchOpenMode.includes(preOpenModeRef.current as any) + SEARCH_OPEN_MODE.includes(openMode as any) && + SEARCH_OPEN_MODE.includes(preOpenModeRef.current as any) ) { return } @@ -519,19 +395,19 @@ const CommandEditDialogInner = ({ }))} /> - {SearchOpenMode.includes(openMode as any) && ( + {SEARCH_OPEN_MODE.includes(openMode as any) && ( ({ + options={SEARCH_OPEN_MODE.map((mode) => ({ name: t(`openMode_${mode}`), value: mode, }))} /> )} - {(SearchOpenMode.includes(openMode as any) || + {(SEARCH_OPEN_MODE.includes(openMode as any) || openMode === OPEN_MODE.API) && ( - {SearchOpenMode.includes(openMode as any) && ( + {SEARCH_OPEN_MODE.includes(openMode as any) && ( diff --git a/src/components/option/editor/CommandList.tsx b/src/components/option/editor/CommandList.tsx index 638db0a1..31dddd30 100644 --- a/src/components/option/editor/CommandList.tsx +++ b/src/components/option/editor/CommandList.tsx @@ -1,6 +1,5 @@ import { useState, useRef, useEffect } from 'react' import { useFieldArray } from 'react-hook-form' -import { z } from 'zod' import { DndContext, @@ -10,7 +9,6 @@ import { useSensor, useSensors, DragStartEvent, - DragEndEvent, } from '@dnd-kit/core' import { @@ -19,219 +17,83 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable' -import { Terminal, FolderPlus, Search } from 'lucide-react' - -import { Button } from '@/components/ui/button' -import { Tooltip } from '@/components/Tooltip' -import { SortableItem } from '@/components/option/SortableItem' -import { EditButton } from '@/components/option/EditButton' -import { CopyButton } from '@/components/option/CopyButton' -import { RemoveButton } from '@/components/option/RemoveButton' -import { - commandSchema, - CommandEditDialog, -} from '@/components/option/editor/CommandEditDialog' +import { CommandEditDialog } from '@/components/option/editor/CommandEditDialog' +import { FolderEditDialog } from '@/components/option/editor/FolderEditDialog' import { - FolderEditDialog, - folderSchema, -} from '@/components/option/editor/FolderEditDialog' -import { MenuImage } from '@/components/menu/MenuImage' + CommandSchemaType, + CommandsSchemaType, + FoldersSchemaType, +} from '@/types/schema' import { ANALYTICS_EVENTS, sendEvent } from '@/services/analytics' import { SCREEN } from '@/const' -import { t as _t } from '@/services/i18n' -const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) -import { OPEN_MODE, ROOT_FOLDER, COMMAND_MAX, HUB_URL } from '@/const' -import { cn, e2a, isEmpty, unique } from '@/lib/utils' -import type { - Command, - CommandFolder, - SelectionCommand, - PageActionCommand, -} from '@/types' - -const commandsSchema = z.object({ - commands: z.array(commandSchema).min(1).max(COMMAND_MAX), -}) - -const foldersSchema = z.object({ - folders: z.array(folderSchema), -}) - -type CommandSchemaType = z.infer -type CommandsSchemaType = z.infer -type FoldersSchemType = z.infer +import type { Command, CommandFolder, SelectionCommand } from '@/types' -type CommandTreeNode = { - type: 'command' | 'folder' - content: Command | CommandFolder - children?: CommandTreeNode[] -} - -export function toCommandTree( - commands: Command[], - folders: CommandFolder[], -): CommandTreeNode[] { - let tree = commands.reduce((acc, command) => { - if (command.parentFolderId && command.parentFolderId !== ROOT_FOLDER) { - const folder = folders.find((f) => f.id === command.parentFolderId) - if (folder) { - const parent = acc.find((node) => node.content.id === folder.id) - if (parent) { - if (parent.children == null) parent.children = [] - parent.children.push({ - type: 'command', - content: command, - }) - } else { - acc.push({ - type: 'folder', - content: folder, - children: [ - { - type: 'command', - content: command, - }, - ], - }) - } - } - } else { - acc.push({ - type: 'command', - content: command, - }) - } - return acc - }, [] as CommandTreeNode[]) - const existsFolders = unique(commands.map((c) => c.parentFolderId)) - const remainingFolders = folders.filter((f) => !existsFolders.includes(f.id)) - tree = tree.concat( - remainingFolders.map((folder) => ({ - type: 'folder', - content: folder, - children: [], - })), - ) - return tree -} - -type FlattenNode = { - id: string - index: number - content: SelectionCommand | CommandFolder - lastChild?: boolean -} - -function _toFlatten( - tree: CommandTreeNode[], - flatten: FlattenNode[] = [], -): FlattenNode[] { - for (const node of tree) { - if (node.type === 'command') { - flatten.push({ - id: node.content.id, - content: node.content, - index: 0, - }) - } else { - flatten.push({ - id: node.content.id, - content: node.content, - index: 0, - }) - _toFlatten(node.children ?? [], flatten) - flatten[flatten.length - 1].lastChild = true - } +// Imported services and hooks +import { + toCommandTree, + toFlatten, + type FlattenNode, +} from '@/services/option/commandTree' +import { + isCommand, + isFolder, + getDescendantFolderIds, +} from '@/services/option/commandUtils' +import { isValidDrop } from '@/services/option/dragAndDrop' +import { useCommandActions } from '@/hooks/option/useCommandActions' +import { useCommandDragDrop } from '@/hooks/option/useCommandDragDrop' +import { CommandListMenu } from './CommandListMenu' +import { CommandTreeRenderer } from './CommandTreeRenderer' + +// Drag filtering utilities + +/** + * Determines if a node is a descendant of any folder in the hidden folder set. + * Used during drag operations to filter out items that should not be visible + * when their parent folder is being dragged. + * + * @param node - The tree node to check + * @param hiddenFolderIds - Set of folder IDs that are currently hidden (being dragged) + * @returns true if the node belongs to any of the hidden folders + */ +const isDescendantOfHiddenFolder = ( + node: FlattenNode, + hiddenFolderIds: Set, +): boolean => { + if (isCommand(node.content) || isFolder(node.content)) { + return !!( + node.content.parentFolderId && + hiddenFolderIds.has(node.content.parentFolderId) + ) } - return flatten -} -export function toFlatten(tree: CommandTreeNode[]): FlattenNode[] { - let flatten = _toFlatten(tree) - flatten = flatten.map((node, index) => ({ ...node, index })) - return flatten + return false } function commandsFilter( nodes: FlattenNode[], draggingId?: string | null, + folders: CommandFolder[] = [], ): FlattenNode[] { - return nodes.filter((node) => { - if (isCommand(node.content)) { - if (node.content.parentFolderId === draggingId) return false - } - return true - }) -} - -function isDroppable(selfNode: FlattenNode, activeNode?: FlattenNode): boolean { - if (!activeNode) return true - if (isCommand(activeNode.content)) return true - - const isMoveDown = activeNode.index < selfNode.index - if (isMoveDown) { - if (isFolder(selfNode.content)) return false - if (hasFolder(selfNode.content) && !selfNode.lastChild) return false - } else { - if (isFolder(selfNode.content)) return true - if (hasFolder(selfNode.content)) return false - } - - return true -} - -export function isCommand( - content: Command | CommandFolder | undefined, -): content is SelectionCommand { - if (content == null) return false - if ('openMode' in content) { - return e2a(OPEN_MODE).includes(content.openMode) - } - return false -} - -export function isPageActionCommand( - content: Command | CommandFolder | undefined, -): content is PageActionCommand { - if (content == null) return false - if ('openMode' in content) { - return OPEN_MODE.PAGE_ACTION === content.openMode + if (!draggingId) return nodes + const draggingNode = nodes.find((n) => n.id === draggingId) + if (isCommand(draggingNode?.content)) { + return nodes } - return false -} - -function isFolder( - content: Command | CommandFolder | undefined, -): content is CommandFolder { - if (content == null) return false - return !('openMode' in content) -} - -function hasFolder(content: Command | CommandFolder | undefined): boolean { - if (content == null) return false - if (isFolder(content)) return false - const folderId = content.parentFolderId - if (!isEmpty(folderId) && folderId != ROOT_FOLDER) { - return true - } - return false -} - -export function removeUnstoredParam(data: Command) { - delete (data as any)._id - return data + const descendantFolderIds = getDescendantFolderIds(draggingId, folders) + const hiddenFolderIds = new Set([draggingId, ...descendantFolderIds]) + return nodes.filter( + (node) => !isDescendantOfHiddenFolder(node, hiddenFolderIds), + ) } -function calcLevel(node: FlattenNode): number { - if (isCommand(node.content)) { - if (hasFolder(node.content)) { - return 1 - } else { - return 0 - } - } else { - return 0 - } +function isDroppable( + selfNode: FlattenNode, + activeNode?: FlattenNode, + folders: CommandFolder[] = [], +): boolean { + if (!activeNode) return true + return isValidDrop(activeNode.content, selfNode.content, folders) } type CommandListProps = { @@ -253,7 +115,7 @@ export const CommandList = ({ control }: CommandListProps) => { keyName: '_id', }) - const folderArray = useFieldArray({ + const folderArray = useFieldArray({ name: 'folders', control: control, keyName: '_id', @@ -268,12 +130,11 @@ export const CommandList = ({ control }: CommandListProps) => { const commandTree = toCommandTree(commandArray.fields, folderArray.fields) let flatten = toFlatten(commandTree) - flatten = commandsFilter(flatten, draggingId) + flatten = commandsFilter(flatten, draggingId, folderArray.fields) const activeNode = flatten.find((f) => f.id === draggingId) const setCommandDialogOpen = (open: boolean) => { if (!open) { - // Reset editData when closing the dialog. editDataRef.current = null } else { sendEvent( @@ -289,7 +150,6 @@ export const CommandList = ({ control }: CommandListProps) => { const setFolderDialogOpen = (open: boolean) => { if (!open) { - // Reset editData when closing the dialog. editDataRef.current = null } _setFolderDialogOpen(open) @@ -300,25 +160,22 @@ export const CommandList = ({ control }: CommandListProps) => { setDraggingId(active.id as string) } - const handleDragEnd = (event: DragEndEvent) => { - setDraggingId(null) - const { active, over } = event - if (!active || !over) return - if (active.id !== over?.id) { - moveArray(`${active.id}`, `${over.id}`) - } - } + // Initialize command actions and drag drop functionality + const commandActions = useCommandActions( + commandArray, + folderArray, + commandTree, + ) - const moveCommands = (srcIdxs: number[], distIdx: number) => { - const sortedIndexes = [...srcIdxs].sort((a, b) => b - a) - const itemsToMove = sortedIndexes.map((index) => commandArray.fields[index]) - sortedIndexes.forEach((index) => commandArray.remove(index)) + const { handleDragEnd } = useCommandDragDrop( + commandActions, + commandArray.fields, + folderArray.fields, + ) - const isMoveDown = srcIdxs[0] < distIdx - if (isMoveDown) distIdx -= srcIdxs.length - 1 - itemsToMove.reverse().forEach((item, i) => { - commandArray.insert(distIdx + i, item) - }) + const handleDragEndWithReset = (event: any) => { + setDraggingId(null) + handleDragEnd(event) } const commandEditorOpen = (idx: number) => { @@ -390,76 +247,7 @@ export const CommandList = ({ control }: CommandListProps) => { SCREEN.OPTION, ) } else { - commandArray.fields - .map((f, i) => ({ - index: i, - id: f.id, - parentFolderId: f.parentFolderId, - data: f, - })) - .filter((f) => f.parentFolderId === node.id) - .forEach((f) => - commandArray.update(f.index, { - ...f.data, - parentFolderId: undefined, - }), - ) - folderArray.remove(folderArray.fields.findIndex((f) => f.id === node.id)) - } - } - - const commandIdx = (id: string) => - commandArray.fields.findIndex((f) => f.id === id) - - const moveArray = (srcId: string, distId: string) => { - const srcNode = flatten.find((f) => f.id === srcId) - const distNode = flatten.find((f) => f.id === distId) - if (!srcNode || !distNode) return - - const isMoveDown = - flatten.findIndex((f) => f.id === srcId) < - flatten.findIndex((f) => f.id === distId) - - if (isCommand(srcNode.content)) { - const srcIdx = commandIdx(srcId) - if (isCommand(distNode.content)) { - // command to command - const distIdx = commandIdx(distId) - commandArray.update(srcIdx, { - ...srcNode.content, - parentFolderId: distNode.content.parentFolderId, - } as CommandSchemaType) - commandArray.move(srcIdx, distIdx) - } else { - // command to folder - let distIdx = commandArray.fields.findIndex( - (f) => f.parentFolderId === distId, - ) - if (distIdx === -1) { - // Empty folders always exist at the end of the list, so move to the end of the dommands. - distIdx = commandArray.fields.length - } - commandArray.update(srcIdx, { - ...srcNode.content, - parentFolderId: isMoveDown ? distId : undefined, - } as CommandSchemaType) - commandArray.move(srcIdx, isMoveDown ? distIdx - 1 : distIdx) - } - } else { - const srcIdxs = commandArray.fields - .filter((f) => f.parentFolderId === srcId) - .map((f) => commandIdx(f.id)) - if (isCommand(distNode.content)) { - // folder to command - const distIdx = commandIdx(distId) - moveCommands(srcIdxs, distIdx) - } else { - // folder to folder - const distIdx = commandArray.fields.findIndex( - (f) => f.parentFolderId === distId, - ) - moveCommands(srcIdxs, distIdx) - } + commandActions.commandRemove(node.id) } } @@ -472,140 +260,49 @@ export const CommandList = ({ control }: CommandListProps) => { return ( <> -
- - {commandArray.fields.length ?? 0} - {t('commands_desc_count')} - - - - - - - commandUpsert(command)} - folders={folderArray.fields} - command={editDataRef.current as SelectionCommand} - /> - commandUpsert(folder)} - folder={editDataRef.current as CommandFolder} - /> -
+ setCommandDialogOpen(true)} + onAddFolder={() => setFolderDialogOpen(true)} + addCommandButtonRef={addCommandButtonRef} + addFolderButtonRef={addFolderButtonRef} + commandCount={commandArray.fields.length} + /> + commandUpsert(command)} + folders={folderArray.fields} + command={editDataRef.current as SelectionCommand} + /> + commandUpsert(folder)} + folder={editDataRef.current as CommandFolder} + folders={folderArray.fields} + />
    - {flatten.map((field, index) => ( - -
    -
    - -
    -

    - - {field.content.title} - -

    - {isCommand(field.content) && ( -

    - {field.content.searchUrl} -

    - )} -
    -
    -
    - {isPageActionCommand(field.content) && ( - commandCopy(index, title)} - /> - )} - commandEditorOpen(index)} /> - commandRemove(index)} - /> -
    -
    -
    - ))} + + isDroppable(node, activeNode, folderArray.fields) + } + />
diff --git a/src/components/option/editor/CommandListMenu.tsx b/src/components/option/editor/CommandListMenu.tsx new file mode 100644 index 00000000..12604e3c --- /dev/null +++ b/src/components/option/editor/CommandListMenu.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { Button } from '@/components/ui/button' +import { Terminal, FolderPlus, Search } from 'lucide-react' +import { Tooltip } from '@/components/Tooltip' +import { HUB_URL } from '@/const' +import { t as _t } from '@/services/i18n' +const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) + +interface Props { + onAddCommand: () => void + onAddFolder: () => void + addCommandButtonRef: React.RefObject + addFolderButtonRef: React.RefObject + commandCount: number +} + +export const CommandListMenu: React.FC = ({ + onAddCommand, + onAddFolder, + addCommandButtonRef, + addFolderButtonRef, + commandCount, +}) => { + return ( +
+ + {commandCount ?? 0} + {t('commands_desc_count')} + + + + + + +
+ ) +} \ No newline at end of file diff --git a/src/components/option/editor/CommandTreeRenderer.tsx b/src/components/option/editor/CommandTreeRenderer.tsx new file mode 100644 index 00000000..c24ebfa0 --- /dev/null +++ b/src/components/option/editor/CommandTreeRenderer.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { SortableItem } from '@/components/option/SortableItem' +import { EditButton } from '@/components/option/EditButton' +import { CopyButton } from '@/components/option/CopyButton' +import { RemoveButton } from '@/components/option/RemoveButton' +import { MenuImage } from '@/components/menu/MenuImage' +import type { FlattenNode } from '@/services/option/commandTree' +import type { CommandFolder } from '@/types' +import { + isCommand, + isFolder, + isPageActionCommand, +} from '@/services/option/commandUtils' +import { calcLevel } from '@/services/option/commandTree' + +interface Props { + nodes: FlattenNode[] + folders: CommandFolder[] + activeNode?: FlattenNode + onEdit: (index: number) => void + onRemove: (index: number) => void + onCopy: (index: number, title: string) => void + isDroppable: (node: FlattenNode, activeNode?: FlattenNode) => boolean +} + +export const CommandTreeRenderer: React.FC = ({ + nodes, + folders, + activeNode, + onEdit, + onRemove, + onCopy, + isDroppable, +}) => { + return ( + <> + {nodes.map((field, index) => ( + +
+
+ +
+

+ + {field.content.title} + +

+ {isCommand(field.content) && ( +

+ {field.content.searchUrl} +

+ )} +
+
+
+ {isPageActionCommand(field.content) && ( + onCopy(index, title)} + /> + )} + onEdit(index)} /> + onRemove(index)} + /> +
+
+
+ ))} + + ) +} diff --git a/src/components/option/editor/FolderEditDialog.tsx b/src/components/option/editor/FolderEditDialog.tsx index 6bb85453..61c1f838 100644 --- a/src/components/option/editor/FolderEditDialog.tsx +++ b/src/components/option/editor/FolderEditDialog.tsx @@ -18,29 +18,22 @@ import { Button } from '@/components/ui/button' import { InputField } from '@/components/option/field/InputField' import { IconField } from '@/components/option/field/IconField' import { SwitchField } from '@/components/option/field/SwitchField' +import { SelectField } from '@/components/option/field/SelectField' import { isEmpty } from '@/lib/utils' import { t as _t } from '@/services/i18n' +import { ROOT_FOLDER } from '@/const' +import { folderSchema } from '@/types/schema' +import { getDescendantFolderIds } from '@/services/option/commandUtils' const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) import type { CommandFolder } from '@/types' - -export const folderSchema = z - .object({ - id: z.string(), - title: z.string().min(1, { message: t('zod_string_min', ['1']) }), - iconUrl: z.string().optional(), - iconSvg: z.string().optional(), - onlyIcon: z.boolean().optional(), - }) - .refine((data) => !isEmpty(data.iconUrl) || !isEmpty(data.iconSvg), { - path: ['iconSvg'], - message: t('icon_required'), - }) +import { calcLevel } from '@/services/option/commandTree' type FolderEditDialog = { open: boolean onOpenChange: (open: boolean) => void onSubmit: (folder: CommandFolder) => void folder?: CommandFolder + folders: CommandFolder[] } export const FolderEditDialog = ({ @@ -48,6 +41,7 @@ export const FolderEditDialog = ({ onOpenChange, onSubmit, folder, + folders, }: FolderEditDialog) => { const DefaultValue = { id: '', @@ -55,6 +49,7 @@ export const FolderEditDialog = ({ iconUrl: 'https://cdn4.iconfinder.com/data/icons/basic-ui-2-line/32/folder-archive-document-archives-fold-1024.png', onlyIcon: true, + parentFolderId: ROOT_FOLDER, } const form = useForm>({ @@ -100,6 +95,34 @@ export const FolderEditDialog = ({ placeholder={t('icon_placeholder')} description={t('icon_desc')} /> + { + if (!folder) return true + const excludedIds = [ + folder.id, + ...getDescendantFolderIds(folder.id, folders), + ] + return !excludedIds.includes(f.id) + }) + .map((f) => ({ + value: f.id, + name: f.title, + iconUrl: f.iconUrl, + iconSvg: f.iconSvg, + level: calcLevel(f, folders), + })), + ]} + /> _t(`Option_${key}`, p) import { cn, e2a, isEmpty, capitalize } from '@/lib/utils' -import { PageActionOption, PageActionStep } from '@/types/schema' +import { PageActionStep } from '@/types/schema' import { DeepPartial } from '@/types' import { InputField } from '@/components/option/field/InputField' @@ -18,28 +17,6 @@ import { InputEditor } from '@/components/pageAction/InputEditor' import { RemoveDialog } from '@/components/option/RemoveDialog' import { TypeIcon } from '@/components/pageAction/TypeIcon' -export const pageActionSchema = z.object({ - openMode: z.enum([OPEN_MODE.PAGE_ACTION]), - id: z.string(), - revision: z.number().optional(), - parentFolderId: z.string().optional(), - title: z - .string() - .min(1, { message: t('zod_string_min', ['1']) }) - .default('Get Text Styles'), - iconUrl: z - .string() - .url({ message: t('zod_url') }) - .max(1000, { message: t('zod_string_max', ['1000']) }), - popupOption: z - .object({ - width: z.number().min(1), - height: z.number().min(1), - }) - .optional(), - pageActionOption: PageActionOption, -}) - type PageActionSectionProps = { form: any openRecorder: () => void diff --git a/src/components/option/editor/PageRuleList.tsx b/src/components/option/editor/PageRuleList.tsx index 6dc61e77..277be600 100644 --- a/src/components/option/editor/PageRuleList.tsx +++ b/src/components/option/editor/PageRuleList.tsx @@ -25,7 +25,7 @@ import { RemoveButton } from '@/components/option/RemoveButton' import { InputField } from '@/components/option/field/InputField' import { SelectField } from '@/components/option/field/SelectField' import { PopupPlacementField } from '@/components/option/field/PopupPlacementField' -import { PopupPlacement } from '@/services/defaultSettings' +import { PopupPlacement } from '@/services/option/defaultSettings' import { t as _t } from '@/services/i18n' const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) import { POPUP_ENABLED, LINK_COMMAND_ENABLED, INHERIT } from '@/const' diff --git a/src/components/option/editor/ShortcutList.tsx b/src/components/option/editor/ShortcutList.tsx index 5ea06ce8..38912926 100644 --- a/src/components/option/editor/ShortcutList.tsx +++ b/src/components/option/editor/ShortcutList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { Control, useFieldArray, useWatch } from 'react-hook-form' import { Keyboard, SquareArrowOutUpRight } from 'lucide-react' import { SelectField } from '@/components/option/field/SelectField' @@ -57,6 +57,7 @@ const groupCommandsByFolder = ( // First, group commands by folder commands.forEach((command) => { + if (!command) return const folderId = command.parentFolderId || 'root' if (!commandsByFolder.has(folderId)) { commandsByFolder.set(folderId, []) @@ -68,6 +69,7 @@ const groupCommandsByFolder = ( const result: (SelectOptionType | SelectGroupType)[] = [] commands.forEach((command) => { + if (!command) return const folderId = command.parentFolderId || 'root' const folderCommands = commandsByFolder.get(folderId) @@ -171,6 +173,11 @@ export function ShortcutList({ control }: ShortcutListProps) { replace(initialData) }, [replace, commands, userCommands]) + const options = useMemo( + () => groupCommandsByFolder(userCommands, folders), + [userCommands, folders], + ) + const noSelectionOptions = [ { name: t('shortcut_no_selection_do_nothing'), @@ -216,12 +223,12 @@ export function ShortcutList({ control }: ShortcutListProps) { name: t('shortcut_select_placeholder'), value: SHORTCUT_PLACEHOLDER, }, - ...groupCommandsByFolder(userCommands, folders), + ...options, ] const targetId = shortcutValues[index]?.commandId const selectedCmd = userCommands.find( - (c: Command) => c.id === targetId, + (c: Command) => c?.id === targetId, ) const showNoSel = selectedCmd && !isTextSelectionOnly(selectedCmd.openMode) diff --git a/src/components/option/field/IconField.tsx b/src/components/option/field/IconField.tsx index 584a5a97..80f63b51 100644 --- a/src/components/option/field/IconField.tsx +++ b/src/components/option/field/IconField.tsx @@ -16,7 +16,7 @@ type IconField = { description?: string } -import { useFavicon } from '@/hooks/useFavicon' +import { useFavicon } from '@/hooks/option/useFavicon' export const IconField = ({ control, @@ -155,7 +155,7 @@ const UrlOrSvgInput = ({ <> React.ReactNode iconUrl?: string iconSvg?: string + level?: number } export type SelectGroupType = { @@ -62,11 +63,20 @@ const renderOptionContent = (opt: SelectOptionType) => { ) } -const renderOption = (opt: SelectOptionType) => ( - - {renderOptionContent(opt)} - -) +const renderOption = (opt: SelectOptionType) => { + const level = opt.level ?? 0 + const paddingLeft = level * 16 + 32 // 32 is the width of pl-8 + return ( + + {renderOptionContent(opt)} + + ) +} const renderGroupLabel = (group: SelectGroupType) => ( diff --git a/src/components/pageAction/PageActionRecorder.tsx b/src/components/pageAction/PageActionRecorder.tsx index 39ec31f6..e61397c4 100644 --- a/src/components/pageAction/PageActionRecorder.tsx +++ b/src/components/pageAction/PageActionRecorder.tsx @@ -79,7 +79,6 @@ export function PageActionRecorder(): JSX.Element { ) } - useEffect(() => { const update = (data: PageActionRecordingData) => { data?.steps && setSteps(data.steps ?? []) diff --git a/src/components/result/ResultPopup.tsx b/src/components/result/ResultPopup.tsx index 1d07e51a..7d6b948a 100644 --- a/src/components/result/ResultPopup.tsx +++ b/src/components/result/ResultPopup.tsx @@ -2,7 +2,7 @@ import React, { useRef } from 'react' import { cn } from '@/lib/utils' import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover' -import { useSetting } from '@/hooks/useSetting' +import { useUserSettings } from '@/hooks/useSetting' import { Icon } from '@/components/Icon' import popupCss from '@/components/Popup.module.css' import { SIDE } from '@/const' @@ -17,9 +17,9 @@ type PopupProps = { } export function ResultPopup(props: PopupProps) { - const { settings } = useSetting() - const placement = settings.popupPlacement - const isBottom = placement.side === SIDE.bottom + const { userSettings } = useUserSettings() + const placement = userSettings?.popupPlacement + const isBottom = placement?.side === SIDE.bottom const visible = props.visible && props.positionRef.current != null diff --git a/src/components/ui/menubar.tsx b/src/components/ui/menubar.tsx index 48b30590..ad4911ae 100644 --- a/src/components/ui/menubar.tsx +++ b/src/components/ui/menubar.tsx @@ -1,10 +1,10 @@ -'use client' +"use client" -import * as React from 'react' -import * as MenubarPrimitive from '@radix-ui/react-menubar' -import { Check, ChevronRight, Circle } from 'lucide-react' +import * as React from "react" +import * as MenubarPrimitive from "@radix-ui/react-menubar" +import { Check, ChevronRight, Circle } from "lucide-react" -import { cn } from '@/lib/utils' +import { cn } from "@/lib/utils" const MenubarMenu = MenubarPrimitive.Menu @@ -23,7 +23,7 @@ const Menubar = React.forwardRef< >( ( - { className, align = 'start', alignOffset = -4, sideOffset, ...props }, + { className, align = "start", alignOffset = -4, sideOffset, ...props }, ref, ) => ( (({ className, ...props }, ref) => ( )) @@ -205,14 +205,14 @@ const MenubarShortcut = ({ return ( ) } -MenubarShortcut.displayname = 'MenubarShortcut' +MenubarShortcut.displayname = "MenubarShortcut" export { Menubar, diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 00000000..e9af867d --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -0,0 +1,45 @@ +'use client' + +import * as React from 'react' +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' +import { CircleIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..05745e9d --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +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/src/content_script.tsx b/src/content_script.tsx index f8d7361e..f5410efb 100644 --- a/src/content_script.tsx +++ b/src/content_script.tsx @@ -17,7 +17,7 @@ const insertCss = (elm: ShadowRoot, filePath: string) => { fetch(url) .then((res) => res.text()) .then((css) => { - let style = document.createElement('style') + const style = document.createElement('style') style.append(document.createTextNode(css)) elm.insertBefore(style, elm.firstChild) }) diff --git a/src/hooks/option/useCommandActions.ts b/src/hooks/option/useCommandActions.ts new file mode 100644 index 00000000..7cfd0ccb --- /dev/null +++ b/src/hooks/option/useCommandActions.ts @@ -0,0 +1,368 @@ +import { useCallback, useMemo, useEffect } from 'react' +import { useFieldArray } from 'react-hook-form' +import { usePrevious } from '@/hooks/usePrevious' +import { ROOT_FOLDER } from '@/const' +import type { CommandFolder } from '@/types' +import { CommandsSchemaType, FoldersSchemaType } from '@/types/schema' +import { + isFolder, + isDescendantOf, + isCircularReference, +} from '@/services/option/commandUtils' +import { + toFlatten, + findNodeInTree, + getAllCommandsFromFolder, + getAllFoldersFromNode, +} from '@/services/option/commandTree' +import type { + CommandTreeNode, + FlattenNode, +} from '@/services/option/commandTree' + +/** + * Finds the appropriate folder between two positions in a flattened node array + * Used during drag operations to determine the target folder when dropping a command or folder + * @param flatten - Array of flattened nodes (commands and folders in display order) + * @param folders - Array of all folders for hierarchy checking + * @param from - Starting position index of the dragged item + * @param to - Target position index where the item is being dropped + * @returns The folder node that should be the target, or null if no suitable folder is found + */ +const findFolder = ( + flatten: FlattenNode[], + folders: CommandFolder[], + from: number, + to: number, +): FlattenNode | null => { + const isForwardDrag = to > from + if (isForwardDrag) { + for (let i = from + 1; i <= to; i++) { + if ( + isFolder(flatten[i].content) && + !isDescendantOf(flatten[i].id, flatten[from].id, folders) + ) { + return flatten[i] + } + } + } else { + for (let i = from - 1; i >= to; i--) { + if ( + isFolder(flatten[i].content) && + !isDescendantOf(flatten[i].id, flatten[from].id, folders) + ) { + return flatten[i] + } + } + } + return null +} + +/** + * Helper function to find array index by ID + * @param array - Array to search + * @param id - ID to find + * @returns Index of the item, or -1 if not found + */ +const findIndexById = ( + array: T[], + id: string, +): number => { + return array.findIndex((item) => item.id === id) +} + +/** + * Helper function to move child items to root folder + * @param items - Array of items with parentFolderId + * @param targetId - ID of the parent to match + * @param updateFn - Function to update the item + */ +const moveChildrenToRoot = ( + items: (T & { index: number })[], + targetId: string, + updateFn: (index: number, item: T) => void, +): void => { + const children = items.filter((item) => item.parentFolderId === targetId) + children.forEach((child) => { + updateFn(child.index, { + ...child, + parentFolderId: ROOT_FOLDER, + }) + }) +} + +/** + * Helper function to determine target folder index when dropping + * @param overContent - The content being dragged over + * @param overContentId - ID of the content being dragged over + * @param sourceFolderId - ID of the source folder + * @param flatten - Flattened node array + * @param folderArray - Array of all folders + * @returns Target folder index or -1 if not found + */ +const getTargetFolderIndex = ( + overContent: CommandTreeNode | null, + overContentId: string, + sourceFolderId: string, + flatten: FlattenNode[], + folderArray: ReturnType>, +): number => { + if (overContent?.type === 'folder') { + return findIndexById(folderArray.fields, overContentId) + } else { + // When the drop target is a command, find a folder between the dragged folder and the target + const from = findIndexById(flatten, sourceFolderId) + const to = findIndexById(flatten, overContentId) + const folder = findFolder(flatten, folderArray.fields, from, to) + return folder ? findIndexById(folderArray.fields, folder.id) : -1 + } +} + +/** + * Helper function to move sub-folders in the correct order + * @param sourceFolder - The source folder node + * @param sourceFolderIndex - Index of the source folder + * @param distFolderIndex - Index of the destination folder + * @param folderArray - Array of all folders + */ +const moveSubFolders = ( + sourceFolder: CommandTreeNode, + sourceFolderIndex: number, + distFolderIndex: number, + folderArray: ReturnType>, +): void => { + const isForwardDrag = distFolderIndex > sourceFolderIndex + const subFolders = getAllFoldersFromNode(sourceFolder) + + if (isForwardDrag) { + subFolders.forEach(() => { + folderArray.move(sourceFolderIndex, distFolderIndex) + }) + } else { + let currentDistIndex = distFolderIndex + subFolders.forEach((f) => { + const idx = findIndexById(folderArray.fields, f.id) + folderArray.move(idx, currentDistIndex) + currentDistIndex++ + }) + } +} + +/* + * Custom hook that provides actions for managing commands and folders in the command editor. + * Handles moving, organizing, and removing commands and folders with proper hierarchy management. + * + * @param commandArray - React Hook Form field array for commands + * @param folderArray - React Hook Form field array for folders + * @returns Object containing command and folder management functions + */ +export const useCommandActions = ( + commandArray: ReturnType< + typeof useFieldArray + >, + folderArray: ReturnType>, + tree: CommandTreeNode[], +) => { + const flatten = useMemo(() => toFlatten(tree), [tree]) + const prevFlatten = usePrevious(flatten) + + useEffect(() => { + if (!prevFlatten) return + const prevFolders = prevFlatten + .filter((node) => isFolder(node.content)) + .map((node) => node.id) + const currentFolders = flatten + .filter((node) => isFolder(node.content)) + .map((node) => node.id) + + // Check if the folder order has changed. + const folderOrderChanged = prevFolders.some((prev, idx) => { + const currentIdx = currentFolders.findIndex((current) => current === prev) + return currentIdx !== idx + }) + + // Reorder folderArray.fields to match flatten. + if (folderOrderChanged) { + // 1. Remove all folders first. + currentFolders.forEach(() => folderArray.remove(0)) + // 2. Append folders in the correct order. + currentFolders.forEach((folderId) => { + const idx = folderArray.fields.findIndex((f) => f.id === folderId) + if (idx > -1) { + const cur = folderArray.fields[idx] + folderArray.append(cur) + } + }) + } + }, [flatten, prevFlatten, folderArray]) + + /** + * Moves a command from one position to another in the commands array + * @param from - Source index of the command + * @param to - Target index where the command should be moved + */ + const moveCommand = useCallback( + (from: number, to: number) => { + commandArray.move(from, to) + }, + [commandArray], + ) + + /** + * Moves a folder to become a child of another folder + * Prevents circular references by checking folder hierarchy + * @param sourceFolderId - ID of the folder to move + * @param overContentId - ID of the content that is being dragged over + * @param distFolderId - ID of the target parent folder (defaults to ROOT_FOLDER) + */ + const moveFolderToFolder = useCallback( + ( + sourceFolderId: string, + overContentId: string, + distFolderId = ROOT_FOLDER, + ) => { + if ( + isCircularReference(sourceFolderId, distFolderId, folderArray.fields) + ) { + return + } + + const sourceFolder = findNodeInTree(tree, sourceFolderId) + const overContent = findNodeInTree(tree, overContentId) + if (!sourceFolder) { + return + } + + const sourceFolderIndex = findIndexById( + folderArray.fields, + sourceFolderId, + ) + const distFolderIndex = getTargetFolderIndex( + overContent, + overContentId, + sourceFolderId, + flatten, + folderArray, + ) + + if (sourceFolderIndex >= 0) { + folderArray.update(sourceFolderIndex, { + ...sourceFolder.content, + parentFolderId: distFolderId, + }) + + if (sourceFolderIndex !== distFolderIndex && distFolderIndex >= 0) { + moveSubFolders( + sourceFolder, + sourceFolderIndex, + distFolderIndex, + folderArray, + ) + } + } + }, + [folderArray, tree, flatten], + ) + + /** + * Moves all commands within a folder to a new position in the commands array + * Handles both forward and backward drag operations to maintain correct order + * @param sourceFolderId - ID of the folder whose contents should be moved + * @param distIndex - Target index where the folder contents should be positioned + * @param firstChildIndex - Index of the first command in the folder + */ + const moveFolderContents = useCallback( + (sourceFolderId: string, distIndex: number, firstChildIndex: number) => { + const folderNode = findNodeInTree(tree, sourceFolderId) + if (!folderNode) { + return + } + const folderCommands = getAllCommandsFromFolder(folderNode) + const isForwardDrag = distIndex > firstChildIndex + if (isForwardDrag) { + folderCommands.forEach(() => { + moveCommand(firstChildIndex, distIndex) + }) + } else { + let currentDistIndex = distIndex + folderCommands.forEach((cmd) => { + const idx = findIndexById(commandArray.fields, cmd.id) + moveCommand(idx, currentDistIndex) + currentDistIndex++ + }) + } + }, + [tree, commandArray, moveCommand], + ) + + /** + * Moves a command to a specific folder and position + * Updates both the parent folder assignment and the position in the commands array + * @param commandId - ID of the command to move + * @param distIndex - Target index where the command should be positioned + * @param parentId - ID of the target parent folder (defaults to ROOT_FOLDER) + */ + const moveCommandToFolder = useCallback( + (commandId: string, distIndex: number, parentId = ROOT_FOLDER) => { + const idx = findIndexById(commandArray.fields, commandId) + if (idx >= 0) { + const command = commandArray.fields[idx] + // Update parent folder first + commandArray.update(idx, { + ...command, + parentFolderId: parentId, + }) + // Then move to target position + moveCommand(idx, distIndex) + } + }, + [commandArray, moveCommand], + ) + + /** + * Removes a command or folder from the arrays + * When removing a folder, moves all child commands and folders to the root folder + * @param id - ID of the command or folder to remove + */ + const commandRemove = useCallback( + (id: string) => { + const commandIndex = findIndexById(commandArray.fields, id) + if (commandIndex >= 0) { + commandArray.remove(commandIndex) + return + } + + const folderIndex = findIndexById(folderArray.fields, id) + if (folderIndex >= 0) { + // Move child commands to root when removing folder + const childCommands = commandArray.fields.map((cmd, index) => ({ + ...cmd, + index, + })) + const childFolders = folderArray.fields.map((folder, index) => ({ + ...folder, + index, + })) + + moveChildrenToRoot(childCommands, id, (index, item) => { + commandArray.update(index, item) + }) + + moveChildrenToRoot(childFolders, id, (index, item) => { + folderArray.update(index, item) + }) + + folderArray.remove(folderIndex) + } + }, + [commandArray, folderArray], + ) + + return { + moveCommand, + moveCommandToFolder, + commandRemove, + moveFolderToFolder, + moveFolderContents, + } +} diff --git a/src/hooks/option/useCommandDragDrop.ts b/src/hooks/option/useCommandDragDrop.ts new file mode 100644 index 00000000..bb04423f --- /dev/null +++ b/src/hooks/option/useCommandDragDrop.ts @@ -0,0 +1,109 @@ +import { useCallback } from 'react' +import type { DragEndEvent } from '@dnd-kit/core' +import type { Command, CommandFolder } from '@/types' +import { isFolder, isCommand } from '@/services/option/commandUtils' +import { + isValidDragTarget, + calculateFolderToFolderPosition, + calculateFolderToCommandPosition, + calculateCommandToFolderPosition, +} from '@/services/option/dragAndDrop' +import { useCommandActions } from './useCommandActions' +import { toCommandTree } from '@/services/option/commandTree' + +export const useCommandDragDrop = ( + commandActions: ReturnType, + commands: Command[], + folders: CommandFolder[], +) => { + const { + moveCommand, + moveFolderToFolder, + moveFolderContents, + moveCommandToFolder, + } = commandActions + const tree = toCommandTree(commands, folders) + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event + + if (!over || !isValidDragTarget(active, over)) { + return + } + + const activeContent = active.data.current?.content + const overContent = over.data.current?.content + + // Folder to Folder drag + if (isFolder(activeContent) && isFolder(overContent)) { + const { distIndex, firstChildIndex, newParentId } = + calculateFolderToFolderPosition(active, over, commands, tree) + moveFolderToFolder(activeContent.id, overContent.id, newParentId) + moveFolderContents(activeContent.id, distIndex, firstChildIndex) + return + } + + // Folder to Command drag + if (isFolder(activeContent) && isCommand(overContent)) { + const { distIndex, firstChildIndex } = calculateFolderToCommandPosition( + active, + over, + commands, + tree, + ) + moveFolderToFolder( + activeContent.id, + overContent.id, + overContent.parentFolderId, + ) + moveFolderContents(activeContent.id, distIndex, firstChildIndex) + return + } + + // Command to Folder drag + if (isCommand(activeContent) && isFolder(overContent)) { + const { distIndex, newParentId } = calculateCommandToFolderPosition( + active, + over, + commands, + tree, + ) + moveCommandToFolder(activeContent.id, distIndex, newParentId) + return + } + + // Command to Command drag + if (isCommand(activeContent) && isCommand(overContent)) { + const activeIndex = commands.findIndex( + (cmd) => cmd.id === activeContent.id, + ) + const overIndex = commands.findIndex((cmd) => cmd.id === overContent.id) + + if (activeIndex !== -1 && overIndex !== -1) { + // First, move command to same parent folder as target command + if (activeContent.parentFolderId !== overContent.parentFolderId) { + moveCommandToFolder( + activeContent.id, + overIndex, + overContent.parentFolderId, + ) + } else { + // If already in same parent, just move position + moveCommand(activeIndex, overIndex) + } + } + } + }, + [ + tree, + commands, + moveFolderToFolder, + moveFolderContents, + moveCommand, + moveCommandToFolder, + ], + ) + + return { handleDragEnd } +} diff --git a/src/hooks/useFavicon.tsx b/src/hooks/option/useFavicon.tsx similarity index 100% rename from src/hooks/useFavicon.tsx rename to src/hooks/option/useFavicon.tsx diff --git a/src/hooks/useDetectLinkCommand.ts b/src/hooks/useDetectLinkCommand.ts index d49d7bfe..02be3f46 100644 --- a/src/hooks/useDetectLinkCommand.ts +++ b/src/hooks/useDetectLinkCommand.ts @@ -11,7 +11,7 @@ import { Point, SettingsType, Command } from '@/types' import { LinkPreview } from '@/action/linkPreview' import { useSetting } from '@/hooks/useSetting' import { useLeftClickHold } from '@/hooks/useLeftClickHold' -import Default, { PopupOption } from '@/services/defaultSettings' +import Default, { PopupOption } from '@/services/option/defaultSettings' import { isPopup, isLinkCommand, isMac } from '@/lib/utils' import { isClickableElement, @@ -48,13 +48,13 @@ const empty = { export function useDetectLinkCommand(): DetectLinkCommandReturn { const { settings, pageRule } = useSetting() - const showIndicator = settings.linkCommand.showIndicator - const command = settings.commands.find(isLinkCommand) as Command + const showIndicator = settings.linkCommand?.showIndicator + const command = settings.commands?.find(isLinkCommand) as Command const enabled = pageRule == null || pageRule.linkCommandEnabled == undefined || pageRule.linkCommandEnabled === LINK_COMMAND_ENABLED.INHERIT - ? settings.linkCommand.enabled === LINK_COMMAND_ENABLED.ENABLE + ? settings.linkCommand?.enabled === LINK_COMMAND_ENABLED.ENABLE : pageRule.linkCommandEnabled === LINK_COMMAND_ENABLED.ENABLE const onChangeState = (state: ExecState, message?: string) => { @@ -75,11 +75,11 @@ export function useDetectLinkCommand(): DetectLinkCommandReturn { } return { - showIndicator, + showIndicator: showIndicator ?? false, ...empty, - ...useDetectDrag(enabled, settings, command, onDetect), - ...useDetectKeyboard(enabled, settings, command, onDetect), - ...useDetectClickHold(enabled, settings, command, onDetect), + ...useDetectDrag(enabled, settings as SettingsType, command, onDetect), + ...useDetectKeyboard(enabled, settings as SettingsType, command, onDetect), + ...useDetectClickHold(enabled, settings as SettingsType, command, onDetect), } } @@ -101,11 +101,11 @@ function useDetectDrag( const dragEnabled = enabled && - settings.linkCommand.startupMethod.method === + settings.linkCommand?.startupMethod?.method === LINK_COMMAND_STARTUP_METHOD.DRAG const threshold = - settings.linkCommand.startupMethod.threshold ?? + settings.linkCommand?.startupMethod?.threshold ?? (Default.linkCommand.startupMethod.threshold as number) useEffect(() => { @@ -181,7 +181,7 @@ const calcPopupPosition = async ( ): Promise => { const popupOption = command?.popupOption ?? PopupOption const s = await getScreenSize() - let x = isCursorInLeft(cursorX, s) + const x = isCursorInLeft(cursorX, s) ? Math.floor(s.width + s.left - popupOption.width - POPUP_OFFSET) : Math.floor(s.left + POPUP_OFFSET) const y = Math.floor((s.height + s.top - popupOption.height) / 2) @@ -208,9 +208,9 @@ function useDetectKeyboard( ): SubHookReturn { const keyboardEnabled = enabled && - settings.linkCommand.startupMethod.method === + settings.linkCommand?.startupMethod?.method === LINK_COMMAND_STARTUP_METHOD.KEYBOARD - const key = settings.linkCommand.startupMethod.keyboardParam + const key = settings.linkCommand?.startupMethod?.keyboardParam const popupOption = command?.popupOption ?? PopupOption const [target, setTarget] = useState(null) const [mousePosition, setMousePosition] = useState(null) @@ -272,9 +272,10 @@ function useDetectClickHold( ): SubHookReturn { const clickHoldEnabled = enabled && - settings.linkCommand.startupMethod.method === + settings.linkCommand?.startupMethod?.method === LINK_COMMAND_STARTUP_METHOD.LEFT_CLICK_HOLD - const duration = settings.linkCommand.startupMethod.leftClickHoldParam ?? 200 + const duration = + settings.linkCommand?.startupMethod?.leftClickHoldParam ?? 200 const detectLinkRef = useRef(false) const [forceClear, setForceClear] = useState(false) const playPixel = 20 diff --git a/src/hooks/useDetectStartup.ts b/src/hooks/useDetectStartup.ts index 6dcd7678..e696bdf2 100644 --- a/src/hooks/useDetectStartup.ts +++ b/src/hooks/useDetectStartup.ts @@ -16,7 +16,7 @@ export function useDetectStartup(props: Props) { const { selectionText } = useSelectContext() const [hide, setHide] = useState(false) const { settings, pageRule } = useSetting() - const { method, leftClickHoldParam } = settings.startupMethod + const { method, leftClickHoldParam } = settings.startupMethod || {} let visible = !isEmpty(selectionText) && positionElm != null if (pageRule != null) { @@ -66,7 +66,7 @@ export function useKeyboard(_: Props) { const { settings } = useSetting() const { selectionText } = useSelectContext() const [detectKey, setDetectKey] = useState(false) - const { method, keyboardParam } = settings.startupMethod + const { method, keyboardParam } = settings.startupMethod || {} useEffect(() => { setDetectKey(false) diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts new file mode 100644 index 00000000..0885d565 --- /dev/null +++ b/src/hooks/usePrevious.ts @@ -0,0 +1,9 @@ +import { useEffect, useRef } from 'react' + +export const usePrevious = (value: T): T | undefined => { + const ref = useRef(value) + useEffect(() => { + ref.current = value + }, [value]) + return ref.current +} diff --git a/src/hooks/useSetting.ts b/src/hooks/useSetting.ts index 600cb793..8fceb839 100644 --- a/src/hooks/useSetting.ts +++ b/src/hooks/useSetting.ts @@ -1,98 +1,282 @@ -import { useState, useEffect } from 'react' -import { Settings } from '../services/settings' -import type { SettingsType, PageRule } from '@/types' +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { enhancedSettings } from '../services/enhancedSettings' +import { + settingsCache, + CacheSection, + CACHE_SECTIONS, +} from '../services/settingsCache' + +import type { + SettingsType, + Command, + Star, + UserStats, + ShortcutSettings, + UserSettings, + PageRule, +} from '@/types' import { isEmpty } from '@/lib/utils' -import { STYLE, STARTUP_METHOD, ALIGN, SIDE, INHERIT } from '@/const' -import Default from '@/services/defaultSettings' +import { INHERIT } from '@/const' -type iconUrlMap = Record +// Type definitions for section-specific hook return values +type SectionData = + T extends typeof CACHE_SECTIONS.COMMANDS + ? Command[] + : T extends typeof CACHE_SECTIONS.USER_SETTINGS + ? UserSettings + : T extends typeof CACHE_SECTIONS.STARS + ? Star[] + : T extends typeof CACHE_SECTIONS.SHORTCUTS + ? ShortcutSettings + : T extends typeof CACHE_SECTIONS.USER_STATS + ? UserStats + : any -type useSettingReturn = { - settings: SettingsType - pageRule: PageRule | undefined - iconUrls: iconUrlMap -} +// Common async data fetching hook +function useAsyncData( + loader: () => Promise, + deps: React.DependencyList, + subscriptions?: { + subscribe: (callback: () => void) => void + unsubscribe: (callback: () => void) => void + }[], +): { + data: T | null + loading: boolean + error: Error | null + refetch: () => Promise +} { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const mountedRef = useRef(true) -const emptySettings: SettingsType = { - settingVersion: '0.0.0', - commands: [], - folders: [], - pageRules: [], - style: STYLE.HORIZONTAL, - popupPlacement: { - side: SIDE.top, - align: ALIGN.start, - alignOffset: 0, - sideOffset: 0, - }, - linkCommand: Default.linkCommand, - userStyles: [], - startupMethod: { method: STARTUP_METHOD.TEXT_SELECTION }, - stars: [], - commandExecutionCount: 0, - hasShownReviewRequest: false, - shortcuts: { shortcuts: [] }, -} + const loadData = useCallback(async () => { + if (!mountedRef.current) return + + try { + setLoading(true) + setError(null) + const result = await loader() -export function useSetting(): useSettingReturn { - const [settings, setSettings] = useState(emptySettings) - const [iconUrls, setIconUrls] = useState({}) + if (mountedRef.current) { + setData(result) + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error('Unknown error')) + } + } finally { + if (mountedRef.current) { + setLoading(false) + } + } + }, deps) + const refetch = useCallback(async () => { + await loadData() + }, [loadData]) + + // Initial load and cleanup useEffect(() => { - updateSettings() - Settings.addChangedListener(updateSettings) + mountedRef.current = true + loadData() + return () => { - Settings.removeChangedListener(updateSettings) + mountedRef.current = false } - }, []) + }, deps) - const updateSettings = async () => { - const caches = await Settings.getCaches() - const data = await Settings.get() - // create iconUrl map to getting iconUrl - const iu = data.commands.reduce( - (acc, cur) => ({ ...acc, [cur.id]: cur.iconUrl }), - {}, - ) - setIconUrls(iu) - // use image cache if available - data.commands = data.commands.map((c) => { - const cache = caches.images[c.iconUrl] - let iconUrl = c.iconUrl - if (!isEmpty(cache)) { - iconUrl = cache - } - return { ...c, iconUrl } - }) - data.folders = data.folders.map((f) => { - if (!f.iconUrl) return f - const cache = caches.images[f.iconUrl] - let iconUrl = f.iconUrl - if (!isEmpty(cache)) { - iconUrl = cache + // Setup change monitoring + useEffect(() => { + if (!subscriptions) return + + const handleChange = () => { + if (mountedRef.current) { + loadData() } - return { ...f, iconUrl } + } + + subscriptions.forEach((subscription) => { + subscription.subscribe(handleChange) }) - setSettings(data) + + return () => { + subscriptions.forEach((subscription) => { + subscription.unsubscribe(handleChange) + }) + } + }, [...deps, loadData]) + + return { data, loading, error, refetch } +} + +// Section-specific hook +export function useSection( + section: T, + forceFresh = false, +): { + data: SectionData | null + loading: boolean + error: Error | null + refetch: () => Promise +} { + return useAsyncData>( + () => enhancedSettings.getSection(section, forceFresh), + [section, forceFresh], + [ + { + subscribe: (callback) => settingsCache.subscribe(section, callback), + unsubscribe: (callback) => settingsCache.unsubscribe(section, callback), + }, + ], + ) +} + +// User settings-specific hook +export function useUserSettings(forceFresh = false) { + const { data, loading, error, refetch } = useSection( + CACHE_SECTIONS.USER_SETTINGS, + forceFresh, + ) + + return { + userSettings: (data || {}) as UserSettings, + loading, + error, + refetch, } +} - let pageRule: PageRule | undefined - if (settings != null) { - pageRule = settings.pageRules +// Integrated settings hook (specify only required sections) +export function useSetting( + sections: CacheSection[] = [ + CACHE_SECTIONS.COMMANDS, + CACHE_SECTIONS.USER_SETTINGS, + ], + forceFresh = false, +): { + settings: Partial + pageRule: PageRule | undefined + loading: boolean + error: Error | null + refetch: () => Promise + invalidateCache: (sectionsToInvalidate?: CacheSection[]) => void +} { + const sectionsRef = useRef(sections) + const sectionsKey = useMemo(() => sections.join(','), [sections]) + + // Update when sections change + useEffect(() => { + sectionsRef.current = sections + }, [sections]) + + const { + data: settings, + loading, + error, + refetch, + } = useAsyncData>( + () => + enhancedSettings.get({ + sections: sectionsRef.current, + forceFresh, + }), + [sectionsKey, forceFresh], + sections.map((section) => ({ + subscribe: (callback) => settingsCache.subscribe(section, callback), + unsubscribe: (callback) => settingsCache.unsubscribe(section, callback), + })), + ) + + // Page rule calculation + const pageRule = useMemo(() => { + if (!settings || typeof window === 'undefined') return undefined + + const rule = (settings.pageRules || []) .filter((r) => !isEmpty(r.urlPattern)) .find((rule) => { - const re = new RegExp(rule.urlPattern) - return window.location.href.match(re) != null + try { + const re = new RegExp(rule.urlPattern) + return window.location.href.match(re) != null + } catch { + return false + } }) - if (pageRule != null && pageRule.popupPlacement !== INHERIT) { - settings.popupPlacement = pageRule.popupPlacement + if ( + rule != null && + rule.popupPlacement !== INHERIT && + settings.popupPlacement + ) { + settings.popupPlacement = rule.popupPlacement } - } + + return rule + }, [settings]) + + const invalidateCache = useCallback( + (sectionsToInvalidate?: CacheSection[]) => { + const targetSections = sectionsToInvalidate || sectionsRef.current + enhancedSettings.invalidateCache(targetSections) + }, + [], + ) return { - settings, + settings: settings || {}, pageRule, + loading, + error, + refetch, + invalidateCache, + } +} + +// Settings hook with image cache applied +export function useSettingsWithImageCache() { + const { settings, pageRule, loading } = useSetting([ + CACHE_SECTIONS.COMMANDS, + CACHE_SECTIONS.USER_SETTINGS, + CACHE_SECTIONS.CACHES, + ]) + + const { commandsWithCache, foldersWithCache, iconUrls } = useMemo(() => { + if (loading || !settings.commands) { + return { commandsWithCache: [], foldersWithCache: [], iconUrls: {} } + } + + const caches = (settings as any).caches || { images: {} } + + // Commands with cache + const commandsWithCache = settings.commands.map((c) => { + const cache = caches.images[c.iconUrl] + const iconUrl = !isEmpty(cache) ? cache : c.iconUrl + return { ...c, iconUrl } + }) + + // Folders with cache + const foldersWithCache = (settings.folders || []).map((f) => { + if (!f.iconUrl) return f + const cache = caches.images[f.iconUrl] + const iconUrl = !isEmpty(cache) ? cache : f.iconUrl + return { ...f, iconUrl } + }) + + // IconUrls map + const iconUrls = commandsWithCache.reduce( + (acc, cur) => ({ ...acc, [cur.id]: cur.iconUrl }), + {} as Record, + ) + + return { commandsWithCache, foldersWithCache, iconUrls } + }, [settings, loading]) + + return { + commands: commandsWithCache, + folders: foldersWithCache, iconUrls, + pageRule, + loading, } } diff --git a/src/services/dom.ts b/src/services/dom.ts index ad0cfadb..eb867436 100644 --- a/src/services/dom.ts +++ b/src/services/dom.ts @@ -183,7 +183,7 @@ export function findClickableElement(elm: Element | null): Element | null { export function getSelectorFromElement(el: Element): string { if (!(el instanceof Element)) return '' - let path = [] + const path = [] while (el.nodeType === Node.ELEMENT_NODE) { let selector = el.nodeName.toLowerCase() if (el.id) { @@ -436,12 +436,12 @@ export function getXPath(element: Element): string { } function getPath(elm: Element, uniqueElement?: Element): string[] { - let path = [] + const path = [] while (elm.nodeType === Node.ELEMENT_NODE && elm !== uniqueElement) { let index = 0 let hasSiblings = false let sibling = elm as Node | null - let nodeName = elm.nodeName.toLowerCase() + const nodeName = elm.nodeName.toLowerCase() // Check if the element has siblings. while (sibling) { diff --git a/src/services/enhancedSettings.ts b/src/services/enhancedSettings.ts new file mode 100644 index 00000000..93601fd8 --- /dev/null +++ b/src/services/enhancedSettings.ts @@ -0,0 +1,199 @@ +import type { + SettingsType, + Command, + Star, + UserStats, + ShortcutSettings, + UserSettings, +} from '@/types' +import { settingsCache, CacheSection, CACHE_SECTIONS } from './settingsCache' +import { Settings } from './settings' +import { OptionSettings } from '@/services/option/optionSettings' +import DefaultSettings from '@/services/option/defaultSettings' +import { OPTION_FOLDER } from '@/const' + +// Settings fetch options +interface GetSettingsOptions { + sections?: CacheSection[] + forceFresh?: boolean + excludeOptions?: boolean +} + +// Enhanced settings service +export class EnhancedSettings { + constructor() { + // Set up legacy listeners for cache invalidation + this.setupLegacyListeners() + } + + // Get integrated settings (with cache utilization) + async get(options: GetSettingsOptions = {}): Promise { + const { + sections = [ + CACHE_SECTIONS.COMMANDS, + CACHE_SECTIONS.USER_SETTINGS, + CACHE_SECTIONS.STARS, + CACHE_SECTIONS.SHORTCUTS, + CACHE_SECTIONS.USER_STATS, + ], + forceFresh = false, + excludeOptions = false, + } = options + + // console.debug('EnhancedSettings.get called with sections:', sections) + + // Get sections in parallel + const results = await Promise.allSettled([ + sections.includes(CACHE_SECTIONS.COMMANDS) + ? settingsCache.get(CACHE_SECTIONS.COMMANDS, forceFresh) + : Promise.resolve([]), + sections.includes(CACHE_SECTIONS.USER_SETTINGS) + ? settingsCache.get( + CACHE_SECTIONS.USER_SETTINGS, + forceFresh, + ) + : Promise.resolve(DefaultSettings as UserSettings), + sections.includes(CACHE_SECTIONS.STARS) + ? settingsCache.get(CACHE_SECTIONS.STARS, forceFresh) + : Promise.resolve([]), + sections.includes(CACHE_SECTIONS.SHORTCUTS) + ? settingsCache.get( + CACHE_SECTIONS.SHORTCUTS, + forceFresh, + ) + : Promise.resolve({ shortcuts: [] }), + sections.includes(CACHE_SECTIONS.USER_STATS) + ? settingsCache.get(CACHE_SECTIONS.USER_STATS, forceFresh) + : Promise.resolve({ + commandExecutionCount: 0, + hasShownReviewRequest: false, + }), + ]) + + // Process results + const [ + commandsResult, + userSettingsResult, + starsResult, + shortcutsResult, + userStatsResult, + ] = results + + const commands = + commandsResult.status === 'fulfilled' ? commandsResult.value : [] + const userSettings = + userSettingsResult.status === 'fulfilled' + ? userSettingsResult.value + : (DefaultSettings as UserSettings) + const stars = starsResult.status === 'fulfilled' ? starsResult.value : [] + const shortcuts = + shortcutsResult.status === 'fulfilled' + ? shortcutsResult.value + : { shortcuts: [] } + const userStats = + userStatsResult.status === 'fulfilled' + ? userStatsResult.value + : { commandExecutionCount: 0, hasShownReviewRequest: false } + + // Merge settings + let mergedSettings = this.mergeSettings({ + commands, + userSettings, + stars, + shortcuts, + userStats, + }) + + // Filter folders + mergedSettings.folders = mergedSettings.folders.filter( + (folder) => !!folder.title, + ) + + // Process option settings + if (!excludeOptions) { + this.removeOptionSettings(mergedSettings) + mergedSettings.commands.push(...OptionSettings.commands) + mergedSettings.folders.push(OptionSettings.folder) + } + + return mergedSettings + } + + // Get partial settings + async getSection( + section: K, + forceFresh = false, + ): Promise< + K extends 'commands' + ? Command[] + : K extends 'userSettings' + ? UserSettings + : K extends 'stars' + ? Star[] + : K extends 'shortcuts' + ? ShortcutSettings + : K extends 'userStats' + ? UserStats + : any + > { + return settingsCache.get(section, forceFresh) + } + + // Merge settings + private mergeSettings(data: { + commands: Command[] + userSettings: UserSettings + stars: Star[] + shortcuts: ShortcutSettings + userStats: UserStats + }): SettingsType { + return { + ...data.userSettings, + commands: data.commands, + stars: data.stars, + shortcuts: data.shortcuts, + commandExecutionCount: data.userStats.commandExecutionCount, + hasShownReviewRequest: data.userStats.hasShownReviewRequest, + } as SettingsType + } + + // Remove option settings + private removeOptionSettings(data: SettingsType): void { + data.commands = data.commands.filter( + (c) => c?.parentFolderId !== OPTION_FOLDER, + ) + data.folders = data.folders.filter((f) => f.id !== OPTION_FOLDER) + } + + // Invalidate cache + invalidateCache(sections: CacheSection[]): void { + settingsCache.invalidate(sections) + } + + // Invalidate all cache + invalidateAllCache(): void { + settingsCache.invalidateAll() + } + + // Cache related + async getCaches() { + return settingsCache.get(CACHE_SECTIONS.CACHES) + } + + // Setup legacy listeners + private setupLegacyListeners(): void { + // Handle legacy Settings.addChangedListener + Settings.addChangedListener((_data: SettingsType) => { + // Invalidate all cache (safety measure) + this.invalidateAllCache() + }) + } + + // For debugging + getCacheStatus() { + return settingsCache.getCacheStatus() + } +} + +// Singleton instance +export const enhancedSettings = new EnhancedSettings() diff --git a/src/services/option/commandTree.ts b/src/services/option/commandTree.ts new file mode 100644 index 00000000..fbb41460 --- /dev/null +++ b/src/services/option/commandTree.ts @@ -0,0 +1,292 @@ +import type { Command, CommandFolder } from '@/types' +import { ROOT_FOLDER } from '@/const' + +export type CommandTreeNode = { + type: 'command' | 'folder' + content: Command | CommandFolder + children?: CommandTreeNode[] +} + +export type FlattenNode = { + id: string + index: number + content: Command | CommandFolder + firstChild?: boolean + lastChild?: boolean +} + +const createFolderNode = (folder: CommandFolder): CommandTreeNode => ({ + type: 'folder', + content: folder, + children: [], +}) + +export const findNodeInTree = ( + tree: CommandTreeNode[], + id: string, +): CommandTreeNode | null => { + for (const node of tree) { + if (node.content.id === id) { + return node + } + if (node.children) { + const found = findNodeInTree(node.children, id) + if (found) return found + } + } + return null +} + +export const findFirstCommand = ( + node: CommandTreeNode, +): CommandTreeNode | null => { + if (node.children == null) return null + const first = node.children[0] + if (first == null) return null + if (first.type === 'folder') return findFirstCommand(first) + return first +} + +const addParentFolderIfNeeded = ( + tree: CommandTreeNode[], + parentFolderId: string, + folders: CommandFolder[], + processedFolders: Set, + addNodeToTree: ( + tree: CommandTreeNode[], + node: CommandTreeNode, + parentId?: string, + ) => void, +) => { + if (parentFolderId === ROOT_FOLDER) return + + const parentFolder = folders.find((f) => f.id === parentFolderId) + if (parentFolder && !processedFolders.has(parentFolder.id)) { + const folderNode = createFolderNode(parentFolder) + addNodeToTree(tree, folderNode, parentFolder.parentFolderId) + processedFolders.add(parentFolder.id) + } +} + +const createAddNodeToTreeFunction = ( + folders: CommandFolder[], + processedFolders: Set, +) => { + const processingStack = new Set() + + const addNodeToTree = ( + tree: CommandTreeNode[], + node: CommandTreeNode, + parentId?: string, + ) => { + if (parentId && parentId !== ROOT_FOLDER) { + const parent = findNodeInTree(tree, parentId) + if (parent) { + if (!parent.children) parent.children = [] + parent.children.push(node) + } else { + // Check for circular dependency before processing + if (processingStack.has(parentId)) { + console.warn(`Circular dependency detected for folder ${parentId}. Adding to root instead.`) + tree.push(node) + return + } + + processingStack.add(parentId) + addParentFolderIfNeeded( + tree, + parentId, + folders, + processedFolders, + addNodeToTree, + ) + processingStack.delete(parentId) + + // Try again after adding parent + const parentAfterAdd = findNodeInTree(tree, parentId) + if (parentAfterAdd) { + if (!parentAfterAdd.children) parentAfterAdd.children = [] + parentAfterAdd.children.push(node) + } else { + console.warn(`Failed to find or create parent folder ${parentId}. Adding to root instead.`) + tree.push(node) + } + } + } else { + tree.push(node) + } + } + return addNodeToTree +} + +const addCommandsToTree = ( + tree: CommandTreeNode[], + commands: Command[], + folders: CommandFolder[], + processedFolders: Set, + addNodeToTree: ( + tree: CommandTreeNode[], + node: CommandTreeNode, + parentId?: string, + ) => void, +) => { + commands.forEach((command) => { + const commandNode: CommandTreeNode = { + type: 'command', + content: command, + } + + if (command.parentFolderId && command.parentFolderId !== ROOT_FOLDER) { + addParentFolderIfNeeded( + tree, + command.parentFolderId, + folders, + processedFolders, + addNodeToTree, + ) + } + + addNodeToTree(tree, commandNode, command.parentFolderId) + }) +} + +const addRemainingFoldersToTree = ( + tree: CommandTreeNode[], + folders: CommandFolder[], + processedFolders: Set, + addNodeToTree: ( + tree: CommandTreeNode[], + node: CommandTreeNode, + parentId?: string, + ) => void, +) => { + folders.forEach((folder) => { + if (!processedFolders.has(folder.id)) { + const folderNode = createFolderNode(folder) + addNodeToTree(tree, folderNode, folder.parentFolderId) + processedFolders.add(folder.id) + } + }) +} + +export function toCommandTree( + commands: Command[], + folders: CommandFolder[], +): CommandTreeNode[] { + const tree: CommandTreeNode[] = [] + const processedFolders = new Set() + if (commands == null || (commands.length === 0 && folders.length === 0)) { + return tree + } + + const addNodeToTree = createAddNodeToTreeFunction(folders, processedFolders) + addCommandsToTree(tree, commands, folders, processedFolders, addNodeToTree) + addRemainingFoldersToTree(tree, folders, processedFolders, addNodeToTree) + + return tree +} + +function _toFlatten( + tree: CommandTreeNode[], + flatten: FlattenNode[] = [], +): FlattenNode[] { + for (const node of tree) { + if (node.type === 'command') { + flatten.push({ + id: node.content.id, + content: node.content, + index: 0, + }) + } else { + flatten.push({ + id: node.content.id, + content: node.content, + index: 0, + }) + + if (node.children && node.children.length > 0) { + const beforeChildrenLength = flatten.length + _toFlatten(node.children, flatten) + const afterChildrenLength = flatten.length + + if (beforeChildrenLength < afterChildrenLength) { + flatten[beforeChildrenLength].firstChild = true + } + if (afterChildrenLength > beforeChildrenLength) { + flatten[afterChildrenLength - 1].lastChild = true + } + } + } + } + return flatten +} + +export function toFlatten(tree: CommandTreeNode[]): FlattenNode[] { + let flatten = _toFlatten(tree) + flatten = flatten.map((node, index) => ({ ...node, index })) + return flatten +} + +export function calcLevel( + node: FlattenNode | Command | CommandFolder, + folders: CommandFolder[], +): number { + const calculateDepth = (parentFolderId?: string): number => { + if (!parentFolderId || parentFolderId === ROOT_FOLDER) { + return 0 + } + + const parentFolder = folders.find((f) => f.id === parentFolderId) + if (!parentFolder) { + return 0 + } + + return 1 + calculateDepth(parentFolder.parentFolderId) + } + + // Handle different input types + const parentFolderId = + 'content' in node ? node.content.parentFolderId : node.parentFolderId + + return calculateDepth(parentFolderId) +} + +export function getAllCommandsFromFolder( + folderNode: CommandTreeNode, +): Command[] { + const commands: Command[] = [] + + const collectCommands = (node: CommandTreeNode) => { + if (node.type === 'command') { + commands.push(node.content as Command) + } + + if (node.children) { + for (const child of node.children) { + collectCommands(child) + } + } + } + + collectCommands(folderNode) + return commands +} + +export function getAllFoldersFromNode(node: CommandTreeNode): CommandFolder[] { + const folders: CommandFolder[] = [] + + const collectFolders = (currentNode: CommandTreeNode) => { + if (currentNode.type === 'folder') { + folders.push(currentNode.content as CommandFolder) + } + + if (currentNode.children) { + for (const child of currentNode.children) { + collectFolders(child) + } + } + } + + collectFolders(node) + return folders +} diff --git a/src/services/option/commandUtils.ts b/src/services/option/commandUtils.ts new file mode 100644 index 00000000..7b3b638e --- /dev/null +++ b/src/services/option/commandUtils.ts @@ -0,0 +1,117 @@ +import type { Command, CommandFolder, SelectionCommand, PageActionCommand } from '@/types' +import { OPEN_MODE, ROOT_FOLDER } from '@/const' +import { e2a, isEmpty } from '@/lib/utils' + +/** + * Type guard to check if the content is a SelectionCommand + * @param content - The content to check + * @returns True if the content is a SelectionCommand, false otherwise + */ +export function isCommand( + content: Command | CommandFolder | undefined, +): content is SelectionCommand { + if (content == null) return false + if ('openMode' in content) { + return e2a(OPEN_MODE).includes(content.openMode) + } + return false +} + +/** + * Type guard to check if the content is a PageActionCommand + * @param content - The content to check + * @returns True if the content is a PageActionCommand, false otherwise + */ +export function isPageActionCommand( + content: Command | CommandFolder | undefined, +): content is PageActionCommand { + if (content == null) return false + if ('openMode' in content) { + return OPEN_MODE.PAGE_ACTION === content.openMode + } + return false +} + +/** + * Type guard to check if the content is a CommandFolder + * @param content - The content to check + * @returns True if the content is a CommandFolder, false otherwise + */ +export function isFolder( + content: Command | CommandFolder | undefined, +): content is CommandFolder { + if (content == null) return false + return !('openMode' in content) +} + +/** + * Checks if the content is inside a folder (not in the root folder) + * @param content - The content to check + * @returns True if the content is in a folder, false otherwise + */ +export function isInFolder(content: Command | CommandFolder | undefined): boolean { + if (content == null) return false + const folderId = content.parentFolderId + return !isEmpty(folderId) && folderId !== ROOT_FOLDER +} + +/** + * Removes unstored parameters from a command (such as temporary IDs) + * @param data - The command data to clean + * @returns The command with unstored parameters removed + */ +export function removeUnstoredParam(data: Command): Command { + const tempData = data as Command & { _id?: unknown } + delete tempData._id + return data +} + +/** + * Gets all descendant folder IDs for a given folder (recursive) + * @param folderId - The ID of the parent folder + * @param folders - Array of all folders to search through + * @returns Array of descendant folder IDs + */ +export const getDescendantFolderIds = ( + folderId: string, + folders: CommandFolder[], +): string[] => { + const children = folders.filter((f) => f.parentFolderId === folderId) + let descendants = children.map((f) => f.id) + for (const child of children) { + descendants = descendants.concat(getDescendantFolderIds(child.id, folders)) + } + return descendants +} + +/** + * Checks if folderA is a descendant of folderB + * @param folderAId - The ID of folder A to check + * @param folderBId - The ID of folder B (potential ancestor) + * @param folders - Array of all folders + * @returns True if folderA is a descendant of folderB, false otherwise + */ +export function isDescendantOf( + folderAId: string, + folderBId: string, + folders: CommandFolder[], +): boolean { + const descendants = getDescendantFolderIds(folderBId, folders) + return descendants.includes(folderAId) +} + +/** + * Checks if moving a folder would create a circular reference + * @param draggedFolderId - The ID of the folder being moved + * @param targetFolderId - The ID of the target folder + * @param folders - Array of all folders + * @returns True if moving would create a circular reference, false otherwise + */ +export function isCircularReference( + draggedFolderId: string, + targetFolderId: string, + folders: CommandFolder[], +): boolean { + const descendants = getDescendantFolderIds(draggedFolderId, folders) + return descendants.includes(targetFolderId) +} \ No newline at end of file diff --git a/src/services/defaultSettings.ts b/src/services/option/defaultSettings.ts similarity index 92% rename from src/services/defaultSettings.ts rename to src/services/option/defaultSettings.ts index 3026f72d..0e0f5845 100644 --- a/src/services/defaultSettings.ts +++ b/src/services/option/defaultSettings.ts @@ -1,4 +1,4 @@ -import { UserSettings, Command } from '@/types' +import { UserSettings, Command, SettingsType } from '@/types' import { VERSION, OPEN_MODE, @@ -22,6 +22,38 @@ export const PopupPlacement = { alignOffset: 0, } +// Empty settings for loading state +export const emptySettings: SettingsType = { + settingVersion: '0.0.0', + commands: [], + folders: [], + pageRules: [], + style: STYLE.HORIZONTAL, + popupPlacement: { + side: SIDE.top, + align: ALIGN.start, + alignOffset: 0, + sideOffset: 0, + }, + linkCommand: { + enabled: LINK_COMMAND_ENABLED.ENABLE, + openMode: DRAG_OPEN_MODE.PREVIEW_POPUP, + showIndicator: true, + startupMethod: { + method: LINK_COMMAND_STARTUP_METHOD.KEYBOARD, + keyboardParam: KEYBOARD.SHIFT, + threshold: 150, + leftClickHoldParam: 200, + }, + }, + userStyles: [], + startupMethod: { method: STARTUP_METHOD.TEXT_SELECTION }, + stars: [], + commandExecutionCount: 0, + hasShownReviewRequest: false, + shortcuts: { shortcuts: [] }, +} + export default { settingVersion: VERSION, popupPlacement: PopupPlacement, diff --git a/src/services/option/dragAndDrop.ts b/src/services/option/dragAndDrop.ts new file mode 100644 index 00000000..f4b4185c --- /dev/null +++ b/src/services/option/dragAndDrop.ts @@ -0,0 +1,144 @@ +import type { Active, Over } from '@dnd-kit/core' +import type { Command, CommandFolder } from '@/types' +import type { CommandTreeNode } from '@/services/option/commandTree' +import { findNodeInTree, findFirstCommand } from '@/services/option/commandTree' +import { isCommand, isFolder, isCircularReference } from '@/services/option/commandUtils' +import { ROOT_FOLDER } from '@/const' + +/** + * Checks if a drag operation between two content items is valid. + * Prevents invalid drops such as self-drops and circular folder references. + * + * @param activeContent - The content being dragged + * @param overContent - The content being dropped onto + * @param folders - Array of all folders for circular reference checking + * @returns true if the drop is valid, false otherwise + */ +export const isValidDrop = ( + activeContent: Command | CommandFolder | null | undefined, + overContent: Command | CommandFolder | null | undefined, + folders: CommandFolder[] = [], +): boolean => { + if (!activeContent || !overContent) return false + + if (isCommand(activeContent)) return true + + if (isFolder(activeContent) && isFolder(overContent)) { + return !isCircularReference(activeContent.id, overContent.id, folders) + } + + return true +} + +export type DragInfo = { + active: Active + over: Over | null +} + +export const isValidDragTarget = (active: Active, over: Over): boolean => { + if (!active || !over) return false + + const activeData = active.data.current?.content + const overData = over.data.current?.content + const folders = active.data.current?.folders || [] + + return isValidDrop(activeData, overData, folders) +} + +export const isForwardDrag = (active: Active, over: Over): boolean => { + const activeIndex = active.data.current?.sortable?.index + const overIndex = over.data.current?.sortable?.index + return typeof activeIndex === 'number' && typeof overIndex === 'number' + ? activeIndex < overIndex + : false +} + +export const calculateFolderToFolderPosition = ( + active: Active, + over: Over, + commands: Command[], + tree: CommandTreeNode[], +): { distIndex: number; firstChildIndex: number; newParentId: string } => { + const isForward = isForwardDrag(active, over) + const activeFolder = active.data.current?.content as CommandFolder + const activeNode = findNodeInTree(tree, activeFolder.id) + const overFolder = over.data.current?.content as CommandFolder + const overNode = findNodeInTree(tree, overFolder.id) + + let firstChildIndex = -1 + if (activeNode) { + const firstCommand = findFirstCommand(activeNode) + firstChildIndex = commands.findIndex( + (c) => c.id === firstCommand?.content.id, + ) + } + + let distIndex = commands.length + if (overNode) { + const firstCommand = findFirstCommand(overNode) + distIndex = commands.findIndex((c) => c.id === firstCommand?.content.id) + } + + return { + distIndex: isForward ? distIndex - 1 : distIndex, + firstChildIndex, + newParentId: isForward + ? overFolder.id + : overFolder.parentFolderId || ROOT_FOLDER, + } +} + +export const calculateFolderToCommandPosition = ( + active: Active, + over: Over, + commands: Command[], + tree: CommandTreeNode[], +): { distIndex: number; firstChildIndex: number } => { + const overCommand = over.data.current?.content as Command + const overCommandIndex = commands.findIndex( + (cmd) => cmd.id === overCommand.id, + ) + + let firstChildIndex = -1 + const folderNode = findNodeInTree(tree, active.data.current?.content.id) + if (folderNode) { + const firstCommand = findFirstCommand(folderNode) + firstChildIndex = commands.findIndex( + (c) => c.id === firstCommand?.content.id, + ) + } + + if (overCommandIndex === -1) + return { distIndex: commands.length, firstChildIndex } + + return { + distIndex: overCommandIndex, + firstChildIndex, + } +} + +export const calculateCommandToFolderPosition = ( + active: Active, + over: Over, + commands: Command[], + tree: CommandTreeNode[], +): { distIndex: number; newParentId?: string } => { + const isForward = isForwardDrag(active, over) + const overFolder = over.data.current?.content as CommandFolder + const overNode = findNodeInTree(tree, overFolder.id) + + let firstChildIndex = commands.length + if (overNode) { + const firstCommand = findFirstCommand(overNode) + firstChildIndex = commands.findIndex( + (c) => c.id === firstCommand?.content.id, + ) + } + + return { + distIndex: isForward ? firstChildIndex - 1 : firstChildIndex, + newParentId: isForward + ? overFolder.id + : overFolder.parentFolderId || ROOT_FOLDER, + } +} diff --git a/src/services/optionSettings.ts b/src/services/option/optionSettings.ts similarity index 100% rename from src/services/optionSettings.ts rename to src/services/option/optionSettings.ts diff --git a/src/services/settings.ts b/src/services/settings.ts index 45132ac3..f1165428 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -2,7 +2,7 @@ import { Storage, STORAGE_KEY } from './storage' import DefaultSettings, { DefaultCommands, PopupPlacement, -} from './defaultSettings' +} from './option/defaultSettings' import { OPTION_FOLDER, VERSION, @@ -28,7 +28,7 @@ import { isLinkCommand, } from '@/lib/utils' import { toDataURL } from '@/services/dom' -import { OptionSettings } from '@/services/optionSettings' +import { OptionSettings } from '@/services/option/optionSettings' enum LOCAL_STORAGE_KEY { CACHES = 'caches', @@ -44,10 +44,12 @@ export type ImageCache = { } const callbacks = [] as ((data: SettingsType) => void)[] + Storage.addListener(STORAGE_KEY.USER, async (settings: SettingsType) => { settings.commands = await Storage.getCommands() callbacks.forEach((cb) => cb(settings)) }) + Storage.addCommandListener(async (commands: Command[]) => { const settings = await Settings.get() settings.commands = commands diff --git a/src/services/settingsCache.ts b/src/services/settingsCache.ts new file mode 100644 index 00000000..8a41b1f1 --- /dev/null +++ b/src/services/settingsCache.ts @@ -0,0 +1,238 @@ +import { Storage, STORAGE_KEY, LOCAL_STORAGE_KEY } from './storage' +import type { UserStats, ShortcutSettings, UserSettings } from '@/types' +import { Settings } from './settings' + +// Cache section constants +export const CACHE_SECTIONS = { + COMMANDS: 'commands', + USER_SETTINGS: 'userSettings', + STARS: 'stars', + SHORTCUTS: 'shortcuts', + USER_STATS: 'userStats', + CACHES: 'caches', +} as const + +// Cache section definition +export type CacheSection = + | typeof CACHE_SECTIONS.COMMANDS + | typeof CACHE_SECTIONS.USER_SETTINGS + | typeof CACHE_SECTIONS.STARS + | typeof CACHE_SECTIONS.SHORTCUTS + | typeof CACHE_SECTIONS.USER_STATS + | typeof CACHE_SECTIONS.CACHES + +// Cache entry (internal use) +interface CacheEntry { + data: T + timestamp: number + version: string + ttl?: number +} + +// Version management (internal use) +class DataVersionManager { + private versions = new Map() + + generateVersion(section: CacheSection, data: any): string { + const dataHash = this.hashData(data) + const timestamp = Date.now() + return `${section}-${timestamp}-${dataHash}` + } + + setVersion(section: CacheSection, version: string): void { + this.versions.set(section, version) + } + + getVersion(section: CacheSection): string | undefined { + return this.versions.get(section) + } + + validateVersion(section: CacheSection, version: string): boolean { + return this.versions.get(section) === version + } + + private hashData(data: any): string { + const normalized = JSON.stringify(data, Object.keys(data || {}).sort()) + let hash = 5381 + for (let i = 0; i < normalized.length; i++) { + hash = (hash << 5) + hash + normalized.charCodeAt(i) + hash = hash & hash + } + return Math.abs(hash).toString(16).padStart(8, '0') + } +} + +// Settings cache manager +export class SettingsCacheManager { + private cache = new Map>() + private versionManager = new DataVersionManager() + private listeners = new Map void>>() + private readonly DEFAULT_TTL = 5 * 60 * 1000 // 5 minutes + private storageListenerSetup = false + + constructor() { + this.setupStorageListener() + } + + // Get data by section + async get(section: CacheSection, forceFresh = false): Promise { + if (!forceFresh && this.isValid(section)) { + // console.debug(`Cache hit for ${section}`) + return this.cache.get(section)!.data + } + + const data = await this.loadFromStorage(section) + this.setCache(section, data) + return data + } + + // Set cache + private setCache(section: CacheSection, data: T, ttl?: number): void { + const version = this.versionManager.generateVersion(section, data) + this.versionManager.setVersion(section, version) + + this.cache.set(section, { + data, + timestamp: Date.now(), + version, + ttl: ttl || this.DEFAULT_TTL, + }) + } + + // Check cache validity + private isValid(section: CacheSection): boolean { + const entry = this.cache.get(section) + if (!entry) return false + + const now = Date.now() + const isExpired = entry.ttl && now - entry.timestamp > entry.ttl + + return !isExpired + } + + // Invalidate by section + invalidate(sections: CacheSection[]): void { + console.log(`Invalidating cache for sections: ${sections.join(', ')}`) + sections.forEach((section) => { + this.cache.delete(section) + this.versionManager.setVersion(section, '') + this.notifyListeners(section) + }) + } + + // Invalidate all cache + invalidateAll(): void { + const allSections = Array.from(this.cache.keys()) + this.invalidate(allSections) + } + + // Subscribe to changes + subscribe(section: CacheSection, callback: () => void): void { + if (!this.listeners.has(section)) { + this.listeners.set(section, new Set()) + } + this.listeners.get(section)!.add(callback) + } + + // Unsubscribe from changes + unsubscribe(section: CacheSection, callback: () => void): void { + const sectionListeners = this.listeners.get(section) + if (sectionListeners) { + sectionListeners.delete(callback) + if (sectionListeners.size === 0) { + this.listeners.delete(section) + } + } + } + + // Notify listeners + private notifyListeners(section: CacheSection): void { + const sectionListeners = this.listeners.get(section) + if (sectionListeners) { + sectionListeners.forEach((callback) => { + try { + callback() + } catch (error) { + console.error(`Error in cache listener for ${section}:`, error) + } + }) + } + } + + // Load data from storage + private async loadFromStorage(section: CacheSection): Promise { + switch (section) { + case CACHE_SECTIONS.COMMANDS: + return (await Storage.getCommands()) as T + + case CACHE_SECTIONS.USER_SETTINGS: + return (await Storage.get(STORAGE_KEY.USER)) as T + + case CACHE_SECTIONS.STARS: + return (await Storage.get(LOCAL_STORAGE_KEY.STARS)) as T + + case CACHE_SECTIONS.SHORTCUTS: + return (await Storage.get(STORAGE_KEY.SHORTCUTS)) as T + + case CACHE_SECTIONS.USER_STATS: + return (await Storage.get(STORAGE_KEY.USER_STATS)) as T + + case CACHE_SECTIONS.CACHES: + return (await Settings.getCaches()) as T + + default: + throw new Error(`Unknown cache section: ${section}`) + } + } + + // Setup storage change monitoring + private setupStorageListener(): void { + if (this.storageListenerSetup) return + + // Monitor Chrome storage changes + chrome.storage.onChanged.addListener((changes, _areaName) => { + // console.log(`Storage changed in ${_areaName}:`, Object.keys(changes)) + + const sectionsToInvalidate: CacheSection[] = [] + + for (const key of Object.keys(changes)) { + if (key === STORAGE_KEY.USER.toString()) { + sectionsToInvalidate.push(CACHE_SECTIONS.USER_SETTINGS) + } else if (key === STORAGE_KEY.USER_STATS.toString()) { + sectionsToInvalidate.push(CACHE_SECTIONS.USER_STATS) + } else if (key === STORAGE_KEY.SHORTCUTS.toString()) { + sectionsToInvalidate.push(CACHE_SECTIONS.SHORTCUTS) + } else if (key === LOCAL_STORAGE_KEY.STARS) { + sectionsToInvalidate.push(CACHE_SECTIONS.STARS) + } else if (key === LOCAL_STORAGE_KEY.CACHES) { + sectionsToInvalidate.push(CACHE_SECTIONS.CACHES) + } else if (key.startsWith('cmd-')) { + sectionsToInvalidate.push(CACHE_SECTIONS.COMMANDS) + } + } + + if (sectionsToInvalidate.length > 0) { + this.invalidate([...new Set(sectionsToInvalidate)]) + } + }) + + this.storageListenerSetup = true + } + + // For debugging: Check cache status + getCacheStatus(): Record { + const status: Record = {} + + for (const [section, entry] of this.cache.entries()) { + status[section] = { + cached: true, + age: Date.now() - entry.timestamp, + } + } + + return status as Record + } +} + +// Singleton instance +export const settingsCache = new SettingsCacheManager() diff --git a/src/services/storage.ts b/src/services/storage.ts index 9442d86e..c8277811 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,294 +1,67 @@ -import DefaultSettings, { DefaultCommands } from './defaultSettings' -import { Command, CaptureDataStorage } from '@/types' - -const SYNC_DEBOUNCE_DELAY = 10 - -let syncSetTimeout: NodeJS.Timeout | null -let syncResolve: (() => void) | null -const syncSetData = new Map() - -const debouncedSyncSet = (data: Record): Promise => { - return new Promise((resolve) => { - if (syncSetTimeout != null) { - clearTimeout(syncSetTimeout) - syncResolve?.() - syncResolve = null - } - - Object.entries(data).forEach(([key, value]) => { - syncSetData.set(key, value) - }) - - syncSetTimeout = setTimeout(async () => { - const dataToSet = Object.fromEntries(syncSetData) - chrome.storage.sync.set(dataToSet, () => { - if (chrome.runtime.lastError != null) { - console.error(chrome.runtime.lastError) - } - syncSetData.clear() - syncSetTimeout = null - resolve() - syncResolve = null - }) - }, SYNC_DEBOUNCE_DELAY) - syncResolve = resolve - }) -} - -export enum STORAGE_KEY { - USER = 0, - COMMAND_COUNT = 2, - USER_STATS = 3, - SHORTCUTS = 4, -} - -export enum LOCAL_STORAGE_KEY { - CACHES = 'caches', - CLIENT_ID = 'clientId', - STARS = 'stars', - CAPTURES = 'captures', -} - -export enum SESSION_STORAGE_KEY { - BG = 'bg', - SELECTION_TEXT = 'selectionText ', - SESSION_DATA = 'sessionData', - MESSAGE_QUEUE = 'messageQueue', - TMP_CAPTURES = 'tmpCaptures', - PA_RECORDING = 'pageActionRecording', - PA_RUNNING = 'pageActionRunning', - PA_CONTEXT = 'pageActionContext', - PA_RECORDER_OPTION = 'pageActionRecorderOption', -} - -type KEY = STORAGE_KEY | LOCAL_STORAGE_KEY | SESSION_STORAGE_KEY - -const CMD_PREFIX = 'cmd-' - -const DEFAULT_COUNT = -1 - -const DEFAULTS = { - [STORAGE_KEY.USER]: DefaultSettings, - [STORAGE_KEY.COMMAND_COUNT]: DEFAULT_COUNT, - [STORAGE_KEY.USER_STATS]: { - commandExecutionCount: 0, - hasShownReviewRequest: false, - }, - [STORAGE_KEY.SHORTCUTS]: { - shortcuts: [], - }, - [LOCAL_STORAGE_KEY.CACHES]: { - images: {}, - }, - [LOCAL_STORAGE_KEY.CLIENT_ID]: '', - [LOCAL_STORAGE_KEY.STARS]: [], - [LOCAL_STORAGE_KEY.CAPTURES]: {}, - [SESSION_STORAGE_KEY.BG]: {}, - [SESSION_STORAGE_KEY.SESSION_DATA]: null, - [SESSION_STORAGE_KEY.MESSAGE_QUEUE]: [], - [SESSION_STORAGE_KEY.PA_RECORDING]: [], - [SESSION_STORAGE_KEY.PA_RUNNING]: {}, - [SESSION_STORAGE_KEY.PA_CONTEXT]: {}, - [SESSION_STORAGE_KEY.PA_RECORDER_OPTION]: {}, - [SESSION_STORAGE_KEY.TMP_CAPTURES]: {}, - [SESSION_STORAGE_KEY.SELECTION_TEXT]: '', -} - -const detectStorageArea = (key: KEY): chrome.storage.StorageArea => { - if (Object.values(STORAGE_KEY).includes(key)) { - return chrome.storage.sync - } - if (Object.values(LOCAL_STORAGE_KEY).includes(key as LOCAL_STORAGE_KEY)) { - return chrome.storage.local - } - if (Object.values(SESSION_STORAGE_KEY).includes(key as SESSION_STORAGE_KEY)) { - return chrome.storage.session - } - throw new Error('Invalid Storage Key') -} - -const getIndicesToRemove = (fromLen: number, toLen: number): number[] => { - if (toLen >= fromLen) { - return [] - } - const removeCount = fromLen - toLen - const startIndex = toLen - const indicesToRemove = [] - for (let i = 0; i < removeCount; i++) { - indicesToRemove.push(startIndex + i) - } - return indicesToRemove +import { Command } from "@/types" +import { + BaseStorage, + STORAGE_KEY, + LOCAL_STORAGE_KEY, + SESSION_STORAGE_KEY, + CMD_PREFIX, + ChangedCallback, + KEY, +} from "./storage/index" +import { + HybridCommandStorage, + CommandMigrationManager, + CommandStorage, + commandChangedCallback, +} from "./storage/commandStorage" +import { + DailyBackupManager, + WeeklyBackupManager, + LegacyBackupManager, +} from "./storage/backupManager" + +// Re-export everything from sub-modules +export { + STORAGE_KEY, + LOCAL_STORAGE_KEY, + SESSION_STORAGE_KEY, + CMD_PREFIX, + CommandMigrationManager, + DailyBackupManager, + WeeklyBackupManager, + LegacyBackupManager, } -export type ChangedCallback = (newVal: T, oldVal: T) => void -const changedCallbacks = {} as { [key: string]: ChangedCallback[] } - -type commandChangedCallback = (commands: Command[]) => void -const commandChangedCallbacks = [] as commandChangedCallback[] - -chrome.storage.onChanged.addListener((changes) => { - const commands = [] as Command[] - for (const [k, { oldValue, newValue }] of Object.entries(changes)) { - for (const [kk, callbacks] of Object.entries(changedCallbacks)) { - if (k === kk) callbacks.forEach((cb) => cb(newValue, oldValue)) - } - if (k.startsWith(CMD_PREFIX)) commands.push(newValue) - } - if (commands.length > 0) { - commandChangedCallbacks.forEach((cb) => cb(commands)) - } -}) - -type UpdateFunc = (currentVal: T) => T +export type { ChangedCallback, KEY, commandChangedCallback } export const Storage = { - /** - * Get a item from chrome sync storage. - * - * @param {STORAGE_KEY} key of item in storage. - */ - get: async (key: KEY): Promise => { - const area = detectStorageArea(key) - let result = await area.get(`${key}`) - if (chrome.runtime.lastError != null) { - throw chrome.runtime.lastError - } - return result[key] ?? structuredClone(DEFAULTS[key]) - }, - - /** - * Set a item to chrome sync storage. - * - * @param {string} key key of item. - * @param {any} value item. - */ - set: async (key: KEY, value: T): Promise => { - const area = detectStorageArea(key) - - if (area === chrome.storage.sync) { - await debouncedSyncSet({ [key.toString()]: value }) - return true - } else { - await area.set({ [key]: value }) - return true - } - }, + // Base storage methods + ...BaseStorage, - /** - * Set a item to chrome sync storage. - * - * @param {string} key key of item. - * @param {UpdateFunc} updater function to update item. - * - * @returns {Promise} true if success's - */ - update: async (key: KEY, updater: UpdateFunc): Promise => { - const data = await Storage.get(key) - const newData = updater(data) - return await Storage.set(key, newData) - }, - - /** - * Remove a item in chrome sync storage. - * - * @param {string} key key of item. - */ - remove: (key: KEY): Promise => { - return new Promise((resolve, reject) => { - const area = detectStorageArea(key) - area.remove(`${key}`, () => { - if (chrome.runtime.lastError != null) { - reject(chrome.runtime.lastError) - } else { - resolve(true) - } - }) - }) - }, + // Command-specific methods + ...CommandStorage, - addListener: (key: KEY, cb: ChangedCallback) => { - changedCallbacks[key] = changedCallbacks[key] ?? [] - changedCallbacks[key].push(cb) - }, + // New methods for hybrid storage (will be initialized after Storage is defined) + hybridStorage: null as unknown as HybridCommandStorage, - removeListener: (key: KEY, cb: ChangedCallback) => { - changedCallbacks[key] = changedCallbacks[key]?.filter((f) => f !== cb) - }, + // Daily backup manager + dailyBackupManager: new DailyBackupManager(), - commandKeys: async (): Promise => { - const count = await Storage.get(STORAGE_KEY.COMMAND_COUNT) - return Array.from({ length: count }, (_, i) => `${CMD_PREFIX}${i}`) - }, - - getCapture: async (key: string): Promise => { - let captures = await Storage.get( - LOCAL_STORAGE_KEY.CAPTURES, - ) - let c = captures[key] - if (c != null) { - return c - } - captures = await Storage.get( - SESSION_STORAGE_KEY.TMP_CAPTURES, - ) - c = captures[key] - if (c != null) { - return c - } - }, + // Weekly backup manager + weeklyBackupManager: new WeeklyBackupManager(), /** - * Get all commands from chrome sync storage. - * - * @returns {Promise} commands - * @throws {chrome.runtime.LastError} if error occurred + * New command getter method (hybrid storage compatible) */ getCommands: async (): Promise => { - // If first time, return DefaultCommands. - const count = await Storage.get(STORAGE_KEY.COMMAND_COUNT) - if (count === DEFAULT_COUNT) return DefaultCommands - - const keys = await Storage.commandKeys() - const res = await chrome.storage.sync.get(keys) - if (chrome.runtime.lastError != null) { - throw chrome.runtime.lastError - } - return keys.map((key) => res[key]).filter((cmd) => cmd != null) + return await Storage.hybridStorage.loadCommands() }, /** - * Set all commands to chrome sync storage. - * - * @returns {Promise} true if success's - * @throws {chrome.runtime.LastError} if error occurred + * New command setter method (hybrid storage compatible) */ - setCommands: async ( - commands: Command[], - ): Promise => { - const count = commands.length - const preCount = await Storage.get(STORAGE_KEY.COMMAND_COUNT) - - // Update commands and count. - const data = commands.reduce( - (acc, cmd, i) => { - acc[`${CMD_PREFIX}${i}`] = cmd - return acc - }, - {} as { [key: string]: Command }, - ) - await debouncedSyncSet({ - ...data, - [STORAGE_KEY.COMMAND_COUNT]: commands.length, - }) - - // Remove surplus commands - if (preCount > count) { - const removeKeys = getIndicesToRemove(preCount, count).map( - (i) => `${CMD_PREFIX}${i}`, - ) - await chrome.storage.sync.remove(removeKeys) - } - return true + setCommands: async (commands: Command[]): Promise => { + return await Storage.hybridStorage.saveCommands(commands) }, /** @@ -300,39 +73,13 @@ export const Storage = { updateCommands: async ( commands: Command[], ): Promise => { - const current = await Storage.getCommands() - - // If update first time, set DefaultCommands. - const count = await Storage.get(STORAGE_KEY.COMMAND_COUNT) - if (count === DEFAULT_COUNT) { - console.debug('Update first time, set DefaultCommands.') - const newCommands = current.map((cmd) => { - return commands.find((c) => c.id === cmd.id) ?? cmd - }) - return Storage.setCommands(newCommands) - } - - // Update commands. - const newCommands = current.reduce( - (acc, cmd, i) => { - const newCmd = commands.find((c) => c.id === cmd.id) - if (newCmd) { - acc[`${CMD_PREFIX}${i}`] = newCmd - } - return acc - }, - {} as { [key: string]: Command }, + return await CommandStorage.updateCommands( + commands, + Storage.hybridStorage, + Storage, ) - await debouncedSyncSet(newCommands) - return true - }, - - addCommandListener: (cb: commandChangedCallback) => { - commandChangedCallbacks.push(cb) - }, - - removeCommandListener: (cb: commandChangedCallback) => { - const idx = commandChangedCallbacks.findIndex((f) => f === cb) - if (idx !== -1) commandChangedCallbacks.splice(idx, 1) }, } + +// Initialize hybrid storage after Storage object is defined +Storage.hybridStorage = new HybridCommandStorage(Storage) diff --git a/src/services/storage/backupManager.ts b/src/services/storage/backupManager.ts new file mode 100644 index 00000000..a798b3a6 --- /dev/null +++ b/src/services/storage/backupManager.ts @@ -0,0 +1,190 @@ +import { Command, CommandFolder } from "@/types" +import { LOCAL_STORAGE_KEY, BaseStorage, STORAGE_KEY } from "./index" +import { HybridCommandStorage } from "./commandStorage" + +export interface BackupData { + version: string + timestamp: number + commands: Command[] + folders: CommandFolder[] +} + +export abstract class BaseBackupManager { + protected abstract readonly BACKUP_KEY: LOCAL_STORAGE_KEY + protected abstract readonly BACKUP_INTERVAL_MS: number + protected abstract readonly VERSION: string + + async shouldBackup(): Promise { + try { + const lastBackup = await this.getLastBackupData() + if (!lastBackup) { + return true + } + + const now = Date.now() + const timeSinceLastBackup = now - lastBackup.timestamp + return timeSinceLastBackup >= this.BACKUP_INTERVAL_MS + } catch (error) { + console.error("Failed to check backup schedule:", error) + return true + } + } + + async performBackup(): Promise { + try { + const hybridStorage = new HybridCommandStorage() + const commands = await hybridStorage.loadCommands() + + // Get folders from settings directly from storage to avoid circular dependency + const userSettings = (await BaseStorage.get(STORAGE_KEY.USER)) as { + folders?: CommandFolder[] + } + const folders = userSettings?.folders || [] + + if (commands.length === 0 && folders.length === 0) { + console.debug("No commands or folders to backup") + return + } + + const backup: BackupData = { + version: this.VERSION, + timestamp: Date.now(), + commands: commands, + folders: folders, + } + + await BaseStorage.set(this.BACKUP_KEY, backup) + + console.debug( + `${this.VERSION} backup completed: ${commands.length} commands and ${folders.length} folders backed up`, + ) + } catch (error) { + console.error(`Failed to perform ${this.VERSION} backup:`, error) + } + } + + async getLastBackupData(): Promise { + try { + const backup = await BaseStorage.get(this.BACKUP_KEY) + + if (backup && backup.timestamp && Array.isArray(backup.commands)) { + return { + version: backup.version, + timestamp: backup.timestamp, + commands: backup.commands, + folders: Array.isArray(backup.folders) ? backup.folders : [], + } + } + return null + } catch (error) { + console.error("Failed to get last backup data:", error) + return null + } + } + + async restoreFromBackup(): Promise<{ + commands: Command[] + folders: CommandFolder[] + }> { + try { + const backup = await this.getLastBackupData() + if (!backup) { + return { commands: [], folders: [] } + } + + return { + commands: backup.commands || [], + folders: backup.folders || [], + } + } catch (error) { + console.error("Failed to restore from backup:", error) + return { commands: [], folders: [] } + } + } + + async getLastBackupDate(): Promise { + const backup = await this.getLastBackupData() + return backup ? new Date(backup.timestamp) : null + } +} + +// Daily backup management class +export class DailyBackupManager extends BaseBackupManager { + protected readonly BACKUP_KEY = LOCAL_STORAGE_KEY.DAILY_COMMANDS_BACKUP + protected readonly BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours + protected readonly VERSION = "daily" + + // 既存のメソッド名を保持するためのエイリアス + async performDailyBackup(): Promise { + return this.performBackup() + } + + async restoreFromDailyBackup(): Promise<{ + commands: Command[] + folders: CommandFolder[] + }> { + return this.restoreFromBackup() + } +} + +// Weekly backup management class +export class WeeklyBackupManager extends BaseBackupManager { + protected readonly BACKUP_KEY = LOCAL_STORAGE_KEY.WEEKLY_COMMANDS_BACKUP + protected readonly BACKUP_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + protected readonly VERSION = "weekly" + + async performWeeklyBackup(): Promise { + return this.performBackup() + } + + async restoreFromWeeklyBackup(): Promise<{ + commands: Command[] + folders: CommandFolder[] + }> { + return this.restoreFromBackup() + } +} + +// Legacy backup management class (for migration purposes) +export class LegacyBackupManager extends BaseBackupManager { + protected readonly BACKUP_KEY = LOCAL_STORAGE_KEY.COMMANDS_BACKUP + protected readonly BACKUP_INTERVAL_MS = 0 // Never auto-backup (manual only) + protected readonly VERSION = "legacy" + + async performLegacyBackup(): Promise { + return this.performBackup() + } + + async restoreFromLegacyBackup(): Promise<{ + commands: Command[] + folders: CommandFolder[] + }> { + return this.restoreFromBackup() + } + + // Migration-specific method that backs up given commands (not from storage) + async backupCommandsForMigration(commands: Command[]): Promise { + try { + // Get folders from settings directly from storage to avoid circular dependency + const userSettings = (await BaseStorage.get(STORAGE_KEY.USER)) as { + folders?: CommandFolder[] + } + const folders = userSettings?.folders || [] + + const backup: BackupData = { + version: this.VERSION, + timestamp: Date.now(), + commands: commands, + folders: folders, + } + + await BaseStorage.set(this.BACKUP_KEY, backup) + + console.debug( + `Legacy migration backup completed: ${commands.length} commands and ${folders.length} folders backed up`, + ) + } catch (error) { + console.error(`Failed to perform legacy migration backup:`, error) + } + } +} diff --git a/src/services/storage/commandStorage.ts b/src/services/storage/commandStorage.ts new file mode 100644 index 00000000..47e8fc48 --- /dev/null +++ b/src/services/storage/commandStorage.ts @@ -0,0 +1,702 @@ +import { DefaultCommands } from "../option/defaultSettings" +import { Command } from "@/types" +import { + BaseStorage, + STORAGE_KEY, + LOCAL_STORAGE_KEY, + CMD_PREFIX, + KEY, + debouncedSyncSet, +} from "./index" +import { VERSION } from "@/const" +import { LegacyBackupManager } from "./backupManager" + +// Storage interface for dependency injection +interface StorageInterface { + get(key: KEY): Promise + set(key: KEY, value: T): Promise +} + +// Command change callbacks +export type commandChangedCallback = (commands: Command[]) => void +const commandChangedCallbacks = [] as commandChangedCallback[] + +// Setup command change listener +chrome.storage.onChanged.addListener((changes) => { + const commands = [] as Command[] + for (const [k, { newValue }] of Object.entries(changes)) { + if (k.startsWith(CMD_PREFIX)) commands.push(newValue) + } + if (commands.length > 0) { + commandChangedCallbacks.forEach((cb) => cb(commands)) + } +}) + +const DEFAULT_COUNT = -1 + +/** + * Generate checksum from object (JSON.stringify based) + * Uses a simple hash algorithm compatible with browser environment + * @param obj - Object to generate checksum for + * @returns Checksum (hexadecimal string) + */ +function generateChecksum(obj: unknown): string { + const normalized = JSON.stringify(obj, Object.keys(obj || {}).sort()) + + // Simple hash algorithm (djb2) that works in browser + let hash = 5381 + for (let i = 0; i < normalized.length; i++) { + hash = (hash << 5) + hash + normalized.charCodeAt(i) + hash = hash & hash // Convert to 32bit integer + } + + // Convert to hex string and pad to ensure consistent length + return Math.abs(hash).toString(16).padStart(8, "0") +} + +async function loadLegacyCommandData( + storage: StorageInterface, + options?: { + returnDefaultOnEmpty?: boolean + throwOnError?: boolean + }, +): Promise { + const { returnDefaultOnEmpty = false, throwOnError = false } = options || {} + + try { + const count = await storage.get(STORAGE_KEY.COMMAND_COUNT) + if (count === DEFAULT_COUNT) { + return returnDefaultOnEmpty ? DefaultCommands : [] + } + + const keys = Array.from({ length: count }, (_, i) => `${CMD_PREFIX}${i}`) + const result = await chrome.storage.sync.get(keys) + + if (throwOnError && chrome.runtime.lastError != null) { + throw chrome.runtime.lastError + } + + return keys.map((key) => result[key]).filter((cmd) => cmd != null) + } catch (error) { + if (throwOnError) { + throw error + } + console.warn("Failed to load legacy commands:", error) + return returnDefaultOnEmpty ? DefaultCommands : [] + } +} + +// Type definitions for hybrid storage +interface CommandMetadata { + count: number // Number of commands saved in this storage + version: number // Data version (timestamp) + checksum: string // Hash for integrity checking +} + +interface GlobalCommandMetadata { + globalOrder: string[] // Global command order array + version: number // Global data version (timestamp) + lastUpdated: number // Last update timestamp +} + +interface StorageAllocation { + sync: { + commands: Command[] + totalBytes: number + itemCount: number + } + local: { + commands: Command[] + totalBytes: number + itemCount: number + } + syncMetadata: CommandMetadata + localMetadata: CommandMetadata + globalMetadata: GlobalCommandMetadata +} + +// Storage capacity calculation class +class StorageCapacityCalculator { + private readonly SYNC_COMMAND_TOTAL = 60 * 1024 // 60KB (including safety margin) + private readonly ITEM_MAX_SIZE = 8 * 1024 // 8KB + + /** + * Calculate accurate byte count with UTF-8 encoding + */ + calculateCommandSize(command: Command): number { + // Serialize command object to JSON + const jsonStr = JSON.stringify(command) + + // Calculate byte count with UTF-8 encoding + const bytes = new TextEncoder().encode(jsonStr).length + + // Add overhead for chrome.storage key name + const keyOverhead = new TextEncoder().encode( + `${CMD_PREFIX}${command.id}`, + ).length + + return bytes + keyOverhead + } + + /** + * Analyze storage capacity for all commands and decide allocation + */ + analyzeAndAllocate(commands: Command[]): StorageAllocation { + // Step 1: Calculate size for each command + const commandSizes = commands.map((command) => ({ + command, + size: this.calculateCommandSize(command), + canFitInSync: this.calculateCommandSize(command) <= this.ITEM_MAX_SIZE, + })) + + // Step 2: Allocate large commands to local storage first + const largeCommands = commandSizes.filter((item) => !item.canFitInSync) + const candidateCommands = commandSizes.filter((item) => item.canFitInSync) + + // Step 3: Sequentially allocate until sync capacity is reached + const syncCommands: Array<{ command: Command; size: number }> = [] + const localCommands: Array<{ command: Command; size: number }> = [ + ...largeCommands, + ] + + let syncUsage = 0 + for (const item of candidateCommands) { + if (syncUsage + item.size <= this.SYNC_COMMAND_TOTAL) { + syncCommands.push(item) + syncUsage += item.size + } else { + localCommands.push(item) + } + } + + // Step 4: Build allocation result + return { + sync: { + commands: syncCommands.map((item) => item.command), + totalBytes: syncUsage, + itemCount: syncCommands.length, + }, + local: { + commands: localCommands.map((item) => item.command), + totalBytes: localCommands.reduce((sum, item) => sum + item.size, 0), + itemCount: localCommands.length, + }, + ...this.createMetadata( + commands, + syncCommands.length, + localCommands.length, + ), + } + } + + private createMetadata( + allCommands: Command[], + syncCount: number, + localCount: number, + ): { + syncMetadata: CommandMetadata + localMetadata: CommandMetadata + globalMetadata: GlobalCommandMetadata + } { + const syncCommands = allCommands.slice(0, syncCount) + const localCommands = allCommands.slice(syncCount) + const timestamp = Date.now() + + return { + syncMetadata: { + count: syncCount, + version: timestamp, + checksum: generateChecksum(syncCommands), + }, + localMetadata: { + count: localCount, + version: timestamp, + checksum: generateChecksum(localCommands), + }, + globalMetadata: { + globalOrder: allCommands.map((cmd) => cmd.id), + version: timestamp, + lastUpdated: timestamp, + }, + } + } +} + +// Command metadata management class +class CommandMetadataManager { + private readonly SYNC_METADATA_KEY = STORAGE_KEY.SYNC_COMMAND_METADATA + private readonly LOCAL_METADATA_KEY = LOCAL_STORAGE_KEY.LOCAL_COMMAND_METADATA + private readonly GLOBAL_METADATA_KEY = + LOCAL_STORAGE_KEY.GLOBAL_COMMAND_METADATA + private storage: StorageInterface + + constructor(storage: StorageInterface = BaseStorage) { + this.storage = storage + } + + async saveSyncCommandMetadata(metadata: CommandMetadata): Promise { + await this.storage.set(this.SYNC_METADATA_KEY, metadata) + } + + async saveLocalCommandMetadata(metadata: CommandMetadata): Promise { + await this.storage.set(this.LOCAL_METADATA_KEY, metadata) + } + + async saveGlobalCommandMetadata( + metadata: GlobalCommandMetadata, + ): Promise { + console.debug("Saving global command metadata:", metadata) + await this.storage.set(this.GLOBAL_METADATA_KEY, metadata) + } + + async loadSyncCommandMetadata(): Promise { + try { + const metadata = await this.storage.get( + this.SYNC_METADATA_KEY, + ) + return metadata || null + } catch (error) { + console.error("Failed to load sync metadata:", error) + return null + } + } + + async loadLocalCommandMetadata(): Promise { + try { + const metadata = await this.storage.get( + this.LOCAL_METADATA_KEY, + ) + return metadata || null + } catch (error) { + console.error("Failed to load local metadata:", error) + return null + } + } + + async loadGlobalCommandMetadata(): Promise { + try { + const metadata = await this.storage.get( + this.GLOBAL_METADATA_KEY, + ) + if (metadata) { + return metadata + } + console.warn("Global command metadata not found.") + return null + } catch (error) { + console.error("Failed to load global metadata:", error) + return null + } + } + + async validateCommandIntegrity( + commands: Command[], + metadata: CommandMetadata, + ): Promise { + if (!metadata) return false + + // Basic integrity check + if (metadata.count !== commands.length) return false + + // Checksum-based integrity check + const currentChecksum = generateChecksum(commands) + return metadata.checksum === currentChecksum + } + + async validateGlobalConsistency(allCommands: Command[]): Promise { + const globalMetadata = await this.loadGlobalCommandMetadata() + if (!globalMetadata) return false + + // Check if global order matches actual commands + const actualIds = allCommands.map((cmd) => cmd.id) + const expectedIds = globalMetadata.globalOrder + + return ( + actualIds.length === expectedIds.length && + actualIds.every((id, index) => id === expectedIds[index]) + ) + } + + // Migration determination + async needsMigration(): Promise { + const syncMetadata = await this.loadSyncCommandMetadata() + const localMetadata = await this.loadLocalCommandMetadata() + const globalMetadata = await this.loadGlobalCommandMetadata() + const oldCount = await this.storage.get(STORAGE_KEY.COMMAND_COUNT) + + // When new metadata doesn't exist but legacy format data exists + return ( + !syncMetadata && + !localMetadata && + !globalMetadata && + oldCount !== DEFAULT_COUNT + ) + } +} + +// Migration management class +export class CommandMigrationManager { + private readonly MIGRATION_VERSION = VERSION + private readonly MIGRATION_FLAG_KEY = LOCAL_STORAGE_KEY.MIGRATION_STATUS + private storage: StorageInterface + + constructor(storage: StorageInterface = BaseStorage) { + this.storage = storage + } + + async performMigration(): Promise { + try { + console.debug("Starting command migration to hybrid storage...") + + // Step 1: Load legacy format data + const legacyCommands = await loadLegacyCommandData(this.storage) + if (legacyCommands.length === 0) { + return DefaultCommands + } + + // Step 2: Backup data + const legacyBackupManager = new LegacyBackupManager() + await legacyBackupManager.backupCommandsForMigration(legacyCommands) + + // Step 3: Save in new format + const hybridStorage = new HybridCommandStorage(this.storage) + await hybridStorage.saveCommands(legacyCommands) + + // Step 4: Set migration completion flag + await this.storage.set(this.MIGRATION_FLAG_KEY, { + version: this.MIGRATION_VERSION, + migratedAt: Date.now(), + commandCount: legacyCommands.length, + }) + + console.debug( + `Migration completed: ${legacyCommands.length} commands migrated`, + ) + return legacyCommands + } catch (error) { + console.error("Migration failed:", error) + + // Attempt to restore from backup when migration fails + const legacyBackupManager = new LegacyBackupManager() + const backupData = await legacyBackupManager.restoreFromLegacyBackup() + if (backupData.commands.length > 0) { + console.debug( + `Migration failed, but restored ${backupData.commands.length} commands from backup`, + ) + return backupData.commands + } + + throw new Error( + `Command migration failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + async needsMigration(): Promise { + try { + const migrationStatus = (await this.storage.get( + this.MIGRATION_FLAG_KEY, + )) as { + version?: string + } | null + if ( + migrationStatus && + migrationStatus.version === this.MIGRATION_VERSION + ) { + return false + } + } catch { + // Migration flag doesn't exist = migration not performed + } + + const legacyCount = await this.storage.get( + STORAGE_KEY.COMMAND_COUNT, + ) + return legacyCount !== DEFAULT_COUNT + } + + /** + * Restore both commands and folders from legacy backup + */ + async restoreFromBackup(): Promise<{ + commands: Command[] + folders: import("@/types").CommandFolder[] + }> { + const legacyBackupManager = new LegacyBackupManager() + return await legacyBackupManager.restoreFromLegacyBackup() + } +} + +// Hybrid command storage class +export class HybridCommandStorage { + public calculator = new StorageCapacityCalculator() + private metadataManager: CommandMetadataManager + private storage: StorageInterface + + constructor(storage: StorageInterface = BaseStorage) { + this.storage = storage + this.metadataManager = new CommandMetadataManager(this.storage) + } + + async saveCommands(commands: Command[]): Promise { + try { + // Step 1: Determine storage allocation + const allocation = this.calculator.analyzeAndAllocate(commands) + + // Step 2: Save commands and metadata atomically + await this.saveCommandsAndMetadata(allocation) + return true + } catch (error) { + console.error("Failed to save commands:", error) + throw error + } + } + + async loadCommands(retryCount = 0): Promise { + const MAX_RETRIES = 3 + const RETRY_DELAY_MS = 10 + + try { + // Step 1: Check if migration is needed + if (await this.metadataManager.needsMigration()) { + const migrationManager = new CommandMigrationManager() + return await migrationManager.performMigration() + } + + // Step 2: Load metadata + const [syncMetadata, localMetadata, globalMetadata] = await Promise.all([ + this.metadataManager.loadSyncCommandMetadata(), + this.metadataManager.loadLocalCommandMetadata(), + this.metadataManager.loadGlobalCommandMetadata(), + ]) + + if (!syncMetadata && !localMetadata && !globalMetadata) { + return DefaultCommands + } + + // Step 3: Load commands from both storage areas + const [syncCommands, localCommands] = await Promise.all([ + this.loadFromSync(syncMetadata?.count || 0), + this.loadFromLocal(localMetadata?.count || 0), + ]) + + // Step 4: Merge and order commands with fallback + const allCommands = [...syncCommands, ...localCommands] + let orderedCommands: Command[] + + if (globalMetadata) { + // Reorder commands according to global order, ignoring non-existent commands + orderedCommands = this.reorderCommands( + allCommands, + globalMetadata.globalOrder, + ) + + // Check if there are missing commands + const expectedCount = globalMetadata.globalOrder.length + const actualCount = orderedCommands.length + + if (actualCount < expectedCount) { + console.debug( + `Missing ${expectedCount - actualCount} commands from global order`, + ) + // Update global metadata if necessary + await this.updateGlobalMetadataForMissingCommands(orderedCommands) + } + } else { + // Fallback: merge with sync commands first + orderedCommands = allCommands + } + + // Step 5: Integrity checks + let hasIntegrityIssues = false + + if (syncMetadata && syncCommands.length > 0) { + if ( + !(await this.metadataManager.validateCommandIntegrity( + syncCommands, + syncMetadata, + )) + ) { + console.debug("Sync command integrity check failed") + hasIntegrityIssues = true + } + } + + if (localMetadata && localCommands.length > 0) { + if ( + !(await this.metadataManager.validateCommandIntegrity( + localCommands, + localMetadata, + )) + ) { + console.debug("Local command integrity check failed") + hasIntegrityIssues = true + } + } + + if ( + globalMetadata && + !(await this.metadataManager.validateGlobalConsistency(orderedCommands)) + ) { + console.debug("Global consistency check failed") + hasIntegrityIssues = true + } + + if (hasIntegrityIssues) { + console.debug( + `Command integrity check failed (attempt ${retryCount + 1}/${MAX_RETRIES + 1})`, + ) + // Retry with delay if we haven't exceeded max retries + if (retryCount < MAX_RETRIES) { + console.info( + `Retrying command load after ${RETRY_DELAY_MS}ms delay...`, + ) + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)) + return await this.loadCommands(retryCount + 1) + } + console.warn("Command integrity check failed after all retries...") + } + + return orderedCommands + } catch (error) { + console.error("Failed to load commands:", error) + return DefaultCommands + } + } + + private async saveCommandsAndMetadata( + allocation: StorageAllocation, + ): Promise { + const syncSavePromises: Promise[] = [] + const localSavePromises: Promise[] = [] + + // Save to sync storage + allocation.sync.commands.forEach((command, index) => { + const key = `${CMD_PREFIX}${index}` + syncSavePromises.push(debouncedSyncSet({ [key]: command })) + }) + + // Save to local storage using BaseStorage + allocation.local.commands.forEach((command, index) => { + const key = `${CMD_PREFIX}local-${index}` as KEY + localSavePromises.push(this.storage.set(key, command)) + }) + + // Add metadata save to the same promise batch + console.log(allocation.globalMetadata) + const metadataSavePromises = [ + this.metadataManager.saveSyncCommandMetadata(allocation.syncMetadata), + this.metadataManager.saveLocalCommandMetadata(allocation.localMetadata), + this.metadataManager.saveGlobalCommandMetadata(allocation.globalMetadata), + ] + + // Save commands and metadata atomically in parallel + await Promise.all([ + await Promise.all(syncSavePromises), + await Promise.all(localSavePromises), + await Promise.all(metadataSavePromises), + ]) + } + + private async loadFromSync(count: number): Promise { + if (count === 0) return [] + + const keys = Array.from({ length: count }, (_, i) => `${CMD_PREFIX}${i}`) + const result = await chrome.storage.sync.get(keys) + + return keys.map((key) => result[key]).filter((cmd) => cmd != null) + } + + private async loadFromLocal(count: number): Promise { + if (count === 0) return [] + + const keys = Array.from( + { length: count }, + (_, i) => `${CMD_PREFIX}local-${i}`, + ) + const result = await chrome.storage.local.get(keys) + + return keys.map((key) => result[key]).filter((cmd) => cmd != null) + } + + private reorderCommands(commands: Command[], order: string[]): Command[] { + const commandMap = new Map(commands.map((cmd) => [cmd.id, cmd])) + const orderedCommands: Command[] = [] + + // Reorder according to global order (existing commands only) + for (const id of order) { + const command = commandMap.get(id) + if (command) { + orderedCommands.push(command) + commandMap.delete(id) + } + } + + // Add new commands not in global order to the end + orderedCommands.push(...commandMap.values()) + + return orderedCommands + } + + private async updateGlobalMetadataForMissingCommands( + actualCommands: Command[], + ): Promise { + const updatedGlobalMetadata: GlobalCommandMetadata = { + globalOrder: actualCommands.map((cmd) => cmd.id), + version: Date.now(), + lastUpdated: Date.now(), + } + + // Update global metadata to match actual commands + await this.metadataManager.saveGlobalCommandMetadata(updatedGlobalMetadata) + } +} + +export const CommandStorage = { + /** + * Update commands to chrome sync storage. + * + * @returns {Promise} true if success's + * @throws {chrome.runtime.LastError} if error occurred + */ + updateCommands: async ( + commands: Command[], + hybridStorage: HybridCommandStorage, + storage: StorageInterface = BaseStorage, + ): Promise => { + const current = await hybridStorage.loadCommands() + + // If update first time, set DefaultCommands. + const count = await storage.get(STORAGE_KEY.COMMAND_COUNT) + if (count === DEFAULT_COUNT) { + console.debug("Update first time, set DefaultCommands.") + const newCommands = current.map((cmd) => { + return commands.find((c) => c.id === cmd.id) ?? cmd + }) + return hybridStorage.saveCommands(newCommands) + } + + // Update commands. + const newCommands = current.reduce( + (acc, cmd, i) => { + const newCmd = commands.find((c) => c.id === cmd.id) + if (newCmd) { + acc[`${CMD_PREFIX}${i}`] = newCmd + } + return acc + }, + {} as { [key: string]: Command }, + ) + await debouncedSyncSet(newCommands) + return true + }, + + addCommandListener: (cb: commandChangedCallback) => { + commandChangedCallbacks.push(cb) + }, + + removeCommandListener: (cb: commandChangedCallback) => { + const idx = commandChangedCallbacks.findIndex((f) => f === cb) + if (idx !== -1) commandChangedCallbacks.splice(idx, 1) + }, +} diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts new file mode 100644 index 00000000..9e9ac17d --- /dev/null +++ b/src/services/storage/index.ts @@ -0,0 +1,224 @@ +import DefaultSettings from "../option/defaultSettings" +import { CaptureDataStorage } from "@/types" + +const SYNC_DEBOUNCE_DELAY = 10 + +export enum STORAGE_KEY { + USER = 0, + COMMAND_COUNT = 2, + USER_STATS = 3, + SHORTCUTS = 4, + SYNC_COMMAND_METADATA = 5, +} + +export enum LOCAL_STORAGE_KEY { + CACHES = "caches", + CLIENT_ID = "clientId", + STARS = "stars", + CAPTURES = "captures", + COMMAND_LOCAL_COUNT = "commandLocalCount", + MIGRATION_STATUS = "migrationStatus", + LOCAL_COMMAND_METADATA = "localCommandMetadata", + GLOBAL_COMMAND_METADATA = "globalCommandMetadata", + COMMANDS_BACKUP = "commandsBackup", + DAILY_COMMANDS_BACKUP = "dailyCommandsBackup", + WEEKLY_COMMANDS_BACKUP = "weeklyCommandsBackup", +} + +export enum SESSION_STORAGE_KEY { + BG = "bg", + SELECTION_TEXT = "selectionText ", + SESSION_DATA = "sessionData", + MESSAGE_QUEUE = "messageQueue", + TMP_CAPTURES = "tmpCaptures", + PA_RECORDING = "pageActionRecording", + PA_RUNNING = "pageActionRunning", + PA_CONTEXT = "pageActionContext", + PA_RECORDER_OPTION = "pageActionRecorderOption", +} + +export const CMD_PREFIX = "cmd-" + +let syncSetTimeout: NodeJS.Timeout | null +let syncSetResolves: (() => void)[] = [] +const syncSetData = new Map() + +const debouncedSyncSet = (data: Record): Promise => { + return new Promise((resolve) => { + if (syncSetTimeout != null) { + clearTimeout(syncSetTimeout) + } + + Object.entries(data).forEach(([key, value]) => { + syncSetData.set(key, value) + }) + + syncSetTimeout = setTimeout(async () => { + const dataToSet = Object.fromEntries(syncSetData) + chrome.storage.sync.set(dataToSet, () => { + if (chrome.runtime.lastError != null) { + console.error(chrome.runtime.lastError) + } + syncSetData.clear() + syncSetTimeout = null + syncSetResolves.forEach((resolve) => resolve()) + syncSetResolves = [] + }) + }, SYNC_DEBOUNCE_DELAY) + syncSetResolves.push(resolve) + }) +} + +export type KEY = STORAGE_KEY | LOCAL_STORAGE_KEY | SESSION_STORAGE_KEY | string + +const DEFAULT_COUNT = -1 + +const DEFAULTS = { + [STORAGE_KEY.USER]: DefaultSettings, + [STORAGE_KEY.COMMAND_COUNT]: DEFAULT_COUNT, + [STORAGE_KEY.USER_STATS]: { + commandExecutionCount: 0, + hasShownReviewRequest: false, + }, + [STORAGE_KEY.SHORTCUTS]: { + shortcuts: [], + }, + [STORAGE_KEY.SYNC_COMMAND_METADATA]: null, + [LOCAL_STORAGE_KEY.CACHES]: { + images: {}, + }, + [LOCAL_STORAGE_KEY.CLIENT_ID]: "", + [LOCAL_STORAGE_KEY.STARS]: [], + [LOCAL_STORAGE_KEY.CAPTURES]: {}, + [LOCAL_STORAGE_KEY.COMMAND_LOCAL_COUNT]: 0, + [LOCAL_STORAGE_KEY.MIGRATION_STATUS]: null, + [LOCAL_STORAGE_KEY.COMMANDS_BACKUP]: null, + [LOCAL_STORAGE_KEY.DAILY_COMMANDS_BACKUP]: null, + [LOCAL_STORAGE_KEY.WEEKLY_COMMANDS_BACKUP]: null, + [LOCAL_STORAGE_KEY.LOCAL_COMMAND_METADATA]: null, + [LOCAL_STORAGE_KEY.GLOBAL_COMMAND_METADATA]: null, + [SESSION_STORAGE_KEY.BG]: {}, + [SESSION_STORAGE_KEY.SESSION_DATA]: null, + [SESSION_STORAGE_KEY.MESSAGE_QUEUE]: [], + [SESSION_STORAGE_KEY.PA_RECORDING]: [], + [SESSION_STORAGE_KEY.PA_RUNNING]: {}, + [SESSION_STORAGE_KEY.PA_CONTEXT]: {}, + [SESSION_STORAGE_KEY.PA_RECORDER_OPTION]: {}, + [SESSION_STORAGE_KEY.TMP_CAPTURES]: {}, + [SESSION_STORAGE_KEY.SELECTION_TEXT]: "", +} as const + +const detectStorageArea = (key: KEY): chrome.storage.StorageArea => { + if (Object.values(STORAGE_KEY).includes(key)) { + return chrome.storage.sync + } + if (Object.values(LOCAL_STORAGE_KEY).includes(key as LOCAL_STORAGE_KEY)) { + return chrome.storage.local + } + if (Object.values(SESSION_STORAGE_KEY).includes(key as SESSION_STORAGE_KEY)) { + return chrome.storage.session + } + + // Handle dynamic command keys + if (typeof key === "string") { + if (key.startsWith(CMD_PREFIX)) { + // Command keys: cmd-0, cmd-1, cmd-local-0, cmd-local-1, etc. + return key.includes("local-") ? chrome.storage.local : chrome.storage.sync + } + } + + throw new Error("Invalid Storage Key") +} + +export type ChangedCallback = (newVal: T, oldVal: T) => void +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const changedCallbacks = {} as { [key: string]: ChangedCallback[] } + +chrome.storage.onChanged.addListener((changes) => { + for (const [k, { oldValue, newValue }] of Object.entries(changes)) { + for (const [kk, callbacks] of Object.entries(changedCallbacks)) { + if (k === kk) callbacks.forEach((cb) => cb(newValue, oldValue)) + } + } +}) + +type UpdateFunc = (currentVal: T) => T + +export const BaseStorage = { + get: async (key: KEY): Promise => { + const area = detectStorageArea(key) + const result = await area.get(`${key}`) + if (chrome.runtime.lastError != null) { + throw chrome.runtime.lastError + } + // For dynamic keys (like command keys), return the raw value or undefined + // For static keys, use the default value from DEFAULTS + const hasDefault = key in DEFAULTS + return ( + result[key] ?? + (hasDefault + ? structuredClone(DEFAULTS[key as keyof typeof DEFAULTS]) + : undefined) + ) + }, + + set: async (key: KEY, value: T): Promise => { + const area = detectStorageArea(key) + + if (area === chrome.storage.sync) { + await debouncedSyncSet({ [key.toString()]: value }) + return true + } else { + await area.set({ [key]: value }) + return true + } + }, + + update: async (key: KEY, updater: UpdateFunc): Promise => { + const data = await BaseStorage.get(key) + const newData = updater(data) + return await BaseStorage.set(key, newData) + }, + + remove: (key: KEY): Promise => { + return new Promise((resolve, reject) => { + const area = detectStorageArea(key) + area.remove(`${key}`, () => { + if (chrome.runtime.lastError != null) { + reject(chrome.runtime.lastError) + } else { + resolve(true) + } + }) + }) + }, + + addListener: (key: KEY, cb: ChangedCallback) => { + changedCallbacks[key] = changedCallbacks[key] ?? [] + changedCallbacks[key].push(cb) + }, + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + removeListener: (key: KEY, cb: ChangedCallback) => { + changedCallbacks[key] = changedCallbacks[key]?.filter((f) => f !== cb) + }, + + getCapture: async (key: string): Promise => { + let captures = await BaseStorage.get( + LOCAL_STORAGE_KEY.CAPTURES, + ) + let c = captures[key] + if (c != null) { + return c + } + captures = await BaseStorage.get( + SESSION_STORAGE_KEY.TMP_CAPTURES, + ) + c = captures[key] + if (c != null) { + return c + } + }, +} + +export { debouncedSyncSet } diff --git a/src/services/storage/storageUsage.ts b/src/services/storage/storageUsage.ts new file mode 100644 index 00000000..524b5ce8 --- /dev/null +++ b/src/services/storage/storageUsage.ts @@ -0,0 +1,183 @@ +import { CMD_PREFIX, STORAGE_KEY, LOCAL_STORAGE_KEY } from "@/services/storage" + +export interface StorageUsageData { + sync: { + total: number + used: number + free: number + system: number + commands: number + reservedRemain: number + systemPercent: number + reservedPercent: number + commandsPercent: number + freePercent: number + } + local: { + total: number + used: number + free: number + system: number + backup: number + commands: number + systemPercent: number + backupPercent: number + commandsPercent: number + freePercent: number + } +} + +const getStorageUsage = async (): Promise => { + try { + const syncSystemKeys = Object.values(STORAGE_KEY).map((key) => String(key)) + const syncSystemBytes = await new Promise((resolve) => { + chrome.storage.sync.getBytesInUse(syncSystemKeys, (bytes) => { + resolve(bytes) + }) + }) + + const allSyncData = await chrome.storage.sync.get(null) + const syncCommandKeys = Object.keys(allSyncData || {}).filter((key) => + key.startsWith(CMD_PREFIX), + ) + + const syncCommandBytes = + syncCommandKeys.length > 0 + ? await new Promise((resolve) => { + chrome.storage.sync.getBytesInUse(syncCommandKeys, (bytes) => { + resolve(bytes) + }) + }) + : 0 + + const syncTotalBytes = await new Promise((resolve) => { + chrome.storage.sync.getBytesInUse(null, (bytes) => { + resolve(bytes) + }) + }) + + const allLocalData = await chrome.storage.local.get(null) + + const localSystemKeys = Object.values(LOCAL_STORAGE_KEY).filter( + (key) => + key !== LOCAL_STORAGE_KEY.COMMANDS_BACKUP && + key !== LOCAL_STORAGE_KEY.DAILY_COMMANDS_BACKUP && + key !== LOCAL_STORAGE_KEY.WEEKLY_COMMANDS_BACKUP, + ) as string[] + + const localBackupKeys = [ + LOCAL_STORAGE_KEY.COMMANDS_BACKUP, + LOCAL_STORAGE_KEY.DAILY_COMMANDS_BACKUP, + LOCAL_STORAGE_KEY.WEEKLY_COMMANDS_BACKUP, + ] as string[] + + const localCommandKeys = Object.keys(allLocalData || {}).filter((key) => + key.startsWith(CMD_PREFIX), + ) + + const localSystemBytes = + localSystemKeys.length > 0 + ? await new Promise((resolve) => { + chrome.storage.local.getBytesInUse(localSystemKeys, (bytes) => { + resolve(bytes) + }) + }) + : 0 + + const localBackupBytes = + localBackupKeys.length > 0 + ? await new Promise((resolve) => { + chrome.storage.local.getBytesInUse(localBackupKeys, (bytes) => { + resolve(bytes) + }) + }) + : 0 + + const localCommandBytes = + localCommandKeys.length > 0 + ? await new Promise((resolve) => { + chrome.storage.local.getBytesInUse(localCommandKeys, (bytes) => { + resolve(bytes) + }) + }) + : 0 + + const localTotalBytes = await new Promise((resolve) => { + chrome.storage.local.getBytesInUse(null, (bytes) => { + resolve(bytes) + }) + }) + + const syncLimitTotal = 100 * 1024 // 100KB + const reservedTotal = 40 * 1024 // 40KB reserved for system data + const localLimitTotal = 10 * 1024 * 1024 // 10MB + + const syncUsed = syncTotalBytes + const reservedRemain = reservedTotal - syncSystemBytes + const syncFree = syncLimitTotal - reservedTotal - syncCommandBytes + const localUsed = localTotalBytes + const localFree = localLimitTotal - localUsed + + return { + sync: { + total: syncLimitTotal, + used: syncUsed, + free: syncFree, + system: syncSystemBytes, + reservedRemain, + commands: syncCommandBytes, + systemPercent: Number( + ((syncSystemBytes / syncLimitTotal) * 100).toFixed(0), + ), + reservedPercent: Number( + ((reservedRemain / syncLimitTotal) * 100).toFixed(0), + ), + commandsPercent: Number( + ((syncCommandBytes / syncLimitTotal) * 100).toFixed(0), + ), + freePercent: Number(((syncFree / syncLimitTotal) * 100).toFixed(0)), + }, + local: { + total: localLimitTotal, + used: localUsed, + free: localFree, + system: localSystemBytes, + backup: localBackupBytes, + commands: localCommandBytes, + systemPercent: Number( + ((localSystemBytes / localLimitTotal) * 100).toFixed(0), + ), + backupPercent: Number( + ((localBackupBytes / localLimitTotal) * 100).toFixed(0), + ), + commandsPercent: Number( + ((localCommandBytes / localLimitTotal) * 100).toFixed(0), + ), + freePercent: Number(((localFree / localLimitTotal) * 100).toFixed(0)), + }, + } + } catch (error) { + console.error("Failed to get storage usage:", error) + throw error + } +} + +export const subscribeStorageUsage = ( + callback: (data: StorageUsageData) => void, +): (() => void) => { + const listener = async () => { + try { + const data = await getStorageUsage() + callback(data) + } catch (error) { + console.error("Failed to get storage usage:", error) + } + } + + chrome.storage.onChanged.addListener(listener) + listener() // Initial call + + return () => { + chrome.storage.onChanged.removeListener(listener) + } +} diff --git a/src/types.ts b/src/types.ts index 72bca7c6..5ba653cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,6 +97,7 @@ export type CommandFolder = { iconUrl?: string iconSvg?: string onlyIcon?: boolean + parentFolderId?: string } export type CommandVariable = { diff --git a/src/types/schema.ts b/src/types/schema.ts index 6a2506e4..0364c182 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -2,6 +2,10 @@ import { z } from 'zod' import { ALIGN, SIDE, + OPEN_MODE, + SPACE_ENCODING, + COPY_OPTION, + COMMAND_MAX, PAGE_ACTION_OPEN_MODE, PAGE_ACTION_EVENT, PAGE_ACTION_CONTROL, @@ -9,21 +13,122 @@ import { SHORTCUT_PLACEHOLDER, SHORTCUT_NO_SELECTION_BEHAVIOR, } from '@/const' + import { t } from '@/services/i18n' +import { isEmpty } from '@/lib/utils' -export const PopupPlacementSchema = z.object({ - side: z.nativeEnum(SIDE), - align: z.nativeEnum(ALIGN), - sideOffset: z - .number({ message: t('Option_zod_number') }) - .min(0, { message: t('Option_zod_number_min', ['0']) }) - .max(100, { message: t('Option_zod_number_max', ['100']) }) - .default(0), - alignOffset: z - .number({ message: t('Option_zod_number') }) - .min(-100, { message: t('Option_zod_number_min', ['-100']) }) - .max(100, { message: t('Option_zod_number_max', ['100']) }) - .default(0), +export const SEARCH_OPEN_MODE = [ + OPEN_MODE.POPUP, + OPEN_MODE.TAB, + OPEN_MODE.WINDOW, +] as const + +const searchSchema = z.object({ + openMode: z.enum(SEARCH_OPEN_MODE), + id: z.string(), + revision: z.number().optional(), + title: z.string().min(1, { message: t('zod_string_min', ['1']) }), + iconUrl: z + .string() + .url({ message: t('zod_url') }) + .max(1000, { message: t('zod_string_max', ['1000']) }), + searchUrl: z.string().url({ message: t('zod_url') }), + parentFolderId: z.string().optional(), + openModeSecondary: z.enum(SEARCH_OPEN_MODE), + spaceEncoding: z.nativeEnum(SPACE_ENCODING), + popupOption: z + .object({ + width: z.number().min(1), + height: z.number().min(1), + }) + .optional(), +}) + +type SearchType = z.infer + +export const isSearchType = (data: any): data is SearchType => { + return SEARCH_OPEN_MODE.includes(data.openMode) +} + +const apiSchema = z.object({ + openMode: z.literal(OPEN_MODE.API), + id: z.string(), + revision: z.number().optional(), + title: z.string().min(1, { message: t('zod_string_min', ['1']) }), + iconUrl: z + .string() + .url({ message: t('zod_url') }) + .max(1000, { message: t('zod_string_max', ['1000']) }), + searchUrl: z.string().url({ message: t('zod_url') }), + parentFolderId: z.string().optional(), + fetchOptions: z.string().optional(), + variables: z + .array( + z.object({ + name: z.string({ message: t('zod_string_min', ['1']) }), + value: z.string({ message: t('zod_string_min', ['1']) }), + }), + ) + .optional(), +}) + +const linkPopupSchema = z.object({ + openMode: z.enum([OPEN_MODE.LINK_POPUP]), + id: z.string(), + revision: z.number().optional(), + parentFolderId: z.string().optional(), + title: z + .string() + .min(1, { message: t('zod_string_min', ['1']) }) + .default('Link Popup'), + iconUrl: z + .string() + .url({ message: t('zod_url') }) + .max(1000, { message: t('zod_string_max', ['1000']) }) + .default( + 'https://cdn4.iconfinder.com/data/icons/basic-ui-2-line/32/folder-archive-document-archives-fold-1024.png', + ), + popupOption: z.object({ + width: z.number().min(1), + height: z.number().min(1), + }), +}) + +const copySchema = z.object({ + openMode: z.enum([OPEN_MODE.COPY]), + id: z.string(), + revision: z.number().optional(), + parentFolderId: z.string().optional(), + title: z + .string() + .min(1, { message: t('zod_string_min', ['1']) }) + .default('Copy text'), + iconUrl: z + .string() + .url({ message: t('zod_url') }) + .max(1000, { message: t('zod_string_max', ['1000']) }) + .default( + 'https://cdn0.iconfinder.com/data/icons/phosphor-light-vol-2/256/copy-light-1024.png', + ), + copyOption: z.nativeEnum(COPY_OPTION).default(COPY_OPTION.DEFAULT), +}) + +const textStyleSchema = z.object({ + openMode: z.enum([OPEN_MODE.GET_TEXT_STYLES]), + id: z.string(), + revision: z.number().optional(), + parentFolderId: z.string().optional(), + title: z + .string() + .min(1, { message: t('zod_string_min', ['1']) }) + .default('Get Text Styles'), + iconUrl: z + .string() + .url({ message: t('zod_url') }) + .max(1000, { message: t('zod_string_max', ['1000']) }) + .default( + 'https://cdn0.iconfinder.com/data/icons/phosphor-light-vol-3/256/paint-brush-light-1024.png', + ), }) const PageActionStartSchema = z.object({ @@ -100,6 +205,79 @@ export const PageActionOption = z.object({ steps: z.array(PageActionStepSchema), }) +const pageActionSchema = z.object({ + openMode: z.enum([OPEN_MODE.PAGE_ACTION]), + id: z.string(), + revision: z.number().optional(), + parentFolderId: z.string().optional(), + title: z + .string() + .min(1, { message: t('zod_string_min', ['1']) }) + .default('Get Text Styles'), + iconUrl: z + .string() + .url({ message: t('zod_url') }) + .max(1000, { message: t('zod_string_max', ['1000']) }), + popupOption: z + .object({ + width: z.number().min(1), + height: z.number().min(1), + }) + .optional(), + pageActionOption: PageActionOption, +}) + +export const commandSchema = z.discriminatedUnion('openMode', [ + searchSchema, + apiSchema, + pageActionSchema, + linkPopupSchema, + copySchema, + textStyleSchema, +]) + +const commandsSchema = z.object({ + commands: z.array(commandSchema).min(1).max(COMMAND_MAX), +}) + +export type CommandSchemaType = z.infer +export type CommandsSchemaType = z.infer + +export const folderSchema = z + .object({ + id: z.string(), + title: z.string().min(1, { message: t('zod_string_min', ['1']) }), + iconUrl: z.string().optional(), + iconSvg: z.string().optional(), + onlyIcon: z.boolean().optional(), + parentFolderId: z.string().optional(), + }) + .refine((data) => !isEmpty(data.iconUrl) || !isEmpty(data.iconSvg), { + path: ['iconSvg'], + message: t('icon_required'), + }) + +const foldersSchema = z.object({ + folders: z.array(folderSchema), +}) + +export type FoldersSchemaType = z.infer + +export const PopupPlacementSchema = z.object({ + side: z.nativeEnum(SIDE), + align: z.nativeEnum(ALIGN), + sideOffset: z + .number({ message: t('Option_zod_number') }) + .min(0, { message: t('Option_zod_number_min', ['0']) }) + .max(100, { message: t('Option_zod_number_max', ['100']) }) + .default(0), + alignOffset: z + .number({ message: t('Option_zod_number') }) + .min(-100, { message: t('Option_zod_number_min', ['-100']) }) + .max(100, { message: t('Option_zod_number_max', ['100']) }) + .default(0), +}) + export const ShortcutCommandSchema = z.object({ id: z.string(), commandId: z.string().default(SHORTCUT_PLACEHOLDER), diff --git a/tsconfig.node.json b/tsconfig.node.json index 0c1b3a9e..d3e7dae1 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,17 +2,12 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "ES2022", - "lib": [ - "ES2023", - "dom" - ], + "lib": ["ES2023", "dom"], "module": "ESNext", "skipLibCheck": true, "baseUrl": ".", "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] }, /* Bundler mode */ "moduleResolution": "bundler", @@ -27,7 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": [ - "vite.config.ts" - ] + "include": ["vite.config.ts"] } diff --git a/yarn.lock b/yarn.lock index 6e87bb8c..4e2b265d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -707,11 +707,21 @@ resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@pkgr/core@^0.2.7": + version "0.2.7" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.7.tgz#eb5014dfd0b03e7f3ba2eeeff506eed89b028058" + integrity sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg== + "@radix-ui/number@1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz" integrity sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ== +"@radix-ui/number@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" + integrity sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g== + "@radix-ui/primitive@1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz" @@ -736,6 +746,13 @@ dependencies: "@radix-ui/react-primitive" "2.0.2" +"@radix-ui/react-arrow@1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz#e14a2657c81d961598c5e72b73dd6098acc04f09" + integrity sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w== + dependencies: + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-collapsible@^1.1.3": version "1.1.3" resolved "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz" @@ -750,16 +767,6 @@ "@radix-ui/react-use-controllable-state" "1.1.0" "@radix-ui/react-use-layout-effect" "1.1.0" -"@radix-ui/react-collection@1.1.1": - version "1.1.1" - resolved "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz" - integrity sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA== - dependencies: - "@radix-ui/react-compose-refs" "1.1.1" - "@radix-ui/react-context" "1.1.1" - "@radix-ui/react-primitive" "2.0.1" - "@radix-ui/react-slot" "1.1.1" - "@radix-ui/react-collection@1.1.2": version "1.1.2" resolved "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz" @@ -780,6 +787,16 @@ "@radix-ui/react-primitive" "2.1.0" "@radix-ui/react-slot" "1.2.0" +"@radix-ui/react-collection@1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz#d05c25ca9ac4695cc19ba91f42f686e3ea2d9aec" + integrity sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw== + 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" + "@radix-ui/react-compose-refs@1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz" @@ -830,6 +847,17 @@ resolved "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz" integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw== +"@radix-ui/react-dismissable-layer@1.1.10": + version "1.1.10" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz#429b9bada3672c6895a5d6a642aca6ecaf4f18c3" + integrity sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@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" + "@radix-ui/react-dismissable-layer@1.1.3": version "1.1.3" resolved "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz" @@ -857,6 +885,11 @@ resolved "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz" integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg== +"@radix-ui/react-focus-guards@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz#4ec9a7e50925f7fb661394460045b46212a33bed" + integrity sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA== + "@radix-ui/react-focus-scope@1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.1.tgz" @@ -875,6 +908,15 @@ "@radix-ui/react-primitive" "2.0.2" "@radix-ui/react-use-callback-ref" "1.1.0" +"@radix-ui/react-focus-scope@1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz#dfe76fc103537d80bf42723a183773fd07bfb58d" + integrity sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw== + 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" + "@radix-ui/react-id@1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz" @@ -896,45 +938,45 @@ dependencies: "@radix-ui/react-primitive" "2.0.2" -"@radix-ui/react-menu@2.1.4": - version "2.1.4" - resolved "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.4.tgz" - integrity sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A== +"@radix-ui/react-menu@2.1.15": + version "2.1.15" + resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.1.15.tgz#a1a8f06cab3c309f9998cdbd2b3ad279e42ed483" + integrity sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew== dependencies: - "@radix-ui/primitive" "1.1.1" - "@radix-ui/react-collection" "1.1.1" - "@radix-ui/react-compose-refs" "1.1.1" - "@radix-ui/react-context" "1.1.1" - "@radix-ui/react-direction" "1.1.0" - "@radix-ui/react-dismissable-layer" "1.1.3" - "@radix-ui/react-focus-guards" "1.1.1" - "@radix-ui/react-focus-scope" "1.1.1" - "@radix-ui/react-id" "1.1.0" - "@radix-ui/react-popper" "1.2.1" - "@radix-ui/react-portal" "1.1.3" - "@radix-ui/react-presence" "1.1.2" - "@radix-ui/react-primitive" "2.0.1" - "@radix-ui/react-roving-focus" "1.1.1" - "@radix-ui/react-slot" "1.1.1" - "@radix-ui/react-use-callback-ref" "1.1.0" - aria-hidden "^1.1.1" - react-remove-scroll "^2.6.1" + "@radix-ui/primitive" "1.1.2" + "@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.10" + "@radix-ui/react-focus-guards" "1.1.2" + "@radix-ui/react-focus-scope" "1.1.7" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-popper" "1.2.7" + "@radix-ui/react-portal" "1.1.9" + "@radix-ui/react-presence" "1.1.4" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-roving-focus" "1.1.10" + "@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" -"@radix-ui/react-menubar@^1.1.2": - version "1.1.4" - resolved "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.4.tgz" - integrity sha512-+KMpi7VAZuB46+1LD7a30zb5IxyzLgC8m8j42gk3N4TUCcViNQdX8FhoH1HDvYiA8quuqcek4R4bYpPn/SY1GA== +"@radix-ui/react-menubar@^1.1.15": + version "1.1.15" + resolved "https://registry.yarnpkg.com/@radix-ui/react-menubar/-/react-menubar-1.1.15.tgz#e597bdf53c9ddeaadf2efadc480cc58a411fa914" + integrity sha512-Z71C7LGD+YDYo3TV81paUs8f3Zbmkvg6VLRQpKYfzioOE6n7fOhA3ApK/V/2Odolxjoc4ENk8AYCjohCNayd5A== dependencies: - "@radix-ui/primitive" "1.1.1" - "@radix-ui/react-collection" "1.1.1" - "@radix-ui/react-compose-refs" "1.1.1" - "@radix-ui/react-context" "1.1.1" - "@radix-ui/react-direction" "1.1.0" - "@radix-ui/react-id" "1.1.0" - "@radix-ui/react-menu" "2.1.4" - "@radix-ui/react-primitive" "2.0.1" - "@radix-ui/react-roving-focus" "1.1.1" - "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/primitive" "1.1.2" + "@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.15" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-roving-focus" "1.1.10" + "@radix-ui/react-use-controllable-state" "1.2.2" "@radix-ui/react-popover@^1.1.2": version "1.1.4" @@ -989,6 +1031,22 @@ "@radix-ui/react-use-size" "1.1.0" "@radix-ui/rect" "1.1.0" +"@radix-ui/react-popper@1.2.7": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.7.tgz#531cf2eebb3d3270d58f7d8136e4517646429978" + integrity sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ== + 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" + "@radix-ui/react-portal@1.1.3": version "1.1.3" resolved "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz" @@ -1005,6 +1063,14 @@ "@radix-ui/react-primitive" "2.0.2" "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-portal@1.1.9": + version "1.1.9" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz#14c3649fe48ec474ac51ed9f2b9f5da4d91c4472" + integrity sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ== + dependencies: + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-presence@1.1.2": version "1.1.2" resolved "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz" @@ -1013,6 +1079,14 @@ "@radix-ui/react-compose-refs" "1.1.1" "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-presence@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.4.tgz#253ac0ad4946c5b4a9c66878335f5cf07c967ced" + integrity sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-primitive@2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz" @@ -1034,6 +1108,13 @@ dependencies: "@radix-ui/react-slot" "1.2.0" +"@radix-ui/react-primitive@2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz#db9b8bcff49e01be510ad79893fb0e4cda50f1bc" + integrity sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ== + dependencies: + "@radix-ui/react-slot" "1.2.3" + "@radix-ui/react-progress@^1.1.2": version "1.1.2" resolved "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz" @@ -1042,20 +1123,36 @@ "@radix-ui/react-context" "1.1.1" "@radix-ui/react-primitive" "2.0.2" -"@radix-ui/react-roving-focus@1.1.1": - version "1.1.1" - resolved "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz" - integrity sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw== +"@radix-ui/react-radio-group@^1.3.7": + version "1.3.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz#49f822d97c26c4745976108a301ba2e8545d8928" + integrity sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g== dependencies: - "@radix-ui/primitive" "1.1.1" - "@radix-ui/react-collection" "1.1.1" - "@radix-ui/react-compose-refs" "1.1.1" - "@radix-ui/react-context" "1.1.1" - "@radix-ui/react-direction" "1.1.0" - "@radix-ui/react-id" "1.1.0" - "@radix-ui/react-primitive" "2.0.1" - "@radix-ui/react-use-callback-ref" "1.1.0" - "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/primitive" "1.1.2" + "@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.4" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-roving-focus" "1.1.10" + "@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" + +"@radix-ui/react-roving-focus@1.1.10": + version "1.1.10" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz#46030496d2a490c4979d29a7e1252465e51e4b0b" + integrity sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@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" "@radix-ui/react-roving-focus@1.1.7": version "1.1.7" @@ -1072,6 +1169,21 @@ "@radix-ui/react-use-callback-ref" "1.1.1" "@radix-ui/react-use-controllable-state" "1.2.2" +"@radix-ui/react-scroll-area@^1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz#90c49bd3231d7f0796d5d12dabc065afa829cf07" + integrity sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A== + dependencies: + "@radix-ui/number" "1.1.1" + "@radix-ui/primitive" "1.1.2" + "@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.4" + "@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-select@^2.1.6": version "2.1.6" resolved "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz" @@ -1120,6 +1232,13 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.2" +"@radix-ui/react-slot@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1" + integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-switch@^1.1.3": version "1.1.3" resolved "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz" @@ -1194,6 +1313,13 @@ dependencies: "@radix-ui/react-use-callback-ref" "1.1.0" +"@radix-ui/react-use-escape-keydown@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz#b3fed9bbea366a118f40427ac40500aa1423cc29" + integrity sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-layout-effect@1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz" @@ -1209,6 +1335,11 @@ resolved "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz" integrity sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og== +"@radix-ui/react-use-previous@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz#1a1ad5568973d24051ed0af687766f6c7cb9b5b5" + integrity sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ== + "@radix-ui/react-use-rect@1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz" @@ -1216,6 +1347,13 @@ dependencies: "@radix-ui/rect" "1.1.0" +"@radix-ui/react-use-rect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz#01443ca8ed071d33023c1113e5173b5ed8769152" + integrity sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w== + dependencies: + "@radix-ui/rect" "1.1.1" + "@radix-ui/react-use-size@1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz" @@ -1223,6 +1361,13 @@ dependencies: "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-use-size@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz#6de276ffbc389a537ffe4316f5b0f24129405b37" + integrity sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-visually-hidden@1.1.2": version "1.1.2" resolved "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz" @@ -1235,6 +1380,11 @@ resolved "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz" integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg== +"@radix-ui/rect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" + integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== + "@rollup/pluginutils@^4.1.2": version "4.2.1" resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz" @@ -1805,6 +1955,11 @@ array-buffer-byte-length@^1.0.0: call-bound "^1.0.3" is-array-buffer "^3.0.5" +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + async@^2.0.0: version "2.6.4" resolved "https://registry.npmjs.org/async/-/async-2.6.4.tgz" @@ -1859,6 +2014,11 @@ boolbase@^1.0.0: resolved "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz" integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== +bowser@^1.7.3: + version "1.9.4" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a" + integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz" @@ -2158,6 +2318,11 @@ convert-source-map@^2.0.0: resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + integrity sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" @@ -2185,7 +2350,7 @@ cross-env@^7.0.3: dependencies: cross-spawn "^7.0.1" -cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -2194,6 +2359,14 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.3, cross-spawn@^7.0.6: shebang-command "^2.0.0" which "^2.0.1" +css-in-js-utils@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz#3b472b398787291b47cfe3e44fecfdd9e914ba99" + integrity sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA== + dependencies: + hyphenate-style-name "^1.0.2" + isobject "^3.0.1" + css-select@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz" @@ -2431,6 +2604,13 @@ encoding-sniffer@^0.2.0: iconv-lite "^0.6.3" whatwg-encoding "^3.1.1" +encoding@^0.1.11: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + end-of-stream@^1.0.0: version "1.4.4" resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz" @@ -2632,21 +2812,6 @@ esutils@^2.0.2: resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -execa@^5.1.1: - version "5.1.1" - resolved "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - expect@^29.0.0: version "29.7.0" resolved "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz" @@ -2691,6 +2856,19 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +fbjs@^0.8.12: + version "0.8.18" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.18.tgz#9835e0addb9aca2eff53295cd79ca1cfc7c9662a" + integrity sha512-EQaWFK+fEPSoibjNy8IxUtaFOMXcWsY0JaVrQoZR9zC8N2Ygf9iDITPWjUTVIax95b6I742JFLqASHfsag/vKA== + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.30" + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz" @@ -2835,16 +3013,22 @@ get-proto@^1.0.0, get-proto@^1.0.1: dunder-proto "^1.0.1" es-object-atoms "^1.0.0" -get-stream@^6.0.0: - version "6.0.1" - resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - get-xpath@^3.3.0: version "3.3.0" resolved "https://registry.npmjs.org/get-xpath/-/get-xpath-3.3.0.tgz" integrity sha512-AKbHVEHSlSDS9AJDmjfSxV010xSVRFzkS4UkGCb8CaGvorp83GCuKwcmzF/64CVp3M5wjgy9tc6y1uR2V+c5Xw== +glamor@^2.20.40: + version "2.20.40" + resolved "https://registry.yarnpkg.com/glamor/-/glamor-2.20.40.tgz#f606660357b7cf18dface731ad1a2cfa93817f05" + integrity sha512-DNXCd+c14N9QF8aAKrfl4xakPk5FdcFwmH7sD0qnC0Pr7xoZ5W9yovhUrY/dJc3psfGGXC58vqQyRtuskyUJxA== + dependencies: + fbjs "^0.8.12" + inline-style-prefixer "^3.0.6" + object-assign "^4.1.1" + prop-types "^15.5.10" + through "^2.3.8" + glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" @@ -2959,17 +3143,17 @@ htmlparser2@^9.1.0: domutils "^3.1.0" entities "^4.5.0" -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - husky@^9.1.7: version "9.1.7" resolved "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz" integrity sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA== -iconv-lite@0.6.3, iconv-lite@^0.6.3: +hyphenate-style-name@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz#1797bf50369588b47b72ca6d5e65374607cf4436" + integrity sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw== + +iconv-lite@0.6.3, iconv-lite@^0.6.2, iconv-lite@^0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -2988,11 +3172,16 @@ ignore-walk@^3.0.1: dependencies: minimatch "^3.0.4" -ignore@^5.2.0, ignore@^5.3.0, ignore@^5.3.1: +ignore@^5.2.0, ignore@^5.3.1: version "5.3.2" resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +ignore@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz" @@ -3024,6 +3213,14 @@ inherits@2, inherits@^2.0.3, inherits@~2.0.3: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +inline-style-prefixer@^3.0.6: + version "3.0.8" + resolved "https://registry.yarnpkg.com/inline-style-prefixer/-/inline-style-prefixer-3.0.8.tgz#8551b8e5b4d573244e66a34b04f7d32076a2b534" + integrity sha512-ne8XIyyqkRaNJ1JfL1NYzNdCNxq+MCBQhC8NgOQlzNm2vv3XxlP0VSLQUbSRCF6KPEoveCVEpayHoHzcMyZsMQ== + dependencies: + bowser "^1.7.3" + css-in-js-utils "^2.0.0" + internal-slot@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz" @@ -3169,10 +3366,10 @@ is-shared-array-buffer@^1.0.2: dependencies: call-bound "^1.0.3" -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== +is-stream@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== is-string@^1.0.7, is-string@^1.1.1: version "1.1.1" @@ -3219,6 +3416,19 @@ isexe@^2.0.0: resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== + +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + integrity sha512-9c4TNAKYXM5PRyVcwUZrF3W09nQ+sO7+jydgs4ZGW9dhsLG2VOlISJABombdQqQRXCwuYG3sYV/puGf5rp0qmA== + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + jackspeak@^3.1.2: version "3.4.3" resolved "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz" @@ -3388,7 +3598,7 @@ lodash@^4.17.14, lodash@^4.17.21, lodash@^4.8.0: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -3434,11 +3644,6 @@ math-intrinsics@^1.1.0: resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz" integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - merge2@^1.3.0: version "1.4.1" resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" @@ -3452,11 +3657,6 @@ micromatch@^4.0.4, micromatch@^4.0.8: braces "^3.0.3" picomatch "^2.3.1" -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - min-indent@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz" @@ -3535,6 +3735,14 @@ ndarray@^1.0.19: iota-array "^1.0.0" is-buffer "^1.0.2" +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + node-releases@^2.0.19: version "2.0.19" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz" @@ -3587,13 +3795,6 @@ npm-packlist@1.4.4: ignore-walk "^3.0.1" npm-bundled "^1.0.1" -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - nth-check@^2.0.1: version "2.1.1" resolved "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz" @@ -3601,7 +3802,7 @@ nth-check@^2.0.1: dependencies: boolbase "^1.0.0" -object-assign@^4.0.1, object-assign@^4.1.1: +object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -3648,13 +3849,6 @@ once@^1.3.0, once@^1.4.0: dependencies: wrappy "1" -onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - optionator@^0.9.3: version "0.9.4" resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz" @@ -3749,7 +3943,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== -path-key@^3.0.0, path-key@^3.1.0: +path-key@^3.1.0: version "3.1.1" resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== @@ -3782,10 +3976,10 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3, picomatc resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz" - integrity sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag== +picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== pify@^2.3.0: version "2.3.0" @@ -3865,10 +4059,10 @@ prelude-ls@^1.2.1: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier@^3.3.3: - version "3.4.2" - resolved "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz" - integrity sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ== +prettier@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393" + integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ== pretty-format@^27.0.2: version "27.5.1" @@ -3888,25 +4082,32 @@ pretty-format@^29.0.0, pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" -pretty-quick@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/pretty-quick/-/pretty-quick-4.0.0.tgz" - integrity sha512-M+2MmeufXb/M7Xw3Afh1gxcYpj+sK0AxEfnfF958ktFeAyi5MsKY5brymVURQLgPLV1QaF5P4pb2oFJ54H3yzQ== +pretty-quick@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/pretty-quick/-/pretty-quick-4.2.2.tgz#0fc31da666f182fe14e119905fc9829b5b85a234" + integrity sha512-uAh96tBW1SsD34VhhDmWuEmqbpfYc/B3j++5MC/6b3Cb8Ow7NJsvKFhg0eoGu2xXX+o9RkahkTK6sUdd8E7g5w== dependencies: - execa "^5.1.1" - find-up "^5.0.0" - ignore "^5.3.0" + "@pkgr/core" "^0.2.7" + ignore "^7.0.5" mri "^1.2.0" - picocolors "^1.0.0" - picomatch "^3.0.1" - tslib "^2.6.2" + picocolors "^1.1.1" + picomatch "^4.0.2" + tinyexec "^0.3.2" + tslib "^2.8.1" process-nextick-args@~2.0.0: 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== -prop-types@^15.6.2: +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + +prop-types@^15.5.10, prop-types@^15.6.2: version "15.8.1" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -3953,6 +4154,13 @@ react-is@^18.0.0: resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +react-multi-progress@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/react-multi-progress/-/react-multi-progress-1.3.0.tgz#c36ee7a019357de30770b47e1bc072604439b6a5" + integrity sha512-uWwcDCBQNlccuyWUVYUBslVoGCvvFXO4GMsHZnlymvIjMMUeY+tjWdXyEtWgdVh3Qgz9mTdDMx8fHtX7bxLjyg== + dependencies: + glamor "^2.20.40" + react-refresh@^0.13.0: version "0.13.0" resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz" @@ -4260,6 +4468,11 @@ set-function-name@^2.0.2: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + sharp@^0.33.4, sharp@^0.33.5: version "0.33.5" resolved "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz" @@ -4346,11 +4559,6 @@ side-channel@^1.0.4, side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^3.0.3: - version "3.0.7" - resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - signal-exit@^4.0.1: version "4.1.0" resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" @@ -4476,11 +4684,6 @@ strip-ansi@^7.0.1: dependencies: ansi-regex "^6.0.1" -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - strip-indent@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz" @@ -4598,6 +4801,16 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +through@^2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + to-buffer@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz" @@ -4640,7 +4853,7 @@ ts-interface-checker@^0.1.9: resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2: +tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -4666,6 +4879,11 @@ typescript@~5.6.2: resolved "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz" integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== +ua-parser-js@^0.7.30: + version "0.7.40" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.40.tgz#c87d83b7bb25822ecfa6397a0da5903934ea1562" + integrity sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ== + undici-types@~6.19.2: version "6.19.8" resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz" @@ -4781,6 +4999,11 @@ whatwg-encoding@^3.1.1: dependencies: iconv-lite "0.6.3" +whatwg-fetch@>=0.10.0: + version "3.6.20" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70" + integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg== + whatwg-mimetype@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz"