diff --git a/.gitignore b/.gitignore index 1eaa3a0..990ddae 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ coverage/ test-results/ playwright-report/ playwright/.cache/ +yt-storage.json # IDE and editor files .vscode/ diff --git a/README.md b/README.md index d8c65b3..ac24004 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ -# ![YT re:Watch](./src/icon48.png) YT re:Watch - YouTube History Extension for Multiple Accounts & Privacy +# ![YT re:Watch](./src/icon48.png) YT re:Watch [![Tests](https://github.com/EdinUser/YouTubeLocalHistory/actions/workflows/ci.yml/badge.svg)](https://github.com/EdinUser/YouTubeLocalHistory/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/EdinUser/YouTubeLocalHistory)](https://github.com/EdinUser/YouTubeLocalHistory/releases) [![Telegram Channel](https://img.shields.io/badge/Community-Telegram-2AABEE?logo=telegram&logoColor=white)](https://t.me/+eFftKWGVvSpiZjZk) -[![Community Tool – YouTube History Converter (tannery495)](https://img.shields.io/badge/Community_Tool-YouTube_History_Converter-2AABEE)](https://github.com/tannery495/youtube-history-converter) -# 📚 **[→ New User? Start Here! User-Friendly Guide](./docs/index.md) ←** +**Private local YouTube history, progress tracking, local subscriptions, playlists, and a YouTube-style feed inside your browser.** + +YT re:Watch keeps your watch progress on your device so you can switch YouTube accounts, use YouTube logged out, import your data, and browse a local subscription feed without relying on Google account history. + +📚 **[New user? Start with the guide](./docs/index.md)**
- 🔄 YouTube Account Switching Made Easy! Same history across ALL accounts + YouTube history privacy! -
- 🔄 Multi-account extension • 📊 History privacy • ✨ No login required • 🎯 Progress tracking + Local history • Local subscriptions • Local playlists • In-extension feed • No Google login required
- 📖 FAQ | 📚 Complete Guide | 🔧 Developer Docs + FAQ | Complete Guide | Developer Docs
--- @@ -23,48 +24,31 @@ [![Chrome Web Store](https://img.shields.io/badge/Get_it_on-Chrome_Web_Store-4285F4?logo=googlechrome&logoColor=white)](https://chromewebstore.google.com/detail/local-youtube-video-histo/pebiokefjgdbfnkolmblaaladkmpilba) [![Firefox Add-ons](https://img.shields.io/badge/Get_it_on-Firefox_Add--ons-FF7139?logo=firefox-browser&logoColor=white)](https://addons.mozilla.org/firefox/addon/yt-rewatch/) - *YouTube progress tracking without account limitations - Your viewing history stays private* + *YouTube progress tracking without account limitations. Your viewing history stays local.* -
- YT re:Watch Extension Interface -
- Main extension interface showing video history, analytics, and settings -
--- -## 🎯 Perfect for YouTube Multiple Accounts & Privacy - -**🔄 Tired of losing YouTube progress when switching accounts? Need YouTube history without login?** - -YT re:Watch is the **YouTube history extension** that solves account switching problems: +## What It Does -### 🔄 **YouTube Multiple Accounts Support** -- ✅ **Same history across ALL YouTube accounts** - switch freely without losing progress -- ✅ **YouTube without login** - track videos without any account required -- ✅ **Account switching made easy** - consistent viewing history regardless of login status -- ✅ **Multi-account workflow** - perfect for work/personal YouTube accounts -- ✅ **Family computer friendly** - separate histories without account conflicts +YT re:Watch gives YouTube a local history layer that works independently from your Google account. -### 🎯 **YouTube Progress Tracking & Visual Overlays** -- ✅ **Progress bars on YouTube** - see completion percentage on video thumbnails -- ✅ **"Viewed" labels** - customizable overlay showing watched videos -- ✅ **Resume where you left off** - automatic video position tracking -- ✅ **YouTube Shorts tracking** - separate tracking for short-form content -- ✅ **Playlist discovery** - track and organize YouTube playlists +- **Track progress locally** across YouTube accounts, private sessions, or logged-out browsing +- **Show watched labels** and progress bars inside the extension feed +- **Resume videos** from your saved local timestamp +- **Subscribe to channels locally** and browse their videos inside the extension feed +- **Manage local channels, subscriptions, playlists, history, and settings** from one full-page extension interface +- **Search YouTube from the extension**, with grouped channel results and in-extension channel pages +- **Import YouTube history and channels** from Google Takeout / CSV files +- **Back up and restore all local data** as a JSON file +- **Analyze your watching patterns** without sending your history to an app server -### 🔒 **YouTube History Privacy** -- ✅ **Local storage only** - Google doesn't get your viewing progress data -- ✅ **No YouTube tracking** - replaces Google's built-in history system -- ✅ **Private YouTube browsing** - watch without affecting recommendations -- ✅ **Unlimited local storage** - GB-scale capacity with hybrid IndexedDB system +### Privacy Transparency -### ⚠️ **Privacy Transparency** -**What this extension protects:** YouTube viewing history and video progress tracking only -**What it DOESN'T protect:** IP tracking, cookies, browser fingerprinting, or other Google tracking +YT re:Watch stores your history, subscriptions, playlists, settings, and analytics locally in your browser. -**For complete privacy:** Combine with VPN, ad blockers, and privacy-focused browsers +It does not provide network anonymity. YouTube can still see normal YouTube page requests, cookies, IP address, and browser fingerprinting signals. For stronger privacy, combine this extension with browser privacy settings, content blockers, VPN/Tor-style tools, and logged-out browsing. ## 🚀 Get Started in 30 Seconds @@ -76,49 +60,65 @@ YT re:Watch is the **YouTube history extension** that solves account switching p ### Step 2: That's It! - Go to YouTube and start watching videos - The extension works automatically in the background -- Click the extension icon to see your watch history +- Click the extension icon to continue watching, open Watch Later, or launch the full feed page +- Use the feed page to search YouTube, subscribe locally, browse channels, manage playlists, view history, and back up your data -## 🎯 Perfect For These YouTube Users +## Perfect For ### 🔄 **Multi-Account Users** (#1 Use Case!) -- "I have work and personal YouTube accounts - hate losing progress when switching" -- "My family shares this computer and we need separate YouTube histories" -- "I manage multiple YouTube channels and need consistent tracking" -- "I want to use YouTube without staying logged in" +- "I switch between work and personal YouTube accounts and hate losing progress" +- "My family shares this computer and we use separate browser profiles" +- "I manage multiple YouTube channels and need consistent local tracking" +- "I want to watch YouTube logged out without losing my history" ### 🔒 **Privacy-Focused Users** -- "I want YouTube history without Google tracking my viewing habits" -- "I need to research topics without affecting my recommendations" -- "I want to browse YouTube anonymously but still track what I watch" -- "I need a private alternative to YouTube's built-in history" +- "I want watch history saved locally instead of tied to my Google account" +- "I need to research topics without reshaping my YouTube recommendations" +- "I want YouTube progress tracking while using stricter browser privacy settings" +- "I need a private local alternative to YouTube's built-in history page" ### 👨‍🎓 **Students & Content Creators** -- "I watch educational content across different devices and accounts" -- "I need to track my viewing progress without Google profiling" -- "I want consistent YouTube history for research projects" -- "I need to analyze my viewing patterns privately" +- "I need reliable progress tracking for research, tutorials, and long videos" +- "I want to keep viewing notes and habits separate from my YouTube profile" +- "I want consistent local history while switching accounts or browsing sessions" +- "I need analytics for my own viewing patterns without sending data to a server" + +### 📺 **Local Feed Users** +- "I want a subscriptions feed without relying on a Google account subscription list" +- "I want to import channels from Google Takeout and browse them locally" +- "I want local playlists, watch later, history, channels, and settings in one place" +- "I want a YouTube-style feed that I can back up and move between browsers" --- -## 📱 What You'll See on YouTube +## 📱 What You'll See -### 🏷️ **Visual Overlays** (Key Feature!) -The extension adds smart visual indicators directly on YouTube: -- **"Viewed" labels** - Customizable text showing watched videos -- **Progress bars** - Visual completion percentage on video thumbnails -- **Custom colors** - Choose from blue, red, green, purple, or orange -- **Adjustable size** - Small, medium, large, or extra-large labels -- **Works everywhere** - Video listings, search results, recommendations +### 🏷️ **Viewed Indicators** +The extension adds visual indicators inside YT re:Watch: +- **"Viewed" labels** - Shows videos already in your local watch history +- **Progress bars** - Shows local completion progress on video cards +- **Works across feed views** - Home, subscriptions, playlists, search, and history +- **Best-effort YouTube support** - Adds watched labels on supported YouTube thumbnail layouts Additionally, the history list now shows the video’s channel name beneath the title for quicker scanning. ### 🎛️ **Extension Interface** -Click the extension icon to access: -- **Videos Tab** - Your main viewing history with progress tracking -- **Shorts Tab** - Separate tracking for YouTube Shorts -- **Playlists Tab** - Discovered playlists with metadata -- **Analytics Tab** - Interactive charts showing viewing patterns -- **Settings Tab** - Full customization options +The popup now focuses on quick actions: +- **Continue watching** - Quickly resume recently watched videos +- **Watch Later** - Open videos you saved for later +- **Open Feed** - Launch the full local YouTube-style interface + +The full feed page contains the larger sections: +- **Home** - Local recommendations from your subscribed channels +- **Subscriptions** - Latest videos from locally subscribed channels in date order +- **Shorts** - Dedicated Shorts view +- **Playlists** - Local playlists you create and manage +- **History** - Watched videos, progress, and date watched +- **Channels** - All locally subscribed channels, with open/unsubscribe actions +- **Analytics** - Watch-time and completion statistics +- **Settings** - Theme, history/feed, import/export, and cleanup controls + +The feed page also includes extension search with YouTube results, grouped channel matches, and in-extension channel pages where you can subscribe locally and browse a channel's videos without using YouTube's account subscriptions. --- @@ -127,15 +127,26 @@ Click the extension icon to access: ### 🔄 **Multi-Account & Privacy** - **Account Independence**: Same YouTube history across all accounts (or no account) - **Local Storage**: All data stored securely on your device only -- **No Google Tracking**: YouTube doesn't get your viewing progress data -- **Export/Import**: Backup and restore your YouTube history anytime +- **Local Progress Tracking**: Use your own browser history instead of relying on YouTube account history +- **Backup/Restore**: Backup and restore your full local YT re:Watch data anytime - **Robust Deletion System**: Deleted videos stay deleted across all devices with tombstone-based protection ### 🎯 **Progress Tracking** -- **Visual Overlays**: Customizable "viewed" labels and progress bars -- **Auto-save**: Tracks video position every 5 seconds -- **YouTube Shorts**: Separate tracking for short-form content -- **Playlist Discovery**: Track and organize YouTube playlists +- **Viewed indicators**: Shows "Viewed" labels and local progress bars inside YT re:Watch, plus best-effort labels on supported YouTube thumbnail layouts +- **Auto-save**: Tracks video position every 5 seconds while you watch +- **YouTube Shorts**: Tracks short-form videos separately from regular videos +- **Local playlists**: Save videos into local playlists and manage them inside the feed page + +### 🏠 **Local YouTube-Style Feed** +- **Local subscriptions**: Subscribe to channels locally without needing a Google account +- **Home page**: Randomized local recommendations from subscribed channels, balanced with freshness and channel diversity +- **Subscriptions page**: Latest videos from subscribed channels in date order +- **Channels page**: View, open, and unsubscribe from local channels +- **Extension search**: Search YouTube from the feed page, with channel matches grouped above video results +- **In-extension channel pages**: Open a channel inside YT re:Watch to subscribe locally and browse that channel's videos +- **Local playlists**: Create playlists, add videos from the feed, view saved videos, and remove items +- **Local history page**: Review watched videos, watch progress, and watched date +- **Cleaner full-page UI**: Larger YouTube-like interface for browsing feed, history, analytics, playlists, and settings ### 📊 **Analytics & Insights** - **Interactive Charts**: Viewing patterns by hour and day @@ -149,19 +160,18 @@ Analytics now prefer locally persisted, privacy-preserving statistics for better ### 🔄 **Data Portability & Local Storage** - **Unlimited local storage**: GB-scale capacity with IndexedDB + localStorage hybrid system +- **Full backup/restore**: Export and restore history, subscriptions, playlists, watch later, settings, stats, preferences, and local caches via JSON files - **Manual export/import**: Transfer data between devices via JSON files - **Bulletproof reliability**: Core functionality works even if IndexedDB unavailable - **Privacy protection**: All data stays local, no cloud storage required - **Performance optimized**: Fast queries with indexed search and memory-efficient pagination -## Community tools -- **YouTube history converter** by [@tannery495](https://github.com/tannery495): Converts Google Takeout `watch-history.json` into a YT re:Watch import file. Repo: [github.com/tannery495/youtube-history-converter](https://github.com/tannery495/youtube-history-converter). - ### 🎨 **User Experience** - **Modern Interface**: Clean, card-based layout - **Dark/Light Theme**: Automatic system theme detection -- **Smart Search**: Find videos quickly in your history +- **Smart Search**: Search local data and YouTube results from the feed page - **Responsive Design**: Works perfectly on all screen sizes +- **Simpler popup**: The toolbar popup stays focused on quick resume/watch-later actions while full browsing happens on the feed page --- @@ -192,15 +202,16 @@ Analytics now prefer locally persisted, privacy-preserving statistics for better Click to expand developer installation instructions **Chrome:** -1. Run `./build.sh` to build the extension +1. Run `npm run build` from Git Bash/WSL to build the Chrome extension 2. Open Chrome → `chrome://extensions/` 3. Enable "Developer mode" -4. Click "Load unpacked" and select the `dist/chrome` folder +4. Click "Load unpacked" and select the `build/chrome` folder **Firefox:** -1. Run `./build.sh` to build the extension -2. Open Firefox → `about:debugging#/runtime/this-firefox` -3. Click "Load Temporary Add-on" and select the `manifest.json` file from the `dist/firefox` folder +1. Run `npm install` +2. Run `npm run prepare:firefox` +3. Open Firefox → `about:debugging#/runtime/this-firefox` +4. Click "Load Temporary Add-on" and select `build/firefox/manifest.json` @@ -231,46 +242,45 @@ The extension automatically migrates from legacy storage to the new hybrid syste 4. **Stats rebuild**: Analytics statistics are recalculated from migrated data 5. **Graceful fallback**: Extension continues working during migration process -### Data Structure +### Data Stored -The extension stores three types of data: +The extension can store: - **Video History**: Video IDs, timestamps, progress, titles, and URLs - **Playlist History**: Playlist IDs, titles, and URLs -- **Settings**: User preferences for overlay appearance, cleanup, and theme preferences - - **Statistics**: Aggregated watch‑time summaries used for analytics (local only) +- **Local Subscriptions**: Channels you subscribe to inside YT re:Watch +- **Local Playlists and Watch Later**: Playlists and saved videos managed by the extension +- **Settings**: User preferences for feed appearance, refresh, cleanup, and default page +- **Statistics**: Aggregated watch-time summaries used for analytics (local only) +- **Caches**: Duration, Shorts detection, and release-date caches used to make the feed faster and more accurate ## Usage 1. **Install the extension** following the instructions above 2. **Visit YouTube** and start watching videos 3. **Your progress is automatically saved** every 5 seconds with smart timestamp loading to prevent video interruption -4. **Click the extension icon** to view your watch history across multiple tabs: - - **Videos**: Regular YouTube videos with progress tracking - - **Shorts**: Dedicated interface for YouTube Shorts - - **Playlists**: Saved playlists with metadata - - **Analytics**: Interactive charts and viewing statistics - - **Settings**: Customize appearance, data management, and preferences -5. **Export/import data** anytime for backup and transfer between devices +4. **Click the extension icon** to quickly continue watching, open Watch Later, or open the full feed page +5. **Use the feed page** for Home, Subscriptions, Shorts, Playlists, History, Channels, Analytics, Settings, search, and in-extension channel pages +6. **Export/import data** anytime for backup and transfer between devices ### Settings #### 🎨 Appearance - **Theme**: Choose between System (follows your OS theme), Light, or Dark theme with instant switching -- **Overlay Title**: Text to show in the overlay (max 12 characters) -- **Overlay Color**: Color of the progress bar overlay (blue, red, green, purple, orange) -- **Overlay Label Size**: Size of the overlay label and progress bar (small, medium, large, extra large) +- **Accent Color**: Choose the feed page accent color +- **Default Feed Page**: Choose whether the full feed opens to the last used page, Home, Subscriptions, Shorts, Playlists, History, Channels, or Analytics -#### 🗂️ Data Management +#### 🗂️ History & Feed - **Auto-clean Period**: Automatically remove history entries older than specified days (1–180 days), or choose **Forever** to disable auto-cleanup -- **Items per Page**: Number of items to show per page in history view (5-20) -- **Debug Mode**: Enable debug logging for troubleshooting +- **Feed Refresh Interval**: Choose how often the extension checks locally subscribed channels for new videos +- **History cleanup**: Use the History tab's clear action when you need to remove local watch history #### 🔄 Data Management -- **Export History**: Download complete history as JSON file for backup -- **Import History**: Restore or merge data from backup files +- **Backup all data**: Download a local JSON backup containing history, subscriptions, playlists, settings, analytics data, recommendation preferences, and caches +- **Restore backup**: Merge a previous YT re:Watch backup into the current browser profile +- **Import YouTube history**: Import Google Takeout watch history +- **Import channels**: Import YouTube subscribed channels from `subscriptions.csv` in Google Takeout - **Data Portability**: Transfer history between devices manually -- **Migration Status**: Monitor hybrid storage migration progress ### Theme System @@ -313,39 +323,39 @@ The Analytics tab provides comprehensive insights into your YouTube viewing habi ## Privacy -- **No Data Collection**: The extension does not collect, transmit, or store any data outside your device -- **Local Storage Only**: All data is stored locally using the browser's secure storage API -- **No External Servers**: The extension does not communicate with any external servers +- **No app backend**: YT re:Watch does not upload your history to an extension-owned server +- **Local Storage First**: Your history, local subscriptions, playlists, settings, backups, and analytics live in browser storage +- **YouTube requests only when needed**: The extension contacts YouTube to read pages, RSS feeds, metadata, and search results needed for YouTube-related features +- **Manual portability**: Backups are local JSON files that you control ## Security -The extension uses the browser's built-in storage API (`chrome.storage.local`/`browser.storage.local`) which provides: +The extension stores data in extension-scoped browser storage (`chrome.storage.local` / `browser.storage.local`) and IndexedDB: -- **Encrypted Storage**: Data is stored in an encrypted format -- **Access Control**: Only the extension can access the stored data -- **No Console Access**: Data cannot be easily dumped through browser developer tools +- **Extension isolation**: Web pages cannot read the extension's storage directly +- **Browser-managed storage**: Data is protected by the browser profile and operating system user account +- **Local backups**: Exported backup files are plain JSON, so store them somewhere you trust ## Troubleshooting ### Extension Not Working -1. **Check YouTube Page**: Make sure you're on a YouTube page (youtube.com) -2. **Refresh and Retry**: Refresh the page and try again +1. **Refresh and Retry**: Refresh the YouTube page or the full feed page +2. **Check Permissions**: Make sure the extension has access to YouTube 3. **Extension Status**: Verify the extension is enabled in your browser -4. **Debug Mode**: Enable debug mode in settings for detailed logging +4. **Reload Extension**: Disable/enable the extension or reload it from the browser extension page ### History Not Loading -1. **Popup Refresh**: Close and reopen the popup interface +1. **Feed Refresh**: Open the feed page and click Refresh 2. **Page Refresh**: Refresh the YouTube page completely 3. **Console Logs**: Check browser console for error messages (F12 → Console) 4. **Storage Check**: Verify extension has storage permissions -5. **Improved Diagnostics**: Benefit from enhanced error messages and loading states +5. **Backup First**: Export a backup before clearing any extension data ### Storage & Migration Issues -1. **Migration Status**: Check popup for hybrid storage migration progress -2. **Storage Space**: Ensure sufficient disk space for IndexedDB storage -3. **Browser Storage**: Verify extension has storage permissions enabled -4. **Fallback Mode**: Extension continues working if IndexedDB unavailable -5. **Export Backup**: Always export data before major troubleshooting +1. **Storage Space**: Ensure sufficient disk space for IndexedDB storage +2. **Browser Storage**: Verify extension has storage permissions enabled +3. **Fallback Mode**: Extension continues working if IndexedDB is unavailable +4. **Export Backup**: Always export data before major troubleshooting ### Migration Issues If you experience issues with data migration from older versions: @@ -358,23 +368,29 @@ If you experience issues with data migration from older versions: ### Project Structure ``` -├── src/ # Source files -│ ├── background/ # Background script -│ ├── content/ # Content scripts -│ ├── popup/ # Popup interface -│ ├── shared/ # Shared utilities and components -│ └── manifest/ # Manifest templates -├── dist/ # Distribution packages -├── helpers/ # Build helper scripts -├── build.sh # Build script -└── youtube-local-history.user.js # Userscript version +├── src/ +│ ├── background.js # Extension background/service worker logic +│ ├── content*.js # YouTube page tracking, overlays, playlists, and messages +│ ├── popup*.js / popup.html # Toolbar popup, settings, history, and quick actions +│ ├── feed*.js / feed.html # Full local feed app, search, subscriptions, playlists, settings, and backup +│ ├── storage.js # Hybrid storage API +│ ├── indexeddb-storage.js # IndexedDB backend +│ ├── manifest.chrome.json # Chrome manifest +│ └── manifest.firefox.json # Firefox manifest +├── docs/ # User and maintainer docs +├── tests/ # Unit and integration tests +├── build/ # Prepared browser builds +├── dist/ # Release packages, when built +├── build.sh # Chrome/Firefox build script +├── prepare-firefox-build.js # Firefox temporary-addon build helper +└── merge_locales.js # Locale build helper ``` ### Building 1. Make changes to the source files in the `src/` directory -2. Run `./build.sh` to build both Chrome and Firefox extensions +2. Run `./build.sh` for release packages, or `npm run prepare:firefox` for a Firefox temporary build 3. Test the built extensions in your browser -4. The built extensions will be available in the `dist/` directory +4. Temporary unpacked builds are written to `build/`; release packages are written to `dist/` ## License @@ -395,7 +411,7 @@ See [CHANGELOG.md](CHANGELOG.md) for detailed release notes and version history. **YT re:Watch** is the ultimate **YouTube history extension** for users who: - Switch between **multiple YouTube accounts** (work/personal) - Want **YouTube history without login** requirements -- Need **YouTube progress tracking** with visual overlays +- Need **YouTube progress tracking** with local viewed indicators - Prefer **YouTube privacy** over Google's tracking - Want **consistent viewing history** regardless of account status @@ -409,10 +425,10 @@ See [CHANGELOG.md](CHANGELOG.md) for detailed release notes and version history. This extension supports multiple languages: - English (en) -- German (de) -- Spanish (es) -- French (fr) -- Bulgarian (bg) +- German (de) (machine-generated) +- Spanish (es) (machine-generated) +- French (fr) (machine-generated) +- Bulgarian (bg) (machine-generated) **Note:** All non-English translations are currently machine-generated. If you are a native speaker and notice any issues, please consider contributing improvements! See `src/_locales/README.md` for translation guidelines. diff --git a/build.sh b/build.sh index 7f02edc..b37e807 100755 --- a/build.sh +++ b/build.sh @@ -20,10 +20,56 @@ copy_common_files() { local target_dir=$1 cp "$PROJECT_ROOT/src/background.js" \ "$PROJECT_ROOT/src/content.js" \ + "$PROJECT_ROOT/src/content-css.js" \ + "$PROJECT_ROOT/src/content-url.js" \ + "$PROJECT_ROOT/src/content-import.js" \ + "$PROJECT_ROOT/src/content-playlists.js" \ + "$PROJECT_ROOT/src/content-info.js" \ + "$PROJECT_ROOT/src/content-thumbnails.js" \ + "$PROJECT_ROOT/src/content-messages.js" \ "$PROJECT_ROOT/src/popup.html" \ + "$PROJECT_ROOT/src/popup-core.js" \ + "$PROJECT_ROOT/src/popup-utils.js" \ + "$PROJECT_ROOT/src/popup-import.js" \ + "$PROJECT_ROOT/src/popup-settings.js" \ + "$PROJECT_ROOT/src/popup-search.js" \ + "$PROJECT_ROOT/src/popup-data-pages.js" \ + "$PROJECT_ROOT/src/popup-history-display.js" \ + "$PROJECT_ROOT/src/popup-video-pagination.js" \ + "$PROJECT_ROOT/src/popup-analytics-core.js" \ + "$PROJECT_ROOT/src/popup-analytics-charts.js" \ + "$PROJECT_ROOT/src/popup-analytics-extra.js" \ + "$PROJECT_ROOT/src/popup-playlists.js" \ + "$PROJECT_ROOT/src/popup-subscriptions.js" \ + "$PROJECT_ROOT/src/popup-theme.js" \ + "$PROJECT_ROOT/src/popup-shorts.js" \ + "$PROJECT_ROOT/src/popup-localization.js" \ "$PROJECT_ROOT/src/popup.js" \ + "$PROJECT_ROOT/src/import.html" \ + "$PROJECT_ROOT/src/import.js" \ + "$PROJECT_ROOT/src/feed.html" \ + "$PROJECT_ROOT/src/feed-core.js" \ + "$PROJECT_ROOT/src/feed-state-utils.js" \ + "$PROJECT_ROOT/src/feed-cards.js" \ + "$PROJECT_ROOT/src/feed-local-search.js" \ + "$PROJECT_ROOT/src/feed-home.js" \ + "$PROJECT_ROOT/src/feed-analytics.js" \ + "$PROJECT_ROOT/src/feed-subscriptions-view.js" \ + "$PROJECT_ROOT/src/feed-playlist-import.js" \ + "$PROJECT_ROOT/src/feed-playlists-view.js" \ + "$PROJECT_ROOT/src/feed-history-view.js" \ + "$PROJECT_ROOT/src/feed-settings.js" \ + "$PROJECT_ROOT/src/feed-localization.js" \ + "$PROJECT_ROOT/src/feed-backup.js" \ + "$PROJECT_ROOT/src/feed-data-pipeline.js" \ + "$PROJECT_ROOT/src/feed-subscribe-results.js" \ + "$PROJECT_ROOT/src/feed-youtube-search-core.js" \ + "$PROJECT_ROOT/src/feed-youtube-search-render.js" \ + "$PROJECT_ROOT/src/feed-channel-view.js" \ + "$PROJECT_ROOT/src/feed-refresh.js" \ + "$PROJECT_ROOT/src/feed.js" \ "$PROJECT_ROOT/src/storage.js" \ - "$PROJECT_ROOT/src/sync-service.js" \ + "$PROJECT_ROOT/src/content-subscriptions.js" \ "$PROJECT_ROOT/src/indexeddb-storage.js" \ "$target_dir/" cp "$PROJECT_ROOT/src/icon"*.png "$target_dir/" @@ -65,7 +111,7 @@ copy_common_files "$PROJECT_ROOT/build/firefox" cp "$PROJECT_ROOT/src/manifest.firefox.json" "$PROJECT_ROOT/build/firefox/manifest.json" cd "$PROJECT_ROOT/build/firefox" # For Firefox, we need to zip the files directly, not the directory -zip -j "../../dist/youtube-local-history-firefox-v$VERSION.zip" manifest.json background.js content.js popup.html popup.js storage.js sync-service.js indexeddb-storage.js icon*.png -x ".*" +zip -j "../../dist/youtube-local-history-firefox-v$VERSION.zip" manifest.json background.js content.js content-css.js content-url.js content-import.js content-playlists.js content-info.js content-thumbnails.js content-messages.js popup.html popup-core.js popup-utils.js popup-import.js popup-settings.js popup-search.js popup-data-pages.js popup-history-display.js popup-video-pagination.js popup-analytics-core.js popup-analytics-charts.js popup-analytics-extra.js popup-playlists.js popup-subscriptions.js popup-theme.js popup-shorts.js popup-localization.js popup.js import.html import.js feed.html feed-core.js feed-state-utils.js feed-cards.js feed-local-search.js feed-home.js feed-analytics.js feed-subscriptions-view.js feed-playlist-import.js feed-playlists-view.js feed-history-view.js feed-settings.js feed-localization.js feed-backup.js feed-data-pipeline.js feed-subscribe-results.js feed-youtube-search-core.js feed-youtube-search-render.js feed-channel-view.js feed-refresh.js feed.js storage.js content-subscriptions.js indexeddb-storage.js icon*.png -x ".*" # Include _locales in the Firefox zip if it exists if [ -d _locales ]; then zip -r "../../dist/youtube-local-history-firefox-v$VERSION.zip" _locales -x ".*" @@ -77,4 +123,4 @@ echo "Chrome extension (ZIP): dist/youtube-local-history-chrome-v$VERSION.zip" echo "Chrome extension (CRX): dist/youtube-local-history-chrome-v$VERSION.crx" echo "Firefox extension: dist/youtube-local-history-firefox-v$VERSION.zip" echo -e "\nPackage contents:" -ls -lh dist/ \ No newline at end of file +ls -lh dist/ diff --git a/docs/images/icon48.png b/docs/assets/icon48.png old mode 100755 new mode 100644 similarity index 100% rename from docs/images/icon48.png rename to docs/assets/icon48.png diff --git a/docs/build.md b/docs/build.md index 698c8c2..dc37ff4 100644 --- a/docs/build.md +++ b/docs/build.md @@ -1,146 +1,126 @@ -# 🔨 Build Instructions +# Build Instructions -## 🛠️ Development Setup +This guide explains how to install dependencies, run checks, and load YT re:Watch for local browser testing. -This guide explains how to set up your development environment and build YT re:Watch extension for both Chrome and Firefox. +## Prerequisites -### Prerequisites -- **Node.js 14+** - [Download here](https://nodejs.org/) -- **NPM or Yarn** - Package manager (comes with Node.js) -- **Chrome/Firefox** - For testing the extension -- **Git** - For cloning the repository +- Node.js 18 or newer +- npm +- Git +- Firefox or Chrome/Chromium +- Git Bash, WSL, or another Bash-compatible shell if you want to run `build.sh` -### Local Development +## Install -#### 1. Clone the Repository ```bash git clone https://github.com/EdinUser/YouTubeLocalHistory.git cd YouTubeLocalHistory +npm install ``` -#### 2. Install Dependencies +## Local Firefox Testing + +This is the recommended development path on Windows. + ```bash -npm install +npm run prepare:firefox ``` -This will install all required dependencies including: -- **Jest** - For unit and integration testing -- **Playwright** - For end-to-end testing -- **ESLint** - For code linting -- **Babel** - For JavaScript transpilation +Then open Firefox: -#### 3. Build the Extension -```bash -# Build both Chrome and Firefox versions -./build.sh +1. Go to `about:debugging`. +2. Click `This Firefox`. +3. Click `Load Temporary Add-on`. +4. Select `build/firefox/manifest.json`. -# Or build specific browser -./build.sh chrome -./build.sh firefox -``` +After code changes, run `npm run prepare:firefox` again, then reload the temporary add-on. + +## Local Chrome Testing -#### 4. Load in Browser -After building, the extensions will be available in the `dist/` folder: -- **Chrome**: `dist/youtube-local-history-chrome-v{version}.zip` -- **Firefox**: `dist/youtube-local-history-firefox-v{version}.zip` +Use the release build script to prepare `build/chrome`, or copy the source files into a Chrome build folder using the same file list from `build.sh`. -**To load in browser:** -- **Chrome**: Go to `chrome://extensions/`, enable "Developer mode", click "Load unpacked" and select the `build/chrome` folder -- **Firefox**: Go to `about:debugging`, click "This Firefox", then "Load Temporary Add-on" and select the `build/firefox` folder +Then open Chrome: -### Testing +1. Go to `chrome://extensions/`. +2. Enable `Developer mode`. +3. Click `Load unpacked`. +4. Select `build/chrome`. + +## Checks + +Run these before opening a pull request: -#### Run All Tests ```bash -npm test +npm run lint +npm test -- --runInBand +npm run prepare:firefox +npx web-ext lint --source-dir=build/firefox ``` -#### Run Specific Test Suites +Useful test commands: + ```bash -# Unit tests only +npm test npm run test:unit - -# Integration tests only npm run test:integration - -# Memory tests only npm run test:memory - -# End-to-end tests (requires Playwright browsers) npm run test:e2e - -# Watch mode for development -npm run test:watch +npm run test:coverage ``` -#### Test Coverage +`npm run test:e2e` uses Playwright. If browsers are missing, run: + ```bash -npm run test:coverage +npx playwright install ``` -This generates a coverage report in the `coverage/` folder. +## Release Packaging -### Building for Production +The release script builds browser folders and creates ZIP packages in `dist/`. -#### Build Both Browsers ```bash -./build.sh +npm run build ``` -This will: -1. Clean previous builds -2. Merge locale files -3. Build Chrome extension (with signing if certificates are available) -4. Build Firefox extension -5. Create distribution packages in `dist/` +or directly: -#### Build Specific Browser ```bash -# Chrome only -./build.sh chrome - -# Firefox only -./build.sh firefox +./build.sh ``` -#### Output Files -After building, you'll find: -- **Chrome**: `dist/youtube-local-history-chrome-v{version}.zip` and `.crx` (if signed) -- **Firefox**: `dist/youtube-local-history-firefox-v{version}.zip` +Notes: -### Project Structure +- `build.sh` is a Bash script. On Windows, run it from Git Bash or WSL. +- Chrome CRX signing requires `google-chrome` and a private key at `certs/privatekey.pem`, or paths supplied through `CHROME_EXTENSION_DIR` and `PRIVATE_KEY_PATH`. +- If Chrome signing is not configured, the Chrome ZIP may still be useful, but CRX generation can warn or fail depending on your environment. -``` -src/ -├── _locales/ # Multi-language support files -│ ├── en/ # English translations -│ ├── de/ # German translations -│ └── ... # Other languages -├── manifest.chrome.json # Chrome extension manifest -├── manifest.firefox.json # Firefox extension manifest -├── background.js # Service worker/background script -├── content.js # Content script for YouTube pages -├── popup.html/js # Extension popup interface -├── storage.js # Local storage management -└── indexeddb-storage.js # IndexedDB wrapper for unlimited storage -``` +Expected release outputs: -### Development Tips +- `dist/youtube-local-history-chrome-v{version}.zip` +- `dist/youtube-local-history-chrome-v{version}.crx` when signing succeeds +- `dist/youtube-local-history-firefox-v{version}.zip` -#### Hot Reloading -- The build script doesn't include hot reloading -- After making changes, run `./build.sh` and reload the extension in your browser -- Use the "Reload" button in `chrome://extensions/` or `about:debugging` +## Project Structure + +```text +src/ + _locales/ Translation files + manifest.chrome.json Chrome extension manifest + manifest.firefox.json Firefox extension manifest + background.js Extension background script + content*.js YouTube page content scripts + popup*.js Toolbar popup + feed*.js Full local feed page + import.html, import.js Import page + storage.js Browser storage wrapper + indexeddb-storage.js IndexedDB history storage +``` -#### Debugging -- Enable "Debug Mode" in the extension settings for detailed logging -- Check browser console for errors -- Use `chrome://extensions/` or `about:debugging` for extension management +## Debugging -#### Common Issues -- **Build fails**: Ensure Node.js 14+ and all dependencies are installed -- **Extension not loading**: Check manifest permissions and browser compatibility -- **Tests failing**: Ensure Playwright browsers are installed (`npx playwright install`) +- Enable Debug Mode in Settings for extra logs. +- Use `about:debugging` in Firefox to inspect the temporary extension. +- Use `chrome://extensions/` in Chrome to inspect service worker/content script errors. +- If the local feed looks stale, reload the extension and click `Refresh` in the feed page. ---- -*For more technical details, see [Technical Documentation](./technical.md)* +For more technical details, see [Technical Documentation](./technical.md). diff --git a/docs/code-notes.md b/docs/code-notes.md new file mode 100644 index 0000000..8423cdc --- /dev/null +++ b/docs/code-notes.md @@ -0,0 +1,127 @@ +# Code Notes for Maintainers + +These notes are for the original developer or anyone reviewing the fork. They explain the main moving parts in plain language and call out recent refactors/behavior changes. + +## High-Level Purpose + +YT re:Watch is a local-first YouTube history extension. It stores watch progress, playlist data, local subscriptions, feed cache, analytics data, and user settings in extension storage/IndexedDB. The extension avoids using a backend server. + +## Runtime Areas + +### YouTube Page Content Scripts + +These files run directly on YouTube pages through the manifest `content_scripts` list. Load order matters because these are plain scripts, not ES modules. + +- `storage.js`: defines the global `ytStorage` API used by the rest of the extension. +- `content-css.js`: injects shared content-script CSS for playlist ignore toggles, the active-info popup, and thumbnail viewed/progress overlays. +- `content-url.js`: extracts YouTube video IDs and handles clean/timestamped YouTube URLs. +- `content-import.js`: shows the in-page import overlay when YouTube opens with `#ytlh_import`. +- `content-playlists.js`: reads playlist metadata from YouTube pages, saves playlist records, and adds the playlist “pause history” toggle. +- `content-info.js`: shows the one-time YT re:Watch active info popup. +- `content-thumbnails.js`: adds viewed/progress overlays to extension-feed thumbnails and best-effort viewed labels on supported YouTube thumbnail layouts. YouTube page markup changes often, so native YouTube overlays are intentionally conservative. +- `content-messages.js`: handles popup/import messages sent to the YouTube tab. +- `content.js`: remaining bootstrap and video tracking logic. It wires the helpers together, tracks video elements, saves/restores timestamps, detects SPA navigation, and registers listeners. +- `feed-core.js`: shared parser/selector helpers for local subscription feed data. +- `content-subscriptions.js`: local subscription behavior inside YouTube pages, local Subscribe buttons, account UI hiding, and feed-cache refresh wiring. + +Important: when adding another content helper file, update all manifests and `build.sh`, otherwise packaged builds can miss the file. + +## Extension Pages + +### Popup + +Files named `popup*.js` power the browser action popup: + +- `popup-core.js`: initializes popup storage/state and shared popup helpers. +- `popup.js`: top-level popup wiring and tab switching. +- `popup-data-pages.js`, `popup-history-display.js`, `popup-video-pagination.js`, `popup-shorts.js`, `popup-playlists.js`: history/shorts/playlist display and paging. +- `popup-search.js`: popup search and search suggestions. +- `popup-settings.js`, `popup-theme.js`, `popup-localization.js`: settings, theme, and localized text. +- `popup-analytics*.js`: local analytics and charts. +- `popup-import.js`: import UI logic. +- `popup-subscriptions.js`: local subscriptions/watch-later related popup behavior. + +### Feed Page + +Files named `feed*.js` power the extension feed page: + +- `feed.js`: top-level feed page wiring. +- `feed-refresh.js`: refresh button/status handling. +- `feed-data-pipeline.js`: fetches RSS/backfill videos, resolves durations/Shorts metadata, and writes `feedCache`. RSS entries are the source of truth for subscription dates/views; YouTube backfill should only add older missing videos or safe metadata such as member badges. +- `feed-home.js`: ranks and renders the local Home view. +- `feed-cards.js`: builds video cards and card menus. Cached feed cards should display dates from `published`; `_whenText` is reserved for live YouTube search/history-style results. +- `feed-local-search.js`: local feed/history search and metadata enrichment. +- `feed-youtube-search-core.js`, `feed-youtube-search-render.js`: YouTube search integration. +- `feed-subscriptions-view.js`, `feed-playlists-view.js`, `feed-playlist-import.js`, `feed-history-view.js`, `feed-analytics.js`, `feed-settings.js`, `feed-backup.js`: individual feed page sections. +- `feed-state-utils.js`: shared feed state, formatting, and utility helpers. + +## Storage Notes + +`storage.js` is still large and central. It exposes `globalThis.ytStorage = new SimpleStorage()`. + +Main responsibilities: + +- browser storage wrapper +- migration from old storage paths +- hybrid IndexedDB/storage.local behavior +- video history methods +- playlist methods +- paginated history methods +- settings +- watch later +- import/export helpers +- stats +- local subscriptions +- feed/duration/Shorts/release-date caches + +Because many files depend on `ytStorage`, split `storage.js` carefully. Good future split targets are subscription/cache methods and stats/import methods, but do that with tests available. + +## Recent Refactor Notes + +`content.js` used to be over 3,000 lines. It has been split into helper files: + +- CSS moved to `content-css.js` +- URL helpers moved to `content-url.js` +- import overlay moved to `content-import.js` +- playlist helpers moved to `content-playlists.js` +- info popup moved to `content-info.js` +- thumbnail overlay logic moved to `content-thumbnails.js` +- popup message listener moved to `content-messages.js` + +The helper files expose small globals like `window.YTVHTContentUrls` because the extension currently uses plain manifest script loading instead of a bundler/module system. + +## Home Feed Ranking Notes + +The Home feed is intentionally randomized across the local back catalog, while still keeping some freshness and channel-affinity signal. + +Recent change in `feed-home.js`: + +- Home ranks across all ready feed videos instead of hard-limiting to recent uploads. +- Random discovery is strong enough to surface uploads from weeks or months ago. +- The ranked list is interleaved by age buckets so the top grid is not mostly this week when older cached videos exist. +- Videos older than the back-catalog window receive only a soft age penalty. +- Channel diversity is capped to avoid one channel, such as EthosLab, filling too much of Home. + +The chronological Subscriptions view should still show newest-first. + +## Build Notes + +`build.sh` copies files explicitly. If a new source file is added, update: + +- the copy list in `copy_common_files` +- the Firefox zip file list +- all manifest files if it is loaded by the browser + +Current manifests: + +- `src/manifest.chrome.json` +- `src/manifest.firefox.json` + +## Known Risk Areas + +- YouTube DOM selectors change often. Content script selectors should be broad but not too eager. +- YouTube SPA navigation reuses video elements, so video tracking must reset per-video state carefully. +- Feed backfill can introduce older videos; ranking should balance discovery with freshness. +- Do not let YouTube channel backfill overwrite RSS dates/views for videos already present in RSS. YouTube's rendered relative text can be stale or page-load relative, which makes Subscriptions show incorrect "minutes ago" dates. +- `storage.js` is a shared public API inside the extension. Avoid changing method names without updating popup/feed/content code. +- Run `npm run lint`, `npm test -- --runInBand`, `npm run prepare:firefox`, and `npx web-ext lint --source-dir=build/firefox` after refactors. diff --git a/docs/contributing.md b/docs/contributing.md index 6891130..e126d63 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -49,7 +49,7 @@ git checkout -b fix/important-bug ### Code Style Guidelines - **JavaScript ES6+** - Use modern JavaScript features - **Consistent naming** - Follow existing variable and function naming patterns -- **JSDoc comments** - Add documentation for public APIs and complex functions +- **Comments where useful** - Add short comments for complex behavior, especially storage migrations, YouTube parser logic, and feed ranking rules - **2-space indentation** - Match the existing codebase style - **Meaningful variable names** - Use descriptive names that explain purpose @@ -58,10 +58,21 @@ git checkout -b fix/important-bug 1. **Create feature branch** from `main` 2. **Make your changes** following the guidelines above 3. **Write tests** for new functionality -4. **Run all tests** to ensure nothing is broken +4. **Run checks** to ensure nothing is broken 5. **Update documentation** if needed 6. **Submit pull request** with clear description +Recommended checks before opening a PR: + +```bash +npm run lint +npm test -- --runInBand +npm run prepare:firefox +npx web-ext lint --source-dir=build/firefox +``` + +For local Firefox testing, load `build/firefox/manifest.json` from `about:debugging` after running `npm run prepare:firefox`. + ### Code Review Guidelines When reviewing contributions, focus on: @@ -93,7 +104,7 @@ When reviewing contributions, focus on: ### Testing Requirements - **Unit tests** for individual functions - **Integration tests** for feature interactions -- **E2E tests** for complete user workflows +- **E2E tests** for complete user workflows when a change affects browser behavior end-to-end - **Cross-browser testing** on Chrome and Firefox ## 🌍 Localization @@ -102,7 +113,8 @@ When reviewing contributions, focus on: 1. Create new folder in `src/_locales/{language_code}/` 2. Copy structure from existing language (e.g., `en/`) 3. Translate all JSON files -4. Update `manifest.json` files to include the new language +4. Keep message keys in sync across all locale JSON files +5. Update browser manifests only if the new language needs manifest metadata ### Translation Guidelines - **Keep keys consistent** - Use the same message keys as English diff --git a/docs/detailed_guide.md b/docs/detailed_guide.md index 8508c36..ca5e3f1 100644 --- a/docs/detailed_guide.md +++ b/docs/detailed_guide.md @@ -48,25 +48,28 @@ Your step-by-step guide to a privacy-first YouTube history extension that keeps - **Ad blocker** (block tracking scripts) - **Privacy browser** (Firefox with strict settings, or Brave) -YT re:Watch stops YouTube from getting your viewing progress data, but Google still knows you visited their site. +YT re:Watch keeps its saved viewing progress in extension storage, but Google/YouTube can still see normal YouTube page requests when you browse the site. -### Step 3: View Your History +### Step 3: Open Your Local Feed and History 1. Click the YT re:Watch icon in your browser toolbar -2. You'll see your watched videos with progress indicators -3. Explore the different tabs to see all features +2. Use the quick actions or open the full feed page +3. Explore Home, Subscriptions, Shorts, Playlists, History, Channels, Analytics, and Settings --- ## 🎯 Understanding the Interface -### Extension Popup Overview -When you click the extension icon, you'll see 5 tabs: +### Extension Interface Overview +The extension icon opens quick actions, while the full feed page contains the main tabs: | Tab | What It Shows | |-----|---------------| -| **Videos** | Regular YouTube videos you've watched | +| **Home** | Local recommendations from subscribed channels | +| **Subscriptions** | Latest videos from locally subscribed channels | | **Shorts** | YouTube Shorts you've watched | -| **Playlists** | Playlists you've discovered | +| **Playlists** | Local playlists you create and manage | +| **History** | Regular YouTube videos you've watched | +| **Channels** | Channels you subscribed to locally | | **Analytics** | Charts and statistics about your viewing | | **Settings** | Customization options | @@ -76,24 +79,18 @@ After watching videos, you'll notice: - **Progress bar**: Shows how much of the video you've watched - **Percentage indicator**: Shows completion percentage (e.g., "75%") -### Videos Tab (Your Main History) +### History Tab (Your Main History) -![Videos tab interface](./images/ytrw_videos.jpg) -*The Videos tab shows your complete YouTube watch history, with progress, search, and delete options.* Your history list shows the channel name under each video title to help you scan quickly. ### 🎬 Shorts Tab (YouTube Shorts) -![Shorts tab interface](./images/ytrw_shorts.jpg) -*The Shorts tab tracks your YouTube Shorts viewing separately, helping you understand your short-form content habits.* Shorts saves are now more reliable: the save cadence is 5 seconds and duration checks are relaxed to avoid missed saves. ### 📝 Playlists Tab -![Playlists tab interface](./images/ytrw_playlists.jpg) -*The Playlists tab lists all playlists you've discovered, with quick access and management options.* **Playlist Controls:** - **Ignore videos (per‑playlist toggle):** When enabled for a specific playlist, YT re:Watch will not save watch progress for videos watched while viewing that playlist. @@ -123,17 +120,9 @@ Shorts saves are now more reliable: the save cadence is 5 seconds and duration c - Each bar shows the count of long videos that were skipped, partially watched, or completed - The legend to the right shows the color, label, and percentage for each segment -![Analytics dashboard summary and top channels](./images/ytrw_stats1.jpg) -*Summary cards and top channels in the Analytics tab.* -![Analytics completion bar and unfinished videos](./images/ytrw_stats2.jpg) -*Completion bar chart, unfinished videos, and skipped channels.* -![Analytics activity by day and hour](./images/ytrw_stats3.jpg) -*Watch activity by day and by hour in the Analytics tab.* -![Analytics detailed view](./images/ytrw_stats4.jpg) -*Additional analytics showing watch time patterns and channel statistics* These charts now prefer locally persisted, privacy‑preserving statistics for better accuracy and responsiveness. Keys are local‑day `YYYY-MM-DD` and 24 hourly buckets. @@ -144,7 +133,7 @@ These charts now prefer locally persisted, privacy‑preserving statistics for b - *System*: Follows your computer's theme - *Light*: Always use light theme - *Dark*: Always use dark theme -- **Quick toggle**: Click the theme button in the popup header to switch between light and dark modes instantly. +- **System default**: Choose System to follow your computer's theme automatically. **Overlay Customization:** - **Overlay Title**: Text shown on video thumbnails (max 12 characters) @@ -162,13 +151,13 @@ These charts now prefer locally persisted, privacy‑preserving statistics for b **Data Management:** - **Auto-clean Period**: Automatically remove old history - Range: 1–180 days, or **Forever** to keep all history without age-based cleanup - - Default: 90 days + - Default: Forever - Helps keep your history manageable while still allowing "forever history" if desired - **Items per Page**: How many videos to show per page - Range: 5-20 items - Larger numbers = less scrolling, smaller numbers = faster loading --- **Export History**: Back up your data to a file --- **Import History**: Restore or merge data from a backup +- **Backup**: Back up all local data to a JSON file +- **Restore**: Restore or merge data from a backup -- **Robust Deletion System**: Deleted videos stay deleted with tombstone protection - Videos deleted from history use tombstone protection so they don't reappear from archives or imports - 30-day protection period ensures deletions persist across migrations and imports @@ -176,8 +165,8 @@ These charts now prefer locally persisted, privacy‑preserving statistics for b - **Persistent Statistics**: Analytics use a local stats snapshot (total, last 7 daily totals, 24 hourly totals). Seeded from your existing history after upgrade. **Data Management:** -- **Export History**: Download complete history as JSON backup -- **Import History**: Restore data from backup files (Merge or Replace modes) +- **Backup**: Download a complete JSON backup +- **Restore**: Restore data from backup files - **Migration Status**: Monitor hybrid storage migration progress - **Cross-device usage**: There is **no automatic sync** – use Export/Import to move history between browsers or devices manually @@ -185,35 +174,29 @@ These charts now prefer locally persisted, privacy‑preserving statistics for b - **Debug Mode**: Enable detailed logging for troubleshooting - **Version Info**: Shows current extension version -![Settings tab interface](./images/ytrw_settings.jpg) -*Customize your experience in the Settings tab, including theme, overlays, and data management.* -![Settings advanced options](./images/ytrw_settings2.jpg) -*Additional settings for data management, privacy, and advanced features* **Playlist Settings:** - **Pause history in playlists (global):** When enabled, the extension does not save watch progress for videos played while you are browsing within any playlist. Use this if you generally don't want playlist sessions to affect your history. ### What You See on YouTube -![YouTube overlay showing viewed videos](./images/ytrw_overlay.jpg) -*Visual overlays on YouTube show which videos you've watched and your progress at a glance.* --- ## 🔄 Data Management & Transfer ### Exporting Your History -1. **Open Settings**: Click the Settings tab in the extension popup +1. **Open Settings**: Open the full feed page and click Settings 2. **Data Management**: Scroll to the "Data Management" section -3. **Export History**: Click the "Export History" button +3. **Backup**: Click the "Backup" button 4. **Download**: Your complete history will download as a JSON file 5. **Storage**: The file includes videos, playlists, and analytics data ### Importing History to Another Device 1. **Transfer File**: Move the exported JSON file to your other device 2. **Open Settings**: In the extension on the target device -3. **Import History**: Click "Import History" and select the JSON file +3. **Restore**: Click "Restore" and select the JSON file 4. **Choose Mode**: - **Merge**: Combines with existing data (recommended) - **Replace**: Completely replaces existing history @@ -232,13 +215,8 @@ These charts now prefer locally persisted, privacy‑preserving statistics for b ### Theme Customization -**Quick Theme Toggle:** -1. Click the theme button in the popup header -2. Toggles between light and dark themes -3. Bypasses system theme preference - -**Detailed Theme Settings:** -1. Go to Settings tab +**Theme Settings:** +1. Go to Settings 2. Choose "Theme Preference" 3. Select your preferred option: - **System**: Matches your OS theme automatically @@ -293,9 +271,9 @@ These charts now prefer locally persisted, privacy‑preserving statistics for b **How to Export:** 1. Go to Settings tab -2. Click "Export History" +2. Click "Backup" 3. Choose a location to save the file -4. File will be named: `youtube-history-[date].json` +4. File will be named as a YT re:Watch backup JSON file ### Importing Data @@ -306,7 +284,7 @@ These charts now prefer locally persisted, privacy‑preserving statistics for b **How to Import:** 1. Go to Settings tab -2. Click "Import History" +2. Click "Restore" 3. Select your backup file 4. Choose import mode: - **Merge**: Combines with existing data @@ -339,7 +317,7 @@ The export file contains: - Disable other YouTube extensions temporarily **Issue: History not showing** -- Close and reopen the extension popup +- Refresh the feed page or close and reopen the extension popup - Refresh the YouTube page - Check if you're logged into the same browser profile - Try exporting and importing your data diff --git a/docs/faq.md b/docs/faq.md index 62a8944..7c76c24 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -12,14 +12,12 @@ Quick answers about the privacy-first YouTube history extension that keeps progr - Log out completely and still keep your viewing history - **Perfect for multi-account users!** -![Extension interface showing consistent history](./images/ytrw_videos.jpg) -*Your viewing history works the same across all accounts* ### Q: Can I use this without a YouTube account? **A:** YES! This is one of our biggest advantages: - No YouTube login required - No Google account needed -- Watch YouTube completely anonymously +- Watch YouTube without signing in, while still keeping local history - Still get full history tracking and progress saving - Perfect for privacy-conscious users @@ -44,9 +42,12 @@ The extension will start working immediately - no setup required! ### Q: How do I see my watch history? **A:** Click the extension icon in your browser's toolbar (looks like a play button). You'll see tabs for: -- **Videos**: Your regular YouTube videos +- **Home**: Local recommendations from subscribed channels +- **Subscriptions**: Latest uploads from locally subscribed channels - **Shorts**: YouTube Shorts you've watched -- **Playlists**: Playlists you've discovered +- **Playlists**: Local playlists you create and manage +- **History**: Your regular YouTube videos +- **Channels**: Channels you subscribed to locally - **Analytics**: Charts showing your viewing patterns ### Q: Why don't I see the "viewed" label on videos? @@ -69,7 +70,7 @@ The extension will start working immediately - no setup required! ### Q: The extension isn't working, what should I do? **A:** Try these steps in order: 1. **Refresh the YouTube page** - this fixes 90% of issues -2. **Close and reopen the extension popup** +2. **Close and reopen the extension popup or refresh the feed page** 3. **Check if you're on youtube.com** (not mobile YouTube or YouTube TV) 4. **Disable and re-enable the extension** 5. **Restart your browser** @@ -80,7 +81,7 @@ The extension will start working immediately - no setup required! - You cleared your browser data - You're using incognito/private browsing mode -Check the extension popup - your history should still be there. +Check the extension popup or feed page - your history should still be there. ### Q: Videos aren't saving automatically **A:** Make sure: @@ -92,9 +93,9 @@ Check the extension popup - your history should still be there. ## 📱 Features & Functionality -### Q: What's the difference between Videos and Shorts tabs? +### Q: What's the difference between History and Shorts tabs? **A:** -- **Videos Tab**: Regular YouTube videos (usually longer content) +- **History view**: Regular YouTube videos (usually longer content) - **Shorts Tab**: YouTube Shorts (vertical videos under 60 seconds) - They're separated to help you track different types of content @@ -108,7 +109,7 @@ Check the extension popup - your history should still be there. - Completion bar chart for long videos (skipped, partial, completed, with legend) ### Q: Can I search through my history? -**A:** Yes! Use the search box at the top of the Videos tab to find specific videos by title. +**A:** Yes! Use the search box in the History tab to find specific videos by title. ### Q: Can I delete individual videos from my history? **A:** Yes, click the "Delete" button next to any video to remove it from your history. @@ -141,15 +142,15 @@ This solves the common problem where deleted videos would reappear in searches. **A:** YES! This is one of our biggest advantages: - No YouTube login required - No Google account needed -- Watch YouTube completely anonymously +- Watch YouTube without signing in, while still keeping local history - Still get full history tracking and progress saving - Perfect for privacy-conscious users ### Q: How is this different from YouTube's built-in history? **A:** More convenient AND your history stays private: - **YouTube's history**: Stored on Google's servers, tied to your account, used for ads/recommendations, **inaccessible when you switch accounts** -- **YT re:Watch**: Stored only on your device, history data stays private, Google doesn't get your progress, **works across ALL accounts** -- Google doesn't get your viewing progress data! +- **YT re:Watch**: Stored only on your device, history data stays in your browser profile, **works across ALL accounts** +- YT re:Watch does not upload saved viewing progress to an extension-owned server. - Never lose your progress when switching between accounts! ### Q: Is my viewing history private? @@ -158,11 +159,11 @@ This solves the common problem where deleted videos would reappear in searches. - We don't collect, see, or store any of your history information - No external servers are involved in history tracking - Your viewing progress is yours alone -- Google doesn't get your progress data +- YT re:Watch does not send your saved progress to an extension-owned server - Even we can't access your history data ### Q: Can I backup my data? -**A:** Yes! Go to Settings tab and click "Export History" to download a backup file. You can import it later if needed. +**A:** Yes! Go to Settings and use **Backup** to download a JSON backup file. You can restore it later if needed. ### Q: Does this affect my YouTube recommendations? **A:** No, this extension doesn't interact with YouTube's recommendation system at all. Your private viewing stays private! @@ -174,8 +175,6 @@ This solves the common problem where deleted videos would reappear in searches. - Learning about subjects privately - Medical research without health profiling -![Analytics showing private viewing patterns](./images/ytrw_stats3.jpg) -*Track your research privately - no Google profiling* ### Q: Does this extension block ALL Google tracking? **A:** ⚠️ **NO - Important limitation to understand:** @@ -208,9 +207,9 @@ This solves the common problem where deleted videos would reappear in searches. ### Q: Can I transfer my history between devices? **A:** Yes! Use the export/import feature to manually transfer your history between devices. There is **no automatic cloud sync** – export/import is the official way to move your data: -1. Go to Settings tab → Data Management → Export History +1. Go to Settings → Data Management → Backup 2. Download the JSON file to your computer -3. On the other device, go to Settings → Import History +3. On the other device, go to Settings → Restore 4. Select the JSON file and choose "Merge" or "Replace" ### Q: How much data can the extension store? @@ -237,8 +236,6 @@ This solves the common problem where deleted videos would reappear in searches. ### Q: Can I customize the visual overlays? **A:** Yes! The overlays are fully customizable. In the Settings tab, you can change: -![YouTube overlay showing viewed videos](./images/ytrw_overlay.jpg) -*Example of overlay customization - change colors, text, and size* - **Overlay Title**: Change "viewed" to any text you prefer (max 12 characters) - **Overlay Color**: Choose from blue, red, green, purple, or orange @@ -247,8 +244,7 @@ This solves the common problem where deleted videos would reappear in searches. ### Q: How do I switch between light and dark theme? **A:** -- **Quick way**: Click the theme button in the extension popup -- **Settings way**: Go to Settings tab and choose your preferred theme +- **Settings way**: Go to Settings and choose your preferred theme ### Q: Can I hide the progress bars? **A:** The extension is designed to be minimal and non-intrusive. You can make the labels smaller in Settings, but they can't be completely hidden since that's the main feature. diff --git a/docs/images/README.md b/docs/images/README.md deleted file mode 100644 index b58680c..0000000 --- a/docs/images/README.md +++ /dev/null @@ -1,161 +0,0 @@ -# 📸 Documentation Images - -This folder contains all visual content for YT re:Watch documentation. - -## 📁 Folder Structure - -``` -images/ -├── installation/ # Installation process screenshots -│ ├── chrome-webstore.png -│ ├── firefox-addons.png -│ ├── extension-installed.png -│ └── toolbar-icon.png -├── interface/ # Extension interface screenshots -│ ├── popup-overview.png -│ ├── videos-tab.png -│ ├── shorts-tab.png -│ ├── playlists-tab.png -│ ├── analytics-tab.png -│ └── settings-tab.png -├── features/ # Feature demonstrations -│ ├── youtube-overlay.png -│ ├── progress-bar.png -│ ├── search-function.png -│ ├── theme-switching.png -│ └── sync-indicator.png # Legacy asset from Firefox Sync era (4.0.0+ no longer uses sync) -├── troubleshooting/ # Problem-solving visuals -│ ├── common-issues.png -│ ├── sync-setup.png # Legacy Firefox Sync configuration (kept for historical reference) -│ └── browser-differences.png -└── videos/ # Video content (if any) - ├── quick-start.mp4 - └── feature-overview.mp4 -``` - -## 📋 Image Requirements - -### Technical Specifications -- **Format**: PNG for screenshots, JPG for photos, SVG for icons -- **Resolution**: Minimum 1920x1080 for screenshots -- **Retina**: 2x versions for high-DPI displays -- **Compression**: Optimize for web (keep under 500KB each) - -### Content Guidelines -- **Annotations**: Use red arrows/circles to highlight important areas -- **Consistency**: Same browser, theme, and UI state across screenshots -- **Context**: Show enough surrounding context to orient users -- **Quality**: Clear, well-lit, professional appearance - -## 🎯 Priority Screenshots Needed - -### High Priority (Week 1) -- [ ] `installation/chrome-webstore.png` - Chrome Web Store page -- [ ] `installation/firefox-addons.png` - Firefox Add-ons page -- [ ] `interface/popup-overview.png` - All 5 tabs visible -- [ ] `features/youtube-overlay.png` - "viewed" label on video - -### Medium Priority (Week 2) -- [ ] `interface/videos-tab.png` - Videos tab with sample data -- [ ] `interface/analytics-tab.png` - Charts and statistics -- [ ] `features/progress-bar.png` - Video thumbnail with progress -- [ ] `interface/settings-tab.png` - Customization options - -### Low Priority (Week 3) -- [ ] `troubleshooting/sync-setup.png` - Firefox Sync configuration -- [ ] `features/theme-switching.png` - Dark/light mode comparison -- [ ] `interface/shorts-tab.png` - YouTube Shorts display -- [ ] `interface/playlists-tab.png` - Playlist management - -## 🎨 Style Guidelines - -### Visual Style -- **Clean backgrounds**: Avoid clutter in screenshots -- **Consistent UI state**: Same browser zoom, window size -- **Highlight important elements**: Use arrows, circles, or borders -- **Professional appearance**: Well-organized, clean interface - -### Annotation Style -- **Color**: Use red (#FF0000) for highlights -- **Arrows**: Simple, bold arrows pointing to key elements -- **Text**: Clear, readable font (minimum 14px) -- **Spacing**: Don't overcrowd annotations - -## 📱 Multi-Platform Considerations - -### Browser Differences -- Chrome vs Firefox extension interfaces -- Different operating systems (Windows, Mac, Linux) -- Various screen sizes and resolutions - -### Accessibility -- High contrast versions for accessibility -- Alt text descriptions for all images -- Clear visual hierarchy and labeling - -## 🔧 Tools Recommended - -### Screenshot Tools -- **Built-in**: macOS Screenshot (Cmd+Shift+4), Windows Snipping Tool -- **Third-party**: Lightshot, Greenshot, CleanShot X -- **Browser extensions**: Full Page Screen Capture - -### Image Editing -- **Simple edits**: Paint, Preview, Photos -- **Advanced**: GIMP (free), Photoshop, Canva -- **Annotations**: Skitch, Annotate, CloudApp - -### Optimization -- **Compression**: TinyPNG, ImageOptim, Squoosh -- **Format conversion**: CloudConvert, Online-Convert -- **Batch processing**: ImageMagick, Automator - -## 📝 Naming Conventions - -### File Naming -- Use lowercase with hyphens: `chrome-webstore.png` -- Be descriptive: `analytics-tab-with-charts.png` -- Include version if needed: `popup-overview-v2.6.4.png` -- Use consistent prefixes: `ui-`, `feature-`, `install-` - -### Alt Text Guidelines -```markdown -![Extension popup showing all five tabs](./images/interface/popup-overview.png) -![Chrome Web Store install button highlighted](./images/installation/chrome-webstore.png) -``` - -## 🚀 Getting Started - -### For Contributors -1. **Check priority list** above for most needed images -2. **Follow technical specifications** for quality -3. **Use consistent style** across all screenshots -4. **Submit via PR** with descriptive commit messages - -### For Maintainers -1. **Review image quality** before merging -2. **Check file sizes** (optimize if needed) -3. **Verify alt text** is descriptive -4. **Update priority list** as images are added - -## 📊 Usage in Documentation - -### Markdown Syntax -```markdown -![Alt text](./images/folder/filename.png) -``` - -### HTML for Advanced Layout -```html -Alt text -``` - -### Responsive Images -```markdown -![Alt text](./images/folder/filename.png) -*Figure 1: Extension popup interface showing all available tabs* -``` - ---- - -*Ready to contribute visual content? Pick an image from the priority list and start creating!* diff --git a/docs/images/hero.jpg b/docs/images/hero.jpg deleted file mode 100644 index bd8ecfe..0000000 Binary files a/docs/images/hero.jpg and /dev/null differ diff --git a/docs/images/ytrw_overlay.jpg b/docs/images/ytrw_overlay.jpg deleted file mode 100644 index 413342b..0000000 Binary files a/docs/images/ytrw_overlay.jpg and /dev/null differ diff --git a/docs/images/ytrw_playlists.jpg b/docs/images/ytrw_playlists.jpg deleted file mode 100644 index b3deb9c..0000000 Binary files a/docs/images/ytrw_playlists.jpg and /dev/null differ diff --git a/docs/images/ytrw_settings.jpg b/docs/images/ytrw_settings.jpg deleted file mode 100644 index ae7a149..0000000 Binary files a/docs/images/ytrw_settings.jpg and /dev/null differ diff --git a/docs/images/ytrw_settings2.jpg b/docs/images/ytrw_settings2.jpg deleted file mode 100644 index a8ae498..0000000 Binary files a/docs/images/ytrw_settings2.jpg and /dev/null differ diff --git a/docs/images/ytrw_shorts.jpg b/docs/images/ytrw_shorts.jpg deleted file mode 100644 index 89ef7c5..0000000 Binary files a/docs/images/ytrw_shorts.jpg and /dev/null differ diff --git a/docs/images/ytrw_stats1.jpg b/docs/images/ytrw_stats1.jpg deleted file mode 100644 index 18ef422..0000000 Binary files a/docs/images/ytrw_stats1.jpg and /dev/null differ diff --git a/docs/images/ytrw_stats2.jpg b/docs/images/ytrw_stats2.jpg deleted file mode 100644 index 4dc9658..0000000 Binary files a/docs/images/ytrw_stats2.jpg and /dev/null differ diff --git a/docs/images/ytrw_stats3.jpg b/docs/images/ytrw_stats3.jpg deleted file mode 100644 index bd1c8b9..0000000 Binary files a/docs/images/ytrw_stats3.jpg and /dev/null differ diff --git a/docs/images/ytrw_stats4.jpg b/docs/images/ytrw_stats4.jpg deleted file mode 100644 index d372cdc..0000000 Binary files a/docs/images/ytrw_stats4.jpg and /dev/null differ diff --git a/docs/images/ytrw_videos.jpg b/docs/images/ytrw_videos.jpg deleted file mode 100644 index bffca4a..0000000 Binary files a/docs/images/ytrw_videos.jpg and /dev/null differ diff --git a/docs/index.md b/docs/index.md index 2b3ef6f..30de85d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,14 +1,13 @@ # 🎬 YT re:Watch — YouTube History Extension for Multi-Account Privacy -![YT re:Watch](./images/hero.jpg) -A private YouTube history extension that keeps multi-account progress in sync while staying privacy-first and local-only. +A private YouTube history extension that keeps multi-account progress local, adds local subscriptions, and provides a YouTube-style feed inside the browser. [![Chrome Web Store](https://img.shields.io/badge/Get_it_on-Chrome_Web_Store-4285F4?logo=googlechrome&logoColor=white)](https://chromewebstore.google.com/detail/local-youtube-video-histo/pebiokefjgdbfnkolmblaaladkmpilba) [![Firefox Add-ons](https://img.shields.io/badge/Get_it_on-Firefox_Add--ons-FF7139?logo=firefox-browser&logoColor=white)](https://addons.mozilla.org/firefox/addon/yt-rewatch/) *Keep track of your YouTube journey — your data stays private on your device* -YT re:Watch is a privacy-first YouTube history extension that works across multiple accounts (or no account), tracks your progress locally, and keeps your watch history independent from Google so you can resume anywhere without losing data. +YT re:Watch is a privacy-first YouTube history extension that works across multiple accounts (or no account), tracks your progress locally, and keeps your watch history independent from your Google account so you can resume without losing data. It also includes a local feed, local channel subscriptions, playlists, history, analytics, and settings. # ❤️ Support the Project @@ -34,13 +33,11 @@ YT re:Watch solves both problems with **Account Independence + YouTube History P - ✅ **Multi-account workflow** - perfect for work/personal account users ### 🔒 **YouTube History Privacy** -- ✅ **Replaces YouTube's built-in history** - completely independent from Google -- ✅ **Local storage only** - Google doesn't get your viewing progress data -- ✅ **No history profiling** - your viewing progress stays yours +- ✅ **Independent from YouTube's built-in history** - stored separately from your Google account +- ✅ **Local storage only** - YT re:Watch does not upload saved progress to an app server +- ✅ **No extension-owned history profiling** - your viewing progress stays in your browser profile - ✅ **Unlimited local storage** - GB-scale capacity with hybrid IndexedDB system -![Extension popup showing all features](./images/ytrw_videos.jpg) -*Your videos, your progress, your way - regardless of which account you use* ## 🚀 Get Started in 30 Seconds @@ -52,7 +49,7 @@ YT re:Watch solves both problems with **Account Independence + YouTube History P ### Step 2: That's It! - Go to YouTube and start watching videos - The extension works automatically in the background -- Click the extension icon to see your watch history +- Click the extension icon for quick actions, or open the full feed page for Home, Subscriptions, Shorts, Playlists, History, Channels, Analytics, and Settings Need a deeper walkthrough? Read the [Detailed Guide](./detailed_guide.md). Quick answers live in the [FAQ](./faq.md), fixes are in [Troubleshooting](./troubleshooting.md), and release notes are in the [Changelog](../CHANGELOG.md). @@ -77,8 +74,6 @@ Need a deeper walkthrough? Read the [Detailed Guide](./detailed_guide.md). Quick - "I want to track my video watching without Google profiling" - "I need consistent history for research projects" -![Analytics dashboard](./images/ytrw_stats1.jpg) -*View your video watching statistics across all accounts* ### 🙋‍♀️ **Anyone Who Wants Convenience** - "I watch long videos but often get interrupted" @@ -87,15 +82,13 @@ Need a deeper walkthrough? Read the [Detailed Guide](./detailed_guide.md). Quick ## 📱 What You'll See -### 🏷️ **Visual Overlays on YouTube** -The extension adds helpful visual indicators directly on YouTube: +### 🏷️ **Viewed Indicators** +The extension adds helpful visual indicators inside YT re:Watch, with best-effort labels on supported YouTube thumbnail layouts: -![YouTube overlay showing viewed videos](./images/ytrw_overlay.jpg) -*Smart overlays show your progress across all accounts* - **"Viewed" labels** - See which videos you've already watched - **Progress bars** - Visual indicator of how much you've completed -- **Works everywhere** - Video listings, search results, recommendations +- **Works across the local feed** - Home, subscriptions, playlists, search, and history - **Fully customizable** - Change colors, text, and size in Settings **Overlay Customization Options:** @@ -105,26 +98,16 @@ The extension adds helpful visual indicators directly on YouTube: - **Learn more**: [Complete customization guide](./detailed_guide.md#overlay-customization) ### 🎛️ **Extension Interface** -**Videos Tab** - Your main viewing history: -![Videos tab interface](./images/ytrw_videos.jpg) +**History Tab** - Your local watch history: Your history list shows the channel name under each video title to help you scan quickly. **Shorts Tab** - Separate tracking for YouTube Shorts: -![Shorts tab interface](./images/ytrw_shorts.jpg) **Analytics Tab** - See your viewing patterns: -![Analytics dashboard summary and top channels](./images/ytrw_stats1.jpg) -*Summary cards and top channels in the Analytics tab* -![Analytics completion bar and unfinished videos](./images/ytrw_stats2.jpg) -*Completion bar chart, unfinished videos, and skipped channels* -![Analytics activity by day and hour](./images/ytrw_stats3.jpg) -*Watch activity by day and by hour in the Analytics tab* -![Analytics detailed view](./images/ytrw_stats4.jpg) -*Additional analytics showing watch time patterns and channel statistics* These charts now prefer a locally persisted, privacy‑preserving stats snapshot (rebuilt from your full hybrid history) for better accuracy and responsiveness. Keys are local‑day `YYYY-MM-DD` and 24 hourly buckets, and the activity view focuses on the last 7 local days only. @@ -134,10 +117,7 @@ These charts now prefer a locally persisted, privacy‑preserving stats snapshot - **Completion Bar Chart**: See your completion rate for long videos (skipped, partial, completed) with a bar chart and legend **Settings Tab** - Customize everything: -![Settings customization](./images/ytrw_settings.jpg) -![Settings advanced options](./images/ytrw_settings2.jpg) -*Additional settings for data management, privacy, and advanced features* ## 🔐 Account Independence + History Privacy = Perfect Combination @@ -150,12 +130,12 @@ Traditional YouTube history is **tied to your account** - meaning: **YT re:Watch fixes ALL of this** by storing data locally on your device, not tied to any account! -### 🔒 **History Privacy Bonus: Google Doesn't Get Your Progress** -Because we store everything locally: -- ✅ **No Google servers** involved in your history tracking -- ✅ **No profiling** based on your viewing progress -- ✅ **No influence** on recommendations from your progress data -- ✅ **No progress data collection** - we literally cannot see what you watch +### 🔒 **History Privacy Bonus: Your Saved Progress Stays Local** +Because the extension stores its own history locally: +- ✅ **No extension-owned server** involved in your history tracking +- ✅ **No app-side profiling** based on your saved progress +- ✅ **No extension sync** that sends your history to a cloud service +- ✅ **No progress data collection by YT re:Watch** - we cannot see what you watch ### 🏠 **Your Data, Your Rules** - **Local storage only** - stays on your device @@ -176,15 +156,15 @@ Because we store everything locally: ## 💡 Pro Tips - **Dark Mode**: The extension automatically matches your system theme -- **Search History**: Use the search box to find specific videos -- **Export Data**: Back up your history from the Settings tab +- **Search**: Use the feed search for YouTube results, or search local history in the History view +- **Export Data**: Back up all local data from the Settings tab - **Export/Import Data**: Manually transfer your history between devices via JSON files ## 🤝 Need Help? ### Quick Fixes - **Extension not working?** Refresh the YouTube page -- **History not showing?** Close and reopen the extension popup +- **History not showing?** Refresh the feed page or reload the extension - **Missing videos?** Check if you're on youtube.com (not youtube.tv or mobile) ### Get Support @@ -196,7 +176,7 @@ Because we store everything locally: ## 📈 What's New -- **v3.1.5**: Enhanced navigation detection, improved timestamp restoration for all YouTube navigation patterns, and intelligent save interval management +- **v5.0.0**: Adds the full local feed, local subscriptions, channel pages, playlists, analytics updates, backup/restore improvements, and Firefox temporary build support - **See all updates**: [CHANGELOG.md](../CHANGELOG.md) ## 🌟 Love YT re:Watch? @@ -220,7 +200,7 @@ Because we store everything locally: - **[Build Instructions](./build.md)** - Development setup ## ℹ️ About YT re:Watch -YT re:Watch is a privacy-first YouTube history extension that keeps watch progress consistent across multiple accounts, stores data locally instead of on Google, and helps you track and resume videos with customizable overlays and stats. +YT re:Watch is a privacy-first YouTube history extension that keeps watch progress consistent across multiple accounts, stores extension data locally, and helps you browse a local YouTube-style feed with subscriptions, playlists, history, analytics, and settings. --- @@ -230,4 +210,4 @@ YT re:Watch is a privacy-first YouTube history extension that keeps watch progre ## 🌐 Multilanguage Support -YT re:Watch is available in multiple languages. All non-English translations are currently machine-generated. If you're a native speaker, your help is welcome—see the technical docs for how to contribute! \ No newline at end of file +YT re:Watch is available in multiple languages. All non-English translations are currently machine-generated. If you're a native speaker, your help is welcome—see the technical docs for how to contribute! diff --git a/docs/readme.md b/docs/readme.md index af162f9..c9dc4cd 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -14,7 +14,8 @@ Welcome! Find the right guide for you: **[→ Start Here: Quick Setup Guide](./index.md)** - Same history across ALL YouTube accounts - Get the extension working in 5 minutes -- YouTube history privacy from Google tracking +- Local history, subscriptions, playlists, and feed basics +- What local storage protects, and what normal YouTube browsing still exposes ### ❓ **Have Questions?** **[→ Frequently Asked Questions](./faq.md)** @@ -59,4 +60,4 @@ Welcome! Find the right guide for you: ## Multilanguage Support -This extension is available in several languages. See the main [README](../README.md) and [technical.md](./technical.md) for details on translation and contributing improvements. \ No newline at end of file +This extension is available in several languages. See the main [README](../README.md) and [technical.md](./technical.md) for details on translation and contributing improvements. diff --git a/docs/technical.md b/docs/technical.md index ef2ce57..ba8775c 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -454,9 +454,10 @@ await ytStorage.getAllVideos(); // Returns merged view ## 🛠️ Development Setup ### Prerequisites -- Node.js 14+ -- NPM or Yarn +- Node.js 18+ +- npm - Chrome/Firefox for testing +- Git Bash, WSL, or another Bash-compatible shell for `build.sh` ### Local Development @@ -468,11 +469,10 @@ cd YouTubeLocalHistory # Install dependencies npm install -# Build extension -./build.sh +# Recommended Firefox temporary build +npm run prepare:firefox -# Built extensions will be in dist/ -# Load dist/chrome or dist/firefox in browser +# Load build/firefox/manifest.json as a temporary Firefox add-on ``` ### Testing @@ -481,6 +481,12 @@ npm install # Run all tests npm test +# Recommended PR checks +npm run lint +npm test -- --runInBand +npm run prepare:firefox +npx web-ext lint --source-dir=build/firefox + # Run specific test suites npm run test:unit npm run test:integration @@ -494,11 +500,7 @@ npm run test:coverage ```bash # Build both Chrome and Firefox versions -./build.sh - -# Build specific browser -./build.sh chrome -./build.sh firefox +npm run build ``` --- @@ -812,4 +814,4 @@ _locales/ --- -*For more technical details, check the source code and inline documentation.* \ No newline at end of file +*For more technical details, check the source code and inline documentation.* diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8ac6c9c..1ac1a3d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -14,7 +14,7 @@ This fixes 90% of all issues by clearing cached content and reloading the extens ### 2. Restart Extension 1. Click the extension icon in your browser toolbar -2. Close the popup completely +2. Close the popup or feed page completely 3. Click the extension icon again to reopen ### 3. Check Browser Compatibility @@ -87,10 +87,10 @@ This fixes 90% of all issues by clearing cached content and reloading the extens ### History Display Problems #### History Tab Empty or Missing Videos -**Symptoms**: Extension popup shows no history or missing videos +**Symptoms**: Extension feed or popup shows no history or missing videos **Solutions**: -1. **Refresh extension popup**: Close and reopen the extension +1. **Refresh the extension UI**: Refresh the feed page or close and reopen the popup 2. **Check storage quota**: Extension has 5GB+ storage limit 3. **Browser data clearing**: If you cleared browser data, history is lost 4. **Incognito mode**: Extension doesn't work in private browsing @@ -119,10 +119,10 @@ This fixes 90% of all issues by clearing cached content and reloading the extens ### Performance Issues #### Extension Slow or Unresponsive -**Symptoms**: Extension popup slow to open, videos slow to track +**Symptoms**: Extension feed or popup slow to open, videos slow to track **Solutions**: -1. **Clear extension data**: Go to extension settings > Advanced > Reset Data +1. **Backup first**: Go to Settings > Data Management and download a backup before clearing data 2. **Browser restart**: Complete restart frees up memory 3. **Check storage usage**: Large history can slow down the extension 4. **Disable unused features**: Turn off features you don't need @@ -141,7 +141,7 @@ This fixes 90% of all issues by clearing cached content and reloading the extens Enable debug mode for detailed logging to help troubleshoot complex issues: ### Enable Debug Mode -1. Open YT re:Watch extension popup +1. Open the YT re:Watch feed page 2. Go to **Settings** tab 3. Enable **"Debug Mode"** 4. Check browser console for detailed logs @@ -149,7 +149,7 @@ Enable debug mode for detailed logging to help troubleshoot complex issues: ### Debug Information to Include in Bug Reports When reporting issues, include: - **Browser and version** (Chrome 120.0, Firefox 119.0) -- **Extension version** (visible in extension popup) +- **Extension version** (visible in the extension UI or browser add-on details) - **Operating system** (Windows 11, macOS 14.0, Ubuntu 22.04) - **Steps to reproduce** the issue - **Expected vs actual behavior** @@ -170,7 +170,7 @@ When reporting issues, include: ### Emergency Fixes If nothing works: -1. **Reset extension data**: Go to Settings > Advanced > Reset Data (loses all history) +1. **Reset extension data**: Use your browser's extension storage controls only after exporting a backup (this loses local data) 2. **Reinstall extension**: Remove and reinstall YT re:Watch 3. **Check for updates**: Ensure you have the latest version diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..85b285e --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,44 @@ +module.exports = [ + { + ignores: [ + 'build/**', + 'coverage/**', + 'node_modules/**' + ] + }, + { + files: ['src/**/*.js', 'tests/**/*.js'], + languageOptions: { + ecmaVersion: 2021, + sourceType: 'script', + globals: { + afterAll: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + beforeEach: 'readonly', + browser: 'readonly', + chrome: 'readonly', + clearInterval: 'readonly', + clearTimeout: 'readonly', + console: 'readonly', + document: 'readonly', + DOMParser: 'readonly', + expect: 'readonly', + global: 'readonly', + jest: 'readonly', + localStorage: 'readonly', + module: 'readonly', + MutationObserver: 'readonly', + navigator: 'readonly', + performance: 'readonly', + process: 'readonly', + require: 'readonly', + setInterval: 'readonly', + setTimeout: 'readonly', + URL: 'readonly', + window: 'readonly' + } + }, + rules: {} + } +]; diff --git a/package-lock.json b/package-lock.json index bd14a71..5b2d27a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "youtube-local-history", - "version": "2.4.0", + "version": "5.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "youtube-local-history", - "version": "2.4.0", + "version": "5.0.0", "license": "MIT", "devDependencies": { "@babel/core": "^7.24.0", @@ -17,20 +17,8 @@ "babel-jest": "^29.7.0", "eslint": "^9.15.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^30.3.0" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" + "jest-environment-jsdom": "^30.3.0", + "web-ext": "^8.3.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -55,13 +43,13 @@ "license": "ISC" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -70,30 +58,32 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", - "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, + "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.4", - "@babel/parser": "^7.27.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.4", - "@babel/types": "^7.27.3", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -109,14 +99,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -138,13 +128,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -208,10 +199,11 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -230,29 +222,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -331,18 +323,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -350,10 +343,11 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -373,26 +367,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1734,34 +1729,44 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -1769,14 +1774,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1903,6 +1908,66 @@ "node": ">=18" } }, + "node_modules/@devicefarmer/adbkit": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@devicefarmer/adbkit/-/adbkit-3.3.8.tgz", + "integrity": "sha512-7rBLLzWQnBwutH2WZ0EWUkQdihqrnLYCUMaB44hSol9e0/cdIhuNFcqZO0xNheAU6qqHVA8sMiLofkYTgb+lmw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@devicefarmer/adbkit-logcat": "^2.1.2", + "@devicefarmer/adbkit-monkey": "~1.2.1", + "bluebird": "~3.7", + "commander": "^9.1.0", + "debug": "~4.3.1", + "node-forge": "^1.3.1", + "split": "~1.0.1" + }, + "bin": { + "adbkit": "bin/adbkit" + }, + "engines": { + "node": ">= 0.10.4" + } + }, + "node_modules/@devicefarmer/adbkit-logcat": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@devicefarmer/adbkit-logcat/-/adbkit-logcat-2.1.3.tgz", + "integrity": "sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@devicefarmer/adbkit-monkey": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@devicefarmer/adbkit-monkey/-/adbkit-monkey-1.2.1.tgz", + "integrity": "sha512-ZzZY/b66W2Jd6NHbAhLyDWOEIBWC11VizGFk7Wx7M61JZRz7HR9Cq5P+65RKWUU7u6wgsE8Lmh9nE4Mz+U2eTg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.10.4" + } + }, + "node_modules/@devicefarmer/adbkit/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -2007,10 +2072,20 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2041,12 +2116,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", - "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { @@ -2054,10 +2130,11 @@ } }, "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", - "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -2065,6 +2142,27 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fluent/syntax": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@fluent/syntax/-/syntax-0.19.0.tgz", + "integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@fregante/relaxed-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@fregante/relaxed-json/-/relaxed-json-2.0.0.tgz", + "integrity": "sha512-PyUXQWB42s4jBli435TDiYuVsadwRHnMc27YaLouINktvTWsL3FcKrRMGawTayFk46X+n5bE23RjUTWQwrukWw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2100,6 +2198,22 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2113,6 +2227,14 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", @@ -2713,6 +2835,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2738,6 +2871,51 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mdn/browser-compat-data": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-7.1.5.tgz", + "integrity": "sha512-j7XpHbvSU3GOtXawlGq6TIETj0wgcE9o7FXu88ragQE/ak8/OsqOxn+wZ2v/2QUe8vrVhb9OJes+gHgyGEOYEQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@playwright/test": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", @@ -2754,6 +2932,51 @@ "node": ">=18" } }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2886,6 +3109,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz", @@ -2923,6 +3153,23 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2944,21 +3191,76 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/addons-linter": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/addons-linter/-/addons-linter-7.20.0.tgz", + "integrity": "sha512-c6drrGsO91oGyqEdEZ3KIBFrSiSlhM5CKxoebBatH1l2vN1ByuRAlp9YUHLCjypLONThPcb+tVTA7X6yKIhpsw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@fluent/syntax": "0.19.0", + "@fregante/relaxed-json": "2.0.0", + "@mdn/browser-compat-data": "7.1.5", + "addons-moz-compare": "1.3.0", + "addons-scanner-utils": "9.13.0", + "ajv": "8.17.1", + "chalk": "4.1.2", + "cheerio": "1.0.0-rc.12", + "columnify": "1.6.0", + "common-tags": "1.8.2", + "deepmerge": "4.3.1", + "eslint": "8.57.1", + "eslint-plugin-no-unsanitized": "4.1.4", + "eslint-visitor-keys": "4.2.1", + "espree": "10.4.0", + "esprima": "4.0.1", + "fast-json-patch": "3.1.1", + "image-size": "2.0.2", + "json-merge-patch": "1.0.2", + "pino": "9.9.5", + "semver": "7.7.2", + "source-map-support": "0.5.21", + "upath": "2.0.1", + "yargs": "17.7.2", + "yauzl": "2.10.0" + }, + "bin": { + "addons-linter": "bin/addons-linter" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/addons-linter/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": ">= 14" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2970,46 +3272,490 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "type-fest": "^0.21.3" + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/addons-linter/node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, + "license": "MIT" + }, + "node_modules/addons-linter/node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/addons-linter/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/anymatch": { + "node_modules/addons-linter/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/addons-linter/node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/addons-linter/node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/addons-linter/node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/addons-linter/node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/addons-linter/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/addons-linter/node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/addons-linter/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/addons-linter/node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/addons-linter/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/addons-linter/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/addons-linter/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/addons-linter/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/addons-linter/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/addons-moz-compare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/addons-moz-compare/-/addons-moz-compare-1.3.0.tgz", + "integrity": "sha512-/rXpQeaY0nOKhNx00pmZXdk5Mu+KhVlL3/pSBuAYwrxRrNiTvI/9xfQI8Lmm7DMMl+PDhtfAHY/0ibTpdeoQQQ==", + "dev": true, + "license": "MPL-2.0" + }, + "node_modules/addons-scanner-utils": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/addons-scanner-utils/-/addons-scanner-utils-9.13.0.tgz", + "integrity": "sha512-8OnHK/pbvgbCejGlnEYw+V3URSTVHLkMZmV270QtNh8N9pAgK10IaiJ9DcL0FsrufZ9HxRcR8/wkavh1FgK6Kg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@types/yauzl": "2.10.3", + "common-tags": "1.8.2", + "first-chunk-stream": "3.0.0", + "strip-bom-stream": "4.0.0", + "upath": "2.0.1", + "yauzl": "2.10.0" + }, + "peerDependencies": { + "body-parser": "1.20.3", + "express": "4.21.2", + "node-fetch": "2.6.11", + "safe-compare": "1.1.4" + }, + "peerDependenciesMeta": { + "body-parser": { + "optional": true + }, + "express": { + "optional": true + }, + "node-fetch": { + "optional": true + }, + "safe-compare": { + "optional": true + } + } + }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", @@ -3031,6 +3777,60 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/array-differ": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-4.0.0.tgz", + "integrity": "sha512-Q6VPTLMsmXZ47ENG3V+wQyZS1ZxXMxFyYzA+Z/GMrJ6yIutAIEf9wTyroTzmGjNfox9/h3GdGBCVh43GVFx4Uw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/array-union": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", + "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3170,11 +3970,173 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/boxen/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/boxen/node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3233,12 +4195,38 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3258,9 +4246,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001731", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", - "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -3275,7 +4263,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2", @@ -3302,6 +4291,65 @@ "node": ">=10" } }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chrome-launcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.0.tgz", + "integrity": "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^2.0.1" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.cjs" + }, + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -3323,6 +4371,19 @@ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "dev": true }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3337,6 +4398,16 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3371,12 +4442,99 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/columnify": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/columnify/-/columnify-1.6.0.tgz", + "integrity": "sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "strip-ansi": "^6.0.1", + "wcwidth": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/configstore": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", + "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "atomically": "^2.0.3", + "dot-prop": "^9.0.0", + "graceful-fs": "^4.2.11", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3396,6 +4554,13 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -3431,6 +4596,36 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -3459,6 +4654,13 @@ "node": ">=18" } }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3476,6 +4678,19 @@ } } }, + "node_modules/decamelize": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.1.tgz", + "integrity": "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -3497,6 +4712,16 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3512,6 +4737,62 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3530,6 +4811,120 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.192", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.192.tgz", @@ -3576,13 +4971,33 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "engines": { - "node": ">=6" + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/escape-string-regexp": { @@ -3657,6 +5072,16 @@ } } }, + "node_modules/eslint-plugin-no-unsanitized": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-unsanitized/-/eslint-plugin-no-unsanitized-4.1.4.tgz", + "integrity": "sha512-cjAoZoq3J+5KJuycYYOWrc0/OpZ7pl2Z3ypfFq4GtaAgheg+L7YGxUo2YS3avIvo/dYU5/zR2hXu3v81M9NxhQ==", + "dev": true, + "license": "MPL-2.0", + "peerDependencies": { + "eslint": "^8 || ^9" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -3881,6 +5306,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3893,6 +5325,43 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3902,6 +5371,16 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3939,6 +5418,36 @@ "node": ">=8" } }, + "node_modules/firefox-profile": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/firefox-profile/-/firefox-profile-4.7.0.tgz", + "integrity": "sha512-aGApEu5bfCNbA4PGUZiRJAIU6jKmghV2UVdklXAofnNtiDjqYw0czLS46W7IfFqVKgKhFB8Ao2YoNGHY4BoIMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "adm-zip": "~0.5.x", + "fs-extra": "^11.2.0", + "ini": "^4.1.3", + "minimist": "^1.2.8", + "xml2js": "^0.6.2" + }, + "bin": { + "firefox-profile": "lib/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/first-chunk-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-3.0.0.tgz", + "integrity": "sha512-LNRvR4hr/S8cXXkIY5pTgVP7L3tq6LlYWcg9nWBuW7o1NMxKZo6oOVa/6GIekMGI0Iw7uC+HWimMe9u/VAeKqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -3959,6 +5468,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3988,6 +5512,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fx-runner": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/fx-runner/-/fx-runner-1.4.0.tgz", + "integrity": "sha512-rci1g6U0rdTg6bAaBboP7XdRu01dzTAaKXxFf+PUqGuCv6Xu7o8NZdY1D5MvKGIjb6EdS1g3VlXOgksir1uGkg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "commander": "2.9.0", + "shell-quote": "1.7.3", + "spawn-sync": "1.0.15", + "when": "3.7.7", + "which": "1.2.4", + "winreg": "0.0.12" + }, + "bin": { + "fx-runner": "bin/fx-runner" + } + }, + "node_modules/fx-runner/node_modules/commander": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", + "integrity": "sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-readlink": ">= 1.0.0" + }, + "engines": { + "node": ">= 0.6.x" + } + }, + "node_modules/fx-runner/node_modules/isexe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz", + "integrity": "sha512-d2eJzK691yZwPHcv1LbeAOa91yMJ9QmfTgSO1oXB65ezVhXQsxBac2vEB4bMVms9cGzaA99n6V2viHMq82VLDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fx-runner/node_modules/which": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/which/-/which-1.2.4.tgz", + "integrity": "sha512-zDRAqDSBudazdfM9zpiI30Fu9ve47htYXcGi3ln0wfKu2a7SmrT6F3VDoYONu//48V8Vz4TdCRNPjtvyRO3yBA==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-absolute": "^0.1.7", + "isexe": "^1.1.1" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4006,6 +5582,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -4060,12 +5649,95 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graceful-readlink": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", + "integrity": "sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==", + "dev": true, + "license": "MIT" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/growly": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", + "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4106,6 +5778,39 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -4165,6 +5870,26 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "dev": true, + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4235,6 +5960,29 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-absolute": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-0.1.7.tgz", + "integrity": "sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4256,43 +6004,140 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "dev": true, + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-fullwidth-code-point": { + "node_modules/is-inside-container/node_modules/is-docker": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "node_modules/is-installed-globally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/is-npm": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-number": { @@ -4304,6 +6149,19 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -4311,6 +6169,15 @@ "dev": true, "license": "MIT" }, + "node_modules/is-relative": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-0.1.3.tgz", + "integrity": "sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4323,6 +6190,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5179,6 +7073,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5186,9 +7090,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -5257,6 +7161,16 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-merge-patch": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-merge-patch/-/json-merge-patch-1.0.2.tgz", + "integrity": "sha512-M6Vp2GN9L7cfuMXiWOmHj9bEFbeC250iVtcKQbqVgEsDVYnIsrNsbU+h/Y/PkbBQCtEa4Bez+Ebv0zfbC8ObLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -5287,6 +7201,32 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5305,6 +7245,35 @@ "node": ">=6" } }, + "node_modules/ky": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/latest-version": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "package-json": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -5327,6 +7296,27 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lighthouse-logger": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz", + "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.1", + "marky": "^1.2.2" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -5393,6 +7383,13 @@ "node": ">=10" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -5402,6 +7399,13 @@ "tmpl": "1.0.5" } }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -5443,24 +7447,91 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/multimatch": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-6.0.0.tgz", + "integrity": "sha512-I7tSVxHGPlmPN/enE3mS1aOSo6bWBfls+3HmuEeCUBCE7gWnm3cBXCBkpurzFjVRwC6Kld8lLaZ1Iv5vOcjvcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^3.0.5", + "array-differ": "^4.0.0", + "array-union": "^3.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true }, + "node_modules/node-notifier": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz", + "integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "growly": "^1.3.0", + "is-wsl": "^2.2.0", + "semver": "^7.3.5", + "shellwords": "^0.1.1", + "uuid": "^8.3.2", + "which": "^2.0.2" + } + }, + "node_modules/node-notifier/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -5488,6 +7559,19 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nwsapi": { "version": "2.2.23", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", @@ -5495,6 +7579,16 @@ "dev": true, "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5519,6 +7613,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5536,6 +7649,15 @@ "node": ">= 0.8.0" } }, + "node_modules/os-shim": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz", + "integrity": "sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5587,6 +7709,45 @@ "node": ">=6" } }, + "node_modules/package-json": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ky": "^1.2.0", + "registry-auth-token": "^5.0.2", + "registry-url": "^6.0.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5630,6 +7791,20 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5663,6 +7838,13 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5675,13 +7857,53 @@ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.9.5", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.9.5.tgz", + "integrity": "sha512-d1s98p8/4TfYhsJ09r/Azt30aYELRi6NNnZtEbqFw6BoGsdPVf5lKNK3kUwH8BmJJfpTLNuicjUQjaMbd93dVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" } }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "dev": true, + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -5785,6 +8007,43 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/promise-toolbox": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/promise-toolbox/-/promise-toolbox-0.21.0.tgz", + "integrity": "sha512-NV8aTmpwrZv+Iys54sSFOBx3tuVaOBvvrft5PNppnxy9xpU/akHbaWIril22AB22zaPgrgwKdD0KsrM0ptUtpg==", + "dev": true, + "license": "ISC", + "dependencies": { + "make-error": "^1.3.2" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -5798,6 +8057,13 @@ "node": ">= 6" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5807,6 +8073,22 @@ "node": ">=6" } }, + "node_modules/pupa": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -5823,12 +8105,99 @@ } ] }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -5864,6 +8233,35 @@ "node": ">=4" } }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/regjsgen": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", @@ -5903,6 +8301,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -5953,6 +8361,34 @@ "node": ">=10" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -5960,6 +8396,60 @@ "dev": true, "license": "MIT" }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -5967,6 +8457,16 @@ "dev": true, "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -5989,6 +8489,13 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6010,6 +8517,20 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shellwords": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", + "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", + "dev": true, + "license": "MIT" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -6031,6 +8552,16 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6050,6 +8581,41 @@ "source-map": "^0.6.0" } }, + "node_modules/spawn-sync": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz", + "integrity": "sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "concat-stream": "^1.4.7", + "os-shim": "^0.1.2" + } + }, + "node_modules/split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -6077,6 +8643,16 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -6125,6 +8701,33 @@ "node": ">=8" } }, + "node_modules/strip-bom-buf": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-buf/-/strip-bom-buf-2.0.0.tgz", + "integrity": "sha512-gLFNHucd6gzb8jMsl5QmZ3QgnUJmp7qn4uUSHNwEXumAp7YizoGYw19ZUVfuq4aBOQUtyn2k8X/CwzWB73W2lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-utf8": "^0.2.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-4.0.0.tgz", + "integrity": "sha512-0ApK3iAkHv6WbgLICw/J4nhwHeDZsBxIIsOD+gHgZICL6SeJ0S9f/WZqemka9cjkTyMN5geId6e8U5WGFAn3cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "first-chunk-stream": "^3.0.0", + "strip-bom-buf": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -6146,6 +8749,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6191,6 +8811,30 @@ "node": ">=8" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -6211,6 +8855,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6288,6 +8942,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", @@ -6334,6 +8995,27 @@ "node": ">=4" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", + "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -6364,6 +9046,57 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/update-notifier": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", + "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "configstore": "^7.0.0", + "is-in-ci": "^1.0.0", + "is-installed-globally": "^1.0.0", + "is-npm": "^6.0.0", + "latest-version": "^9.0.0", + "pupa": "^3.1.0", + "semver": "^7.6.3", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -6373,6 +9106,24 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -6409,6 +9160,176 @@ "makeerror": "1.0.12" } }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-ext": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/web-ext/-/web-ext-8.10.0.tgz", + "integrity": "sha512-RM15PF8/xBBTe3pkgTvTkPaRoJbwE4HZcKy9NeD0lHIdir23oy9kd2oC4LuOqhrtarFvhTK4HlJ6NPYXt8Isxw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@babel/runtime": "7.28.4", + "@devicefarmer/adbkit": "3.3.8", + "addons-linter": "7.20.0", + "camelcase": "8.0.0", + "chrome-launcher": "1.2.0", + "debounce": "1.2.1", + "decamelize": "6.0.1", + "es6-error": "4.1.1", + "firefox-profile": "4.7.0", + "fx-runner": "1.4.0", + "https-proxy-agent": "^7.0.0", + "jose": "5.9.6", + "jszip": "3.10.1", + "multimatch": "6.0.0", + "node-notifier": "10.0.1", + "open": "10.2.0", + "parse-json": "7.1.1", + "pino": "9.9.5", + "promise-toolbox": "0.21.0", + "source-map-support": "0.5.21", + "strip-bom": "5.0.0", + "strip-json-comments": "5.0.3", + "tmp": "0.2.5", + "update-notifier": "7.3.1", + "watchpack": "2.4.4", + "yargs": "17.7.2", + "zip-dir": "2.0.0" + }, + "bin": { + "web-ext": "bin/web-ext.js" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + } + }, + "node_modules/web-ext/node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/web-ext/node_modules/json-parse-even-better-errors": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", + "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/web-ext/node_modules/lines-and-columns": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", + "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/web-ext/node_modules/parse-json": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-7.1.1.tgz", + "integrity": "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.21.4", + "error-ex": "^1.3.2", + "json-parse-even-better-errors": "^3.0.0", + "lines-and-columns": "^2.0.3", + "type-fest": "^3.8.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/web-ext/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/web-ext/node_modules/strip-bom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-5.0.0.tgz", + "integrity": "sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/web-ext/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/web-ext/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -6457,6 +9378,20 @@ "node": ">=18" } }, + "node_modules/when": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/when/-/when-3.7.7.tgz", + "integrity": "sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw==", + "dev": true, + "license": "MIT" + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6472,6 +9407,83 @@ "node": ">= 8" } }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/winreg": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/winreg/-/winreg-0.0.12.tgz", + "integrity": "sha512-typ/+JRmi7RqP1NanzFULK36vczznSNN8kWVA9vIqXyv8GhghUlwhGp1Xj3Nms1FsPcNnsQrJOR10N58/nQ9hQ==", + "dev": true, + "license": "BSD" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6518,9 +9530,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { @@ -6539,6 +9551,51 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -6549,6 +9606,30 @@ "node": ">=18" } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -6598,6 +9679,17 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -6609,6 +9701,17 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/zip-dir/-/zip-dir-2.0.0.tgz", + "integrity": "sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.0", + "jszip": "^3.2.2" + } } } } diff --git a/package.json b/package.json index 0d6091f..5e2cb2a 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,11 @@ { "name": "youtube-local-history", - "version": "2.4.0", + "version": "5.0.0", "description": "Store YouTube video timestamps using secure browser storage for larger storage capacity without expiry.", "main": "src/content.js", "scripts": { + "start": "web-ext run", + "start:debug": "web-ext run --browser-console", "test": "jest", "test:unit": "jest --testPathPattern=unit", "test:integration": "jest --testPathPattern=integration", @@ -12,6 +14,7 @@ "test:coverage": "jest --coverage", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "prepare:firefox": "node prepare-firefox-build.js", "build": "./build.sh", "lint": "eslint src/", "lint:fix": "eslint src/ --fix" @@ -35,7 +38,8 @@ "babel-jest": "^29.7.0", "eslint": "^9.15.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^30.3.0" + "jest-environment-jsdom": "^30.3.0", + "web-ext": "^8.3.0" }, "browserslist": [ "last 2 versions", diff --git a/playwright.config.js b/playwright.config.js index a8b7a4f..d541cc7 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,6 +1,9 @@ // @ts-check const { defineConfig, devices } = require('@playwright/test'); const path = require('path'); +const fs = require('fs'); + +const youtubeStorageState = path.join(__dirname, 'yt-storage.json'); /** * @see https://playwright.dev/docs/test-configuration @@ -46,8 +49,8 @@ module.exports = defineConfig({ use: { ...devices['Desktop Chrome'], headless: false, - // Reuse a profile where YouTube cookies/consent have been accepted - storageState: path.join(__dirname, 'yt-storage.json'), + // Optional local-only profile state for YouTube cookies/consent. + ...(fs.existsSync(youtubeStorageState) ? { storageState: youtubeStorageState } : {}), args: (() => { const extensionPath = path.join(__dirname, 'build', 'chrome'); return [ diff --git a/prepare-firefox-build.js b/prepare-firefox-build.js new file mode 100644 index 0000000..a930371 --- /dev/null +++ b/prepare-firefox-build.js @@ -0,0 +1,23 @@ +const fs = require('fs'); +const path = require('path'); + +const root = __dirname; +const source = path.join(root, 'src'); +const target = path.join(root, 'build', 'firefox'); + +fs.rmSync(target, { recursive: true, force: true }); +fs.mkdirSync(target, { recursive: true }); + +for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + if (entry.name === 'manifest.chrome.json' || entry.name === 'manifest.firefox.json') continue; + const from = path.join(source, entry.name); + const to = path.join(target, entry.name); + fs.cpSync(from, to, { recursive: true }); +} + +fs.copyFileSync( + path.join(source, 'manifest.firefox.json'), + path.join(target, 'manifest.json') +); + +console.log('Prepared Firefox extension in build/firefox'); diff --git a/src/_locales/bg/feed.json b/src/_locales/bg/feed.json new file mode 100644 index 0000000..81c6838 --- /dev/null +++ b/src/_locales/bg/feed.json @@ -0,0 +1,506 @@ +{ + "feed_menu": { + "message": "????", + "description": "Menu button label" + }, + "feed_back_to_feed": { + "message": "????? ??? ???? ?????", + "description": "Tooltip for returning to the feed" + }, + "feed_search_youtube": { + "message": "??????? ? YouTube", + "description": "Search field placeholder" + }, + "feed_refresh": { + "message": "??????", + "description": "Refresh button label" + }, + "feed_create": { + "message": "??????", + "description": "Create button label" + }, + "feed_save": { + "message": "??????", + "description": "Save button label" + }, + "feed_clear_all": { + "message": "??????? ??????", + "description": "Clear all button label" + }, + "feed_options": { + "message": "?????", + "description": "Options menu label" + }, + "feed_unwatched_only": { + "message": "???? ?????????", + "description": "Filter label for only unwatched videos" + }, + "feed_hide_members": { + "message": "Hide member videos", + "description": "Filter label for hiding members-only or members-first videos" + }, + "feed_nav_home": { + "message": "??????", + "description": "Home navigation label" + }, + "feed_nav_home_title": { + "message": "??????????????? ??????? ?? ????????? ??", + "description": "Home navigation tooltip" + }, + "feed_nav_subscriptions": { + "message": "??????????", + "description": "Subscriptions navigation label" + }, + "feed_nav_subscriptions_title": { + "message": "???-???? ????? ?? ??????????? ??????", + "description": "Subscriptions navigation tooltip" + }, + "feed_nav_shorts": { + "message": "Shorts", + "description": "Shorts navigation label" + }, + "feed_nav_playlists": { + "message": "?????????", + "description": "Playlists navigation label" + }, + "feed_nav_playlists_title": { + "message": "??? ???????? ?????????", + "description": "Playlists navigation tooltip" + }, + "feed_nav_history": { + "message": "???????", + "description": "History navigation label" + }, + "feed_nav_history_title": { + "message": "??? ????????? ??????? ?? ???????", + "description": "History navigation tooltip" + }, + "feed_nav_channels": { + "message": "??????", + "description": "Channels navigation label" + }, + "feed_nav_channels_title": { + "message": "?????????? ??????? ????????? ??????", + "description": "Channels navigation tooltip" + }, + "feed_nav_analytics": { + "message": "???????", + "description": "Analytics navigation label" + }, + "feed_nav_analytics_title": { + "message": "??? ??????? ?? ?????????", + "description": "Analytics navigation tooltip" + }, + "feed_nav_settings": { + "message": "?????????", + "description": "Settings navigation label" + }, + "feed_nav_settings_title": { + "message": "?????? ??????????? ?? ????????????", + "description": "Settings navigation tooltip" + }, + "feed_your_feed": { + "message": "????? ?????", + "description": "Main feed heading" + }, + "feed_search_options": { + "message": "????? ?? ???????", + "description": "Search options aria label" + }, + "feed_filters": { + "message": "??????", + "description": "Filters button label" + }, + "feed_upload_date": { + "message": "???? ?? ???????", + "description": "Upload date filter label" + }, + "feed_today": { + "message": "????", + "description": "Today filter label" + }, + "feed_this_week": { + "message": "???? ???????", + "description": "This week filter label" + }, + "feed_this_month": { + "message": "???? ?????", + "description": "This month filter label" + }, + "feed_this_year": { + "message": "???? ??????", + "description": "This year filter label" + }, + "feed_duration": { + "message": "???????????????", + "description": "Duration filter label" + }, + "feed_under_4_minutes": { + "message": "??? 4 ??????", + "description": "Under 4 minutes filter label" + }, + "feed_4_to_20_minutes": { + "message": "4-20 ??????", + "description": "4 to 20 minutes filter label" + }, + "feed_over_20_minutes": { + "message": "??? 20 ??????", + "description": "Over 20 minutes filter label" + }, + "feed_watch_status": { + "message": "?????? ?? ???????", + "description": "Watch status filter label" + }, + "feed_unwatched": { + "message": "?????????", + "description": "Unwatched filter label" + }, + "feed_in_progress": { + "message": "? ??????", + "description": "In progress filter label" + }, + "feed_watched": { + "message": "???????", + "description": "Watched filter label" + }, + "feed_sort_search_results": { + "message": "???????? ???????????", + "description": "Sort search results aria label" + }, + "feed_sort": { + "message": "?????????", + "description": "Sort filter label" + }, + "feed_newest": { + "message": "???-????", + "description": "Newest sort label" + }, + "feed_oldest": { + "message": "???-?????", + "description": "Oldest sort label" + }, + "feed_search_results_for": { + "message": "????????? ??", + "description": "Search results heading prefix" + }, + "feed_back_to_results": { + "message": "????? ??? ???????????", + "description": "Back to results button label" + }, + "feed_no_channel_videos": { + "message": "??? ??? ???? ???????? ????? ?? ???? ?????.", + "description": "Empty state for channel videos" + }, + "feed_channels": { + "message": "??????", + "description": "Channels label" + }, + "feed_no_local_channels": { + "message": "??? ???? ??????? ????????? ??????. ????? ? ???????????? ? ????????? ?????????.", + "description": "Empty state for local channels" + }, + "feed_playlists": { + "message": "?????????", + "description": "Playlists label" + }, + "feed_no_saved_playlists": { + "message": "??? ???? ???????? ?????????.", + "description": "Empty state for saved playlists" + }, + "feed_history": { + "message": "???????", + "description": "History label" + }, + "feed_no_history_matches": { + "message": "???? ???????, ????? ?? ??????? ? ???? ??????.", + "description": "Empty state for filtered history" + }, + "feed_load_more": { + "message": "?????? ???", + "description": "Load more button label" + }, + "feed_analytics": { + "message": "???????", + "description": "Analytics label" + }, + "feed_analytics_subtitle": { + "message": "?????? ??????????, ???????? ???????", + "description": "Analytics heading subtitle" + }, + "feed_watch_activity_7_days": { + "message": "????????? ?? ??????? (?????????? 7 ???)", + "description": "Watch activity chart title" + }, + "feed_watch_time_by_hour": { + "message": "????? ?? ??????? ?? ???", + "description": "Watch time by hour chart title" + }, + "feed_how_much_finish": { + "message": "????? ?????????", + "description": "Completion analytics title" + }, + "feed_top_channels": { + "message": "??? ??????", + "description": "Top channels analytics title" + }, + "feed_continue_watching": { + "message": "???????? ?????????", + "description": "Continue watching analytics title" + }, + "feed_settings": { + "message": "?????????", + "description": "Settings label" + }, + "feed_settings_subtitle": { + "message": "?????? ?????? ??????? ? ???? ???????.", + "description": "Settings subtitle" + }, + "feed_settings_display": { + "message": "??????", + "description": "Display settings section title" + }, + "feed_settings_theme": { + "message": "????", + "description": "Theme setting label" + }, + "feed_system_default": { + "message": "???????? ?????????", + "description": "System default theme label" + }, + "feed_dark": { + "message": "?????", + "description": "Dark theme label" + }, + "feed_light": { + "message": "??????", + "description": "Light theme label" + }, + "feed_accent_color": { + "message": "???????? ????", + "description": "Accent color setting label" + }, + "feed_blue": { + "message": "?????", + "description": "Blue color label" + }, + "feed_red": { + "message": "???????", + "description": "Red color label" + }, + "feed_green": { + "message": "??????", + "description": "Green color label" + }, + "feed_purple": { + "message": "??????", + "description": "Purple color label" + }, + "feed_orange": { + "message": "????????", + "description": "Orange color label" + }, + "feed_default_tab": { + "message": "?????? ?? ????????????", + "description": "Default tab setting label" + }, + "feed_last_used_tab": { + "message": "???????? ????????? ??????", + "description": "Last used tab option label" + }, + "feed_history_and_feed": { + "message": "??????? ? ?????", + "description": "History and feed settings section title" + }, + "feed_check_new_videos": { + "message": "???????? ?? ???? ?????", + "description": "Check for new videos setting label" + }, + "feed_every_15_minutes": { + "message": "?? ????? 15 ??????", + "description": "Every 15 minutes interval label" + }, + "feed_every_30_minutes": { + "message": "?? ????? 30 ??????", + "description": "Every 30 minutes interval label" + }, + "feed_every_hour": { + "message": "????? ???", + "description": "Every hour interval label" + }, + "feed_every_3_hours": { + "message": "?? ????? 3 ????", + "description": "Every 3 hours interval label" + }, + "feed_every_6_hours": { + "message": "?? ????? 6 ????", + "description": "Every 6 hours interval label" + }, + "feed_every_12_hours": { + "message": "?? ????? 12 ????", + "description": "Every 12 hours interval label" + }, + "feed_once_a_day": { + "message": "?????? ?? ???", + "description": "Once a day interval label" + }, + "feed_keep_watch_history": { + "message": "???? ?????????", + "description": "Keep watch history setting label" + }, + "feed_30_days": { + "message": "30 ???", + "description": "30 days option label" + }, + "feed_90_days": { + "message": "90 ???", + "description": "90 days option label" + }, + "feed_180_days": { + "message": "180 ???", + "description": "180 days option label" + }, + "feed_forever": { + "message": "????????", + "description": "Forever option label" + }, + "feed_data": { + "message": "?????", + "description": "Data settings section title" + }, + "feed_backup_restore": { + "message": "????? ? ??????????????", + "description": "Backup and restore title" + }, + "feed_backup_restore_help": { + "message": "??????? ??? ?????? JSON ???? ? ???????, ??????????, ????????? ? ?????????.", + "description": "Backup and restore help text" + }, + "feed_backup": { + "message": "?????", + "description": "Backup button label" + }, + "feed_restore": { + "message": "??????????", + "description": "Restore button label" + }, + "feed_import_from_youtube": { + "message": "?????? ?? YouTube", + "description": "Import from YouTube title" + }, + "feed_import_from_youtube_help": { + "message": "????????? watch-history.html ?? ????????? ? subscriptions.csv ?? ??????????? ?????? ?? Google Takeout.", + "description": "Import from YouTube help text" + }, + "feed_reset_all_data": { + "message": "??????? ?????? ?????", + "description": "Reset all data title" + }, + "feed_reset_all_data_help": { + "message": "?????? ??????, ??????????? ??????????, ????????? ? ??????.", + "description": "Reset all data help text" + }, + "feed_reset_all": { + "message": "??????? ??????", + "description": "Reset all button label" + }, + "feed_add_to_playlist": { + "message": "?????? ??? ????????", + "description": "Add to playlist dialog title" + }, + "feed_close": { + "message": "???????", + "description": "Close button aria label" + }, + "feed_create_new_playlist": { + "message": "?????? ??? ????????", + "description": "Create new playlist placeholder" + }, + "feed_video_options": { + "message": "????? ?? ???????", + "description": "feed video options" + }, + "feed_more_from_channel": { + "message": "??? ?? ???? ?????", + "description": "feed more from channel" + }, + "feed_less_from_channel": { + "message": "??-????? ?? ???? ?????", + "description": "feed less from channel" + }, + "feed_hide_this_video": { + "message": "????? ???? ?????", + "description": "feed hide this video" + }, + "feed_unsubscribe": { + "message": "?????? ??", + "description": "feed unsubscribe" + }, + "feed_not_subscribed": { + "message": "?? ?? ????????", + "description": "feed not subscribed" + }, + "feed_unsubscribed": { + "message": "????????", + "description": "feed unsubscribed" + }, + "feed_save_to_watch_later": { + "message": "?????? ?? ??-?????", + "description": "feed save to watch later" + }, + "feed_saved_to_watch_later": { + "message": "???????? ?? ??-?????", + "description": "feed saved to watch later" + }, + "feed_added_to_playlist": { + "message": "???????? ??? ????????", + "description": "feed added to playlist" + }, + "feed_set_as_playlist_cover": { + "message": "????? ???? ?????? ?? ?????????", + "description": "feed set as playlist cover" + }, + "feed_cover_updated": { + "message": "???????? ? ????????", + "description": "feed cover updated" + }, + "feed_remove_from_playlist": { + "message": "???????? ?? ???? ????????", + "description": "feed remove from playlist" + }, + "feed_this_channel": { + "message": "???? ?????", + "description": "feed this channel" + }, + "feed_channel": { + "message": "?????", + "description": "feed channel" + }, + "feed_recommend_more_from_status": { + "message": "?? ???????????? ?????? ?? $1.", + "description": "feed recommend more from status" + }, + "feed_recommend_more_status": { + "message": "?? ???????????? ??????", + "description": "feed recommend more status" + }, + "feed_unsubscribed_from_status": { + "message": "?????? ?? ?? $1.", + "description": "feed unsubscribed from status" + }, + "feed_refreshing": { + "message": "??????????...", + "description": "feed refreshing" + }, + "feed_updated": { + "message": "??????? ? ???????.", + "description": "feed updated" + }, + "feed_no_videos_subscribe_first": { + "message": "???? ???????? ?????. ????? ?? ???????? ?? ??????.", + "description": "feed no videos subscribe first" + }, + "feed_refresh_failed_status": { + "message": "???????????? ?? ????: $1. ??? YouTube ??????? ????? ?? ????????, ?????? youtube.com ?????? ? ???? ???????.", + "description": "feed refresh failed status" + } +} diff --git a/src/_locales/bg/messages-group.json b/src/_locales/bg/messages-group.json index cd2a60a..efbaf12 100644 --- a/src/_locales/bg/messages-group.json +++ b/src/_locales/bg/messages-group.json @@ -47,10 +47,6 @@ "message": "Сигурни ли сте, че искате да изчистите цялата история? Това не може да се отмени.", "description": "Confirmation for clearing all history." }, - "message_confirm_full_sync": { - "message": "Пълната синхронизация ще изчисти старите данни и ще синхронизира всичко наново. Може да отнеме време. Продължавате ли?", - "description": "Confirmation for full sync." - }, "message_warning_clear_all": { "message": "ВНИМАНИЕ: Това ще изтрие цялата ви история и плейлисти в YouTube. Това не може да се отмени. Продължавате ли?", "description": "Warning for clearing all history and playlists." @@ -119,114 +115,6 @@ "message": "Режимът за отстраняване на грешки е деактивиран", "description": "Message when debug mode is disabled" }, - "message_sync_disabled": { - "message": "Синхронизацията е изключена - Кликнете, за да я активирате", - "description": "Tooltip for disabled sync status" - }, - "message_sync_not_available": { - "message": "Синхронизацията не е налична - Изисква се Firefox Sync", - "description": "Tooltip for unavailable sync status" - }, - "message_sync_initializing": { - "message": "Инициализиране на синхронизацията...", - "description": "Tooltip for initializing sync status" - }, - "message_sync_syncing": { - "message": "Синхронизиране на данни...", - "description": "Tooltip for syncing data status" - }, - "message_sync_ready": { - "message": "Синхронизацията е готова", - "description": "Tooltip for sync ready status" - }, - "message_sync_error": { - "message": "Грешка при синхронизация - Кликнете за повторен опит", - "description": "Tooltip for sync error status" - }, - "message_sync_off": { - "message": "Изключено", - "description": "Label for sync off status" - }, - "message_sync_na": { - "message": "Н/Д", - "description": "Label for sync not available status" - }, - "message_sync_init": { - "message": "Инициализиране", - "description": "Label for sync initializing status" - }, - "message_sync_on": { - "message": "Включено", - "description": "Label for sync on status" - }, - "message_sync": { - "message": "Синхронизация", - "description": "Label for sync status" - }, - "message_syncing": { - "message": "Синхронизиране...", - "description": "Label for syncing status" - }, - "message_synced": { - "message": "Синхронизирано", - "description": "Label for synced status" - }, - "message_sync_now": { - "message": "Синхронизирай сега", - "description": "Button label for sync now" - }, - "message_full_sync": { - "message": "Пълна синхронизация", - "description": "Button label for full sync" - }, - "message_syncing_status": { - "message": "Синхронизира се...", - "description": "Status text for syncing" - }, - "message_synced_status": { - "message": "Синхронизирано", - "description": "Status text for synced" - }, - "message_never": { - "message": "Никога", - "description": "Label for never synced" - }, - "message_just_now": { - "message": "Току-що", - "description": "Label for just now sync time" - }, - "message_minutes_ago": { - "message": "преди {MINUTES} минути", - "description": "Label for minutes ago sync time" - }, - "message_hours_ago": { - "message": "преди {HOURS} часа", - "description": "Label for hours ago sync time" - }, - "message_days_ago": { - "message": "преди {DAYS} дни", - "description": "Label for days ago sync time" - }, - "message_firefox_sync_not_available": { - "message": "Firefox Sync не е наличен. Моля, активирайте Firefox Sync в настройките на браузъра си.", - "description": "Error message when Firefox Sync is not available" - }, - "message_failed_to_enable_sync": { - "message": "Неуспешно активиране на синхронизацията. Моля, проверете настройките на Firefox Sync.", - "description": "Error message when enabling sync fails" - }, - "message_failed_to_disable_sync": { - "message": "Неуспешно изключване на синхронизацията.", - "description": "Error message when disabling sync fails" - }, - "message_sync_currently_disabled": { - "message": "Синхронизацията е изключена в момента.", - "description": "Error message when sync is currently disabled" - }, - "message_full_sync_initiated": { - "message": "Стартира се пълна синхронизация... Това може да отнеме малко време.", - "description": "Message when full sync is initiated" - }, "settings_convert_history": { "message": "Конвертиране на YouTube история", "description": "Button label to open the community converter tool" @@ -239,4 +127,4 @@ "message": "Неизвестна грешка", "description": "Fallback for unknown error details" } -} \ No newline at end of file +} diff --git a/src/_locales/bg/messages.json b/src/_locales/bg/messages.json index 7d81a60..5d022de 100644 --- a/src/_locales/bg/messages.json +++ b/src/_locales/bg/messages.json @@ -1,192 +1,166 @@ { "extensionName": { - "message": "YouTube Локална История", - "description": "The name of the extension" + "message": "YouTube Локална История", + "description": "The name of the extension" }, - "popup_title" : { - "message": "YT re:Watch", - "description": "Popup window title and main heading" + "popup_title": { + "message": "YT re:Watch", + "description": "Popup window title and main heading" }, - "theme_toggle" : { - "message": "Тема", - "description": "Button label for toggling theme" + "theme_toggle": { + "message": "Тема", + "description": "Button label for toggling theme" }, - "theme_toggle_tooltip" : { - "message": "Превключване на темата", - "description": "Tooltip for theme toggle button" + "theme_toggle_tooltip": { + "message": "Превключване на темата", + "description": "Tooltip for theme toggle button" }, - "theme_light" : { - "message": "Светла", - "description": "Label for light theme option" + "theme_light": { + "message": "Светла", + "description": "Label for light theme option" }, - "theme_dark" : { - "message": "Тъмна", - "description": "Label for dark theme option" + "theme_dark": { + "message": "Тъмна", + "description": "Label for dark theme option" }, - "theme_auto" : { - "message": "Автоматично", - "description": "Label for auto/system theme option" + "theme_auto": { + "message": "Автоматично", + "description": "Label for auto/system theme option" }, - "theme_switch_to_dark" : { - "message": "Превключване на тъмна тема", - "description": "Tooltip for switching to dark theme" + "theme_switch_to_dark": { + "message": "Превключване на тъмна тема", + "description": "Tooltip for switching to dark theme" }, - "theme_switch_to_light" : { - "message": "Превключване на светла тема", - "description": "Tooltip for switching to light theme" + "theme_switch_to_light": { + "message": "Превключване на светла тема", + "description": "Tooltip for switching to light theme" }, - "theme_system" : { - "message": "Системни настройки", - "description": "Label for system default theme option" + "theme_system": { + "message": "Системни настройки", + "description": "Label for system default theme option" }, - "theme_currently_following_system" : { - "message": "Следва системната тема - Кликнете за ръчно превключване", - "description": "Tooltip for auto theme option" + "theme_currently_following_system": { + "message": "Следва системната тема - Кликнете за ръчно превключване", + "description": "Tooltip for auto theme option" }, - "clear_label" : { - "message": "Изчисти", - "description": "Button label for clearing all history" + "clear_label": { + "message": "Изчисти", + "description": "Button label for clearing all history" }, - "clear_tooltip" : { - "message": "Изчисти цялата история", - "description": "Tooltip for clear history button" + "clear_tooltip": { + "message": "Изчисти цялата история", + "description": "Tooltip for clear history button" }, - "close_label" : { - "message": "Затвори", - "description": "Button label for closing the popup" + "close_label": { + "message": "Затвори", + "description": "Button label for closing the popup" }, - "sync_status" : { - "message": "Синхронизация", - "description": "Label for sync status indicator" + "instructions_script_disabled_title": { + "message": "Изпълнението на скриптове е изключено", + "description": "Title for script execution disabled instructions" }, - "sync_status_tooltip" : { - "message": "Състояние на синхронизацията", - "description": "Tooltip for sync status indicator" + "instructions_script_disabled_body": { + "message": "Ако виждате това съобщение, изпълнението на скриптове е изключено за това разширение. Моля, следвайте тези стъпки, за да го активирате:", + "description": "Body text for script execution disabled instructions" }, - - "instructions_script_disabled_title" : { - "message": "Изпълнението на скриптове е изключено", - "description": "Title for script execution disabled instructions" + "instructions_script_disabled_step1": { + "message": "Отидете в настройките на Firefox: about:preferences#privacy", + "description": "Step 1 for enabling script execution" }, - "instructions_script_disabled_body" : { - "message": "Ако виждате това съобщение, изпълнението на скриптове е изключено за това разширение. Моля, следвайте тези стъпки, за да го активирате:", - "description": "Body text for script execution disabled instructions" + "instructions_script_disabled_step2": { + "message": "Уверете се, че \"Enhanced Tracking Protection\" е на \"Standard\" или \"Custom\" и не блокира скриптове", + "description": "Step 2 for enabling script execution" }, - "instructions_script_disabled_step1" : { - "message": "Отидете в настройките на Firefox: about:preferences#privacy", - "description": "Step 1 for enabling script execution" + "instructions_script_disabled_step3": { + "message": "Проверете за блокиране на съдържание: кликнете върху иконата на щит в адресната лента, когато прозорецът е отворен, и се уверете, че няма блокирани скриптове", + "description": "Step 3 for enabling script execution" }, - "instructions_script_disabled_step2" : { - "message": "Уверете се, че \"Enhanced Tracking Protection\" е на \"Standard\" или \"Custom\" и не блокира скриптове", - "description": "Step 2 for enabling script execution" + "delete_label": { + "message": "Изтрий", + "description": "Button label for deleting an item (video, playlist, short)" }, - "instructions_script_disabled_step3" : { - "message": "Проверете за блокиране на съдържание: кликнете върху иконата на щит в адресната лента, когато прозорецът е отворен, и се уверете, че няма блокирани скриптове", - "description": "Step 3 for enabling script execution" + "pagination_page_info": { + "message": "Страница $1 от $2", + "description": "Pagination info, e.g. Page 1 of 11" }, - "delete_label" : { - "message": "Изтрий", - "description": "Button label for deleting an item (video, playlist, short)" + "chart_skipped": { + "message": "Пропуснато", + "description": "Chart label: Skipped" }, - "pagination_page_info" : { - "message": "Страница $1 от $2", - "description": "Pagination info, e.g. Page 1 of 11" + "chart_partial": { + "message": "Частично", + "description": "Chart label: Partial" }, - "chart_skipped" : { - "message": "Пропуснато", - "description": "Chart label: Skipped" + "chart_completed": { + "message": "Завършено", + "description": "Chart label: Completed" }, - "chart_partial" : { - "message": "Частично", - "description": "Chart label: Partial" + "chart_skipped_legend": { + "message": "Пропуснато (<10%)", + "description": "Chart legend: Skipped (<10%)" }, - "chart_completed" : { - "message": "Завършено", - "description": "Chart label: Completed" + "chart_partial_legend": { + "message": "Частично (10-90%)", + "description": "Chart legend: Partial (10-90%)" }, - "chart_skipped_legend" : { - "message": "Пропуснато (<10%)", - "description": "Chart legend: Skipped (<10%)" + "chart_completed_legend": { + "message": "Завършено (>=90%)", + "description": "Chart legend: Completed (>=90%)" }, - "chart_partial_legend" : { - "message": "Частично (10-90%)", - "description": "Chart legend: Partial (10-90%)" + "chart_minutes": { + "message": "Минути", + "description": "Chart axis label: Minutes" }, - "chart_completed_legend" : { - "message": "Завършено (>=90%)", - "description": "Chart legend: Completed (>=90%)" + "chart_hour_of_day": { + "message": "Час от деня", + "description": "Chart axis label: Hour of Day" }, - "chart_minutes" : { - "message": "Минути", - "description": "Chart axis label: Minutes" + "date_today": { + "message": "Днес", + "description": "Label for today's date" }, - "chart_hour_of_day" : { - "message": "Час от деня", - "description": "Chart axis label: Hour of Day" + "date_yesterday": { + "message": "Вчера", + "description": "Label for yesterday's date" }, - - "date_today" : { - "message": "Днес", - "description": "Label for today's date" + "date_unknown": { + "message": "Непозната", + "description": "Label for unknown date" }, - "date_yesterday" : { - "message": "Вчера", - "description": "Label for yesterday's date" + "search_no_videos_found": { + "message": "Не са намерени видеа, отговарящи на търсенето ви.", + "description": "Message when no videos match the search" }, - "date_unknown" : { - "message": "Непозната", - "description": "Label for unknown date" - }, - "search_no_videos_found" : { - "message": "Не са намерени видеа, отговарящи на търсенето ви.", - "description": "Message when no videos match the search" - }, - "history_no_history_found" : { - "message": "Не е намерена история. Започнете да гледате видеа, за да се появи тук!", - "description": "Message when no history is found" + "history_no_history_found": { + "message": "Не е намерена история. Започнете да гледате видеа, за да се появи тук!", + "description": "Message when no history is found" }, "message_pause_in_playlists_enabled": { - "message": "Пауза на историята в плейлисти е включена", - "description": "Toast when global pause in playlists is enabled" + "message": "Пауза на историята в плейлисти е включена", + "description": "Toast when global pause in playlists is enabled" }, "message_pause_in_playlists_disabled": { - "message": "Пауза на историята в плейлисти е изключена", - "description": "Toast when global pause in playlists is disabled" + "message": "Пауза на историята в плейлисти е изключена", + "description": "Toast when global pause in playlists is disabled" }, "playlists_ignore_toggle_label": { - "message": "Игнорирай", - "description": "Label next to playlist ignore checkbox" + "message": "Игнорирай", + "description": "Label next to playlist ignore checkbox" }, "playlists_ignore_toggle_tooltip": { - "message": "Игнорирай видеата в този плейлист", - "description": "Tooltip for playlist ignore checkbox" + "message": "Игнорирай видеата в този плейлист", + "description": "Tooltip for playlist ignore checkbox" }, "playlists_ignore_enabled": { - "message": "Плейлистът ще бъде игнориран", - "description": "Toast when ignore is enabled" + "message": "Плейлистът ще бъде игнориран", + "description": "Toast when ignore is enabled" }, "playlists_ignore_disabled": { - "message": "Плейлистът ще бъде проследяван", - "description": "Toast when ignore is disabled" - }, - "content_toggle_pause": { - "message": "re:Watch — Кликнете за пауза на историята", - "description": "Button text when tracking is active" - }, - "content_toggle_paused": { - "message": "re:Watch — Историята е на пауза. Кликнете за активиране", - "description": "Button text when tracking is paused" - }, - "content_toggle_pause_title": { - "message": "re:Watch — Кликнете за пауза на историята за този плейлист", - "description": "Tooltip when tracking is active" - }, - "content_toggle_paused_title": { - "message": "re:Watch — Историята е на пауза за този плейлист. Кликнете за активиране", - "description": "Tooltip when tracking is paused" + "message": "Плейлистът ще бъде проследяван", + "description": "Toast when ignore is disabled" }, "global_search_placeholder": { - "message": "Търсене във всички съдържание...", - "description": "Placeholder text for the global search input" + "message": "Търсене във всички съдържание...", + "description": "Placeholder text for the global search input" } -} \ No newline at end of file +} diff --git a/src/_locales/bg/settings.json b/src/_locales/bg/settings.json index 3c3fe1b..6130e32 100644 --- a/src/_locales/bg/settings.json +++ b/src/_locales/bg/settings.json @@ -44,8 +44,8 @@ "description": "Help text for overlay size" }, "settings_data_sync": { - "message": "Данни и Синхронизация", - "description": "Settings section title for data and sync" + "message": "Data", + "description": "Settings section title for data" }, "settings_pause_in_playlists_label": { "message": "Пауза на историята в плейлисти", @@ -79,30 +79,6 @@ "message": "Автоматично премахва история след избрания период", "description": "Help text for auto-clean period" }, - "settings_sync_label": { - "message": "Firefox Sync", - "description": "Label for Firefox Sync option" - }, - "settings_sync_help": { - "message": "Синхронизирайте вашите данни между инстанции на браузъра (само за Firefox)", - "description": "Help text for Firefox Sync" - }, - "settings_last_sync_label": { - "message": "Последна синхронизация", - "description": "Label for last sync time" - }, - "settings_sync_now": { - "message": "Синхронизирай сега", - "description": "Button label for triggering sync" - }, - "settings_full_sync": { - "message": "Пълна синхронизация", - "description": "Button label for triggering full sync" - }, - "settings_full_sync_help": { - "message": "Пълната синхронизация изчиства старите данни и ги синхронизира отново", - "description": "Help text for full sync" - }, "settings_debug_label": { "message": "Режим за отстраняване на грешки", "description": "Label for debug mode option" @@ -118,5 +94,97 @@ "settings_save": { "message": "Запазете настройките", "description": "Button label for saving settings" + }, + "settings_data_label": { + "message": "Data", + "description": "Label for the data section in settings" + }, + "settings_import_history": { + "message": "Import History", + "description": "Button label to import watch history" + }, + "settings_import_history_help": { + "message": "Import History accepts the watch-history.html file from Google Takeout.", + "description": "Help text for the import history button" + }, + "settings_import_subs": { + "message": "Import Subscriptions", + "description": "Button label to import subscriptions" + }, + "settings_import_subs_help": { + "message": "Import Subscriptions accepts the subscriptions.csv file from Google Takeout.", + "description": "Help text for the import subscriptions button" + }, + "settings_clear_help": { + "message": "Clear removes all saved video history (this cannot be undone)", + "description": "Help text for the Clear history button" + }, + "settings_local_feed_label": { + "message": "Local feed", + "description": "Label for the local subscriptions feed toggle" + }, + "settings_local_feed_help": { + "message": "Use locally subscribed channels to build the YT re:Watch feed", + "description": "Help text for the local subscriptions feed toggle" + }, + "settings_hide_recs_label": { + "message": "Hide recommendations & popups", + "description": "Label for the hide recommendations toggle" + }, + "settings_hide_recs_help": { + "message": "Hide YouTube's home recommendations and \"history is off\" nag popups.", + "description": "Help text for the hide recommendations toggle" + }, + "settings_hide_account_label": { + "message": "Hide login & account UI", + "description": "Label for the hide account UI toggle" + }, + "settings_hide_account_help": { + "message": "Hide YouTube's sign-in buttons, account menu, and native Subscribe/Join buttons (your local Subscribe stays).", + "description": "Help text for the hide account UI toggle" + }, + "settings_feed_refresh_label": { + "message": "Feed refresh interval (minutes)", + "description": "Label for the feed refresh interval input" + }, + "settings_feed_refresh_help": { + "message": "How often the local feed checks subscribed channels for new uploads", + "description": "Help text for the feed refresh interval input" + }, + "subscriptions_refresh": { + "message": "Refresh feed", + "description": "Button label to refresh the local subscriptions feed" + }, + "subscriptions_open_feed": { + "message": "Open YT re:Watch", + "description": "Button label to open the dedicated feed page" + }, + "subscriptions_clear_all": { + "message": "Clear all", + "description": "Button label to remove all local subscriptions" + }, + "subscriptions_no_subs": { + "message": "No local subscriptions yet. Search in the extension and use Subscribe.", + "description": "Empty state for the subscriptions tab" + }, + "subscriptions_unsubscribe": { + "message": "Unsubscribe", + "description": "Button label to remove a local subscription" + }, + "subscriptions_refreshing": { + "message": "Refreshing…", + "description": "Refresh button label while a refresh is in progress" + }, + "subscriptions_refresh_started": { + "message": "Refreshing feed in your YouTube tab…", + "description": "Message shown when a feed refresh starts" + }, + "subscriptions_open_youtube": { + "message": "Open a YouTube tab to refresh the feed.", + "description": "Message shown when no YouTube tab is open to refresh the feed" + }, + "message_settings_saved": { + "message": "Settings saved", + "description": "Generic confirmation that settings were saved" } -} \ No newline at end of file +} diff --git a/src/_locales/bg/tabs.json b/src/_locales/bg/tabs.json index dd18a48..a3b45ae 100644 --- a/src/_locales/bg/tabs.json +++ b/src/_locales/bg/tabs.json @@ -226,5 +226,25 @@ "playlists_unknown_title": { "message": "Неизвестен плейлист", "description": "Fallback title for playlists with no title" + }, + "tab_subscriptions": { + "message": "Subs", + "description": "Tab label for local subscriptions" + }, + "tab_watchlater": { + "message": "Later", + "description": "Tab label for local Watch Later list" + }, + "watchlater_empty": { + "message": "Nothing saved yet. On YouTube, right-click a video and choose \"Save to Watch Later (local)\".", + "description": "Empty state for the Watch Later tab" + }, + "watchlater_clear_all": { + "message": "Clear all", + "description": "Button to remove all Watch Later items" + }, + "watchlater_confirm_clear": { + "message": "Clear all Watch Later items?", + "description": "Confirmation prompt before clearing the Watch Later list" } -} \ No newline at end of file +} diff --git a/src/_locales/de/feed.json b/src/_locales/de/feed.json new file mode 100644 index 0000000..fb43934 --- /dev/null +++ b/src/_locales/de/feed.json @@ -0,0 +1,506 @@ +{ + "feed_menu": { + "message": "Men?", + "description": "Menu button label" + }, + "feed_back_to_feed": { + "message": "Zur?ck zu deinem Feed", + "description": "Tooltip for returning to the feed" + }, + "feed_search_youtube": { + "message": "YouTube suchen", + "description": "Search field placeholder" + }, + "feed_refresh": { + "message": "Aktualisieren", + "description": "Refresh button label" + }, + "feed_create": { + "message": "Erstellen", + "description": "Create button label" + }, + "feed_save": { + "message": "Speichern", + "description": "Save button label" + }, + "feed_clear_all": { + "message": "Alle l?schen", + "description": "Clear all button label" + }, + "feed_options": { + "message": "Optionen", + "description": "Options menu label" + }, + "feed_unwatched_only": { + "message": "Nur ungesehen", + "description": "Filter label for only unwatched videos" + }, + "feed_hide_members": { + "message": "Mitglieder-Videos ausblenden", + "description": "Filter label for hiding members-only or members-first videos" + }, + "feed_nav_home": { + "message": "Startseite", + "description": "Home navigation label" + }, + "feed_nav_home_title": { + "message": "Lokal aus deinem Wiedergabeverlauf personalisiert", + "description": "Home navigation tooltip" + }, + "feed_nav_subscriptions": { + "message": "Abos", + "description": "Subscriptions navigation label" + }, + "feed_nav_subscriptions_title": { + "message": "Neueste Videos deiner abonnierten Kan?le", + "description": "Subscriptions navigation tooltip" + }, + "feed_nav_shorts": { + "message": "Shorts", + "description": "Shorts navigation label" + }, + "feed_nav_playlists": { + "message": "Playlists", + "description": "Playlists navigation label" + }, + "feed_nav_playlists_title": { + "message": "Gespeicherte Playlists anzeigen", + "description": "Playlists navigation tooltip" + }, + "feed_nav_history": { + "message": "Verlauf", + "description": "History navigation label" + }, + "feed_nav_history_title": { + "message": "Deinen lokalen Wiedergabeverlauf anzeigen", + "description": "History navigation tooltip" + }, + "feed_nav_channels": { + "message": "Kan?le", + "description": "Channels navigation label" + }, + "feed_nav_channels_title": { + "message": "Lokal abonnierte Kan?le verwalten", + "description": "Channels navigation tooltip" + }, + "feed_nav_analytics": { + "message": "Analysen", + "description": "Analytics navigation label" + }, + "feed_nav_analytics_title": { + "message": "Deine Wiedergabeanalysen anzeigen", + "description": "Analytics navigation tooltip" + }, + "feed_nav_settings": { + "message": "Einstellungen", + "description": "Settings navigation label" + }, + "feed_nav_settings_title": { + "message": "Erweiterungseinstellungen ?ffnen", + "description": "Settings navigation tooltip" + }, + "feed_your_feed": { + "message": "Dein Feed", + "description": "Main feed heading" + }, + "feed_search_options": { + "message": "Suchoptionen", + "description": "Search options aria label" + }, + "feed_filters": { + "message": "Filter", + "description": "Filters button label" + }, + "feed_upload_date": { + "message": "Uploaddatum", + "description": "Upload date filter label" + }, + "feed_today": { + "message": "Heute", + "description": "Today filter label" + }, + "feed_this_week": { + "message": "Diese Woche", + "description": "This week filter label" + }, + "feed_this_month": { + "message": "Diesen Monat", + "description": "This month filter label" + }, + "feed_this_year": { + "message": "Dieses Jahr", + "description": "This year filter label" + }, + "feed_duration": { + "message": "Dauer", + "description": "Duration filter label" + }, + "feed_under_4_minutes": { + "message": "Unter 4 Minuten", + "description": "Under 4 minutes filter label" + }, + "feed_4_to_20_minutes": { + "message": "4-20 Minuten", + "description": "4 to 20 minutes filter label" + }, + "feed_over_20_minutes": { + "message": "?ber 20 Minuten", + "description": "Over 20 minutes filter label" + }, + "feed_watch_status": { + "message": "Wiedergabestatus", + "description": "Watch status filter label" + }, + "feed_unwatched": { + "message": "Ungesehen", + "description": "Unwatched filter label" + }, + "feed_in_progress": { + "message": "Angefangen", + "description": "In progress filter label" + }, + "feed_watched": { + "message": "Gesehen", + "description": "Watched filter label" + }, + "feed_sort_search_results": { + "message": "Suchergebnisse sortieren", + "description": "Sort search results aria label" + }, + "feed_sort": { + "message": "Sortieren", + "description": "Sort filter label" + }, + "feed_newest": { + "message": "Neueste", + "description": "Newest sort label" + }, + "feed_oldest": { + "message": "?lteste", + "description": "Oldest sort label" + }, + "feed_search_results_for": { + "message": "Suchergebnisse f?r", + "description": "Search results heading prefix" + }, + "feed_back_to_results": { + "message": "Zur?ck zu den Ergebnissen", + "description": "Back to results button label" + }, + "feed_no_channel_videos": { + "message": "Noch keine Videos f?r diesen Kanal gefunden.", + "description": "Empty state for channel videos" + }, + "feed_channels": { + "message": "Kan?le", + "description": "Channels label" + }, + "feed_no_local_channels": { + "message": "Noch keine lokal abonnierten Kan?le. Suche in der Erweiterung und nutze Abonnieren.", + "description": "Empty state for local channels" + }, + "feed_playlists": { + "message": "Playlists", + "description": "Playlists label" + }, + "feed_no_saved_playlists": { + "message": "Noch keine gespeicherten Playlists.", + "description": "Empty state for saved playlists" + }, + "feed_history": { + "message": "Verlauf", + "description": "History label" + }, + "feed_no_history_matches": { + "message": "Kein Wiedergabeverlauf passt zu diesen Filtern.", + "description": "Empty state for filtered history" + }, + "feed_load_more": { + "message": "Mehr laden", + "description": "Load more button label" + }, + "feed_analytics": { + "message": "Analysen", + "description": "Analytics label" + }, + "feed_analytics_subtitle": { + "message": "deine Wiedergabestatistiken, lokal gespeichert", + "description": "Analytics heading subtitle" + }, + "feed_watch_activity_7_days": { + "message": "Wiedergabeaktivit?t (letzte 7 Tage)", + "description": "Watch activity chart title" + }, + "feed_watch_time_by_hour": { + "message": "Wiedergabezeit nach Stunde", + "description": "Watch time by hour chart title" + }, + "feed_how_much_finish": { + "message": "Wie viel du beendest", + "description": "Completion analytics title" + }, + "feed_top_channels": { + "message": "Top-Kan?le", + "description": "Top channels analytics title" + }, + "feed_continue_watching": { + "message": "Weiter ansehen", + "description": "Continue watching analytics title" + }, + "feed_settings": { + "message": "Einstellungen", + "description": "Settings label" + }, + "feed_settings_subtitle": { + "message": "Alles bleibt lokal in diesem Browser.", + "description": "Settings subtitle" + }, + "feed_settings_display": { + "message": "Anzeige", + "description": "Display settings section title" + }, + "feed_settings_theme": { + "message": "Design", + "description": "Theme setting label" + }, + "feed_system_default": { + "message": "Systemstandard", + "description": "System default theme label" + }, + "feed_dark": { + "message": "Dunkel", + "description": "Dark theme label" + }, + "feed_light": { + "message": "Hell", + "description": "Light theme label" + }, + "feed_accent_color": { + "message": "Akzentfarbe", + "description": "Accent color setting label" + }, + "feed_blue": { + "message": "Blau", + "description": "Blue color label" + }, + "feed_red": { + "message": "Rot", + "description": "Red color label" + }, + "feed_green": { + "message": "Gr?n", + "description": "Green color label" + }, + "feed_purple": { + "message": "Lila", + "description": "Purple color label" + }, + "feed_orange": { + "message": "Orange", + "description": "Orange color label" + }, + "feed_default_tab": { + "message": "Standard-Tab", + "description": "Default tab setting label" + }, + "feed_last_used_tab": { + "message": "Zuletzt verwendeter Tab", + "description": "Last used tab option label" + }, + "feed_history_and_feed": { + "message": "Verlauf & Feed", + "description": "History and feed settings section title" + }, + "feed_check_new_videos": { + "message": "Nach neuen Videos suchen", + "description": "Check for new videos setting label" + }, + "feed_every_15_minutes": { + "message": "Alle 15 Minuten", + "description": "Every 15 minutes interval label" + }, + "feed_every_30_minutes": { + "message": "Alle 30 Minuten", + "description": "Every 30 minutes interval label" + }, + "feed_every_hour": { + "message": "Jede Stunde", + "description": "Every hour interval label" + }, + "feed_every_3_hours": { + "message": "Alle 3 Stunden", + "description": "Every 3 hours interval label" + }, + "feed_every_6_hours": { + "message": "Alle 6 Stunden", + "description": "Every 6 hours interval label" + }, + "feed_every_12_hours": { + "message": "Alle 12 Stunden", + "description": "Every 12 hours interval label" + }, + "feed_once_a_day": { + "message": "Einmal t?glich", + "description": "Once a day interval label" + }, + "feed_keep_watch_history": { + "message": "Wiedergabeverlauf behalten", + "description": "Keep watch history setting label" + }, + "feed_30_days": { + "message": "30 Tage", + "description": "30 days option label" + }, + "feed_90_days": { + "message": "90 Tage", + "description": "90 days option label" + }, + "feed_180_days": { + "message": "180 Tage", + "description": "180 days option label" + }, + "feed_forever": { + "message": "F?r immer", + "description": "Forever option label" + }, + "feed_data": { + "message": "Daten", + "description": "Data settings section title" + }, + "feed_backup_restore": { + "message": "Backup & Wiederherstellung", + "description": "Backup and restore title" + }, + "feed_backup_restore_help": { + "message": "Lade eine JSON-Datei mit Verlauf, Abos, Playlists und Einstellungen herunter oder wiederher.", + "description": "Backup and restore help text" + }, + "feed_backup": { + "message": "Backup", + "description": "Backup button label" + }, + "feed_restore": { + "message": "Wiederherstellen", + "description": "Restore button label" + }, + "feed_import_from_youtube": { + "message": "Von YouTube importieren", + "description": "Import from YouTube title" + }, + "feed_import_from_youtube_help": { + "message": "Nutze watch-history.html f?r deinen Wiedergabeverlauf und subscriptions.csv f?r deine abonnierten Kan?le aus Google Takeout.", + "description": "Import from YouTube help text" + }, + "feed_reset_all_data": { + "message": "Alle Daten zur?cksetzen", + "description": "Reset all data title" + }, + "feed_reset_all_data_help": { + "message": "Alles l?schen, einschlie?lich Abos, Einstellungen und Backups.", + "description": "Reset all data help text" + }, + "feed_reset_all": { + "message": "Alles zur?cksetzen", + "description": "Reset all button label" + }, + "feed_add_to_playlist": { + "message": "Zur Playlist hinzuf?gen", + "description": "Add to playlist dialog title" + }, + "feed_close": { + "message": "Schlie?en", + "description": "Close button aria label" + }, + "feed_create_new_playlist": { + "message": "Neue Playlist erstellen", + "description": "Create new playlist placeholder" + }, + "feed_video_options": { + "message": "Videooptionen", + "description": "feed video options" + }, + "feed_more_from_channel": { + "message": "Mehr von diesem Kanal", + "description": "feed more from channel" + }, + "feed_less_from_channel": { + "message": "Weniger von diesem Kanal", + "description": "feed less from channel" + }, + "feed_hide_this_video": { + "message": "Dieses Video ausblenden", + "description": "feed hide this video" + }, + "feed_unsubscribe": { + "message": "Abo beenden", + "description": "feed unsubscribe" + }, + "feed_not_subscribed": { + "message": "Nicht abonniert", + "description": "feed not subscribed" + }, + "feed_unsubscribed": { + "message": "Abbestellt", + "description": "feed unsubscribed" + }, + "feed_save_to_watch_later": { + "message": "In Sp?ter ansehen speichern", + "description": "feed save to watch later" + }, + "feed_saved_to_watch_later": { + "message": "In Sp?ter ansehen gespeichert", + "description": "feed saved to watch later" + }, + "feed_added_to_playlist": { + "message": "Zur Playlist hinzugef?gt", + "description": "feed added to playlist" + }, + "feed_set_as_playlist_cover": { + "message": "Als Playlist-Cover festlegen", + "description": "feed set as playlist cover" + }, + "feed_cover_updated": { + "message": "Cover aktualisiert", + "description": "feed cover updated" + }, + "feed_remove_from_playlist": { + "message": "Aus dieser Playlist entfernen", + "description": "feed remove from playlist" + }, + "feed_this_channel": { + "message": "diesem Kanal", + "description": "feed this channel" + }, + "feed_channel": { + "message": "Kanal", + "description": "feed channel" + }, + "feed_recommend_more_from_status": { + "message": "Wir empfehlen mehr von $1.", + "description": "feed recommend more from status" + }, + "feed_recommend_more_status": { + "message": "Wir empfehlen mehr", + "description": "feed recommend more status" + }, + "feed_unsubscribed_from_status": { + "message": "Abo von $1 beendet.", + "description": "feed unsubscribed from status" + }, + "feed_refreshing": { + "message": "Aktualisieren...", + "description": "feed refreshing" + }, + "feed_updated": { + "message": "Feed aktualisiert.", + "description": "feed updated" + }, + "feed_no_videos_subscribe_first": { + "message": "Keine Videos gefunden. Abonniere zuerst Kan?le.", + "description": "feed no videos subscribe first" + }, + "feed_refresh_failed_status": { + "message": "Aktualisierung fehlgeschlagen: $1. Wenn YouTube eine Zustimmungsseite zeigt, ?ffne youtube.com einmal in diesem Browser.", + "description": "feed refresh failed status" + } +} diff --git a/src/_locales/de/messages-group.json b/src/_locales/de/messages-group.json index f338c4d..1993448 100644 --- a/src/_locales/de/messages-group.json +++ b/src/_locales/de/messages-group.json @@ -47,10 +47,6 @@ "message": "Sind Sie sicher, dass Sie den gesamten Verlauf löschen möchten? Dies kann nicht rückgängig gemacht werden.", "description": "Confirmation for clearing all history." }, - "message_confirm_full_sync": { - "message": "Vollständige Synchronisierung bereinigt alte Daten und synchronisiert alles neu. Dies kann dauern. Fortfahren?", - "description": "Confirmation for full sync." - }, "message_warning_clear_all": { "message": "WARNUNG: Dadurch wird Ihr gesamter YouTube-Verlauf und alle Playlists gelöscht. Dies kann nicht rückgängig gemacht werden. Fortfahren?", "description": "Warning for clearing all history and playlists." @@ -119,114 +115,6 @@ "message": "Debug-Modus deaktiviert", "description": "Message when debug mode is disabled" }, - "message_sync_disabled": { - "message": "Synchronisierung deaktiviert - Klicken zum Aktivieren", - "description": "Tooltip for disabled sync status" - }, - "message_sync_not_available": { - "message": "Synchronisierung nicht verfügbar - Firefox Sync erforderlich", - "description": "Tooltip for unavailable sync status" - }, - "message_sync_initializing": { - "message": "Synchronisierung wird initialisiert...", - "description": "Tooltip for initializing sync status" - }, - "message_sync_syncing": { - "message": "Daten werden synchronisiert...", - "description": "Tooltip for syncing data status" - }, - "message_sync_ready": { - "message": "Synchronisierung bereit", - "description": "Tooltip for sync ready status" - }, - "message_sync_error": { - "message": "Synchronisierungsfehler - Klicken zum Wiederholen", - "description": "Tooltip for sync error status" - }, - "message_sync_off": { - "message": "Aus", - "description": "Label for sync off status" - }, - "message_sync_na": { - "message": "N/A", - "description": "Label for sync not available status" - }, - "message_sync_init": { - "message": "Init", - "description": "Label for sync initializing status" - }, - "message_sync_on": { - "message": "An", - "description": "Label for sync on status" - }, - "message_sync": { - "message": "Synchronisierung", - "description": "Label for sync status" - }, - "message_syncing": { - "message": "Synchronisieren...", - "description": "Label for syncing status" - }, - "message_synced": { - "message": "Synchronisiert", - "description": "Label for synced status" - }, - "message_sync_now": { - "message": "Jetzt synchronisieren", - "description": "Button label for sync now" - }, - "message_full_sync": { - "message": "Vollständige Synchronisierung", - "description": "Button label for full sync" - }, - "message_syncing_status": { - "message": "Synchronisieren...", - "description": "Status text for syncing" - }, - "message_synced_status": { - "message": "Synchronisiert", - "description": "Status text for synced" - }, - "message_never": { - "message": "Nie", - "description": "Label for never synced" - }, - "message_just_now": { - "message": "Gerade eben", - "description": "Label for just now sync time" - }, - "message_minutes_ago": { - "message": "vor {MINUTES}m", - "description": "Label for minutes ago sync time" - }, - "message_hours_ago": { - "message": "vor {HOURS}h", - "description": "Label for hours ago sync time" - }, - "message_days_ago": { - "message": "vor {DAYS}T", - "description": "Label for days ago sync time" - }, - "message_firefox_sync_not_available": { - "message": "Firefox Sync ist nicht verfügbar. Bitte aktivieren Sie Firefox Sync in Ihren Browsereinstellungen.", - "description": "Error message when Firefox Sync is not available" - }, - "message_failed_to_enable_sync": { - "message": "Synchronisierung konnte nicht aktiviert werden. Bitte überprüfen Sie Ihre Firefox Sync-Einstellungen.", - "description": "Error message when enabling sync fails" - }, - "message_failed_to_disable_sync": { - "message": "Synchronisierung konnte nicht deaktiviert werden.", - "description": "Error message when disabling sync fails" - }, - "message_sync_currently_disabled": { - "message": "Synchronisierung ist derzeit deaktiviert.", - "description": "Error message when sync is currently disabled" - }, - "message_full_sync_initiated": { - "message": "Vollständige Synchronisierung gestartet... Dies kann einen Moment dauern.", - "description": "Message when full sync is initiated" - }, "settings_convert_history": { "message": "YouTube-Verlauf konvertieren", "description": "Button label to open the community converter tool" @@ -239,4 +127,4 @@ "message": "Unbekannter Fehler", "description": "Fallback for unknown error details" } -} \ No newline at end of file +} diff --git a/src/_locales/de/messages.json b/src/_locales/de/messages.json index e1c2c18..5f42a19 100644 --- a/src/_locales/de/messages.json +++ b/src/_locales/de/messages.json @@ -55,14 +55,6 @@ "message": "Schließen", "description": "Button label for closing the popup" }, - "sync_status": { - "message": "Sync", - "description": "Label for sync status indicator" - }, - "sync_status_tooltip": { - "message": "Sync-Status", - "description": "Tooltip for sync status indicator" - }, "instructions_script_disabled_title": { "message": "Skriptausführung deaktiviert", "description": "Title for script execution disabled instructions" @@ -167,24 +159,8 @@ "message": "Playlist wird verfolgt", "description": "Toast when ignore is disabled" }, - "content_toggle_pause": { - "message": "re:Watch — Klicken, um Verlauf zu pausieren", - "description": "Button text when tracking is active" - }, - "content_toggle_paused": { - "message": "re:Watch — Verlauf pausiert. Klicken zum Aktivieren", - "description": "Button text when tracking is paused" - }, - "content_toggle_pause_title": { - "message": "re:Watch — Klicken, um den Verlauf für diese Playlist zu pausieren", - "description": "Tooltip when tracking is active" - }, - "content_toggle_paused_title": { - "message": "re:Watch — Verlauf für diese Playlist pausiert. Klicken zum Aktivieren", - "description": "Tooltip when tracking is paused" - }, "global_search_placeholder": { "message": "Alle Inhalte durchsuchen...", "description": "Placeholder text for the global search input" } -} \ No newline at end of file +} diff --git a/src/_locales/de/settings.json b/src/_locales/de/settings.json index 83e413a..edf4da9 100644 --- a/src/_locales/de/settings.json +++ b/src/_locales/de/settings.json @@ -44,8 +44,8 @@ "description": "Help text for overlay size" }, "settings_data_sync": { - "message": "Daten & Synchronisierung", - "description": "Settings section title for data and sync" + "message": "Data", + "description": "Settings section title for data" }, "settings_pause_in_playlists_label": { "message": "Verlauf in Playlists pausieren", @@ -79,30 +79,6 @@ "message": "Entfernt automatisch Verlauf nach dem ausgewählten Zeitraum", "description": "Help text for auto-clean period" }, - "settings_sync_label": { - "message": "Firefox Sync", - "description": "Label for Firefox Sync option" - }, - "settings_sync_help": { - "message": "Synchronisieren Sie Ihre Daten über Browser-Instanzen hinweg (nur Firefox)", - "description": "Help text for Firefox Sync" - }, - "settings_last_sync_label": { - "message": "Letzte Synchronisierung", - "description": "Label for last sync time" - }, - "settings_sync_now": { - "message": "Jetzt synchronisieren", - "description": "Button label for triggering sync" - }, - "settings_full_sync": { - "message": "Vollständige Synchronisierung", - "description": "Button label for triggering full sync" - }, - "settings_full_sync_help": { - "message": "Vollständige Synchronisierung bereinigt alte Daten und synchronisiert alles neu", - "description": "Help text for full sync" - }, "settings_debug_label": { "message": "Debug-Modus", "description": "Label for debug mode option" @@ -118,5 +94,97 @@ "settings_save": { "message": "Einstellungen speichern", "description": "Button label for saving settings" + }, + "settings_data_label": { + "message": "Data", + "description": "Label for the data section in settings" + }, + "settings_import_history": { + "message": "Import History", + "description": "Button label to import watch history" + }, + "settings_import_history_help": { + "message": "Import History accepts the watch-history.html file from Google Takeout.", + "description": "Help text for the import history button" + }, + "settings_import_subs": { + "message": "Import Subscriptions", + "description": "Button label to import subscriptions" + }, + "settings_import_subs_help": { + "message": "Import Subscriptions accepts the subscriptions.csv file from Google Takeout.", + "description": "Help text for the import subscriptions button" + }, + "settings_clear_help": { + "message": "Clear removes all saved video history (this cannot be undone)", + "description": "Help text for the Clear history button" + }, + "settings_local_feed_label": { + "message": "Local feed", + "description": "Label for the local subscriptions feed toggle" + }, + "settings_local_feed_help": { + "message": "Use locally subscribed channels to build the YT re:Watch feed", + "description": "Help text for the local subscriptions feed toggle" + }, + "settings_hide_recs_label": { + "message": "Hide recommendations & popups", + "description": "Label for the hide recommendations toggle" + }, + "settings_hide_recs_help": { + "message": "Hide YouTube's home recommendations and \"history is off\" nag popups.", + "description": "Help text for the hide recommendations toggle" + }, + "settings_hide_account_label": { + "message": "Hide login & account UI", + "description": "Label for the hide account UI toggle" + }, + "settings_hide_account_help": { + "message": "Hide YouTube's sign-in buttons, account menu, and native Subscribe/Join buttons (your local Subscribe stays).", + "description": "Help text for the hide account UI toggle" + }, + "settings_feed_refresh_label": { + "message": "Feed refresh interval (minutes)", + "description": "Label for the feed refresh interval input" + }, + "settings_feed_refresh_help": { + "message": "How often the local feed checks subscribed channels for new uploads", + "description": "Help text for the feed refresh interval input" + }, + "subscriptions_refresh": { + "message": "Refresh feed", + "description": "Button label to refresh the local subscriptions feed" + }, + "subscriptions_open_feed": { + "message": "Open YT re:Watch", + "description": "Button label to open the dedicated feed page" + }, + "subscriptions_clear_all": { + "message": "Clear all", + "description": "Button label to remove all local subscriptions" + }, + "subscriptions_no_subs": { + "message": "No local subscriptions yet. Search in the extension and use Subscribe.", + "description": "Empty state for the subscriptions tab" + }, + "subscriptions_unsubscribe": { + "message": "Unsubscribe", + "description": "Button label to remove a local subscription" + }, + "subscriptions_refreshing": { + "message": "Refreshing…", + "description": "Refresh button label while a refresh is in progress" + }, + "subscriptions_refresh_started": { + "message": "Refreshing feed in your YouTube tab…", + "description": "Message shown when a feed refresh starts" + }, + "subscriptions_open_youtube": { + "message": "Open a YouTube tab to refresh the feed.", + "description": "Message shown when no YouTube tab is open to refresh the feed" + }, + "message_settings_saved": { + "message": "Settings saved", + "description": "Generic confirmation that settings were saved" } -} \ No newline at end of file +} diff --git a/src/_locales/de/tabs.json b/src/_locales/de/tabs.json index 2d65fbf..f82bfbf 100644 --- a/src/_locales/de/tabs.json +++ b/src/_locales/de/tabs.json @@ -226,5 +226,25 @@ "playlists_unknown_title": { "message": "Unbekannte Playlist", "description": "Fallback title for playlists with no title" + }, + "tab_subscriptions": { + "message": "Subs", + "description": "Tab label for local subscriptions" + }, + "tab_watchlater": { + "message": "Later", + "description": "Tab label for local Watch Later list" + }, + "watchlater_empty": { + "message": "Nothing saved yet. On YouTube, right-click a video and choose \"Save to Watch Later (local)\".", + "description": "Empty state for the Watch Later tab" + }, + "watchlater_clear_all": { + "message": "Clear all", + "description": "Button to remove all Watch Later items" + }, + "watchlater_confirm_clear": { + "message": "Clear all Watch Later items?", + "description": "Confirmation prompt before clearing the Watch Later list" } -} \ No newline at end of file +} diff --git a/src/_locales/en/feed.json b/src/_locales/en/feed.json new file mode 100644 index 0000000..e98e6ee --- /dev/null +++ b/src/_locales/en/feed.json @@ -0,0 +1,506 @@ +{ + "feed_menu": { + "message": "Menu", + "description": "Menu button label" + }, + "feed_back_to_feed": { + "message": "Back to your feed", + "description": "Tooltip for returning to the feed" + }, + "feed_search_youtube": { + "message": "Search YouTube", + "description": "Search field placeholder" + }, + "feed_refresh": { + "message": "Refresh", + "description": "Refresh button label" + }, + "feed_create": { + "message": "Create", + "description": "Create button label" + }, + "feed_save": { + "message": "Save", + "description": "Save button label" + }, + "feed_clear_all": { + "message": "Clear all", + "description": "Clear all button label" + }, + "feed_options": { + "message": "Options", + "description": "Options menu label" + }, + "feed_unwatched_only": { + "message": "Unwatched only", + "description": "Filter label for only unwatched videos" + }, + "feed_hide_members": { + "message": "Hide member videos", + "description": "Filter label for hiding members-only or members-first videos" + }, + "feed_nav_home": { + "message": "Home", + "description": "Home navigation label" + }, + "feed_nav_home_title": { + "message": "Personalized locally from your watch history", + "description": "Home navigation tooltip" + }, + "feed_nav_subscriptions": { + "message": "Subscriptions", + "description": "Subscriptions navigation label" + }, + "feed_nav_subscriptions_title": { + "message": "Latest videos from your subscribed channels", + "description": "Subscriptions navigation tooltip" + }, + "feed_nav_shorts": { + "message": "Shorts", + "description": "Shorts navigation label" + }, + "feed_nav_playlists": { + "message": "Playlists", + "description": "Playlists navigation label" + }, + "feed_nav_playlists_title": { + "message": "View saved playlists", + "description": "Playlists navigation tooltip" + }, + "feed_nav_history": { + "message": "History", + "description": "History navigation label" + }, + "feed_nav_history_title": { + "message": "View your local watch history", + "description": "History navigation tooltip" + }, + "feed_nav_channels": { + "message": "Channels", + "description": "Channels navigation label" + }, + "feed_nav_channels_title": { + "message": "Manage your locally subscribed channels", + "description": "Channels navigation tooltip" + }, + "feed_nav_analytics": { + "message": "Analytics", + "description": "Analytics navigation label" + }, + "feed_nav_analytics_title": { + "message": "View your watch analytics", + "description": "Analytics navigation tooltip" + }, + "feed_nav_settings": { + "message": "Settings", + "description": "Settings navigation label" + }, + "feed_nav_settings_title": { + "message": "Open extension settings", + "description": "Settings navigation tooltip" + }, + "feed_your_feed": { + "message": "Your feed", + "description": "Main feed heading" + }, + "feed_search_options": { + "message": "Search options", + "description": "Search options aria label" + }, + "feed_filters": { + "message": "Filters", + "description": "Filters button label" + }, + "feed_upload_date": { + "message": "Upload date", + "description": "Upload date filter label" + }, + "feed_today": { + "message": "Today", + "description": "Today filter label" + }, + "feed_this_week": { + "message": "This week", + "description": "This week filter label" + }, + "feed_this_month": { + "message": "This month", + "description": "This month filter label" + }, + "feed_this_year": { + "message": "This year", + "description": "This year filter label" + }, + "feed_duration": { + "message": "Duration", + "description": "Duration filter label" + }, + "feed_under_4_minutes": { + "message": "Under 4 minutes", + "description": "Under 4 minutes filter label" + }, + "feed_4_to_20_minutes": { + "message": "4-20 minutes", + "description": "4 to 20 minutes filter label" + }, + "feed_over_20_minutes": { + "message": "Over 20 minutes", + "description": "Over 20 minutes filter label" + }, + "feed_watch_status": { + "message": "Watch status", + "description": "Watch status filter label" + }, + "feed_unwatched": { + "message": "Unwatched", + "description": "Unwatched filter label" + }, + "feed_in_progress": { + "message": "In progress", + "description": "In progress filter label" + }, + "feed_watched": { + "message": "Watched", + "description": "Watched filter label" + }, + "feed_sort_search_results": { + "message": "Sort search results", + "description": "Sort search results aria label" + }, + "feed_sort": { + "message": "Sort", + "description": "Sort filter label" + }, + "feed_newest": { + "message": "Newest", + "description": "Newest sort label" + }, + "feed_oldest": { + "message": "Oldest", + "description": "Oldest sort label" + }, + "feed_search_results_for": { + "message": "Search results for", + "description": "Search results heading prefix" + }, + "feed_back_to_results": { + "message": "Back to results", + "description": "Back to results button label" + }, + "feed_no_channel_videos": { + "message": "No videos found for this channel yet.", + "description": "Empty state for channel videos" + }, + "feed_channels": { + "message": "Channels", + "description": "Channels label" + }, + "feed_no_local_channels": { + "message": "No locally subscribed channels yet. Search in the extension and use Subscribe.", + "description": "Empty state for local channels" + }, + "feed_playlists": { + "message": "Playlists", + "description": "Playlists label" + }, + "feed_no_saved_playlists": { + "message": "No saved playlists yet.", + "description": "Empty state for saved playlists" + }, + "feed_history": { + "message": "History", + "description": "History label" + }, + "feed_no_history_matches": { + "message": "No watch history matches these filters.", + "description": "Empty state for filtered history" + }, + "feed_load_more": { + "message": "Load more", + "description": "Load more button label" + }, + "feed_analytics": { + "message": "Analytics", + "description": "Analytics label" + }, + "feed_analytics_subtitle": { + "message": "your watch stats, kept locally", + "description": "Analytics heading subtitle" + }, + "feed_watch_activity_7_days": { + "message": "Watch activity (last 7 days)", + "description": "Watch activity chart title" + }, + "feed_watch_time_by_hour": { + "message": "Watch time by hour", + "description": "Watch time by hour chart title" + }, + "feed_how_much_finish": { + "message": "How much you finish", + "description": "Completion analytics title" + }, + "feed_top_channels": { + "message": "Top channels", + "description": "Top channels analytics title" + }, + "feed_continue_watching": { + "message": "Continue watching", + "description": "Continue watching analytics title" + }, + "feed_settings": { + "message": "Settings", + "description": "Settings label" + }, + "feed_settings_subtitle": { + "message": "Everything stays local in this browser.", + "description": "Settings subtitle" + }, + "feed_settings_display": { + "message": "Display", + "description": "Display settings section title" + }, + "feed_settings_theme": { + "message": "Theme", + "description": "Theme setting label" + }, + "feed_system_default": { + "message": "System default", + "description": "System default theme label" + }, + "feed_dark": { + "message": "Dark", + "description": "Dark theme label" + }, + "feed_light": { + "message": "Light", + "description": "Light theme label" + }, + "feed_accent_color": { + "message": "Accent color", + "description": "Accent color setting label" + }, + "feed_blue": { + "message": "Blue", + "description": "Blue color label" + }, + "feed_red": { + "message": "Red", + "description": "Red color label" + }, + "feed_green": { + "message": "Green", + "description": "Green color label" + }, + "feed_purple": { + "message": "Purple", + "description": "Purple color label" + }, + "feed_orange": { + "message": "Orange", + "description": "Orange color label" + }, + "feed_default_tab": { + "message": "Default tab", + "description": "Default tab setting label" + }, + "feed_last_used_tab": { + "message": "Last used tab", + "description": "Last used tab option label" + }, + "feed_history_and_feed": { + "message": "History & feed", + "description": "History and feed settings section title" + }, + "feed_check_new_videos": { + "message": "Check for new videos", + "description": "Check for new videos setting label" + }, + "feed_every_15_minutes": { + "message": "Every 15 minutes", + "description": "Every 15 minutes interval label" + }, + "feed_every_30_minutes": { + "message": "Every 30 minutes", + "description": "Every 30 minutes interval label" + }, + "feed_every_hour": { + "message": "Every hour", + "description": "Every hour interval label" + }, + "feed_every_3_hours": { + "message": "Every 3 hours", + "description": "Every 3 hours interval label" + }, + "feed_every_6_hours": { + "message": "Every 6 hours", + "description": "Every 6 hours interval label" + }, + "feed_every_12_hours": { + "message": "Every 12 hours", + "description": "Every 12 hours interval label" + }, + "feed_once_a_day": { + "message": "Once a day", + "description": "Once a day interval label" + }, + "feed_keep_watch_history": { + "message": "Keep watch history", + "description": "Keep watch history setting label" + }, + "feed_30_days": { + "message": "30 days", + "description": "30 days option label" + }, + "feed_90_days": { + "message": "90 days", + "description": "90 days option label" + }, + "feed_180_days": { + "message": "180 days", + "description": "180 days option label" + }, + "feed_forever": { + "message": "Forever", + "description": "Forever option label" + }, + "feed_data": { + "message": "Data", + "description": "Data settings section title" + }, + "feed_backup_restore": { + "message": "Backup & restore", + "description": "Backup and restore title" + }, + "feed_backup_restore_help": { + "message": "Download or load a JSON file with history, subscriptions, playlists, and settings.", + "description": "Backup and restore help text" + }, + "feed_backup": { + "message": "Backup", + "description": "Backup button label" + }, + "feed_restore": { + "message": "Restore", + "description": "Restore button label" + }, + "feed_import_from_youtube": { + "message": "Import from YouTube", + "description": "Import from YouTube title" + }, + "feed_import_from_youtube_help": { + "message": "Use the watch-history.html file for your watch history and the subscriptions.csv file for your subscribed channels from Google Takeout.", + "description": "Import from YouTube help text" + }, + "feed_reset_all_data": { + "message": "Reset all data", + "description": "Reset all data title" + }, + "feed_reset_all_data_help": { + "message": "Delete everything, including subscriptions, settings, and backups.", + "description": "Reset all data help text" + }, + "feed_reset_all": { + "message": "Reset all", + "description": "Reset all button label" + }, + "feed_add_to_playlist": { + "message": "Add to playlist", + "description": "Add to playlist dialog title" + }, + "feed_close": { + "message": "Close", + "description": "Close button aria label" + }, + "feed_create_new_playlist": { + "message": "Create new playlist", + "description": "Create new playlist placeholder" + }, + "feed_video_options": { + "message": "Video options", + "description": "feed video options" + }, + "feed_more_from_channel": { + "message": "More from this channel", + "description": "feed more from channel" + }, + "feed_less_from_channel": { + "message": "Less from this channel", + "description": "feed less from channel" + }, + "feed_hide_this_video": { + "message": "Hide this video", + "description": "feed hide this video" + }, + "feed_unsubscribe": { + "message": "Unsubscribe", + "description": "feed unsubscribe" + }, + "feed_not_subscribed": { + "message": "Not subscribed", + "description": "feed not subscribed" + }, + "feed_unsubscribed": { + "message": "Unsubscribed", + "description": "feed unsubscribed" + }, + "feed_save_to_watch_later": { + "message": "Save to Watch Later", + "description": "feed save to watch later" + }, + "feed_saved_to_watch_later": { + "message": "Saved to Watch Later", + "description": "feed saved to watch later" + }, + "feed_added_to_playlist": { + "message": "Added to playlist", + "description": "feed added to playlist" + }, + "feed_set_as_playlist_cover": { + "message": "Set as playlist cover", + "description": "feed set as playlist cover" + }, + "feed_cover_updated": { + "message": "Cover updated", + "description": "feed cover updated" + }, + "feed_remove_from_playlist": { + "message": "Remove from this playlist", + "description": "feed remove from playlist" + }, + "feed_this_channel": { + "message": "this channel", + "description": "feed this channel" + }, + "feed_channel": { + "message": "channel", + "description": "feed channel" + }, + "feed_recommend_more_from_status": { + "message": "We'll recommend more from $1.", + "description": "feed recommend more from status" + }, + "feed_recommend_more_status": { + "message": "We'll recommend more", + "description": "feed recommend more status" + }, + "feed_unsubscribed_from_status": { + "message": "Unsubscribed from $1.", + "description": "feed unsubscribed from status" + }, + "feed_refreshing": { + "message": "Refreshing...", + "description": "feed refreshing" + }, + "feed_updated": { + "message": "Feed updated.", + "description": "feed updated" + }, + "feed_no_videos_subscribe_first": { + "message": "No videos found. Subscribe to channels first.", + "description": "feed no videos subscribe first" + }, + "feed_refresh_failed_status": { + "message": "Refresh failed: $1. If YouTube shows a consent wall, open youtube.com once in this browser.", + "description": "feed refresh failed status" + } +} diff --git a/src/_locales/en/messages-group.json b/src/_locales/en/messages-group.json index d725633..dc2dfe1 100644 --- a/src/_locales/en/messages-group.json +++ b/src/_locales/en/messages-group.json @@ -1,242 +1,130 @@ { - "message_no_active_tab": { - "message": "No active tab found.", - "description": "Error message when no active tab is found" - }, - "message_video_removed": { - "message": "Video removed from history", - "description": "Success message when a video is removed from history" - }, - "message_error_removing_video": { - "message": "Error removing video: $1", - "description": "Error message when removing a video fails, $1 is the error detail" - }, - "message_history_cleared": { - "message": "History cleared successfully", - "description": "Success message when history is cleared" - }, - "message_error_clearing_history": { - "message": "Error clearing history: $1", - "description": "Error message when clearing history fails, $1 is the error detail" - }, - "message_export_success": { - "message": "History exported successfully: $1 videos, $2 playlists", - "description": "Success message when history is exported, $1 is video count, $2 is playlist count" - }, - "message_error_exporting_history": { - "message": "Error exporting history: $1", - "description": "Error message when exporting history fails, $1 is the error detail" - }, - "message_import_success": { - "message": "History $1 successfully: $2 videos, $3 playlists", - "description": "Success message when history is imported, $1 is mode, $2 is video count, $3 is playlist count" - }, - "message_error_importing_history": { - "message": "Error importing history: $1", - "description": "Error message when importing history fails, $1 is the error detail" - }, - "message_no_data_in_import": { - "message": "No data found in import file", - "description": "Error message when no data is found in import file" - }, - "message_invalid_file_format": { - "message": "Invalid file format: $1", - "description": "Error message for invalid import file format, $1 is the detail" - }, - "message_confirm_clear_history": { - "message": "Are you sure you want to clear all history? This cannot be undone.", - "description": "Confirmation for clearing all history." - }, - "message_confirm_full_sync": { - "message": "Full Sync will clean up old data and re-sync everything. This may take a moment. Continue?", - "description": "Confirmation for full sync." - }, - "message_warning_clear_all": { - "message": "WARNING: This will permanently delete ALL your YouTube viewing history and playlists. This cannot be undone. Continue?", - "description": "Warning for clearing all history and playlists." - }, - "message_all_history_cleared": { - "message": "All video and playlist history has been cleared successfully", - "description": "Success message when all history and playlists are cleared" - }, - "message_export_completed": { - "message": "Export completed successfully", - "description": "Success message when export is completed" - }, - "message_failed_to_initialize": { - "message": "Failed to initialize extension: $1", - "description": "Error message when extension initialization fails, $1 is the error detail" - }, - "message_error_loading_history": { - "message": "Error loading history: $1", - "description": "Error message when loading history fails, $1 is the error detail" - }, - "message_no_playlists_found": { - "message": "No playlists found.", - "description": "Message when no playlists are found" - }, - "message_error_loading_playlists": { - "message": "Error loading playlists: $1", - "description": "Error message when loading playlists fails, $1 is the error detail" - }, - "message_playlist_removed": { - "message": "Playlist removed", - "description": "Success message when a playlist is removed" - }, - "message_error_removing_playlist": { - "message": "Error removing playlist: $1", - "description": "Error message when removing a playlist fails, $1 is the error detail" - }, - "message_auto_clean_updated": { - "message": "Auto-clean period updated", - "description": "Success message when auto-clean period is updated" - }, - "message_pagination_count_updated": { - "message": "Pagination count updated", - "description": "Message when pagination count is updated" - }, - "message_overlay_title_updated": { - "message": "Overlay title updated", - "description": "Message when overlay title is updated" - }, - "message_overlay_color_updated": { - "message": "Overlay color updated", - "description": "Message when overlay color is updated" - }, - "message_overlay_size_updated": { - "message": "Overlay size updated", - "description": "Message when overlay label size is updated" - }, - "message_theme_preference_updated": { - "message": "Theme preference updated", - "description": "Message when theme preference is updated" - }, - "message_debug_mode_enabled": { - "message": "Debug mode enabled", - "description": "Message when debug mode is enabled" - }, - "message_debug_mode_disabled": { - "message": "Debug mode disabled", - "description": "Message when debug mode is disabled" - }, - "message_sync_disabled": { - "message": "Sync disabled - Click to enable", - "description": "Tooltip for disabled sync status" - }, - "message_sync_not_available": { - "message": "Sync not available - Firefox Sync required", - "description": "Tooltip for unavailable sync status" - }, - "message_sync_initializing": { - "message": "Initializing sync...", - "description": "Tooltip for initializing sync status" - }, - "message_sync_syncing": { - "message": "Syncing data...", - "description": "Tooltip for syncing data status" - }, - "message_sync_ready": { - "message": "Sync ready", - "description": "Tooltip for sync ready status" - }, - "message_sync_error": { - "message": "Sync error - Click to retry", - "description": "Tooltip for sync error status" - }, - "message_sync_off": { - "message": "Off", - "description": "Label for sync off status" - }, - "message_sync_na": { - "message": "N/A", - "description": "Label for sync not available status" - }, - "message_sync_init": { - "message": "Init", - "description": "Label for sync initializing status" - }, - "message_sync_on": { - "message": "On", - "description": "Label for sync on status" - }, - "message_sync": { - "message": "Sync", - "description": "Label for sync status" - }, - "message_syncing": { - "message": "Syncing...", - "description": "Label for syncing status" - }, - "message_synced": { - "message": "Synced", - "description": "Label for synced status" - }, - "message_sync_now": { - "message": "Sync Now", - "description": "Button label for sync now" - }, - "message_full_sync": { - "message": "Full Sync", - "description": "Button label for full sync" - }, - "message_syncing_status": { - "message": "Syncing...", - "description": "Status text for syncing" - }, - "message_synced_status": { - "message": "Synced", - "description": "Status text for synced" - }, - "message_never": { - "message": "Never", - "description": "Label for never synced" - }, - "message_just_now": { - "message": "Just now", - "description": "Label for just now sync time" - }, - "message_minutes_ago": { - "message": "{MINUTES}m ago", - "description": "Label for minutes ago sync time" - }, - "message_hours_ago": { - "message": "{HOURS}h ago", - "description": "Label for hours ago sync time" - }, - "message_days_ago": { - "message": "{DAYS}d ago", - "description": "Label for days ago sync time" - }, - "message_firefox_sync_not_available": { - "message": "Firefox Sync is not available. Please enable Firefox Sync in your browser settings.", - "description": "Error message when Firefox Sync is not available" - }, - "message_failed_to_enable_sync": { - "message": "Failed to enable sync. Please check your Firefox Sync settings.", - "description": "Error message when enabling sync fails" - }, - "message_failed_to_disable_sync": { - "message": "Failed to disable sync.", - "description": "Error message when disabling sync fails" - }, - "message_sync_currently_disabled": { - "message": "Sync is currently disabled.", - "description": "Error message when sync is currently disabled" - }, - "message_full_sync_initiated": { - "message": "Full sync initiated... This may take a moment.", - "description": "Message when full sync is initiated" - }, - "settings_convert_history": { - "message": "Convert YouTube history", - "description": "Button label to open the community converter tool" - }, - "settings_convert_history_help": { - "message": "Open the community tool to convert Google Takeout watch-history.json into a YT re:Watch import file.", - "description": "Help text for the community converter tool link" - }, - "message_unknown_error": { - "message": "Unknown error", - "description": "Fallback for unknown error details" - } -} \ No newline at end of file + "message_no_active_tab": { + "message": "No active tab found.", + "description": "Error message when no active tab is found" + }, + "message_video_removed": { + "message": "Video removed from history", + "description": "Success message when a video is removed from history" + }, + "message_error_removing_video": { + "message": "Error removing video: $1", + "description": "Error message when removing a video fails, $1 is the error detail" + }, + "message_history_cleared": { + "message": "History cleared successfully", + "description": "Success message when history is cleared" + }, + "message_error_clearing_history": { + "message": "Error clearing history: $1", + "description": "Error message when clearing history fails, $1 is the error detail" + }, + "message_export_success": { + "message": "History exported successfully: $1 videos, $2 playlists", + "description": "Success message when history is exported, $1 is video count, $2 is playlist count" + }, + "message_error_exporting_history": { + "message": "Error exporting history: $1", + "description": "Error message when exporting history fails, $1 is the error detail" + }, + "message_import_success": { + "message": "History $1 successfully: $2 videos, $3 playlists", + "description": "Success message when history is imported, $1 is mode, $2 is video count, $3 is playlist count" + }, + "message_error_importing_history": { + "message": "Error importing history: $1", + "description": "Error message when importing history fails, $1 is the error detail" + }, + "message_no_data_in_import": { + "message": "No data found in import file", + "description": "Error message when no data is found in import file" + }, + "message_invalid_file_format": { + "message": "Invalid file format: $1", + "description": "Error message for invalid import file format, $1 is the detail" + }, + "message_confirm_clear_history": { + "message": "Are you sure you want to clear all history? This cannot be undone.", + "description": "Confirmation for clearing all history." + }, + "message_warning_clear_all": { + "message": "WARNING: This will permanently delete ALL your YouTube viewing history and playlists. This cannot be undone. Continue?", + "description": "Warning for clearing all history and playlists." + }, + "message_all_history_cleared": { + "message": "All video and playlist history has been cleared successfully", + "description": "Success message when all history and playlists are cleared" + }, + "message_export_completed": { + "message": "Export completed successfully", + "description": "Success message when export is completed" + }, + "message_failed_to_initialize": { + "message": "Failed to initialize extension: $1", + "description": "Error message when extension initialization fails, $1 is the error detail" + }, + "message_error_loading_history": { + "message": "Error loading history: $1", + "description": "Error message when loading history fails, $1 is the error detail" + }, + "message_no_playlists_found": { + "message": "No playlists found.", + "description": "Message when no playlists are found" + }, + "message_error_loading_playlists": { + "message": "Error loading playlists: $1", + "description": "Error message when loading playlists fails, $1 is the error detail" + }, + "message_playlist_removed": { + "message": "Playlist removed", + "description": "Success message when a playlist is removed" + }, + "message_error_removing_playlist": { + "message": "Error removing playlist: $1", + "description": "Error message when removing a playlist fails, $1 is the error detail" + }, + "message_auto_clean_updated": { + "message": "Auto-clean period updated", + "description": "Success message when auto-clean period is updated" + }, + "message_pagination_count_updated": { + "message": "Pagination count updated", + "description": "Message when pagination count is updated" + }, + "message_overlay_title_updated": { + "message": "Overlay title updated", + "description": "Message when overlay title is updated" + }, + "message_overlay_color_updated": { + "message": "Overlay color updated", + "description": "Message when overlay color is updated" + }, + "message_overlay_size_updated": { + "message": "Overlay size updated", + "description": "Message when overlay label size is updated" + }, + "message_theme_preference_updated": { + "message": "Theme preference updated", + "description": "Message when theme preference is updated" + }, + "message_debug_mode_enabled": { + "message": "Debug mode enabled", + "description": "Message when debug mode is enabled" + }, + "message_debug_mode_disabled": { + "message": "Debug mode disabled", + "description": "Message when debug mode is disabled" + }, + "settings_convert_history": { + "message": "Convert YouTube history", + "description": "Button label to open the community converter tool" + }, + "settings_convert_history_help": { + "message": "Open the community tool to convert Google Takeout watch-history.json into a YT re:Watch import file.", + "description": "Help text for the community converter tool link" + }, + "message_unknown_error": { + "message": "Unknown error", + "description": "Fallback for unknown error details" + } +} diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index f19b54b..e801ed4 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -1,191 +1,166 @@ { - "extensionName": { - "message": "YouTube Local History", - "description": "The name of the extension" - }, - "popup_title": { - "message": "YT re:Watch", - "description": "Popup window title and main heading" - }, - "theme_toggle": { - "message": "Theme", - "description": "Button label for toggling theme" - }, - "theme_toggle_tooltip": { - "message": "Toggle theme", - "description": "Tooltip for theme toggle button" - }, - "theme_light": { - "message": "Light", - "description": "Label for light theme option" - }, - "theme_dark": { - "message": "Dark", - "description": "Label for dark theme option" - }, - "theme_auto": { - "message": "Auto", - "description": "Label for auto/system theme option" - }, - "theme_switch_to_dark": { - "message": "Switch to dark theme", - "description": "Tooltip for switching to dark theme" - }, - "theme_switch_to_light": { - "message": "Switch to light theme", - "description": "Tooltip for switching to light theme" - }, - "theme_system": { - "message": "System Default", - "description": "Label for system default theme option" - }, - "theme_currently_following_system": { - "message": "Currently following system theme - Click to override", - "description": "Tooltip for auto theme option" - }, - "clear_label": { - "message": "Clear", - "description": "Button label for clearing all history" - }, - "clear_tooltip": { - "message": "Clear all history", - "description": "Tooltip for clear history button" - }, - "close_label": { - "message": "Close", - "description": "Button label for closing the popup" - }, - "sync_status": { - "message": "Sync", - "description": "Label for sync status indicator" - }, - "sync_status_tooltip": { - "message": "Sync status", - "description": "Tooltip for sync status indicator" - }, - "instructions_script_disabled_title": { - "message": "Script Execution Disabled", - "description": "Title for script execution disabled instructions" - }, - "instructions_script_disabled_body": { - "message": "If you are seeing this message, it means that script execution is disabled for this extension. Please follow these steps to enable it:", - "description": "Body text for script execution disabled instructions" - }, - "instructions_script_disabled_step1": { - "message": "Go to Firefox settings: about:preferences#privacy", - "description": "Step 1 for enabling script execution" - }, - "instructions_script_disabled_step2": { - "message": "Ensure \"Enhanced Tracking Protection\" is set to \"Standard\" or \"Custom\" and does not block scripts", - "description": "Step 2 for enabling script execution" - }, - "instructions_script_disabled_step3": { - "message": "Check for any content blocking: click the shield icon in the address bar when the popup is open and ensure no scripts are blocked", - "description": "Step 3 for enabling script execution" - }, - "delete_label": { - "message": "Delete", - "description": "Button label for deleting an item (video, playlist, short)" - }, - "pagination_page_info": { - "message": "Page $1 of $2", - "description": "Pagination info, e.g. Page 1 of 11" - }, - "chart_skipped": { - "message": "Skipped", - "description": "Chart label: Skipped" - }, - "chart_partial": { - "message": "Partial", - "description": "Chart label: Partial" - }, - "chart_completed": { - "message": "Completed", - "description": "Chart label: Completed" - }, - "chart_skipped_legend": { - "message": "Skipped (<10%)", - "description": "Chart legend: Skipped (<10%)" - }, - "chart_partial_legend": { - "message": "Partial (10-90%)", - "description": "Chart legend: Partial (10-90%)" - }, - "chart_completed_legend": { - "message": "Completed (>=90%)", - "description": "Chart legend: Completed (>=90%)" - }, - "chart_minutes": { - "message": "Minutes", - "description": "Chart axis label: Minutes" - }, - "chart_hour_of_day": { - "message": "Hour of Day", - "description": "Chart axis label: Hour of Day" - }, - "message_pause_in_playlists_enabled": { - "message": "Paused history in playlists enabled", - "description": "Toast when global pause in playlists is enabled" - }, - "message_pause_in_playlists_disabled": { - "message": "Paused history in playlists disabled", - "description": "Toast when global pause in playlists is disabled" - }, - "date_today": { - "message": "Today", - "description": "Label for today's date" - }, - "date_yesterday": { - "message": "Yesterday", - "description": "Label for yesterday's date" - }, - "date_unknown": { - "message": "Unknown", - "description": "Label for unknown date" - }, - "search_no_videos_found": { - "message": "No videos found matching your search.", - "description": "Message when no videos match the search" - }, - "history_no_history_found": { - "message": "No history found. Start watching some videos!", - "description": "Message when no history is found" - } - , - "playlists_ignore_toggle_label": { - "message": "Ignore", - "description": "Label next to playlist ignore checkbox" - }, - "playlists_ignore_toggle_tooltip": { - "message": "Ignore videos in this playlist", - "description": "Tooltip for playlist ignore checkbox" - }, - "playlists_ignore_enabled": { - "message": "Playlist will be ignored", - "description": "Toast when ignore is enabled" - }, - "playlists_ignore_disabled": { - "message": "Playlist will be tracked", - "description": "Toast when ignore is disabled" - }, - "content_toggle_pause": { - "message": "re:Watch — Click to pause history", - "description": "Button text when tracking is active" - }, - "content_toggle_paused": { - "message": "re:Watch — History paused. Click to activate", - "description": "Button text when tracking is paused" - }, - "content_toggle_pause_title": { - "message": "re:Watch — Click to pause history for this playlist", - "description": "Tooltip when tracking is active" - }, - "content_toggle_paused_title": { - "message": "re:Watch — History is paused for this playlist. Click to activate tracking", - "description": "Tooltip when tracking is paused" - }, - "global_search_placeholder": { - "message": "Search all content...", - "description": "Placeholder text for the global search input" - } -} \ No newline at end of file + "extensionName": { + "message": "YT re:Watch", + "description": "The name of the extension" + }, + "popup_title": { + "message": "YT re:Watch", + "description": "Popup window title and main heading" + }, + "theme_toggle": { + "message": "Theme", + "description": "Button label for toggling theme" + }, + "theme_toggle_tooltip": { + "message": "Toggle theme", + "description": "Tooltip for theme toggle button" + }, + "theme_light": { + "message": "Light", + "description": "Label for light theme option" + }, + "theme_dark": { + "message": "Dark", + "description": "Label for dark theme option" + }, + "theme_auto": { + "message": "Auto", + "description": "Label for auto/system theme option" + }, + "theme_switch_to_dark": { + "message": "Switch to dark theme", + "description": "Tooltip for switching to dark theme" + }, + "theme_switch_to_light": { + "message": "Switch to light theme", + "description": "Tooltip for switching to light theme" + }, + "theme_system": { + "message": "System Default", + "description": "Label for system default theme option" + }, + "theme_currently_following_system": { + "message": "Currently following system theme - Click to override", + "description": "Tooltip for auto theme option" + }, + "clear_label": { + "message": "Clear", + "description": "Button label for clearing all history" + }, + "clear_tooltip": { + "message": "Clear all history", + "description": "Tooltip for clear history button" + }, + "close_label": { + "message": "Close", + "description": "Button label for closing the popup" + }, + "instructions_script_disabled_title": { + "message": "Script Execution Disabled", + "description": "Title for script execution disabled instructions" + }, + "instructions_script_disabled_body": { + "message": "If you are seeing this message, it means that script execution is disabled for this extension. Please follow these steps to enable it:", + "description": "Body text for script execution disabled instructions" + }, + "instructions_script_disabled_step1": { + "message": "Go to Firefox settings: about:preferences#privacy", + "description": "Step 1 for enabling script execution" + }, + "instructions_script_disabled_step2": { + "message": "Ensure \"Enhanced Tracking Protection\" is set to \"Standard\" or \"Custom\" and does not block scripts", + "description": "Step 2 for enabling script execution" + }, + "instructions_script_disabled_step3": { + "message": "Check for any content blocking: click the shield icon in the address bar when the popup is open and ensure no scripts are blocked", + "description": "Step 3 for enabling script execution" + }, + "delete_label": { + "message": "Delete", + "description": "Button label for deleting an item (video, playlist, short)" + }, + "pagination_page_info": { + "message": "Page $1 of $2", + "description": "Pagination info, e.g. Page 1 of 11" + }, + "chart_skipped": { + "message": "Skipped", + "description": "Chart label: Skipped" + }, + "chart_partial": { + "message": "Partial", + "description": "Chart label: Partial" + }, + "chart_completed": { + "message": "Completed", + "description": "Chart label: Completed" + }, + "chart_skipped_legend": { + "message": "Skipped (<10%)", + "description": "Chart legend: Skipped (<10%)" + }, + "chart_partial_legend": { + "message": "Partial (10-90%)", + "description": "Chart legend: Partial (10-90%)" + }, + "chart_completed_legend": { + "message": "Completed (>=90%)", + "description": "Chart legend: Completed (>=90%)" + }, + "chart_minutes": { + "message": "Minutes", + "description": "Chart axis label: Minutes" + }, + "chart_hour_of_day": { + "message": "Hour of Day", + "description": "Chart axis label: Hour of Day" + }, + "message_pause_in_playlists_enabled": { + "message": "Paused history in playlists enabled", + "description": "Toast when global pause in playlists is enabled" + }, + "message_pause_in_playlists_disabled": { + "message": "Paused history in playlists disabled", + "description": "Toast when global pause in playlists is disabled" + }, + "date_today": { + "message": "Today", + "description": "Label for today's date" + }, + "date_yesterday": { + "message": "Yesterday", + "description": "Label for yesterday's date" + }, + "date_unknown": { + "message": "Unknown", + "description": "Label for unknown date" + }, + "search_no_videos_found": { + "message": "No videos found matching your search.", + "description": "Message when no videos match the search" + }, + "history_no_history_found": { + "message": "No history found. Start watching some videos!", + "description": "Message when no history is found" + }, + "playlists_ignore_toggle_label": { + "message": "Ignore", + "description": "Label next to playlist ignore checkbox" + }, + "playlists_ignore_toggle_tooltip": { + "message": "Ignore videos in this playlist", + "description": "Tooltip for playlist ignore checkbox" + }, + "playlists_ignore_enabled": { + "message": "Playlist will be ignored", + "description": "Toast when ignore is enabled" + }, + "playlists_ignore_disabled": { + "message": "Playlist will be tracked", + "description": "Toast when ignore is disabled" + }, + "global_search_placeholder": { + "message": "Search all content...", + "description": "Placeholder text for the global search input" + } +} diff --git a/src/_locales/en/settings.json b/src/_locales/en/settings.json index 7d3e6d2..d46fa46 100644 --- a/src/_locales/en/settings.json +++ b/src/_locales/en/settings.json @@ -1,122 +1,190 @@ { - "settings_interface_display": { - "message": "Interface & Display", - "description": "Settings section title for interface and display" - }, - "settings_theme_label": { - "message": "Theme", - "description": "Label for theme selection in settings" - }, - "settings_theme_help": { - "message": "Choose your preferred theme", - "description": "Help text for theme selection" - }, - "settings_pagination_label": { - "message": "Items per Page", - "description": "Label for pagination count in settings" - }, - "settings_pagination_help": { - "message": "Number of items to show per page (1-100)", - "description": "Help text for pagination count" - }, - "settings_overlay_title_label": { - "message": "Overlay Title", - "description": "Label for overlay title in settings" - }, - "settings_overlay_title_help": { - "message": "Text to show in the overlay (max 12 characters)", - "description": "Help text for overlay title" - }, - "settings_overlay_color_label": { - "message": "Overlay Color", - "description": "Label for overlay color in settings" - }, - "settings_overlay_color_help": { - "message": "Color of the progress bar overlay", - "description": "Help text for overlay color" - }, - "settings_overlay_size_label": { - "message": "Overlay Size", - "description": "Label for overlay size in settings" - }, - "settings_overlay_size_help": { - "message": "Size of the overlay label and progress bar", - "description": "Help text for overlay size" - }, - "settings_data_sync": { - "message": "Data & Sync", - "description": "Settings section title for data and sync" - }, - "settings_pause_in_playlists_label": { - "message": "Pause history in playlists", - "description": "Label for pause-in-playlists setting" - }, - "settings_pause_in_playlists_help": { - "message": "When playing from a playlist, don't save video progress", - "description": "Help text for pause-in-playlists setting" - }, - "settings_data_management_label": { - "message": "Data Management", - "description": "Label for data management section" - }, - "settings_export": { - "message": "Export", - "description": "Button label for exporting history" - }, - "settings_import": { - "message": "Import", - "description": "Button label for importing history" - }, - "settings_export_help": { - "message": "Export your history data (Import coming soon)", - "description": "Help text for export/import" - }, - "settings_auto_clean_label": { - "message": "Auto-clean Period", - "description": "Label for auto-clean period in settings" - }, - "settings_auto_clean_help": { - "message": "Automatically remove history older than selected period", - "description": "Help text for auto-clean period" - }, - "settings_sync_label": { - "message": "Firefox Sync", - "description": "Label for Firefox Sync option" - }, - "settings_sync_help": { - "message": "Sync your data across browser instances (Firefox only)", - "description": "Help text for Firefox Sync" - }, - "settings_last_sync_label": { - "message": "Last Sync", - "description": "Label for last sync time" - }, - "settings_sync_now": { - "message": "Sync Now", - "description": "Button label for triggering sync" - }, - "settings_full_sync": { - "message": "Full Sync", - "description": "Button label for triggering full sync" - }, - "settings_full_sync_help": { - "message": "Full Sync cleans old data and re-syncs everything", - "description": "Help text for full sync" - }, - "settings_debug_label": { - "message": "Debug Mode", - "description": "Label for debug mode option" - }, - "settings_debug_help": { - "message": "Enable debug logging (for troubleshooting)", - "description": "Help text for debug mode" - }, - "settings_version_label": { - "message": "Extension Version", - "description": "Label for extension version display" - }, - "settings_save": { - "message": "Save Settings", - "description": "Button label for saving settings" - } -} \ No newline at end of file + "settings_interface_display": { + "message": "Interface & Display", + "description": "Settings section title for interface and display" + }, + "settings_data_label": { + "message": "Data", + "description": "Label for the data section in settings" + }, + "settings_import_history": { + "message": "Import History", + "description": "Button label to import watch history" + }, + "settings_import_history_help": { + "message": "Import History accepts the watch-history.html file from Google Takeout.", + "description": "Help text for the import history button" + }, + "settings_import_subs": { + "message": "Import Subscriptions", + "description": "Button label to import subscriptions" + }, + "settings_import_subs_help": { + "message": "Import Subscriptions accepts the subscriptions.csv file from Google Takeout.", + "description": "Help text for the import subscriptions button" + }, + "settings_theme_label": { + "message": "Theme", + "description": "Label for theme selection in settings" + }, + "settings_theme_help": { + "message": "Choose your preferred theme", + "description": "Help text for theme selection" + }, + "settings_pagination_label": { + "message": "Items per Page", + "description": "Label for pagination count in settings" + }, + "settings_pagination_help": { + "message": "Number of items to show per page (1-100)", + "description": "Help text for pagination count" + }, + "settings_overlay_title_label": { + "message": "Viewed Label", + "description": "Label for overlay title in settings" + }, + "settings_overlay_title_help": { + "message": "Text shown on viewed videos inside YT re:Watch", + "description": "Help text for overlay title" + }, + "settings_overlay_color_label": { + "message": "Viewed Color", + "description": "Label for overlay color in settings" + }, + "settings_overlay_color_help": { + "message": "Color used for viewed labels and progress bars inside YT re:Watch", + "description": "Help text for overlay color" + }, + "settings_overlay_size_label": { + "message": "Viewed Size", + "description": "Label for overlay size in settings" + }, + "settings_overlay_size_help": { + "message": "Size of viewed labels and progress bars inside YT re:Watch", + "description": "Help text for overlay size" + }, + "settings_data_sync": { + "message": "Data", + "description": "Settings section title for data" + }, + "settings_pause_in_playlists_label": { + "message": "Pause history in playlists", + "description": "Label for pause-in-playlists setting" + }, + "settings_pause_in_playlists_help": { + "message": "When playing from a playlist, don't save video progress", + "description": "Help text for pause-in-playlists setting" + }, + "settings_data_management_label": { + "message": "Data Management", + "description": "Label for data management section" + }, + "settings_export": { + "message": "Export", + "description": "Button label for exporting history" + }, + "settings_import": { + "message": "Import", + "description": "Button label for importing history" + }, + "settings_export_help": { + "message": "Export or import your local history, subscriptions, playlists, settings, and caches", + "description": "Help text for export/import" + }, + "settings_clear_help": { + "message": "Clear removes all saved video history (this cannot be undone)", + "description": "Help text for the Clear history button" + }, + "settings_auto_clean_label": { + "message": "Auto-clean Period", + "description": "Label for auto-clean period in settings" + }, + "settings_auto_clean_help": { + "message": "Automatically remove history older than selected period", + "description": "Help text for auto-clean period" + }, + "settings_debug_label": { + "message": "Debug Mode", + "description": "Label for debug mode option" + }, + "settings_debug_help": { + "message": "Enable debug logging (for troubleshooting)", + "description": "Help text for debug mode" + }, + "settings_version_label": { + "message": "Extension Version", + "description": "Label for extension version display" + }, + "settings_save": { + "message": "Save Settings", + "description": "Button label for saving settings" + }, + "settings_local_feed_label": { + "message": "Local feed", + "description": "Label for the local subscriptions feed toggle" + }, + "settings_local_feed_help": { + "message": "Use locally subscribed channels to build the YT re:Watch feed", + "description": "Help text for the local subscriptions feed toggle" + }, + "settings_hide_recs_label": { + "message": "Hide recommendations & popups", + "description": "Label for the hide recommendations toggle" + }, + "settings_hide_recs_help": { + "message": "Hide YouTube's home recommendations and \"history is off\" nag popups.", + "description": "Help text for the hide recommendations toggle" + }, + "settings_hide_account_label": { + "message": "Hide login & account UI", + "description": "Label for the hide account UI toggle" + }, + "settings_hide_account_help": { + "message": "Hide YouTube's sign-in buttons, account menu, and native Subscribe/Join buttons (your local Subscribe stays).", + "description": "Help text for the hide account UI toggle" + }, + "settings_feed_refresh_label": { + "message": "Feed refresh interval (minutes)", + "description": "Label for the feed refresh interval input" + }, + "settings_feed_refresh_help": { + "message": "How often the local feed checks subscribed channels for new uploads", + "description": "Help text for the feed refresh interval input" + }, + "subscriptions_refresh": { + "message": "Refresh feed", + "description": "Button label to refresh the local subscriptions feed" + }, + "subscriptions_open_feed": { + "message": "Open YT re:Watch", + "description": "Button label to open the dedicated feed page" + }, + "subscriptions_clear_all": { + "message": "Clear all", + "description": "Button label to remove all local subscriptions" + }, + "subscriptions_no_subs": { + "message": "No local subscriptions yet. Search in the extension and use Subscribe.", + "description": "Empty state for the subscriptions tab" + }, + "subscriptions_unsubscribe": { + "message": "Unsubscribe", + "description": "Button label to remove a local subscription" + }, + "subscriptions_refreshing": { + "message": "Refreshing…", + "description": "Refresh button label while a refresh is in progress" + }, + "subscriptions_refresh_started": { + "message": "Refreshing feed in your YouTube tab…", + "description": "Message shown when a feed refresh starts" + }, + "subscriptions_open_youtube": { + "message": "Open a YouTube tab to refresh the feed.", + "description": "Message shown when no YouTube tab is open to refresh the feed" + }, + "message_settings_saved": { + "message": "Settings saved", + "description": "Generic confirmation that settings were saved" + } +} diff --git a/src/_locales/en/tabs.json b/src/_locales/en/tabs.json index 91c8962..bf70c9e 100644 --- a/src/_locales/en/tabs.json +++ b/src/_locales/en/tabs.json @@ -11,6 +11,14 @@ "message": "Playlists", "description": "Tab label for playlists" }, + "tab_subscriptions": { + "message": "Subs", + "description": "Tab label for local subscriptions" + }, + "tab_watchlater": { + "message": "Later", + "description": "Tab label for local Watch Later list" + }, "tab_analytics": { "message": "Analytics", "description": "Tab label for analytics" @@ -226,5 +234,17 @@ "playlists_unknown_title": { "message": "Unknown Playlist", "description": "Fallback title for playlists with no title" + }, + "watchlater_empty": { + "message": "Nothing saved yet. On YouTube, right-click a video and choose \"Save to Watch Later (local)\".", + "description": "Empty state for the Watch Later tab" + }, + "watchlater_clear_all": { + "message": "Clear all", + "description": "Button to remove all Watch Later items" + }, + "watchlater_confirm_clear": { + "message": "Clear all Watch Later items?", + "description": "Confirmation prompt before clearing the Watch Later list" } } \ No newline at end of file diff --git a/src/_locales/es/feed.json b/src/_locales/es/feed.json new file mode 100644 index 0000000..588f9e3 --- /dev/null +++ b/src/_locales/es/feed.json @@ -0,0 +1,506 @@ +{ + "feed_menu": { + "message": "Men?", + "description": "Menu button label" + }, + "feed_back_to_feed": { + "message": "Volver a tu feed", + "description": "Tooltip for returning to the feed" + }, + "feed_search_youtube": { + "message": "Buscar en YouTube", + "description": "Search field placeholder" + }, + "feed_refresh": { + "message": "Actualizar", + "description": "Refresh button label" + }, + "feed_create": { + "message": "Crear", + "description": "Create button label" + }, + "feed_save": { + "message": "Guardar", + "description": "Save button label" + }, + "feed_clear_all": { + "message": "Borrar todo", + "description": "Clear all button label" + }, + "feed_options": { + "message": "Opciones", + "description": "Options menu label" + }, + "feed_unwatched_only": { + "message": "Solo no vistos", + "description": "Filter label for only unwatched videos" + }, + "feed_hide_members": { + "message": "Ocultar videos de miembros", + "description": "Filter label for hiding members-only or members-first videos" + }, + "feed_nav_home": { + "message": "Inicio", + "description": "Home navigation label" + }, + "feed_nav_home_title": { + "message": "Personalizado localmente desde tu historial", + "description": "Home navigation tooltip" + }, + "feed_nav_subscriptions": { + "message": "Suscripciones", + "description": "Subscriptions navigation label" + }, + "feed_nav_subscriptions_title": { + "message": "?ltimos videos de tus canales suscritos", + "description": "Subscriptions navigation tooltip" + }, + "feed_nav_shorts": { + "message": "Shorts", + "description": "Shorts navigation label" + }, + "feed_nav_playlists": { + "message": "Listas", + "description": "Playlists navigation label" + }, + "feed_nav_playlists_title": { + "message": "Ver listas guardadas", + "description": "Playlists navigation tooltip" + }, + "feed_nav_history": { + "message": "Historial", + "description": "History navigation label" + }, + "feed_nav_history_title": { + "message": "Ver tu historial local", + "description": "History navigation tooltip" + }, + "feed_nav_channels": { + "message": "Canales", + "description": "Channels navigation label" + }, + "feed_nav_channels_title": { + "message": "Administrar canales suscritos localmente", + "description": "Channels navigation tooltip" + }, + "feed_nav_analytics": { + "message": "Anal?ticas", + "description": "Analytics navigation label" + }, + "feed_nav_analytics_title": { + "message": "Ver tus anal?ticas de reproducci?n", + "description": "Analytics navigation tooltip" + }, + "feed_nav_settings": { + "message": "Ajustes", + "description": "Settings navigation label" + }, + "feed_nav_settings_title": { + "message": "Abrir ajustes de la extensi?n", + "description": "Settings navigation tooltip" + }, + "feed_your_feed": { + "message": "Tu feed", + "description": "Main feed heading" + }, + "feed_search_options": { + "message": "Opciones de b?squeda", + "description": "Search options aria label" + }, + "feed_filters": { + "message": "Filtros", + "description": "Filters button label" + }, + "feed_upload_date": { + "message": "Fecha de subida", + "description": "Upload date filter label" + }, + "feed_today": { + "message": "Hoy", + "description": "Today filter label" + }, + "feed_this_week": { + "message": "Esta semana", + "description": "This week filter label" + }, + "feed_this_month": { + "message": "Este mes", + "description": "This month filter label" + }, + "feed_this_year": { + "message": "Este a?o", + "description": "This year filter label" + }, + "feed_duration": { + "message": "Duraci?n", + "description": "Duration filter label" + }, + "feed_under_4_minutes": { + "message": "Menos de 4 minutos", + "description": "Under 4 minutes filter label" + }, + "feed_4_to_20_minutes": { + "message": "4-20 minutos", + "description": "4 to 20 minutes filter label" + }, + "feed_over_20_minutes": { + "message": "M?s de 20 minutos", + "description": "Over 20 minutes filter label" + }, + "feed_watch_status": { + "message": "Estado de visualizaci?n", + "description": "Watch status filter label" + }, + "feed_unwatched": { + "message": "No visto", + "description": "Unwatched filter label" + }, + "feed_in_progress": { + "message": "En progreso", + "description": "In progress filter label" + }, + "feed_watched": { + "message": "Visto", + "description": "Watched filter label" + }, + "feed_sort_search_results": { + "message": "Ordenar resultados", + "description": "Sort search results aria label" + }, + "feed_sort": { + "message": "Ordenar", + "description": "Sort filter label" + }, + "feed_newest": { + "message": "M?s reciente", + "description": "Newest sort label" + }, + "feed_oldest": { + "message": "M?s antiguo", + "description": "Oldest sort label" + }, + "feed_search_results_for": { + "message": "Resultados para", + "description": "Search results heading prefix" + }, + "feed_back_to_results": { + "message": "Volver a resultados", + "description": "Back to results button label" + }, + "feed_no_channel_videos": { + "message": "A?n no se encontraron videos para este canal.", + "description": "Empty state for channel videos" + }, + "feed_channels": { + "message": "Canales", + "description": "Channels label" + }, + "feed_no_local_channels": { + "message": "A?n no hay canales suscritos localmente. Busca en la extensi?n y usa Suscribirse.", + "description": "Empty state for local channels" + }, + "feed_playlists": { + "message": "Listas", + "description": "Playlists label" + }, + "feed_no_saved_playlists": { + "message": "A?n no hay listas guardadas.", + "description": "Empty state for saved playlists" + }, + "feed_history": { + "message": "Historial", + "description": "History label" + }, + "feed_no_history_matches": { + "message": "Ning?n historial coincide con estos filtros.", + "description": "Empty state for filtered history" + }, + "feed_load_more": { + "message": "Cargar m?s", + "description": "Load more button label" + }, + "feed_analytics": { + "message": "Anal?ticas", + "description": "Analytics label" + }, + "feed_analytics_subtitle": { + "message": "tus estad?sticas, guardadas localmente", + "description": "Analytics heading subtitle" + }, + "feed_watch_activity_7_days": { + "message": "Actividad de reproducci?n (?ltimos 7 d?as)", + "description": "Watch activity chart title" + }, + "feed_watch_time_by_hour": { + "message": "Tiempo visto por hora", + "description": "Watch time by hour chart title" + }, + "feed_how_much_finish": { + "message": "Cu?nto terminas", + "description": "Completion analytics title" + }, + "feed_top_channels": { + "message": "Canales principales", + "description": "Top channels analytics title" + }, + "feed_continue_watching": { + "message": "Continuar viendo", + "description": "Continue watching analytics title" + }, + "feed_settings": { + "message": "Ajustes", + "description": "Settings label" + }, + "feed_settings_subtitle": { + "message": "Todo permanece local en este navegador.", + "description": "Settings subtitle" + }, + "feed_settings_display": { + "message": "Pantalla", + "description": "Display settings section title" + }, + "feed_settings_theme": { + "message": "Tema", + "description": "Theme setting label" + }, + "feed_system_default": { + "message": "Predeterminado del sistema", + "description": "System default theme label" + }, + "feed_dark": { + "message": "Oscuro", + "description": "Dark theme label" + }, + "feed_light": { + "message": "Claro", + "description": "Light theme label" + }, + "feed_accent_color": { + "message": "Color de acento", + "description": "Accent color setting label" + }, + "feed_blue": { + "message": "Azul", + "description": "Blue color label" + }, + "feed_red": { + "message": "Rojo", + "description": "Red color label" + }, + "feed_green": { + "message": "Verde", + "description": "Green color label" + }, + "feed_purple": { + "message": "Morado", + "description": "Purple color label" + }, + "feed_orange": { + "message": "Naranja", + "description": "Orange color label" + }, + "feed_default_tab": { + "message": "Pesta?a predeterminada", + "description": "Default tab setting label" + }, + "feed_last_used_tab": { + "message": "?ltima pesta?a usada", + "description": "Last used tab option label" + }, + "feed_history_and_feed": { + "message": "Historial y feed", + "description": "History and feed settings section title" + }, + "feed_check_new_videos": { + "message": "Buscar videos nuevos", + "description": "Check for new videos setting label" + }, + "feed_every_15_minutes": { + "message": "Cada 15 minutos", + "description": "Every 15 minutes interval label" + }, + "feed_every_30_minutes": { + "message": "Cada 30 minutos", + "description": "Every 30 minutes interval label" + }, + "feed_every_hour": { + "message": "Cada hora", + "description": "Every hour interval label" + }, + "feed_every_3_hours": { + "message": "Cada 3 horas", + "description": "Every 3 hours interval label" + }, + "feed_every_6_hours": { + "message": "Cada 6 horas", + "description": "Every 6 hours interval label" + }, + "feed_every_12_hours": { + "message": "Cada 12 horas", + "description": "Every 12 hours interval label" + }, + "feed_once_a_day": { + "message": "Una vez al d?a", + "description": "Once a day interval label" + }, + "feed_keep_watch_history": { + "message": "Conservar historial", + "description": "Keep watch history setting label" + }, + "feed_30_days": { + "message": "30 d?as", + "description": "30 days option label" + }, + "feed_90_days": { + "message": "90 d?as", + "description": "90 days option label" + }, + "feed_180_days": { + "message": "180 d?as", + "description": "180 days option label" + }, + "feed_forever": { + "message": "Para siempre", + "description": "Forever option label" + }, + "feed_data": { + "message": "Datos", + "description": "Data settings section title" + }, + "feed_backup_restore": { + "message": "Copia y restauraci?n", + "description": "Backup and restore title" + }, + "feed_backup_restore_help": { + "message": "Descarga o carga un archivo JSON con historial, suscripciones, listas y ajustes.", + "description": "Backup and restore help text" + }, + "feed_backup": { + "message": "Copia", + "description": "Backup button label" + }, + "feed_restore": { + "message": "Restaurar", + "description": "Restore button label" + }, + "feed_import_from_youtube": { + "message": "Importar desde YouTube", + "description": "Import from YouTube title" + }, + "feed_import_from_youtube_help": { + "message": "Usa watch-history.html para tu historial y subscriptions.csv para tus canales suscritos desde Google Takeout.", + "description": "Import from YouTube help text" + }, + "feed_reset_all_data": { + "message": "Restablecer todos los datos", + "description": "Reset all data title" + }, + "feed_reset_all_data_help": { + "message": "Eliminar todo, incluidas suscripciones, ajustes y copias.", + "description": "Reset all data help text" + }, + "feed_reset_all": { + "message": "Restablecer todo", + "description": "Reset all button label" + }, + "feed_add_to_playlist": { + "message": "A?adir a lista", + "description": "Add to playlist dialog title" + }, + "feed_close": { + "message": "Cerrar", + "description": "Close button aria label" + }, + "feed_create_new_playlist": { + "message": "Crear nueva lista", + "description": "Create new playlist placeholder" + }, + "feed_video_options": { + "message": "Opciones del video", + "description": "feed video options" + }, + "feed_more_from_channel": { + "message": "M?s de este canal", + "description": "feed more from channel" + }, + "feed_less_from_channel": { + "message": "Menos de este canal", + "description": "feed less from channel" + }, + "feed_hide_this_video": { + "message": "Ocultar este video", + "description": "feed hide this video" + }, + "feed_unsubscribe": { + "message": "Cancelar suscripci?n", + "description": "feed unsubscribe" + }, + "feed_not_subscribed": { + "message": "No suscrito", + "description": "feed not subscribed" + }, + "feed_unsubscribed": { + "message": "Suscripci?n cancelada", + "description": "feed unsubscribed" + }, + "feed_save_to_watch_later": { + "message": "Guardar en Ver m?s tarde", + "description": "feed save to watch later" + }, + "feed_saved_to_watch_later": { + "message": "Guardado en Ver m?s tarde", + "description": "feed saved to watch later" + }, + "feed_added_to_playlist": { + "message": "A?adido a la lista", + "description": "feed added to playlist" + }, + "feed_set_as_playlist_cover": { + "message": "Establecer como portada", + "description": "feed set as playlist cover" + }, + "feed_cover_updated": { + "message": "Portada actualizada", + "description": "feed cover updated" + }, + "feed_remove_from_playlist": { + "message": "Quitar de esta lista", + "description": "feed remove from playlist" + }, + "feed_this_channel": { + "message": "este canal", + "description": "feed this channel" + }, + "feed_channel": { + "message": "canal", + "description": "feed channel" + }, + "feed_recommend_more_from_status": { + "message": "Recomendaremos m?s de $1.", + "description": "feed recommend more from status" + }, + "feed_recommend_more_status": { + "message": "Recomendaremos m?s", + "description": "feed recommend more status" + }, + "feed_unsubscribed_from_status": { + "message": "Suscripci?n cancelada a $1.", + "description": "feed unsubscribed from status" + }, + "feed_refreshing": { + "message": "Actualizando...", + "description": "feed refreshing" + }, + "feed_updated": { + "message": "Feed actualizado.", + "description": "feed updated" + }, + "feed_no_videos_subscribe_first": { + "message": "No se encontraron videos. Suscr?bete primero a canales.", + "description": "feed no videos subscribe first" + }, + "feed_refresh_failed_status": { + "message": "Error al actualizar: $1. Si YouTube muestra una pantalla de consentimiento, abre youtube.com una vez en este navegador.", + "description": "feed refresh failed status" + } +} diff --git a/src/_locales/es/messages-group.json b/src/_locales/es/messages-group.json index 2b255dd..d65b25b 100644 --- a/src/_locales/es/messages-group.json +++ b/src/_locales/es/messages-group.json @@ -47,10 +47,6 @@ "message": "¿Está seguro de que desea borrar todo el historial? Esto no se puede deshacer.", "description": "Confirmation for clearing all history." }, - "message_confirm_full_sync": { - "message": "La sincronización completa limpiará los datos antiguos y volverá a sincronizar todo. Puede tardar. ¿Continuar?", - "description": "Confirmation for full sync." - }, "message_warning_clear_all": { "message": "ADVERTENCIA: Esto eliminará todo su historial y listas de reproducción de YouTube. Esto no se puede deshacer. ¿Continuar?", "description": "Warning for clearing all history and playlists." @@ -119,114 +115,6 @@ "message": "Modo de depuración deshabilitado", "description": "Message when debug mode is disabled" }, - "message_sync_disabled": { - "message": "Sincronización deshabilitada - Haga clic para habilitar", - "description": "Tooltip for disabled sync status" - }, - "message_sync_not_available": { - "message": "Sincronización no disponible - Se requiere Firefox Sync", - "description": "Tooltip for unavailable sync status" - }, - "message_sync_initializing": { - "message": "Inicializando sincronización...", - "description": "Tooltip for initializing sync status" - }, - "message_sync_syncing": { - "message": "Sincronizando datos...", - "description": "Tooltip for syncing data status" - }, - "message_sync_ready": { - "message": "Sincronización lista", - "description": "Tooltip for sync ready status" - }, - "message_sync_error": { - "message": "Error de sincronización - Haga clic para reintentar", - "description": "Tooltip for sync error status" - }, - "message_sync_off": { - "message": "Desactivado", - "description": "Label for sync off status" - }, - "message_sync_na": { - "message": "N/A", - "description": "Label for sync not available status" - }, - "message_sync_init": { - "message": "Init", - "description": "Label for sync initializing status" - }, - "message_sync_on": { - "message": "Activado", - "description": "Label for sync on status" - }, - "message_sync": { - "message": "Sincronización", - "description": "Label for sync status" - }, - "message_syncing": { - "message": "Sincronizando...", - "description": "Label for syncing status" - }, - "message_synced": { - "message": "Sincronizado", - "description": "Label for synced status" - }, - "message_sync_now": { - "message": "Sincronizar ahora", - "description": "Button label for sync now" - }, - "message_full_sync": { - "message": "Sincronización completa", - "description": "Button label for full sync" - }, - "message_syncing_status": { - "message": "Sincronizando...", - "description": "Status text for syncing" - }, - "message_synced_status": { - "message": "Sincronizado", - "description": "Status text for synced" - }, - "message_never": { - "message": "Nunca", - "description": "Label for never synced" - }, - "message_just_now": { - "message": "Justo ahora", - "description": "Label for just now sync time" - }, - "message_minutes_ago": { - "message": "hace {MINUTES}m", - "description": "Label for minutes ago sync time" - }, - "message_hours_ago": { - "message": "hace {HOURS}h", - "description": "Label for hours ago sync time" - }, - "message_days_ago": { - "message": "hace {DAYS}d", - "description": "Label for days ago sync time" - }, - "message_firefox_sync_not_available": { - "message": "Firefox Sync no está disponible. Por favor, active Firefox Sync en la configuración de su navegador.", - "description": "Error message when Firefox Sync is not available" - }, - "message_failed_to_enable_sync": { - "message": "No se pudo habilitar la sincronización. Por favor, revise la configuración de Firefox Sync.", - "description": "Error message when enabling sync fails" - }, - "message_failed_to_disable_sync": { - "message": "No se pudo deshabilitar la sincronización.", - "description": "Error message when disabling sync fails" - }, - "message_sync_currently_disabled": { - "message": "La sincronización está actualmente deshabilitada.", - "description": "Error message when sync is currently disabled" - }, - "message_full_sync_initiated": { - "message": "Sincronización completa iniciada... Esto puede tardar un momento.", - "description": "Message when full sync is initiated" - }, "settings_convert_history": { "message": "Convertir historial de YouTube", "description": "Button label to open the community converter tool" @@ -239,4 +127,4 @@ "message": "Error desconocido", "description": "Fallback for unknown error details" } -} \ No newline at end of file +} diff --git a/src/_locales/es/messages.json b/src/_locales/es/messages.json index 712d9f6..18ee746 100644 --- a/src/_locales/es/messages.json +++ b/src/_locales/es/messages.json @@ -55,14 +55,6 @@ "message": "Cerrar", "description": "Button label for closing the popup" }, - "sync_status": { - "message": "Sincronización", - "description": "Label for sync status indicator" - }, - "sync_status_tooltip": { - "message": "Estado de sincronización", - "description": "Tooltip for sync status indicator" - }, "instructions_script_disabled_title": { "message": "Ejecución de scripts deshabilitada", "description": "Title for script execution disabled instructions" @@ -167,24 +159,8 @@ "message": "La lista será rastreada", "description": "Toast when ignore is disabled" }, - "content_toggle_pause": { - "message": "re:Watch — Clic para pausar el historial", - "description": "Button text when tracking is active" - }, - "content_toggle_paused": { - "message": "re:Watch — Historial en pausa. Clic para activar", - "description": "Button text when tracking is paused" - }, - "content_toggle_pause_title": { - "message": "re:Watch — Clic para pausar el historial para esta lista", - "description": "Tooltip when tracking is active" - }, - "content_toggle_paused_title": { - "message": "re:Watch — El historial está en pausa para esta lista. Clic para activar", - "description": "Tooltip when tracking is paused" - }, "global_search_placeholder": { "message": "Buscar en todo el contenido...", "description": "Placeholder text for the global search input" } -} \ No newline at end of file +} diff --git a/src/_locales/es/settings.json b/src/_locales/es/settings.json index 099d22c..1a3e90e 100644 --- a/src/_locales/es/settings.json +++ b/src/_locales/es/settings.json @@ -44,8 +44,8 @@ "description": "Help text for overlay size" }, "settings_data_sync": { - "message": "Datos y sincronización", - "description": "Settings section title for data and sync" + "message": "Data", + "description": "Settings section title for data" }, "settings_pause_in_playlists_label": { "message": "Pausar historial en listas", @@ -79,30 +79,6 @@ "message": "Elimina automáticamente el historial después del período seleccionado", "description": "Help text for auto-clean period" }, - "settings_sync_label": { - "message": "Firefox Sync", - "description": "Label for Firefox Sync option" - }, - "settings_sync_help": { - "message": "Sincroniza tus datos entre instancias del navegador (solo Firefox)", - "description": "Help text for Firefox Sync" - }, - "settings_last_sync_label": { - "message": "Última sincronización", - "description": "Label for last sync time" - }, - "settings_sync_now": { - "message": "Sincronizar ahora", - "description": "Button label for triggering sync" - }, - "settings_full_sync": { - "message": "Sincronización completa", - "description": "Button label for triggering full sync" - }, - "settings_full_sync_help": { - "message": "La sincronización completa limpia los datos antiguos y vuelve a sincronizar todo", - "description": "Help text for full sync" - }, "settings_debug_label": { "message": "Modo de depuración", "description": "Label for debug mode option" @@ -118,5 +94,97 @@ "settings_save": { "message": "Guardar configuración", "description": "Button label for saving settings" + }, + "settings_data_label": { + "message": "Data", + "description": "Label for the data section in settings" + }, + "settings_import_history": { + "message": "Import History", + "description": "Button label to import watch history" + }, + "settings_import_history_help": { + "message": "Import History accepts the watch-history.html file from Google Takeout.", + "description": "Help text for the import history button" + }, + "settings_import_subs": { + "message": "Import Subscriptions", + "description": "Button label to import subscriptions" + }, + "settings_import_subs_help": { + "message": "Import Subscriptions accepts the subscriptions.csv file from Google Takeout.", + "description": "Help text for the import subscriptions button" + }, + "settings_clear_help": { + "message": "Clear removes all saved video history (this cannot be undone)", + "description": "Help text for the Clear history button" + }, + "settings_local_feed_label": { + "message": "Local feed", + "description": "Label for the local subscriptions feed toggle" + }, + "settings_local_feed_help": { + "message": "Use locally subscribed channels to build the YT re:Watch feed", + "description": "Help text for the local subscriptions feed toggle" + }, + "settings_hide_recs_label": { + "message": "Hide recommendations & popups", + "description": "Label for the hide recommendations toggle" + }, + "settings_hide_recs_help": { + "message": "Hide YouTube's home recommendations and \"history is off\" nag popups.", + "description": "Help text for the hide recommendations toggle" + }, + "settings_hide_account_label": { + "message": "Hide login & account UI", + "description": "Label for the hide account UI toggle" + }, + "settings_hide_account_help": { + "message": "Hide YouTube's sign-in buttons, account menu, and native Subscribe/Join buttons (your local Subscribe stays).", + "description": "Help text for the hide account UI toggle" + }, + "settings_feed_refresh_label": { + "message": "Feed refresh interval (minutes)", + "description": "Label for the feed refresh interval input" + }, + "settings_feed_refresh_help": { + "message": "How often the local feed checks subscribed channels for new uploads", + "description": "Help text for the feed refresh interval input" + }, + "subscriptions_refresh": { + "message": "Refresh feed", + "description": "Button label to refresh the local subscriptions feed" + }, + "subscriptions_open_feed": { + "message": "Open YT re:Watch", + "description": "Button label to open the dedicated feed page" + }, + "subscriptions_clear_all": { + "message": "Clear all", + "description": "Button label to remove all local subscriptions" + }, + "subscriptions_no_subs": { + "message": "No local subscriptions yet. Search in the extension and use Subscribe.", + "description": "Empty state for the subscriptions tab" + }, + "subscriptions_unsubscribe": { + "message": "Unsubscribe", + "description": "Button label to remove a local subscription" + }, + "subscriptions_refreshing": { + "message": "Refreshing…", + "description": "Refresh button label while a refresh is in progress" + }, + "subscriptions_refresh_started": { + "message": "Refreshing feed in your YouTube tab…", + "description": "Message shown when a feed refresh starts" + }, + "subscriptions_open_youtube": { + "message": "Open a YouTube tab to refresh the feed.", + "description": "Message shown when no YouTube tab is open to refresh the feed" + }, + "message_settings_saved": { + "message": "Settings saved", + "description": "Generic confirmation that settings were saved" } -} \ No newline at end of file +} diff --git a/src/_locales/es/tabs.json b/src/_locales/es/tabs.json index 6b3c914..6a606c3 100644 --- a/src/_locales/es/tabs.json +++ b/src/_locales/es/tabs.json @@ -226,5 +226,25 @@ "playlists_unknown_title": { "message": "Lista desconocida", "description": "Fallback title for playlists with no title" + }, + "tab_subscriptions": { + "message": "Subs", + "description": "Tab label for local subscriptions" + }, + "tab_watchlater": { + "message": "Later", + "description": "Tab label for local Watch Later list" + }, + "watchlater_empty": { + "message": "Nothing saved yet. On YouTube, right-click a video and choose \"Save to Watch Later (local)\".", + "description": "Empty state for the Watch Later tab" + }, + "watchlater_clear_all": { + "message": "Clear all", + "description": "Button to remove all Watch Later items" + }, + "watchlater_confirm_clear": { + "message": "Clear all Watch Later items?", + "description": "Confirmation prompt before clearing the Watch Later list" } -} \ No newline at end of file +} diff --git a/src/_locales/fr/feed.json b/src/_locales/fr/feed.json new file mode 100644 index 0000000..145fcba --- /dev/null +++ b/src/_locales/fr/feed.json @@ -0,0 +1,506 @@ +{ + "feed_menu": { + "message": "Menu", + "description": "Menu button label" + }, + "feed_back_to_feed": { + "message": "Retour ? votre fil", + "description": "Tooltip for returning to the feed" + }, + "feed_search_youtube": { + "message": "Rechercher sur YouTube", + "description": "Search field placeholder" + }, + "feed_refresh": { + "message": "Actualiser", + "description": "Refresh button label" + }, + "feed_create": { + "message": "Cr?er", + "description": "Create button label" + }, + "feed_save": { + "message": "Enregistrer", + "description": "Save button label" + }, + "feed_clear_all": { + "message": "Tout effacer", + "description": "Clear all button label" + }, + "feed_options": { + "message": "Options", + "description": "Options menu label" + }, + "feed_unwatched_only": { + "message": "Non vus uniquement", + "description": "Filter label for only unwatched videos" + }, + "feed_hide_members": { + "message": "Masquer les videos membres", + "description": "Filter label for hiding members-only or members-first videos" + }, + "feed_nav_home": { + "message": "Accueil", + "description": "Home navigation label" + }, + "feed_nav_home_title": { + "message": "Personnalis? localement depuis votre historique", + "description": "Home navigation tooltip" + }, + "feed_nav_subscriptions": { + "message": "Abonnements", + "description": "Subscriptions navigation label" + }, + "feed_nav_subscriptions_title": { + "message": "Derni?res vid?os de vos cha?nes abonn?es", + "description": "Subscriptions navigation tooltip" + }, + "feed_nav_shorts": { + "message": "Shorts", + "description": "Shorts navigation label" + }, + "feed_nav_playlists": { + "message": "Playlists", + "description": "Playlists navigation label" + }, + "feed_nav_playlists_title": { + "message": "Voir les playlists enregistr?es", + "description": "Playlists navigation tooltip" + }, + "feed_nav_history": { + "message": "Historique", + "description": "History navigation label" + }, + "feed_nav_history_title": { + "message": "Voir votre historique local", + "description": "History navigation tooltip" + }, + "feed_nav_channels": { + "message": "Cha?nes", + "description": "Channels navigation label" + }, + "feed_nav_channels_title": { + "message": "G?rer vos cha?nes abonn?es localement", + "description": "Channels navigation tooltip" + }, + "feed_nav_analytics": { + "message": "Analyses", + "description": "Analytics navigation label" + }, + "feed_nav_analytics_title": { + "message": "Voir vos analyses de visionnage", + "description": "Analytics navigation tooltip" + }, + "feed_nav_settings": { + "message": "Param?tres", + "description": "Settings navigation label" + }, + "feed_nav_settings_title": { + "message": "Ouvrir les param?tres de l?extension", + "description": "Settings navigation tooltip" + }, + "feed_your_feed": { + "message": "Votre fil", + "description": "Main feed heading" + }, + "feed_search_options": { + "message": "Options de recherche", + "description": "Search options aria label" + }, + "feed_filters": { + "message": "Filtres", + "description": "Filters button label" + }, + "feed_upload_date": { + "message": "Date de mise en ligne", + "description": "Upload date filter label" + }, + "feed_today": { + "message": "Aujourd?hui", + "description": "Today filter label" + }, + "feed_this_week": { + "message": "Cette semaine", + "description": "This week filter label" + }, + "feed_this_month": { + "message": "Ce mois-ci", + "description": "This month filter label" + }, + "feed_this_year": { + "message": "Cette ann?e", + "description": "This year filter label" + }, + "feed_duration": { + "message": "Dur?e", + "description": "Duration filter label" + }, + "feed_under_4_minutes": { + "message": "Moins de 4 minutes", + "description": "Under 4 minutes filter label" + }, + "feed_4_to_20_minutes": { + "message": "4-20 minutes", + "description": "4 to 20 minutes filter label" + }, + "feed_over_20_minutes": { + "message": "Plus de 20 minutes", + "description": "Over 20 minutes filter label" + }, + "feed_watch_status": { + "message": "?tat de visionnage", + "description": "Watch status filter label" + }, + "feed_unwatched": { + "message": "Non vu", + "description": "Unwatched filter label" + }, + "feed_in_progress": { + "message": "En cours", + "description": "In progress filter label" + }, + "feed_watched": { + "message": "Vu", + "description": "Watched filter label" + }, + "feed_sort_search_results": { + "message": "Trier les r?sultats", + "description": "Sort search results aria label" + }, + "feed_sort": { + "message": "Trier", + "description": "Sort filter label" + }, + "feed_newest": { + "message": "Plus r?cent", + "description": "Newest sort label" + }, + "feed_oldest": { + "message": "Plus ancien", + "description": "Oldest sort label" + }, + "feed_search_results_for": { + "message": "R?sultats pour", + "description": "Search results heading prefix" + }, + "feed_back_to_results": { + "message": "Retour aux r?sultats", + "description": "Back to results button label" + }, + "feed_no_channel_videos": { + "message": "Aucune vid?o trouv?e pour cette cha?ne pour le moment.", + "description": "Empty state for channel videos" + }, + "feed_channels": { + "message": "Cha?nes", + "description": "Channels label" + }, + "feed_no_local_channels": { + "message": "Aucune cha?ne abonn?e localement. Recherchez dans l?extension et utilisez S?abonner.", + "description": "Empty state for local channels" + }, + "feed_playlists": { + "message": "Playlists", + "description": "Playlists label" + }, + "feed_no_saved_playlists": { + "message": "Aucune playlist enregistr?e.", + "description": "Empty state for saved playlists" + }, + "feed_history": { + "message": "Historique", + "description": "History label" + }, + "feed_no_history_matches": { + "message": "Aucun historique ne correspond ? ces filtres.", + "description": "Empty state for filtered history" + }, + "feed_load_more": { + "message": "Charger plus", + "description": "Load more button label" + }, + "feed_analytics": { + "message": "Analyses", + "description": "Analytics label" + }, + "feed_analytics_subtitle": { + "message": "vos statistiques, conserv?es localement", + "description": "Analytics heading subtitle" + }, + "feed_watch_activity_7_days": { + "message": "Activit? de visionnage (7 derniers jours)", + "description": "Watch activity chart title" + }, + "feed_watch_time_by_hour": { + "message": "Temps de visionnage par heure", + "description": "Watch time by hour chart title" + }, + "feed_how_much_finish": { + "message": "Ce que vous terminez", + "description": "Completion analytics title" + }, + "feed_top_channels": { + "message": "Meilleures cha?nes", + "description": "Top channels analytics title" + }, + "feed_continue_watching": { + "message": "Continuer ? regarder", + "description": "Continue watching analytics title" + }, + "feed_settings": { + "message": "Param?tres", + "description": "Settings label" + }, + "feed_settings_subtitle": { + "message": "Tout reste local dans ce navigateur.", + "description": "Settings subtitle" + }, + "feed_settings_display": { + "message": "Affichage", + "description": "Display settings section title" + }, + "feed_settings_theme": { + "message": "Th?me", + "description": "Theme setting label" + }, + "feed_system_default": { + "message": "Par d?faut du syst?me", + "description": "System default theme label" + }, + "feed_dark": { + "message": "Sombre", + "description": "Dark theme label" + }, + "feed_light": { + "message": "Clair", + "description": "Light theme label" + }, + "feed_accent_color": { + "message": "Couleur d?accent", + "description": "Accent color setting label" + }, + "feed_blue": { + "message": "Bleu", + "description": "Blue color label" + }, + "feed_red": { + "message": "Rouge", + "description": "Red color label" + }, + "feed_green": { + "message": "Vert", + "description": "Green color label" + }, + "feed_purple": { + "message": "Violet", + "description": "Purple color label" + }, + "feed_orange": { + "message": "Orange", + "description": "Orange color label" + }, + "feed_default_tab": { + "message": "Onglet par d?faut", + "description": "Default tab setting label" + }, + "feed_last_used_tab": { + "message": "Dernier onglet utilis?", + "description": "Last used tab option label" + }, + "feed_history_and_feed": { + "message": "Historique et fil", + "description": "History and feed settings section title" + }, + "feed_check_new_videos": { + "message": "Rechercher de nouvelles vid?os", + "description": "Check for new videos setting label" + }, + "feed_every_15_minutes": { + "message": "Toutes les 15 minutes", + "description": "Every 15 minutes interval label" + }, + "feed_every_30_minutes": { + "message": "Toutes les 30 minutes", + "description": "Every 30 minutes interval label" + }, + "feed_every_hour": { + "message": "Toutes les heures", + "description": "Every hour interval label" + }, + "feed_every_3_hours": { + "message": "Toutes les 3 heures", + "description": "Every 3 hours interval label" + }, + "feed_every_6_hours": { + "message": "Toutes les 6 heures", + "description": "Every 6 hours interval label" + }, + "feed_every_12_hours": { + "message": "Toutes les 12 heures", + "description": "Every 12 hours interval label" + }, + "feed_once_a_day": { + "message": "Une fois par jour", + "description": "Once a day interval label" + }, + "feed_keep_watch_history": { + "message": "Conserver l?historique", + "description": "Keep watch history setting label" + }, + "feed_30_days": { + "message": "30 jours", + "description": "30 days option label" + }, + "feed_90_days": { + "message": "90 jours", + "description": "90 days option label" + }, + "feed_180_days": { + "message": "180 jours", + "description": "180 days option label" + }, + "feed_forever": { + "message": "Toujours", + "description": "Forever option label" + }, + "feed_data": { + "message": "Donn?es", + "description": "Data settings section title" + }, + "feed_backup_restore": { + "message": "Sauvegarde et restauration", + "description": "Backup and restore title" + }, + "feed_backup_restore_help": { + "message": "T?l?chargez ou chargez un fichier JSON avec historique, abonnements, playlists et param?tres.", + "description": "Backup and restore help text" + }, + "feed_backup": { + "message": "Sauvegarde", + "description": "Backup button label" + }, + "feed_restore": { + "message": "Restaurer", + "description": "Restore button label" + }, + "feed_import_from_youtube": { + "message": "Importer depuis YouTube", + "description": "Import from YouTube title" + }, + "feed_import_from_youtube_help": { + "message": "Utilisez watch-history.html pour votre historique et subscriptions.csv pour vos cha?nes abonn?es depuis Google Takeout.", + "description": "Import from YouTube help text" + }, + "feed_reset_all_data": { + "message": "R?initialiser toutes les donn?es", + "description": "Reset all data title" + }, + "feed_reset_all_data_help": { + "message": "Tout supprimer, y compris abonnements, param?tres et sauvegardes.", + "description": "Reset all data help text" + }, + "feed_reset_all": { + "message": "Tout r?initialiser", + "description": "Reset all button label" + }, + "feed_add_to_playlist": { + "message": "Ajouter ? la playlist", + "description": "Add to playlist dialog title" + }, + "feed_close": { + "message": "Fermer", + "description": "Close button aria label" + }, + "feed_create_new_playlist": { + "message": "Cr?er une nouvelle playlist", + "description": "Create new playlist placeholder" + }, + "feed_video_options": { + "message": "Options de la vid?o", + "description": "feed video options" + }, + "feed_more_from_channel": { + "message": "Plus de cette cha?ne", + "description": "feed more from channel" + }, + "feed_less_from_channel": { + "message": "Moins de cette cha?ne", + "description": "feed less from channel" + }, + "feed_hide_this_video": { + "message": "Masquer cette vid?o", + "description": "feed hide this video" + }, + "feed_unsubscribe": { + "message": "Se d?sabonner", + "description": "feed unsubscribe" + }, + "feed_not_subscribed": { + "message": "Non abonn?", + "description": "feed not subscribed" + }, + "feed_unsubscribed": { + "message": "D?sabonn?", + "description": "feed unsubscribed" + }, + "feed_save_to_watch_later": { + "message": "Enregistrer dans ? regarder plus tard", + "description": "feed save to watch later" + }, + "feed_saved_to_watch_later": { + "message": "Enregistr? dans ? regarder plus tard", + "description": "feed saved to watch later" + }, + "feed_added_to_playlist": { + "message": "Ajout? ? la playlist", + "description": "feed added to playlist" + }, + "feed_set_as_playlist_cover": { + "message": "D?finir comme couverture", + "description": "feed set as playlist cover" + }, + "feed_cover_updated": { + "message": "Couverture mise ? jour", + "description": "feed cover updated" + }, + "feed_remove_from_playlist": { + "message": "Retirer de cette playlist", + "description": "feed remove from playlist" + }, + "feed_this_channel": { + "message": "cette cha?ne", + "description": "feed this channel" + }, + "feed_channel": { + "message": "cha?ne", + "description": "feed channel" + }, + "feed_recommend_more_from_status": { + "message": "Nous recommanderons plus de contenus de $1.", + "description": "feed recommend more from status" + }, + "feed_recommend_more_status": { + "message": "Nous recommanderons plus", + "description": "feed recommend more status" + }, + "feed_unsubscribed_from_status": { + "message": "D?sabonn? de $1.", + "description": "feed unsubscribed from status" + }, + "feed_refreshing": { + "message": "Actualisation...", + "description": "feed refreshing" + }, + "feed_updated": { + "message": "Fil mis ? jour.", + "description": "feed updated" + }, + "feed_no_videos_subscribe_first": { + "message": "Aucune vid?o trouv?e. Abonnez-vous d?abord ? des cha?nes.", + "description": "feed no videos subscribe first" + }, + "feed_refresh_failed_status": { + "message": "?chec de l?actualisation : $1. Si YouTube affiche un ?cran de consentement, ouvrez youtube.com une fois dans ce navigateur.", + "description": "feed refresh failed status" + } +} diff --git a/src/_locales/fr/messages-group.json b/src/_locales/fr/messages-group.json index 5c45b50..885fb68 100644 --- a/src/_locales/fr/messages-group.json +++ b/src/_locales/fr/messages-group.json @@ -47,10 +47,6 @@ "message": "Êtes-vous sûr de vouloir effacer tout l'historique ? Cette action est irréversible.", "description": "Confirmation message for clearing all history" }, - "message_confirm_full_sync": { - "message": "La synchronisation complète nettoiera les anciennes données et resynchronisera tout. Cela peut prendre un moment. Continuer ?", - "description": "Confirmation message for full sync" - }, "message_warning_clear_all": { "message": "ATTENTION : Cela supprimera tout votre historique et vos playlists YouTube. Cette action est irréversible. Continuer ?", "description": "Warning message for clearing all history and playlists" @@ -119,114 +115,6 @@ "message": "Mode débogage désactivé", "description": "Message when debug mode is disabled" }, - "message_sync_disabled": { - "message": "Synchronisation désactivée - Cliquez pour activer", - "description": "Tooltip for disabled sync status" - }, - "message_sync_not_available": { - "message": "Synchronisation non disponible - Firefox Sync requis", - "description": "Tooltip for unavailable sync status" - }, - "message_sync_initializing": { - "message": "Initialisation de la synchronisation...", - "description": "Tooltip for initializing sync status" - }, - "message_sync_syncing": { - "message": "Synchronisation des données...", - "description": "Tooltip for syncing data status" - }, - "message_sync_ready": { - "message": "Synchronisation prête", - "description": "Tooltip for sync ready status" - }, - "message_sync_error": { - "message": "Erreur de synchronisation - Cliquez pour réessayer", - "description": "Tooltip for sync error status" - }, - "message_sync_off": { - "message": "Désactivé", - "description": "Label for sync off status" - }, - "message_sync_na": { - "message": "N/A", - "description": "Label for sync not available status" - }, - "message_sync_init": { - "message": "Init", - "description": "Label for sync initializing status" - }, - "message_sync_on": { - "message": "Activé", - "description": "Label for sync on status" - }, - "message_sync": { - "message": "Synchronisation", - "description": "Label for sync status" - }, - "message_syncing": { - "message": "Synchronisation...", - "description": "Label for syncing status" - }, - "message_synced": { - "message": "Synchronisé", - "description": "Label for synced status" - }, - "message_sync_now": { - "message": "Synchroniser maintenant", - "description": "Button label for sync now" - }, - "message_full_sync": { - "message": "Synchronisation complète", - "description": "Button label for full sync" - }, - "message_syncing_status": { - "message": "Synchronisation...", - "description": "Status text for syncing" - }, - "message_synced_status": { - "message": "Synchronisé", - "description": "Status text for synced" - }, - "message_never": { - "message": "Jamais", - "description": "Label for never synced" - }, - "message_just_now": { - "message": "À l'instant", - "description": "Label for just now sync time" - }, - "message_minutes_ago": { - "message": "il y a {MINUTES}m", - "description": "Label for minutes ago sync time" - }, - "message_hours_ago": { - "message": "il y a {HOURS}h", - "description": "Label for hours ago sync time" - }, - "message_days_ago": { - "message": "il y a {DAYS}j", - "description": "Label for days ago sync time" - }, - "message_firefox_sync_not_available": { - "message": "Firefox Sync n'est pas disponible. Veuillez activer Firefox Sync dans les paramètres de votre navigateur.", - "description": "Error message when Firefox Sync is not available" - }, - "message_failed_to_enable_sync": { - "message": "Échec de l'activation de la synchronisation. Veuillez vérifier vos paramètres Firefox Sync.", - "description": "Error message when enabling sync fails" - }, - "message_failed_to_disable_sync": { - "message": "Échec de la désactivation de la synchronisation.", - "description": "Error message when disabling sync fails" - }, - "message_sync_currently_disabled": { - "message": "La synchronisation est actuellement désactivée.", - "description": "Error message when sync is currently disabled" - }, - "message_full_sync_initiated": { - "message": "Synchronisation complète lancée... Cela peut prendre un moment.", - "description": "Message when full sync is initiated" - }, "settings_convert_history": { "message": "Convertir l’historique YouTube", "description": "Button label to open the community converter tool" @@ -239,4 +127,4 @@ "message": "Erreur inconnue", "description": "Fallback for unknown error details" } -} \ No newline at end of file +} diff --git a/src/_locales/fr/messages.json b/src/_locales/fr/messages.json index 244da6d..86fb270 100644 --- a/src/_locales/fr/messages.json +++ b/src/_locales/fr/messages.json @@ -55,14 +55,6 @@ "message": "Fermer", "description": "Button label for closing the popup" }, - "sync_status": { - "message": "Sync", - "description": "Label for sync status indicator" - }, - "sync_status_tooltip": { - "message": "État de la synchronisation", - "description": "Tooltip for sync status indicator" - }, "instructions_script_disabled_title": { "message": "Exécution des scripts désactivée", "description": "Title for script execution disabled instructions" @@ -167,24 +159,8 @@ "message": "La playlist sera suivie", "description": "Toast when ignore is disabled" }, - "content_toggle_pause": { - "message": "re:Watch — Cliquez pour mettre l'historique en pause", - "description": "Button text when tracking is active" - }, - "content_toggle_paused": { - "message": "re:Watch — Historique en pause. Cliquez pour activer", - "description": "Button text when tracking is paused" - }, - "content_toggle_pause_title": { - "message": "re:Watch — Cliquez pour mettre l'historique en pause pour cette playlist", - "description": "Tooltip when tracking is active" - }, - "content_toggle_paused_title": { - "message": "re:Watch — L'historique est en pause pour cette playlist. Cliquez pour activer", - "description": "Tooltip when tracking is paused" - }, "global_search_placeholder": { "message": "Rechercher dans tout le contenu...", "description": "Placeholder text for the global search input" } -} \ No newline at end of file +} diff --git a/src/_locales/fr/settings.json b/src/_locales/fr/settings.json index b9e7e13..05b47cb 100644 --- a/src/_locales/fr/settings.json +++ b/src/_locales/fr/settings.json @@ -44,8 +44,8 @@ "description": "Help text for overlay size" }, "settings_data_sync": { - "message": "Données & Synchronisation", - "description": "Settings section title for data and sync" + "message": "Data", + "description": "Settings section title for data" }, "settings_pause_in_playlists_label": { "message": "Mettre l'historique en pause dans les playlists", @@ -79,30 +79,6 @@ "message": "Supprimer automatiquement l'historique après la période sélectionnée", "description": "Help text for auto-clean period" }, - "settings_sync_label": { - "message": "Firefox Sync", - "description": "Label for Firefox Sync option" - }, - "settings_sync_help": { - "message": "Synchronisez vos données entre les instances du navigateur (Firefox uniquement)", - "description": "Help text for Firefox Sync" - }, - "settings_last_sync_label": { - "message": "Dernière synchronisation", - "description": "Label for last sync time" - }, - "settings_sync_now": { - "message": "Synchroniser maintenant", - "description": "Button label for triggering sync" - }, - "settings_full_sync": { - "message": "Synchronisation complète", - "description": "Button label for triggering full sync" - }, - "settings_full_sync_help": { - "message": "La synchronisation complète nettoie les anciennes données et resynchronise tout", - "description": "Help text for full sync" - }, "settings_debug_label": { "message": "Mode débogage", "description": "Label for debug mode option" @@ -118,5 +94,97 @@ "settings_save": { "message": "Enregistrer les paramètres", "description": "Button label for saving settings" + }, + "settings_data_label": { + "message": "Data", + "description": "Label for the data section in settings" + }, + "settings_import_history": { + "message": "Import History", + "description": "Button label to import watch history" + }, + "settings_import_history_help": { + "message": "Import History accepts the watch-history.html file from Google Takeout.", + "description": "Help text for the import history button" + }, + "settings_import_subs": { + "message": "Import Subscriptions", + "description": "Button label to import subscriptions" + }, + "settings_import_subs_help": { + "message": "Import Subscriptions accepts the subscriptions.csv file from Google Takeout.", + "description": "Help text for the import subscriptions button" + }, + "settings_clear_help": { + "message": "Clear removes all saved video history (this cannot be undone)", + "description": "Help text for the Clear history button" + }, + "settings_local_feed_label": { + "message": "Local feed", + "description": "Label for the local subscriptions feed toggle" + }, + "settings_local_feed_help": { + "message": "Use locally subscribed channels to build the YT re:Watch feed", + "description": "Help text for the local subscriptions feed toggle" + }, + "settings_hide_recs_label": { + "message": "Hide recommendations & popups", + "description": "Label for the hide recommendations toggle" + }, + "settings_hide_recs_help": { + "message": "Hide YouTube's home recommendations and \"history is off\" nag popups.", + "description": "Help text for the hide recommendations toggle" + }, + "settings_hide_account_label": { + "message": "Hide login & account UI", + "description": "Label for the hide account UI toggle" + }, + "settings_hide_account_help": { + "message": "Hide YouTube's sign-in buttons, account menu, and native Subscribe/Join buttons (your local Subscribe stays).", + "description": "Help text for the hide account UI toggle" + }, + "settings_feed_refresh_label": { + "message": "Feed refresh interval (minutes)", + "description": "Label for the feed refresh interval input" + }, + "settings_feed_refresh_help": { + "message": "How often the local feed checks subscribed channels for new uploads", + "description": "Help text for the feed refresh interval input" + }, + "subscriptions_refresh": { + "message": "Refresh feed", + "description": "Button label to refresh the local subscriptions feed" + }, + "subscriptions_open_feed": { + "message": "Open YT re:Watch", + "description": "Button label to open the dedicated feed page" + }, + "subscriptions_clear_all": { + "message": "Clear all", + "description": "Button label to remove all local subscriptions" + }, + "subscriptions_no_subs": { + "message": "No local subscriptions yet. Search in the extension and use Subscribe.", + "description": "Empty state for the subscriptions tab" + }, + "subscriptions_unsubscribe": { + "message": "Unsubscribe", + "description": "Button label to remove a local subscription" + }, + "subscriptions_refreshing": { + "message": "Refreshing…", + "description": "Refresh button label while a refresh is in progress" + }, + "subscriptions_refresh_started": { + "message": "Refreshing feed in your YouTube tab…", + "description": "Message shown when a feed refresh starts" + }, + "subscriptions_open_youtube": { + "message": "Open a YouTube tab to refresh the feed.", + "description": "Message shown when no YouTube tab is open to refresh the feed" + }, + "message_settings_saved": { + "message": "Settings saved", + "description": "Generic confirmation that settings were saved" } -} \ No newline at end of file +} diff --git a/src/_locales/fr/tabs.json b/src/_locales/fr/tabs.json index e4440ee..b4cab8f 100644 --- a/src/_locales/fr/tabs.json +++ b/src/_locales/fr/tabs.json @@ -226,5 +226,25 @@ "playlists_unknown_title": { "message": "Playlist inconnue", "description": "Fallback title for playlists with no title" + }, + "tab_subscriptions": { + "message": "Subs", + "description": "Tab label for local subscriptions" + }, + "tab_watchlater": { + "message": "Later", + "description": "Tab label for local Watch Later list" + }, + "watchlater_empty": { + "message": "Nothing saved yet. On YouTube, right-click a video and choose \"Save to Watch Later (local)\".", + "description": "Empty state for the Watch Later tab" + }, + "watchlater_clear_all": { + "message": "Clear all", + "description": "Button to remove all Watch Later items" + }, + "watchlater_confirm_clear": { + "message": "Clear all Watch Later items?", + "description": "Confirmation prompt before clearing the Watch Later list" } -} \ No newline at end of file +} diff --git a/src/background.js b/src/background.js index 2f98f63..053e5cd 100644 --- a/src/background.js +++ b/src/background.js @@ -1,10 +1,9 @@ console.log('YouTube Video History Tracker background script running.'); -// Import required scripts for Chrome service worker +// Load shared storage modules when this file runs as a Chrome MV3 service worker. if (typeof importScripts === 'function') { try { - importScripts('indexeddb-storage.js', 'storage.js'); // sync-service.js removed - redundant with hybrid storage - // Verify critical globals are available + importScripts('indexeddb-storage.js', 'storage.js'); if (typeof ytIndexedDBStorage === 'undefined') { console.error('[Background] ytIndexedDBStorage not available after import'); } @@ -39,6 +38,180 @@ const stateManager = { } }; +function sendContinuationThroughTab(tabId, token, config) { + return new Promise((resolve) => { + chrome.tabs.sendMessage( + tabId, + { type: 'fetchYouTubeSearchContinuationInTab', token, config }, + (response) => { + if (chrome.runtime.lastError) { + resolve({ error: chrome.runtime.lastError.message }); + return; + } + resolve(response || {}); + } + ); + }); +} + +function sendSearchPageThroughTab(tabId, query) { + return new Promise((resolve) => { + chrome.tabs.sendMessage( + tabId, + { type: 'fetchYouTubeSearchPageInTab', query }, + (response) => { + if (chrome.runtime.lastError) { + resolve({ error: chrome.runtime.lastError.message }); + return; + } + resolve(response || {}); + } + ); + }); +} + +async function createTemporaryYouTubeContext(url = 'https://www.youtube.com/') { + if (chrome.windows && typeof chrome.windows.create === 'function') { + try { + const win = await chrome.windows.create({ + url, + focused: false, + type: 'popup', + width: 120, + height: 120, + left: -32000, + top: -32000 + }); + let tab = win && win.tabs && win.tabs[0]; + if (!tab && win && win.id != null) { + const tabs = await chrome.tabs.query({ windowId: win.id }); + tab = tabs && tabs[0]; + } + if (tab && tab.id != null) { + return { tab, windowId: win.id || null }; + } + if (win && win.id != null) { + try { await chrome.windows.remove(win.id); } catch (_) {} + } + } catch (_) { + // Some browsers do not allow offscreen extension-created windows. + } + } + + const tab = await chrome.tabs.create({ + url, + active: false + }); + return { tab, windowId: null }; +} + +async function waitForTabReady(tabId, maxWaitMs = 7000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < maxWaitMs) { + try { + const tab = await chrome.tabs.get(tabId); + if (tab && tab.status === 'complete') return; + } catch (_) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } +} + +async function fetchSearchPageThroughYouTubeTab(query) { + const searchUrl = 'https://www.youtube.com/results?search_query=' + encodeURIComponent(query); + try { + const response = await fetch(searchUrl, { credentials: 'include' }); + if (!response.ok) throw new Error(`YouTube returned ${response.status}`); + return { html: await response.text() }; + } catch (error) { + // Fall through to a real youtube.com tab. Firefox can reject direct + // extension-page requests while allowing same-origin tab requests. + } + + const tabs = await chrome.tabs.query({ url: ['*://www.youtube.com/*'] }); + let temporaryTab = null; + let temporaryWindowId = null; + let lastError = 'Could not contact a YouTube tab'; + + async function tryTab(tab, attempts = 16) { + if (!tab || tab.id == null) return null; + for (let attempt = 0; attempt < attempts; attempt++) { + const response = await sendSearchPageThroughTab(tab.id, query); + if (response.html) return response; + lastError = response.error || lastError; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return null; + } + + try { + for (const tab of tabs) { + const response = await tryTab(tab, 8); + if (response) return response; + } + + const temporary = await createTemporaryYouTubeContext(searchUrl); + temporaryTab = temporary.tab; + temporaryWindowId = temporary.windowId; + if (temporaryTab && temporaryTab.id != null) { + await waitForTabReady(temporaryTab.id); + } + const temporaryResponse = await tryTab(temporaryTab, 28); + if (temporaryResponse) return temporaryResponse; + return { error: lastError }; + } finally { + if (temporaryWindowId != null && chrome.windows && typeof chrome.windows.remove === 'function') { + try { await chrome.windows.remove(temporaryWindowId); } catch (_) {} + } else if (temporaryTab && temporaryTab.id != null) { + try { await chrome.tabs.remove(temporaryTab.id); } catch (_) {} + } + } +} + +async function fetchContinuationThroughYouTubeTab(token, config) { + const tabs = await chrome.tabs.query({ url: ['*://www.youtube.com/*'] }); + let temporaryTab = null; + let temporaryWindowId = null; + let lastError = 'Could not contact a YouTube tab'; + + async function tryTab(tab, attempts = 16) { + if (!tab || tab.id == null) return null; + for (let attempt = 0; attempt < attempts; attempt++) { + const response = await sendContinuationThroughTab(tab.id, token, config); + if (response.data) return response; + lastError = response.error || lastError; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return null; + } + + try { + for (const tab of tabs) { + const response = await tryTab(tab, 8); + if (response) return response; + } + + const temporary = await createTemporaryYouTubeContext(); + temporaryTab = temporary.tab; + temporaryWindowId = temporary.windowId; + if (temporaryTab && temporaryTab.id != null) { + await waitForTabReady(temporaryTab.id); + } + const temporaryResponse = await tryTab(temporaryTab, 28); + if (temporaryResponse) { + return temporaryResponse; + } + return { error: lastError }; + } finally { + if (temporaryWindowId != null && chrome.windows && typeof chrome.windows.remove === 'function') { + try { await chrome.windows.remove(temporaryWindowId); } catch (_) {} + } else if (temporaryTab && temporaryTab.id != null) { + try { await chrome.tabs.remove(temporaryTab.id); } catch (_) {} + } + } +} + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { (async () => { @@ -65,7 +238,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); await stateManager.set({ activePopupWindowId: newWindow.id }); } - + if (message.type === 'videoUpdate') { await stateManager.set({ lastVideoUpdate: message.data }); @@ -83,6 +256,111 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return; // Return early because we're using sendResponse } + if (message.type === 'getPlaylistMetadata') { + const playlistId = String(message.playlistId || '').trim(); + if (!playlistId) { + sendResponse({ error: 'Missing playlist ID' }); + return; + } + try { + const playlistUrl = `https://www.youtube.com/playlist?list=${encodeURIComponent(playlistId)}`; + const oembedUrl = 'https://www.youtube.com/oembed?format=json&url=' + encodeURIComponent(playlistUrl); + const response = await fetch(oembedUrl, { credentials: 'omit' }); + if (!response.ok) throw new Error(`YouTube returned ${response.status}`); + const data = await response.json(); + sendResponse({ + title: data && data.title ? data.title : '', + thumbnail: data && data.thumbnail_url ? data.thumbnail_url : '' + }); + } catch (error) { + sendResponse({ error: error && error.message ? error.message : String(error) }); + } + return; + } + + if (message.type === 'fetchYouTubeSearchPage') { + const query = String(message.query || '').trim(); + if (!query) { + sendResponse({ error: 'Missing YouTube search query' }); + return; + } + try { + sendResponse(await fetchSearchPageThroughYouTubeTab(query)); + } catch (error) { + sendResponse({ error: error && error.message ? error.message : String(error) }); + } + return; + } + + if (message.type === 'fetchYouTubeSearchContinuation') { + const token = String(message.token || ''); + const config = message.config || {}; + if (!token || !config.clientVersion) { + sendResponse({ error: 'Missing YouTube search continuation data' }); + return; + } + try { + const tabResponse = await fetchContinuationThroughYouTubeTab(token, config); + if (tabResponse.data) { + sendResponse(tabResponse); + return; + } + + const key = config.apiKey ? `?key=${encodeURIComponent(config.apiKey)}` : ''; + const headers = { + 'Content-Type': 'application/json', + 'X-YouTube-Client-Name': '1', + 'X-YouTube-Client-Version': String(config.clientVersion) + }; + if (config.visitorData) headers['X-Goog-Visitor-Id'] = String(config.visitorData); + const context = { + client: { + clientName: 'WEB', + clientVersion: String(config.clientVersion), + visitorData: String(config.visitorData || ''), + hl: 'en', + gl: 'US' + } + }; + if (config.clickTrackingParams) { + context.clickTracking = { clickTrackingParams: String(config.clickTrackingParams) }; + } + const body = JSON.stringify({ + context, + continuation: token + }); + const endpoints = [ + `https://www.youtube.com/youtubei/v1/search${key}`, + `https://youtubei.googleapis.com/youtubei/v1/search${key}` + ]; + let lastError = null; + for (const endpoint of endpoints) { + try { + const response = await fetch(endpoint, { + method: 'POST', + credentials: endpoint.includes('www.youtube.com') ? 'include' : 'omit', + headers, + body + }); + if (!response.ok) { + lastError = new Error(`YouTube returned ${response.status}`); + continue; + } + const data = await response.json(); + sendResponse({ data }); + return; + } catch (error) { + lastError = error; + } + } + throw lastError || new Error('YouTube search paging failed'); + } catch (error) { + sendResponse({ error: error && error.message ? error.message : String(error) }); + } + return; + } + + // Handle content script storage RPC calls (ytStorageCall) if (message.type === 'ytStorageCall') { if (typeof ytStorage === 'undefined') { @@ -150,3 +428,139 @@ chrome.windows.onRemoved.addListener(async (windowId) => { } }); +// ============================================================ +// Watch Later — right-click menu (no button injected into YouTube) +// ============================================================ +const WATCH_LATER_MENU_ID = 'ytvht-add-watchlater'; + +function createContextMenus() { + if (!chrome.contextMenus) return; + // removeAll first so reloading the extension doesn't throw "duplicate id". + chrome.contextMenus.removeAll(() => { + chrome.contextMenus.create({ + id: WATCH_LATER_MENU_ID, + title: 'Save to Watch Later (local)', + contexts: ['link', 'page', 'video'], + documentUrlPatterns: ['*://*.youtube.com/*'], + targetUrlPatterns: ['*://*.youtube.com/*', '*://youtu.be/*'] + }); + }); +} + +// MV3 service workers are torn down and restarted; re-create the menu on both +// install/update and browser startup so it's always present. +chrome.runtime.onInstalled.addListener(createContextMenus); +if (chrome.runtime.onStartup) chrome.runtime.onStartup.addListener(createContextMenus); + +// Extract a YouTube video id (and shorts flag) from any link/page URL. +function parseYouTubeVideo(rawUrl) { + if (!rawUrl) return null; + try { + const u = new URL(rawUrl); + let videoId = u.searchParams.get('v'); + let isShorts = false; + if (!videoId) { + const m = u.pathname.match(/\/shorts\/([\w-]{6,})/); + if (m) { videoId = m[1]; isShorts = true; } + } + if (!videoId && /(^|\.)youtu\.be$/.test(u.hostname)) { + const seg = u.pathname.split('/').filter(Boolean)[0]; + if (seg) videoId = seg; + } + if (!videoId) return null; + videoId = videoId.slice(0, 20); + const url = isShorts + ? `https://www.youtube.com/shorts/${videoId}` + : `https://www.youtube.com/watch?v=${videoId}`; + return { videoId, url, isShorts }; + } catch (_) { + return null; + } +} + +// Injected into the page to read a video's title/channel from the DOM. Must be +// fully self-contained (it runs in the tab, not here). +function ytvhtExtractVideoMeta(videoId) { + const txt = (el) => (el && (el.textContent || '').trim()) || ''; + const onWatch = new URLSearchParams(location.search).get('v') === videoId; + const onShorts = location.pathname.indexOf('/shorts/' + videoId) === 0; + if (onWatch || onShorts) { + const h1 = document.querySelector('h1.ytd-watch-metadata yt-formatted-string, h1.ytd-watch-metadata, yt-shorts-video-title-view-model h2 span'); + const title = txt(h1) || document.title.replace(/ - YouTube( Shorts)?$/, '').trim(); + const ch = document.querySelector('ytd-video-owner-renderer #channel-name a, #owner #channel-name a, ytd-channel-name a, #owner-name a'); + return { title, channelName: txt(ch) }; + } + const a = document.querySelector( + 'a#thumbnail[href*="' + videoId + '"], a[href*="watch?v=' + videoId + '"], a[href*="/shorts/' + videoId + '"]' + ); + if (a) { + const box = a.closest('ytd-rich-item-renderer, ytd-video-renderer, ytd-grid-video-renderer, ytd-compact-video-renderer, ytd-playlist-video-renderer, ytd-playlist-panel-video-renderer, yt-lockup-view-model') || document; + const t = box.querySelector('#video-title, a#video-title-link, yt-formatted-string#video-title, .yt-lockup-metadata-view-model-wiz__title'); + let title = txt(t) || a.getAttribute('title') || a.getAttribute('aria-label') || ''; + const ch = box.querySelector('ytd-channel-name a, #channel-name a, .yt-content-metadata-view-model-wiz__metadata-text'); + return { title: title.trim(), channelName: txt(ch) }; + } + return { title: '', channelName: '' }; +} + +// Brief icon-badge feedback so the user knows the save worked (no extra +// "notifications" permission needed). +function flashBadge(tabId, text, color) { + if (!chrome.action || tabId == null) return; + try { + chrome.action.setBadgeBackgroundColor({ color: color || '#34a853', tabId }); + chrome.action.setBadgeText({ text, tabId }); + setTimeout(() => { + try { chrome.action.setBadgeText({ text: '', tabId }); } catch (_) {} + }, 2000); + } catch (_) { /* ignore */ } +} + +async function handleAddWatchLater(info, tab) { + const src = info.linkUrl || info.pageUrl || (tab && tab.url) || ''; + const parsed = parseYouTubeVideo(src); + if (!parsed) { + flashBadge(tab && tab.id, '?', '#ea4335'); + return; + } + + // Best-effort title/channel from the page; falls back to empty if blocked. + let meta = { title: '', channelName: '' }; + try { + if (tab && tab.id != null && chrome.scripting) { + const results = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: ytvhtExtractVideoMeta, + args: [parsed.videoId] + }); + if (results && results[0] && results[0].result) meta = results[0].result; + } + } catch (_) { /* ignore — save with what we have */ } + + const record = { + videoId: parsed.videoId, + url: parsed.url, + isShorts: parsed.isShorts, + title: (meta.title || '').slice(0, 300), + channelName: (meta.channelName || '').slice(0, 200), + addedAt: Date.now() + }; + + try { + if (typeof ytStorage === 'undefined' || typeof ytStorage.setWatchLater !== 'function') { + throw new Error('ytStorage.setWatchLater unavailable'); + } + await ytStorage.setWatchLater(parsed.videoId, record); + flashBadge(tab && tab.id, '+1', '#34a853'); + } catch (e) { + console.error('[WatchLater] save failed:', e); + flashBadge(tab && tab.id, 'x', '#ea4335'); + } +} + +if (chrome.contextMenus && chrome.contextMenus.onClicked) { + chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId !== WATCH_LATER_MENU_ID) return; + handleAddWatchLater(info, tab).catch((e) => console.error('[WatchLater]', e)); + }); +} diff --git a/src/content-css.js b/src/content-css.js new file mode 100644 index 0000000..840de4d --- /dev/null +++ b/src/content-css.js @@ -0,0 +1,260 @@ +(function() { + 'use strict'; + + function injectCSS() { + document.getElementById('ytvht-styles')?.remove(); + const isExtensionFeed = !!document.getElementById('ytvht-home-feed'); + document.documentElement.toggleAttribute('ytvht-extension-feed', isExtensionFeed); + document.documentElement.removeAttribute('ytvht-native-badge-only'); + + const style = document.createElement('style'); + style.id = 'ytvht-styles'; + style.textContent = ` + .ytvht-viewed-label { + position: absolute !important; + top: 0 !important; + left: 0 !important; + padding: 8px 4px !important; + background-color: #4285f4 !important; + color: #fff !important; + font-size: 16px !important; + font-weight: bold !important; + z-index: 9999 !important; + border-radius: 0 0 4px 0 !important; + pointer-events: none !important; + } + .ytvht-progress-bar { + position: absolute !important; + bottom: 0 !important; + left: 0 !important; + height: 4px !important; + background-color: #4285f4 !important; + z-index: 2147483647 !important; + pointer-events: none !important; + } + .ytvht-progress-mask { + position: absolute !important; + bottom: 0 !important; + left: 0 !important; + width: 100% !important; + height: 6px !important; + background: rgba(0, 0, 0, 0.72) !important; + z-index: 2147483646 !important; + pointer-events: none !important; + } + /* Compact overlay for small extension-feed thumbnails. + Two-class selectors keep higher specificity than the dynamic + single-class rules in updateOverlayCSS, so size stays small here. */ + .ytvht-viewed-label.ytvht-compact { + padding: 2px 4px !important; + font-size: 10px !important; + border-radius: 0 0 3px 0 !important; + } + .ytvht-progress-bar.ytvht-compact { + height: 3px !important; + } + .ytvht-progress-mask.ytvht-compact { + height: 5px !important; + } + .ytvht-native-overlay-target { + position: relative !important; + } + .ytvht-native-overlay-target::before { + content: attr(data-ytvht-label) !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + padding: var(--ytvht-label-padding, 8px 4px) !important; + background-color: var(--ytvht-overlay-color, #4285f4) !important; + color: #fff !important; + font-size: var(--ytvht-label-font-size, 16px) !important; + font-weight: bold !important; + z-index: 2147483647 !important; + border-radius: 0 0 4px 0 !important; + pointer-events: none !important; + } + .ytvht-native-overlay-target::after { + content: "" !important; + position: absolute !important; + bottom: 0 !important; + left: 0 !important; + width: var(--ytvht-progress-width, 100%) !important; + height: var(--ytvht-progress-height, 4px) !important; + background-color: var(--ytvht-overlay-color, #4285f4) !important; + z-index: 2147483647 !important; + pointer-events: none !important; + } + .ytvht-native-overlay-target.ytvht-native-overlay-no-progress::after { + content: none !important; + display: none !important; + } + .ytvht-native-progress-line { + position: absolute !important; + bottom: 0 !important; + left: 0 !important; + height: 4px !important; + background-color: #4285f4 !important; + z-index: 2147483647 !important; + pointer-events: none !important; + } + ytd-notification-renderer .ytvht-viewed-label, + ytd-notification-renderer .ytvht-progress-mask, + ytd-notification-renderer .ytvht-progress-bar, + ytd-notification-renderer .ytvht-native-progress-line, + ytd-notification-renderer .ytvht-native-overlay-target::before, + ytd-notification-renderer .ytvht-native-overlay-target::after, + ytd-multi-page-menu-renderer .ytvht-viewed-label, + ytd-multi-page-menu-renderer .ytvht-progress-mask, + ytd-multi-page-menu-renderer .ytvht-progress-bar, + ytd-multi-page-menu-renderer .ytvht-native-progress-line, + ytd-multi-page-menu-renderer .ytvht-native-overlay-target::before, + ytd-multi-page-menu-renderer .ytvht-native-overlay-target::after, + ytd-popup-container .ytvht-viewed-label, + ytd-popup-container .ytvht-progress-mask, + ytd-popup-container .ytvht-progress-bar, + ytd-popup-container .ytvht-native-progress-line, + ytd-popup-container .ytvht-native-overlay-target::before, + ytd-popup-container .ytvht-native-overlay-target::after { + content: none !important; + display: none !important; + } + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) ytd-thumbnail-overlay-resume-playback-renderer, + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) yt-thumbnail-overlay-resume-playback-renderer, + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) yt-thumbnail-overlay-progress-bar-view-model, + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) yt-thumbnail-overlay-progress-view-model, + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) .ytThumbnailOverlayProgressBarHost, + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) .ytThumbnailOverlayProgressBarProgress, + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) [class*="ThumbnailOverlayProgress"], + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) [class*="thumbnailOverlayProgress"], + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) #progress:not(.ytp-progress-bar):not(.ytp-play-progress):not(.ytp-load-progress), + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) tp-yt-paper-progress, + html[ytvht-native-badge-only]:not([ytvht-extension-feed]) yt-progress-bar { + display: none !important; + } + .ytvht-has-overlay ytd-thumbnail-overlay-resume-playback-renderer, + .ytvht-has-overlay yt-thumbnail-overlay-resume-playback-renderer, + .ytvht-has-overlay yt-thumbnail-overlay-progress-bar-view-model, + .ytvht-has-overlay yt-thumbnail-overlay-progress-view-model, + .ytvht-has-overlay .ytThumbnailOverlayProgressBarHost, + .ytvht-has-overlay .ytThumbnailOverlayProgressBarProgress, + .ytvht-has-overlay [class*="ThumbnailOverlayProgress"], + .ytvht-has-overlay [class*="thumbnailOverlayProgress"], + .ytvht-has-overlay #progress:not(.ytvht-progress-bar), + .ytvht-has-overlay tp-yt-paper-progress, + .ytvht-has-overlay yt-progress-bar { + display: none !important; + } + .ytvht-remove-button { + position: absolute !important; + bottom: 10px !important; + right: 10px !important; + width: 26px !important; + height: 26px !important; + line-height: 26px !important; + text-align: center !important; + font-size: 18px !important; + font-weight: 700 !important; + color: #fff !important; + background: #4285f4 !important; + border: none !important; + border-radius: 50% !important; + cursor: pointer !important; + z-index: 10000 !important; + pointer-events: auto !important; + opacity: 0 !important; + transition: opacity 0.15s ease-in-out !important; + user-select: none !important; + } + ytd-thumbnail:hover .ytvht-remove-button, + a#thumbnail:hover .ytvht-remove-button, + ytd-playlist-video-renderer:hover .ytvht-remove-button, + ytd-playlist-panel-video-renderer:hover .ytvht-remove-button, + yt-lockup-view-model:hover .ytvht-remove-button, + ytd-video-renderer:hover .ytvht-remove-button, + ytd-rich-item-renderer:hover .ytvht-remove-button, + ytd-grid-video-renderer:hover .ytvht-remove-button { + opacity: 0.95 !important; + } + .ytvht-info { + position: absolute !important; + top: -120px !important; + right: 0 !important; + background: var(--yt-spec-brand-background-primary, #0f0f0f) !important; + border: 1px solid var(--yt-spec-text-secondary, #aaa) !important; + border-radius: 8px !important; + padding: 12px !important; + width: 300px !important; + z-index: 9999 !important; + color: var(--yt-spec-text-primary, #fff) !important; + font-size: 14px !important; + box-shadow: 0 4px 8px rgba(0,0,0,0.1) !important; + } + .ytvht-info-content { + display: flex !important; + align-items: start !important; + gap: 12px !important; + } + .ytvht-info-text { + flex-grow: 1 !important; + } + .ytvht-info-title { + font-weight: 500 !important; + margin-bottom: 8px !important; + color: #fff !important; + } + .ytvht-info-description { + color: #aaa !important; + line-height: 1.4 !important; + } + .ytvht-info-highlight { + color: #fff !important; + background: rgba(255,255,255,0.1) !important; + padding: 2px 6px !important; + border-radius: 4px !important; + } + .ytvht-close { + background: none !important; + border: none !important; + padding: 4px 8px !important; + cursor: pointer !important; + color: #aaa !important; + font-size: 20px !important; + opacity: 0.8 !important; + transition: opacity 0.2s !important; + } + .ytvht-close:hover { + opacity: 1 !important; + } + `; + document.head.appendChild(style); + } + + function updateOverlayCSS(size, color) { + let styleElement = document.getElementById('ytvht-dynamic-styles'); + if (!styleElement) { + styleElement = document.createElement('style'); + styleElement.id = 'ytvht-dynamic-styles'; + document.head.appendChild(styleElement); + } + + styleElement.textContent = ` + .ytvht-viewed-label { + padding: ${size.fontSize / 2}px 4px !important; + background-color: ${color} !important; + font-size: ${size.fontSize}px !important; + } + .ytvht-progress-bar { + height: ${size.bar}px !important; + background-color: ${color} !important; + } + .ytvht-remove-button { + background: ${color} !important; + } + `; + } + + window.YTVHTContentCss = { + injectCSS, + updateOverlayCSS + }; +})(); diff --git a/src/content-import.js b/src/content-import.js new file mode 100644 index 0000000..3f73b24 --- /dev/null +++ b/src/content-import.js @@ -0,0 +1,168 @@ +(function() { + 'use strict'; + + function createContentImportHelpers(dependencies) { + const log = dependencies.log; + const getStorage = dependencies.getStorage; + + async function runImport(records, playlists, mergeMode) { + return getStorage().importRecords(records || [], playlists || [], !!mergeMode); + } + + function maybeShowImportOverlayFromHash() { + if (window.location.hash === '#ytlh_import') { + showImportOverlay(); + } + } + + function showImportOverlay() { + if (document.getElementById('ytvhtImportOverlay')) { + return; + } + if (!document.body) { + return; + } + + const overlay = document.createElement('div'); + overlay.id = 'ytvhtImportOverlay'; + overlay.style.position = 'fixed'; + overlay.style.inset = '0'; + overlay.style.background = 'rgba(0,0,0,0.4)'; + overlay.style.zIndex = '999999'; + overlay.style.display = 'flex'; + overlay.style.alignItems = 'center'; + overlay.style.justifyContent = 'center'; + + const modal = document.createElement('div'); + modal.style.background = '#222'; + modal.style.color = '#fff'; + modal.style.padding = '20px'; + modal.style.borderRadius = '8px'; + modal.style.minWidth = '360px'; + modal.style.maxWidth = '480px'; + modal.style.boxShadow = '0 4px 16px rgba(0,0,0,0.5)'; + + modal.innerHTML = ` +

Choose a file to import:

+ +
+ + +
+
+ + +
+
+ `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + const fileInput = modal.querySelector('#ytvhtImportFile'); + const mergeRadio = modal.querySelector('#ytvhtImportMerge'); + const statusEl = modal.querySelector('#ytvhtImportStatus'); + + modal.querySelector('#ytvhtImportCancel').onclick = () => { + overlay.remove(); + if (window.location.hash === '#ytlh_import') { + try { + history.replaceState(null, '', window.location.pathname + window.location.search); + } catch (e) { + log('Error clearing import hash:', e); + } + } + }; + + modal.querySelector('#ytvhtImportStart').onclick = async () => { + const file = fileInput.files && fileInput.files[0]; + if (!file) { + statusEl.textContent = 'Please choose a JSON file.'; + return; + } + + try { + statusEl.textContent = 'Reading file...'; + const text = await file.text(); + const data = JSON.parse(text); + + let records = []; + let playlists = []; + let mergeMode = !!mergeRadio.checked; + + if (data && typeof data === 'object' && data.history) { + if (Array.isArray(data.history)) { + records = data.history; + } else if (typeof data.history === 'object') { + records = Object.values(data.history); + } else { + throw new Error('Invalid file format: unexpected history structure'); + } + + if (Array.isArray(data.playlists)) { + playlists = data.playlists; + } else if (data.playlists && typeof data.playlists === 'object') { + playlists = Object.values(data.playlists); + } + } else if (Array.isArray(data)) { + records = data; + mergeMode = false; + } else { + throw new Error('Invalid file format: expected an array of videos or an object with history/playlists'); + } + + if (!records.length && !playlists.length) { + statusEl.textContent = 'No videos or playlists found in file.'; + return; + } + + statusEl.textContent = 'Importing...'; + + try { + const response = await runImport(records, playlists, mergeMode); + + if (response && response.status === 'success') { + statusEl.textContent = + `Import complete: ${response.importedVideos} videos, ` + + `${response.importedPlaylists} playlists.`; + } else { + const errorMsg = response && response.error ? response.error : 'Unknown error'; + statusEl.textContent = `Import failed: ${errorMsg}`; + console.error('Import failed:', errorMsg); + } + } catch (importError) { + console.error('Import overlay error:', importError); + let errorMsg = importError.message || 'Unknown error'; + + if (errorMsg.includes('Extension context invalidated') || errorMsg.includes('Background script')) { + errorMsg = 'Extension context lost. Please reload the extension and try again.'; + } else if (errorMsg.includes('IndexedDB')) { + errorMsg = 'IndexedDB not available. Please reload the extension.'; + } + + statusEl.textContent = `Error: ${errorMsg}`; + } + } catch (err) { + console.error('Import overlay error:', err); + statusEl.textContent = `Error: ${err.message || 'Unknown error'}`; + } + }; + } + + return { + runImport, + maybeShowImportOverlayFromHash, + showImportOverlay + }; + } + + window.YTVHTContentImport = { + create: createContentImportHelpers + }; +})(); diff --git a/src/content-info.js b/src/content-info.js new file mode 100644 index 0000000..3e4b07d --- /dev/null +++ b/src/content-info.js @@ -0,0 +1,70 @@ +(function() { + 'use strict'; + + function createContentInfoHelpers(dependencies) { + const log = dependencies.log; + const storage = dependencies.storage; + + function showExtensionInfo() { + storage.get(['infoShown']).then(result => { + if (!result.infoShown) { + const topLevelButtons = document.querySelector('#top-level-buttons-computed'); + if (!topLevelButtons) return; + + const infoDiv = document.createElement('div'); + infoDiv.className = 'ytvht-info'; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'ytvht-info-content'; + + const textDiv = document.createElement('div'); + textDiv.className = 'ytvht-info-text'; + + const titleDiv = document.createElement('div'); + titleDiv.className = 'ytvht-info-title'; + titleDiv.textContent = 'YT re:Watch is active'; + + const descDiv = document.createElement('div'); + descDiv.className = 'ytvht-info-description'; + descDiv.innerHTML = 'Your watch progress is being saved locally. Click the extension icon Open to continue watching or open your local feed.'; + + const closeButton = document.createElement('button'); + closeButton.className = 'ytvht-close'; + closeButton.textContent = 'x'; + + textDiv.appendChild(titleDiv); + textDiv.appendChild(descDiv); + contentDiv.appendChild(textDiv); + contentDiv.appendChild(closeButton); + infoDiv.appendChild(contentDiv); + + closeButton.addEventListener('click', () => { + infoDiv.style.display = 'none'; + storage.set({ infoShown: true }); + }); + + const container = topLevelButtons.closest('#actions'); + if (container) { + container.style.position = 'relative'; + container.appendChild(infoDiv); + } else { + topLevelButtons.parentElement.style.position = 'relative'; + topLevelButtons.parentElement.appendChild(infoDiv); + } + + log('Info div added to page'); + } + }).catch(error => { + log('Error checking infoShown status:', error); + }); + } + + return { + showExtensionInfo + }; + } + + window.YTVHTContentInfo = { + create: createContentInfoHelpers + }; +})(); diff --git a/src/content-messages.js b/src/content-messages.js new file mode 100644 index 0000000..f81d249 --- /dev/null +++ b/src/content-messages.js @@ -0,0 +1,185 @@ +(function() { + 'use strict'; + + function createContentMessageListener(dependencies) { + const log = dependencies.log; + const getStorage = dependencies.getStorage; + const isInitialized = dependencies.isInitialized; + const initializeIfNeeded = dependencies.initializeIfNeeded; + const injectCSS = dependencies.injectCSS; + const updateOverlayCSS = dependencies.updateOverlayCSS; + const overlayColors = dependencies.overlayColors; + const overlayLabelSizeMap = dependencies.overlayLabelSizeMap; + const getAccentOverlayColor = dependencies.getAccentOverlayColor; + const setCurrentSettings = dependencies.setCurrentSettings; + const processExistingThumbnails = dependencies.processExistingThumbnails; + + return function contentMessageListener(message, sender, sendResponse) { + const storage = getStorage(); + + if (message.type === 'fetchYouTubeSearchPageInTab') { + const query = String(message.query || '').trim(); + if (!query) { + sendResponse({ error: 'Missing YouTube search query' }); + return true; + } + fetch(`/results?search_query=${encodeURIComponent(query)}`, { + credentials: 'include' + }).then(async (response) => { + if (!response.ok) throw new Error(`YouTube returned ${response.status}`); + sendResponse({ html: await response.text() }); + }).catch((error) => { + sendResponse({ error: error && error.message ? error.message : String(error) }); + }); + return true; + } else if (message.type === 'fetchYouTubeSearchContinuationInTab') { + const token = String(message.token || ''); + const config = message.config || {}; + if (!token || !config.clientVersion) { + sendResponse({ error: 'Missing YouTube search continuation data' }); + return true; + } + const key = config.apiKey ? `?key=${encodeURIComponent(config.apiKey)}` : ''; + const headers = { + 'Content-Type': 'application/json', + 'X-YouTube-Client-Name': '1', + 'X-YouTube-Client-Version': String(config.clientVersion) + }; + if (config.visitorData) headers['X-Goog-Visitor-Id'] = String(config.visitorData); + const context = { + client: { + clientName: 'WEB', + clientVersion: String(config.clientVersion), + visitorData: String(config.visitorData || ''), + hl: 'en', + gl: 'US' + } + }; + if (config.clickTrackingParams) { + context.clickTracking = { clickTrackingParams: String(config.clickTrackingParams) }; + } + fetch(`/youtubei/v1/search${key}`, { + method: 'POST', + credentials: 'include', + headers, + body: JSON.stringify({ + context, + continuation: token + }) + }).then(async (response) => { + if (!response.ok) throw new Error(`YouTube returned ${response.status}`); + sendResponse({ data: await response.json() }); + }).catch((error) => { + sendResponse({ error: error && error.message ? error.message : String(error) }); + }); + return true; + } else if (message.type === 'getHistory') { + if (!isInitialized()) { + log('Not initialized yet, initializing now'); + initializeIfNeeded(); + } + storage.getAllVideos().then(allVideos => { + const history = Object.values(allVideos); + log('Sending history to popup:', history); + sendResponse({history: history}); + }).catch(error => { + log('Error getting history:', error); + sendResponse({history: []}); + }); + return true; + } else if (message.type === 'exportHistory') { + if (!isInitialized()) { + log('Not initialized yet, initializing now'); + initializeIfNeeded(); + } + Promise.all([ + storage.getAllVideos(), + storage.getAllPlaylists() + ]).then(([allVideos, allPlaylists]) => { + const history = Object.values(allVideos); + const playlists = Object.values(allPlaylists); + log('Sending export data to popup:', { history, playlists }); + sendResponse({history: history, playlists: playlists}); + }).catch(error => { + log('Error getting export data:', error); + sendResponse({history: [], playlists: []}); + }); + return true; + } else if (message.type === 'pauseVideoForImport') { + try { + const video = document.querySelector('video'); + if (video && !video.paused) { + video.pause(); + log('Paused video for import flow'); + } + sendResponse({ status: 'success' }); + } catch (error) { + log('Error pausing video for import:', error); + sendResponse({ status: 'error', error: error && error.message ? error.message : String(error) }); + } + return true; + } else if (message.type === 'clearHistory') { + storage.clear().then(() => { + log('History cleared successfully'); + sendResponse({status: 'success'}); + }).catch(error => { + log('Error clearing history:', error); + sendResponse({status: 'error'}); + }); + return true; + } else if (message.type === 'deleteRecord') { + const videoId = message.videoId; + storage.removeVideo(videoId).then(() => { + log('Record deleted successfully:', videoId); + sendResponse({status: 'success'}); + }).catch(error => { + log('Error deleting record:', videoId); + sendResponse({status: 'error'}); + }); + return true; + } else if (message.type === 'getPlaylists') { + storage.getAllPlaylists().then(allPlaylists => { + const playlists = Object.values(allPlaylists); + log('Sending playlists to popup:', playlists); + sendResponse({playlists: playlists}); + }).catch(error => { + log('Error getting playlists:', error); + sendResponse({playlists: []}); + }); + return true; + } else if (message.type === 'deletePlaylist') { + const playlistId = message.playlistId; + storage.removePlaylist(playlistId).then(() => { + log('Playlist deleted successfully:', playlistId); + sendResponse({status: 'success'}); + }).catch(error => { + log('Error deleting playlist:', playlistId); + sendResponse({status: 'error'}); + }); + return true; + } else if (message.type === 'updateSettings') { + const currentSettings = message.settings; + setCurrentSettings(currentSettings); + if (currentSettings.debug) { + console.log('[ythdb] Debug mode enabled'); + } + injectCSS(); + updateOverlayCSS( + overlayLabelSizeMap[currentSettings.overlayLabelSize] || overlayLabelSizeMap.medium, + getAccentOverlayColor + ? getAccentOverlayColor(currentSettings) + : (overlayColors[currentSettings.accentColor] || overlayColors[currentSettings.overlayColor] || overlayColors.blue) + ); + processExistingThumbnails(); + sendResponse({status: 'success'}); + return true; + } + + return false; + }; + } + + window.YTVHTContentMessages = { + create: createContentMessageListener + }; +})(); diff --git a/src/content-playlists.js b/src/content-playlists.js new file mode 100644 index 0000000..ca3cf17 --- /dev/null +++ b/src/content-playlists.js @@ -0,0 +1,216 @@ +(function() { + 'use strict'; + + function createContentPlaylistHelpers(dependencies) { + const log = dependencies.log; + const getStorage = dependencies.getStorage; + const getPlaylistRetryTimeout = dependencies.getPlaylistRetryTimeout; + const setPlaylistRetryTimeout = dependencies.setPlaylistRetryTimeout; + + function getPlaylistInfo() { + const urlParams = new URLSearchParams(window.location.search); + const playlistId = urlParams.get('list'); + if (!playlistId) { + log('No playlist ID found in URL'); + return null; + } + + log('Found playlist ID:', playlistId); + + const selectors = [ + 'ytd-playlist-panel-renderer #playlist-title yt-formatted-string', + 'ytd-playlist-panel-renderer #playlist-name yt-formatted-string', + 'ytd-playlist-panel-renderer .title yt-formatted-string', + '.ytd-watch-flexy[playlist] .playlist-title', + '#secondary .title.ytd-playlist-panel-renderer', + 'ytd-playlist-metadata-header-renderer yt-formatted-string.title', + 'h3.ytd-playlist-panel-renderer', + '#playlist-title', + '#playlist-name', + 'ytd-playlist-panel-renderer h3 yt-formatted-string', + 'ytd-playlist-panel-renderer .title', + '#secondary-inner ytd-playlist-panel-renderer .title', + 'ytd-playlist-header-renderer h1.ytd-playlist-header-renderer', + '.playlist-title yt-formatted-string', + '.ytd-playlist-panel-renderer .index-message + .title', + 'yt-page-header-view-model h1.dynamicTextViewModelH1 span', + 'yt-page-header-view-model .yt-page-header-view-model__page-header-title h1 span', + 'yt-dynamic-text-view-model h1.dynamicTextViewModelH1 span' + ]; + + let playlistTitle = null; + for (const selector of selectors) { + const element = document.querySelector(selector); + if (element) { + playlistTitle = element.textContent?.trim(); + log(`Tried selector "${selector}": "${playlistTitle}"`); + if (playlistTitle && playlistTitle !== 'Unknown Playlist' && playlistTitle.length > 0) { + log('Found valid playlist title:', playlistTitle); + break; + } + } + } + + if (!playlistTitle || playlistTitle === 'Unknown Playlist') { + log('No valid playlist title found'); + return null; + } + + let thumbnail = ''; + const currentVideoId = urlParams.get('v'); + if (currentVideoId) { + thumbnail = `https://i.ytimg.com/vi/${currentVideoId}/hqdefault.jpg`; + } else { + const firstVideoLink = document.querySelector( + 'ytd-playlist-video-renderer a[href*="watch?v="], ' + + 'ytd-playlist-panel-video-renderer a[href*="watch?v="]' + ); + if (firstVideoLink) { + try { + const firstVideoId = new URL(firstVideoLink.href, location.origin).searchParams.get('v'); + if (firstVideoId) thumbnail = `https://i.ytimg.com/vi/${firstVideoId}/hqdefault.jpg`; + } catch (_) { /* thumbnail enrichment can retry later */ } + } + } + + const localItems = {}; + document.querySelectorAll( + 'ytd-playlist-video-renderer, ytd-playlist-panel-video-renderer' + ).forEach((row) => { + const link = row.querySelector('a[href*="watch?v="]'); + if (!link) return; + let videoId = ''; + try { videoId = new URL(link.href, location.origin).searchParams.get('v') || ''; } catch (_) { return; } + if (!videoId || localItems[videoId]) return; + const titleElement = row.querySelector( + '#video-title, #video-title-link, .title, yt-formatted-string.title' + ); + const channelElement = row.querySelector( + 'ytd-channel-name a, #byline a, .byline a, .channel-name a' + ); + const durationElement = row.querySelector( + 'ytd-thumbnail-overlay-time-status-renderer, #text.ytd-thumbnail-overlay-time-status-renderer' + ); + localItems[videoId] = { + videoId, + title: titleElement?.textContent?.trim() || link.title || 'YouTube video', + channelName: channelElement?.textContent?.trim() || '', + channelUrl: channelElement?.href || '', + thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, + url: `https://www.youtube.com/watch?v=${videoId}`, + _durationText: (durationElement?.textContent || '') + .replace(/\s+/g, ' ') + .match(/\b(?:\d+:)?\d{1,2}:\d{2}\b/)?.[0] || '', + savedAt: Date.now() + }; + }); + + const playlistInfo = { + playlistId, + title: playlistTitle, + url: `https://www.youtube.com/playlist?list=${playlistId}`, + timestamp: Date.now(), + ...(currentVideoId ? { videoId: currentVideoId } : {}), + ...(thumbnail ? { thumbnail } : {}), + ...(Object.keys(localItems).length ? { + localItems, + videoCount: Object.keys(localItems).length + } : {}) + }; + + log('Created playlist info:', playlistInfo); + return playlistInfo; + } + + async function savePlaylistInfo(playlistInfo = null) { + const info = playlistInfo || getPlaylistInfo(); + if (!info) return; + + log('Saving playlist info:', info); + + try { + const existing = await getStorage().getPlaylist(info.playlistId); + const merged = { + ...(existing || {}), + ...info, + ...((existing?.localItems || info.localItems) ? { + localItems: { + ...((existing && existing.localItems) || {}), + ...(info.localItems || {}) + } + } : {}), + lastUpdated: Date.now() + }; + if (merged.localItems) merged.videoCount = Object.keys(merged.localItems).length; + await getStorage().setPlaylist(info.playlistId, merged); + log('Playlist info saved successfully:', merged); + } catch (error) { + log('Error saving playlist info:', error); + } + } + + function tryToSavePlaylist(retries = 3) { + if (window.location.pathname.startsWith('/watch') || + window.location.pathname.startsWith('/shorts/')) { + log('Skipping automatic playlist save during video playback'); + attachPlaylistIgnoreToggles(); + return; + } + + const urlParams = new URLSearchParams(window.location.search); + const playlistId = urlParams.get('list'); + + if (!playlistId) { + log('No playlist ID in URL, skipping playlist save'); + return; + } + + log(`Trying to save playlist (${retries} retries left)...`); + const playlistInfo = getPlaylistInfo(); + + if (playlistInfo) { + log('Playlist info found, saving...'); + savePlaylistInfo(playlistInfo); + attachPlaylistIgnoreToggles(); + } else if (retries > 0) { + log(`Playlist title not found for ID ${playlistId}, will retry in 3 seconds... (${retries} retries left)`); + clearTimeout(getPlaylistRetryTimeout()); + + const delay = Math.min(3000 * (4 - retries), 5000); + setPlaylistRetryTimeout(setTimeout(() => { + const currentPlaylistId = new URLSearchParams(window.location.search).get('list'); + if (currentPlaylistId === playlistId) { + tryToSavePlaylist(retries - 1); + attachPlaylistIgnoreToggles(); + } else { + log('Playlist ID changed, stopping retry attempts'); + } + }, delay)); + } else { + log('Failed to get playlist title after retries; skipping placeholder playlist save'); + attachPlaylistIgnoreToggles(); + } + } + + async function attachPlaylistIgnoreToggles() { + document.querySelectorAll('.ytvht-ignore-toggle, .ytvht-ignore-row') + .forEach((node) => node.remove()); + } + + function ensurePlaylistIgnoreToggles(retries = 12) { + attachPlaylistIgnoreToggles(); + } + + return { + getPlaylistInfo, + savePlaylistInfo, + tryToSavePlaylist, + attachPlaylistIgnoreToggles, + ensurePlaylistIgnoreToggles + }; + } + + window.YTVHTContentPlaylists = { + create: createContentPlaylistHelpers + }; +})(); diff --git a/src/content-subscriptions.js b/src/content-subscriptions.js new file mode 100644 index 0000000..7a010de --- /dev/null +++ b/src/content-subscriptions.js @@ -0,0 +1,1400 @@ +// Local subscriptions and feed cache for YT re:Watch. +// +// Adds a "lightweight local YouTube account" layer that needs no Google login: +// - Subscribe / unsubscribe to channels (stored in extension storage). +// - Build the local feed cache from subscribed channels' public RSS feeds +// and channel pages. The dedicated extension feed page renders the cache. +// +// Runs as a content script alongside content.js and reuses the global +// `ytStorage` instance provided by storage.js. + +(function () { + 'use strict'; + + // ------------------------------------------------------------------ + // Constants & small helpers + // ------------------------------------------------------------------ + const PER_CHANNEL_LIMIT = 15; // newest videos kept per channel (RSS max ~15) + const TOTAL_FEED_LIMIT = 300; // overall cap for the aggregated feed + const FETCH_CONCURRENCY = 4; // parallel RSS fetches + const DEFAULT_REFRESH_MIN = 60; // feed cache TTL (minutes) + const YT_NS = 'http://www.youtube.com/xml/schemas/2015'; + const MEDIA_NS = 'http://search.yahoo.com/mrss/'; + const CHANNEL_BACKFILL_CONCURRENCY = 2; + const feedCore = globalThis.ytvhtFeedCore || {}; + const FEED_CACHE_POLICY = feedCore.FEED_CACHE_POLICY || 'balanced-backfill-lockup-v1'; + // Keep the feed inside the extension page. Injecting a replacement feed on + // youtube.com is fragile because YouTube changes the home layout often. + const EMBED_YOUTUBE_HOME_FEED = false; + + let cachedSettings = null; + let isRefreshing = false; + let lastRoutePath = null; + + function debugLog(...args) { + if (cachedSettings && cachedSettings.debug) { + console.log('[ythdb-subs]', ...args); + } + } + + function waitForStorage() { + // storage.js sets globalThis.ytStorage; it loads before us, but guard anyway. + return new Promise((resolve) => { + if (typeof ytStorage !== 'undefined' && ytStorage) { + resolve(); + return; + } + const start = Date.now(); + const timer = setInterval(() => { + if (typeof ytStorage !== 'undefined' && ytStorage) { + clearInterval(timer); + resolve(); + } else if (Date.now() - start > 10000) { + clearInterval(timer); + resolve(); // give up gracefully + } + }, 100); + }); + } + + async function getFeedSettings() { + let s = {}; + try { + s = (await ytStorage.getSettings()) || {}; + } catch (e) { + // ignore; use defaults + } + // Two consolidated "clean interface" toggles (migrated from the older + // three). hideRecommendations falls back to the old hideHomeRecommendations. + const hideRecs = (typeof s.hideRecommendations === 'boolean') + ? s.hideRecommendations + : (typeof s.hideHomeRecommendations === 'boolean' ? s.hideHomeRecommendations : true); + cachedSettings = { + localFeedEnabled: s.localFeedEnabled !== false, + hideAccountUI: s.hideAccountUI === true, + hideRecommendations: hideRecs, + feedRefreshMinutes: Number(s.feedRefreshMinutes) > 0 + ? Number(s.feedRefreshMinutes) + : DEFAULT_REFRESH_MIN, + overlayTitle: s.overlayTitle || 'viewed', + debug: s.debug === true + }; + return cachedSettings; + } + + function relativeTime(ms) { + if (!ms) return ''; + const diff = Date.now() - ms; + const sec = Math.floor(diff / 1000); + if (sec < 60) return 'just now'; + const min = Math.floor(sec / 60); + if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`; + const day = Math.floor(hr / 24); + if (day < 7) return `${day} day${day === 1 ? '' : 's'} ago`; + const wk = Math.floor(day / 7); + if (wk < 5) return `${wk} week${wk === 1 ? '' : 's'} ago`; + const mo = Math.floor(day / 30); + if (mo < 12) return `${mo} month${mo === 1 ? '' : 's'} ago`; + const yr = Math.floor(day / 365); + return `${yr} year${yr === 1 ? '' : 's'} ago`; + } + + function newestFirstVideos(videos) { + return videos.slice() + .sort((a, b) => Number(b?.published || 0) - Number(a?.published || 0)); + } + + // ------------------------------------------------------------------ + // Styles + // ------------------------------------------------------------------ + function injectStyles() { + if (document.getElementById('ytvht-subs-styles')) return; + const style = document.createElement('style'); + style.id = 'ytvht-subs-styles'; + style.textContent = ` + #ytvht-home-feed { + margin: 0 0 24px 0; + padding: 0 0 12px 0; + border-bottom: 1px solid var(--yt-spec-10-percent-layer, rgba(255,255,255,0.1)); + } + #ytvht-home-feed .ytvht-feed-header { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0 14px 0; + } + #ytvht-home-feed .ytvht-feed-title { + font-family: "Roboto","Arial",sans-serif; + font-size: 18px; + font-weight: 700; + color: var(--yt-spec-text-primary, #f1f1f1); + } + #ytvht-home-feed .ytvht-feed-count { + font-size: 13px; + color: var(--yt-spec-text-secondary, #aaa); + } + #ytvht-home-feed .ytvht-feed-refresh { + margin-left: auto; + padding: 6px 12px; + border-radius: 16px; + border: 1px solid var(--yt-spec-10-percent-layer, rgba(255,255,255,0.2)); + background: transparent; + color: var(--yt-spec-text-primary, #f1f1f1); + font-size: 13px; + cursor: pointer; + } + #ytvht-home-feed .ytvht-feed-refresh:disabled { opacity: 0.5; cursor: default; } + #ytvht-home-feed .ytvht-feed-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--yt-spec-text-secondary, #aaa); + border-top-color: transparent; + border-radius: 50%; + animation: ytvht-feed-spin 0.8s linear infinite; + vertical-align: middle; + } + #ytvht-home-feed .ytvht-feed-loading { + font-size: 13px; + color: var(--yt-spec-text-secondary, #aaa); + display: inline-flex; + align-items: center; + gap: 8px; + } + @keyframes ytvht-feed-spin { to { transform: rotate(360deg); } } + #ytvht-home-feed .ytvht-feed-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + column-gap: 16px; + row-gap: 40px; + } + #ytvht-home-feed .ytvht-feed-empty { + font-size: 14px; + color: var(--yt-spec-text-secondary, #aaa); + padding: 8px 0 4px 0; + } + #ytvht-home-feed .ytvht-feed-diag { + margin-top: 14px; + padding: 12px 14px; + border-radius: 8px; + background: var(--yt-spec-badge-chip-background, #1f1f1f); + font-family: "Roboto","Arial",sans-serif; + font-size: 13px; + line-height: 1.7; + } + #ytvht-home-feed .ytvht-feed-diag-title { + font-weight: 700; + margin-bottom: 4px; + color: var(--yt-spec-text-primary, #f1f1f1); + } + #ytvht-home-feed .ytvht-diag-ok { color: #2ba640; } + #ytvht-home-feed .ytvht-diag-fail { color: #ff7070; } + .ytvht-feed-card { display: flex; flex-direction: column; text-decoration: none; } + .ytvht-feed-card a { text-decoration: none !important; color: inherit; } + .ytvht-feed-card .ytvht-thumb-link { display: block; } + .ytvht-feed-card .ytvht-avatar-link { flex: 0 0 36px; display: block; } + .ytvht-feed-card .ytvht-card-channel:hover { color: var(--yt-spec-text-primary, #f1f1f1); } + .ytvht-feed-card .ytvht-thumb-wrap { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 12px; + overflow: hidden; + background: var(--yt-spec-badge-chip-background, #272727); + } + .ytvht-feed-card .ytvht-thumb-wrap img { + width: 100%; height: 100%; object-fit: cover; display: block; + } + .ytvht-feed-card .ytvht-card-duration { + position: absolute; + right: 4px; + bottom: 4px; + padding: 1px 4px; + border-radius: 4px; + background: rgba(0,0,0,0.8); + color: #fff; + font-family: "Roboto","Arial",sans-serif; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + z-index: 2; + } + .ytvht-feed-card .ytvht-card-body { + display: flex; + gap: 12px; + margin-top: 12px; + } + .ytvht-feed-card .ytvht-card-avatar { + flex: 0 0 36px; + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + background: var(--yt-spec-badge-chip-background, #373737); + display: flex; + align-items: center; + justify-content: center; + color: var(--yt-spec-text-primary, #f1f1f1); + font-family: "Roboto","Arial",sans-serif; + font-size: 16px; + font-weight: 500; + } + .ytvht-feed-card .ytvht-card-text { min-width: 0; flex: 1 1 auto; } + .ytvht-feed-card .ytvht-card-title { + margin: 0 0 4px 0; + font-family: "Roboto","Arial",sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 1.4; + color: var(--yt-spec-text-primary, #f1f1f1); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .ytvht-feed-card .ytvht-card-channel, + .ytvht-feed-card .ytvht-card-stats { + font-family: "Roboto","Arial",sans-serif; + font-size: 12px; + color: var(--yt-spec-text-secondary, #aaa); + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + /* Thicker watched-progress bar on feed cards (overrides content.js height). */ + #ytvht-home-feed .ytvht-feed-card .ytvht-progress-bar { + height: 6px !important; + border-radius: 0 3px 0 0 !important; + } + html[ytvht-hide-recs] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer, + html[ytvht-hide-recs] ytd-browse[page-subtype="home"] ytd-message-renderer, + html[ytvht-hide-recs] ytd-browse[page-subtype="home"] ytd-feed-nudge-renderer, + html[ytvht-hide-recs] ytd-browse[page-subtype="home"] #contents.ytd-rich-grid-renderer { + display: none !important; + } + + /* Hide YouTube's account / login UI for an account-free look. */ + html[ytvht-hide-account] ytd-guide-signin-promo-renderer, + html[ytvht-hide-account] ytd-mini-guide-signin-promo-renderer, + html[ytvht-hide-account] ytd-masthead a[href*="accounts.google.com"], + html[ytvht-hide-account] ytd-masthead #buttons a[aria-label="Sign in"], + html[ytvht-hide-account] #masthead #avatar-btn { + display: none !important; + } + /* :has()-based account selectors, kept separate for resilience. */ + html[ytvht-hide-account] ytd-masthead ytd-button-renderer:has(a[href*="accounts.google.com"]), + html[ytvht-hide-account] ytd-masthead yt-button-view-model:has(a[href*="accounts.google.com"]), + html[ytvht-hide-account] ytd-button-renderer:has(a[href*="accounts.google.com"]), + html[ytvht-hide-account] ytd-guide-collapsible-section-entry-renderer:has(ytd-guide-signin-promo-renderer) { + display: none !important; + } + + /* Hide YouTube's native (account-based) Subscribe / Join — keep only ours. + Covers watch pages, the new channel header, and recommendation cards. + aria-label matches catch the channel-header buttons whose wrapper + element types vary between YouTube layouts. Our own button uses a + title (not aria-label), so it is never matched here. */ + html[ytvht-hide-native-subs] ytd-subscribe-button-renderer, + html[ytvht-hide-native-subs] yt-subscribe-button-view-model, + html[ytvht-hide-native-subs] button[aria-label^="Subscribe"]:not(.ytvht-sub-btn), + html[ytvht-hide-native-subs] button[aria-label^="Unsubscribe"]:not(.ytvht-sub-btn), + html[ytvht-hide-native-subs] #sponsor-button, + html[ytvht-hide-native-subs] button[aria-label^="Join"] { + display: none !important; + } + /* :has()-based native-subscription selectors, kept separate. */ + html[ytvht-hide-native-subs] ytd-guide-entry-renderer:has(a[href="/feed/subscriptions"]), + html[ytvht-hide-native-subs] ytd-mini-guide-entry-renderer:has(a[href="/feed/subscriptions"]) { + display: none !important; + } + `; + (document.head || document.documentElement).appendChild(style); + } + + // ------------------------------------------------------------------ + // Page-type detection & channel-info extraction + // ------------------------------------------------------------------ + function isHomePage() { + return window.location.pathname === '/' || window.location.pathname === ''; + } + + function isChannelPage() { + const p = window.location.pathname; + return /^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)/.test(p); + } + + function isWatchPage() { + return window.location.pathname === '/watch'; + } + + // Extract channel identity from a channel page. Returns null until an id + // can be read. + // + // IMPORTANT: the URL is authoritative. During in-app (SPA) navigation the + // URL updates immediately but / og: meta tags lag + // behind, still describing the PREVIOUS channel. Trusting those caused a new + // subscription to be saved with the wrong channel's id. So we derive the + // identity from the URL and only use canonical/og when they are consistent + // with the current URL. + // Read the current channel's OWN UC id from the live page. ytInitialData / + // config blobs carry "externalId":"UC..." for the page's own channel; this + // is the same field extractOwnUcid trusts on fetched HTML. Used so a sub + // stored only by UC id (e.g. CSV import) can be matched on a /@handle page. + function getOwnUcidFromDom() { + const scripts = document.querySelectorAll('script'); + for (const s of scripts) { + const t = s.textContent; + if (!t || t.indexOf('externalId') === -1) continue; + const m = t.match(/"externalId":"(UC[\w-]+)"/); + if (m) return m[1]; + } + const meta = document.querySelector('meta[itemprop="identifier"], meta[itemprop="channelId"]'); + if (meta) { + const c = meta.getAttribute('content') || ''; + if (/^UC[\w-]+$/.test(c)) return c; + } + return null; + } + + function getChannelPageInfo() { + const path = window.location.pathname; + const handleMatch = path.match(/^\/(@[\w.\-]+)/); + const ucMatch = path.match(/^\/channel\/(UC[\w-]+)/); + const urlHandle = handleMatch ? handleMatch[1] : null; + let ucid = ucMatch ? ucMatch[1] : null; + + if (!urlHandle && !ucid) return null; + + // Canonical href is only trusted if it matches the current URL. + const canonical = document.querySelector('link[rel="canonical"]'); + const canonicalHref = canonical ? (canonical.getAttribute('href') || '') : ''; + const canonicalConsistent = urlHandle + ? canonicalHref.toLowerCase().includes(urlHandle.toLowerCase()) + : (ucid ? canonicalHref.includes(ucid) : false); + if (!ucid && canonicalConsistent) { + const m = canonicalHref.match(/\/channel\/(UC[\w-]+)/); + if (m) ucid = m[1]; + } + + // On a /@handle page the canonical is the handle form, so we still have + // no UC id. Pull it from the page's own ytInitialData (externalId). + if (!ucid) { + ucid = getOwnUcidFromDom(); + } + + // og: title/image are only trusted when og:url (or canonical) matches. + let channelName = null; + let thumbnail = null; + const ogUrl = document.querySelector('meta[property="og:url"]'); + const ogUrlHref = ogUrl ? (ogUrl.getAttribute('content') || '') : ''; + const ogConsistent = urlHandle + ? (ogUrlHref.toLowerCase().includes(urlHandle.toLowerCase()) || canonicalConsistent) + : (ucid ? (ogUrlHref.includes(ucid) || canonicalConsistent) : false); + if (ogConsistent) { + const ogTitle = document.querySelector('meta[property="og:title"]'); + if (ogTitle) channelName = ogTitle.getAttribute('content'); + const ogImage = document.querySelector('meta[property="og:image"]'); + if (ogImage) thumbnail = ogImage.getAttribute('content'); + } + + // Prefer the URL handle as the stable id (consistent across visits); + // fall back to a URL-derived UC id. ucid (if any) is resolved/persisted + // later from the channel's own page. + const channelId = urlHandle || ucid; + const url = urlHandle + ? `https://www.youtube.com/${urlHandle}` + : `https://www.youtube.com/channel/${ucid}`; + + return { + channelId, + ucid: ucid || null, + handle: urlHandle || null, + channelName: channelName || 'Unknown Channel', + thumbnail: thumbnail || null, + url + }; + } + + // Extract channel identity of the video owner on a watch page. + function getWatchOwnerInfo() { + const link = document.querySelector('ytd-video-owner-renderer ytd-channel-name a, #owner ytd-channel-name a'); + if (!link) return null; + + const href = link.getAttribute('href') || ''; + let ucid = null; + let handle = null; + const ucMatch = href.match(/\/channel\/(UC[\w-]+)/); + if (ucMatch) ucid = ucMatch[1]; + const hMatch = href.match(/\/(@[\w.\-]+)/); + if (hMatch) handle = hMatch[1]; + + if (!ucid && !handle) return null; + + const channelName = (link.textContent || '').trim() || 'Unknown Channel'; + const avatar = document.querySelector( + 'ytd-video-owner-renderer #avatar img, #owner #avatar img, ytd-video-owner-renderer img#img' + ); + let thumbnail = avatar ? avatar.getAttribute('src') : null; + // Ignore lazy-load placeholders / empty sources. + if (thumbnail && (thumbnail.startsWith('data:') || thumbnail.trim() === '')) { + thumbnail = null; + } + + return { + channelId: ucid || handle, + ucid: ucid || null, + handle: handle || null, + channelName, + thumbnail, + url: ucid + ? `https://www.youtube.com/channel/${ucid}` + : `https://www.youtube.com/${handle}` + }; + } + + function removeSubButton() { + document.querySelectorAll('.ytvht-sub-btn').forEach((button) => button.remove()); + } + + // ------------------------------------------------------------------ + // RSS fetching & parsing + // ------------------------------------------------------------------ + function getXmlText(parent, ns, localName, prefixedName) { + let el = parent.getElementsByTagNameNS(ns, localName)[0]; + if (!el && prefixedName) el = parent.getElementsByTagName(prefixedName)[0]; + return el ? (el.textContent || '').trim() : ''; + } + + function parseFeedEntry(entry, sub) { + let videoId = getXmlText(entry, YT_NS, 'videoId', 'yt:videoId'); + if (!videoId) { + const idText = getXmlText(entry, '', 'id', 'id'); + const m = idText.match(/yt:video:([\w-]+)/); + if (m) videoId = m[1]; + } + if (!videoId) return null; + + const title = getXmlText(entry, '', 'title', 'title') || 'Untitled'; + const publishedRaw = getXmlText(entry, '', 'published', 'published'); + const published = publishedRaw ? Date.parse(publishedRaw) : 0; + + // Thumbnail from media:group/media:thumbnail; fall back to ytimg. + let thumbnail = ''; + const thumbEl = entry.getElementsByTagNameNS(MEDIA_NS, 'thumbnail')[0] + || entry.getElementsByTagName('media:thumbnail')[0]; + if (thumbEl) thumbnail = thumbEl.getAttribute('url') || ''; + if (!thumbnail) thumbnail = `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`; + + let channelName = getXmlText(entry, '', 'name', 'name') || sub.channelName || ''; + + // View count from media:community/media:statistics. + let views = 0; + const statsEl = entry.getElementsByTagNameNS(MEDIA_NS, 'statistics')[0] + || entry.getElementsByTagName('media:statistics')[0]; + if (statsEl) { + views = parseInt(statsEl.getAttribute('views') || '0', 10) || 0; + } + + const channelUrl = sub.url + || (sub.handle ? `https://www.youtube.com/${sub.handle}` : null) + || (sub.id && sub.id.startsWith('@') ? `https://www.youtube.com/${sub.id}` : null) + || (sub.ucid ? `https://www.youtube.com/channel/${sub.ucid}` : null); + + return { + videoId, + title, + published, + thumbnail, + channelName, + channelThumbnail: sub.thumbnail || null, + channelUrl, + views, + channelId: sub.id, + url: `https://www.youtube.com/watch?v=${videoId}` + }; + } + + function formatDuration(seconds) { + const s = Math.floor(seconds); + if (!s || s < 0) return ''; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const pad = (n) => String(n).padStart(2, '0'); + return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`; + } + + function formatViews(n) { + if (!n || n < 1) return ''; + if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B views'; + if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M views'; + if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K views'; + return n + ' views'; + } + + // Extract a channel's OWN UC id from its page HTML. Must avoid generic + // "/channel/UC..." or "channelId" matches, which can hit Featured Channels + // or recommended channels on the page (e.g. resolving EthosLab to SethBling). + // "externalId" and the canonical link both refer to the page's own channel. + function extractOwnUcid(text) { + const m = text.match(/"externalId":"(UC[\w-]+)"/) + || text.match(/]*rel="canonical"[^>]*\/channel\/(UC[\w-]+)/) + || text.match(/]*itemprop="(?:identifier|channelId)"[^>]*content="(UC[\w-]+)"/) + || text.match(/"channelId":"(UC[\w-]+)"/); // last resort + return m ? m[1] : null; + } + + // Fetch the channel page once to fill in missing channel id / avatar / + // name, so a subscription saved from anywhere (e.g. a watch page) is + // complete. `info` is the object passed to addSubscription. + async function enrichSubscription(info, overwrite = false) { + const channelUrl = info.url + || (info.handle ? `https://www.youtube.com/${info.handle}` : null) + || (info.ucid ? `https://www.youtube.com/channel/${info.ucid}` : null); + if (!channelUrl) return; + try { + const res = await fetch(channelUrl, { credentials: 'include' }); + if (!res.ok) return; + const text = await res.text(); + const patch = {}; + + if (overwrite || !info.ucid) { + const ucid = extractOwnUcid(text); + if (ucid) patch.ucid = ucid; + } + // Capture @handle from the final redirect URL so CSV-imported subs + // (keyed by UC id, no handle field) can be found by @handle lookups. + if (overwrite || !info.handle) { + const finalUrl = res.url || ''; + const hm = finalUrl.match(/\/(@[\w.\-]+)/); + if (hm) patch.handle = hm[1]; + } + if (overwrite || !info.thumbnail) { + let av = (text.match(/', ucid); + } + return ucid; + } catch (e) { + debugLog('resolveUcid failed for', sub.id, e.message); + return null; + } + } + + // Returns { sub, ucid, videos, error } so callers can report per-channel status. + async function fetchChannelFeed(sub) { + const ucid = await resolveUcid(sub); + if (!ucid) { + debugLog('No channel id resolved for', sub.id, '(name:', sub.channelName + ')'); + return { sub, ucid: null, videos: [], error: 'no-id' }; + } + try { + const res = await fetch( + `https://www.youtube.com/feeds/videos.xml?channel_id=${ucid}`, + { credentials: 'include' } + ); + if (!res.ok) { + debugLog('Feed HTTP', res.status, 'for', sub.id); + return { sub, ucid, videos: [], error: 'http-' + res.status }; + } + const text = await res.text(); + const doc = new DOMParser().parseFromString(text, 'text/xml'); + if (doc.querySelector('parsererror')) { + debugLog('XML parse error for', sub.id); + return { sub, ucid, videos: [], error: 'parse' }; + } + const entries = Array.from(doc.getElementsByTagName('entry')); + const parsed = entries + .map((e) => parseFeedEntry(e, sub)) + .filter(Boolean) + .slice(0, PER_CHANNEL_LIMIT); + debugLog('Feed for', sub.channelName, `(${ucid}):`, parsed.length, 'videos'); + return { sub, ucid, videos: parsed, error: null }; + } catch (e) { + debugLog('fetchChannelFeed failed for', sub.id, e.message); + return { sub, ucid, videos: [], error: 'fetch:' + (e.message || 'failed') }; + } + } + + // Fetch a single video's duration (seconds) via YouTube's own player + // endpoint. RSS has no duration, so this is the account-free way to get it. + async function fetchDuration(videoId) { + try { + const res = await fetch( + 'https://www.youtube.com/youtubei/v1/player?prettyPrint=false', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + context: { client: { clientName: 'WEB', clientVersion: '2.20240731.00.00' } }, + videoId + }) + } + ); + if (!res.ok) return null; + const data = await res.json(); + const len = data && data.videoDetails && data.videoDetails.lengthSeconds; + const n = len ? parseInt(len, 10) : 0; + return n > 0 ? n : null; + } catch (e) { + return null; + } + } + + // Resolve durations for the given videos (uses + updates the persistent + // cache). Limited concurrency to avoid hammering YouTube. + async function attachDurations(videos) { + let cache = {}; + try { cache = await ytStorage.getDurationCache(); } catch (_) {} + + // Cap per-refresh fetches (videos are newest-first); the rest fill in on + // later refreshes. Cached durations are reused, so this is one-time work. + const missing = videos.filter((v) => v.videoId && !cache[v.videoId]).slice(0, 120); + if (missing.length > 0) { + let index = 0; + const worker = async () => { + while (index < missing.length) { + const v = missing[index++]; + const dur = await fetchDuration(v.videoId); + if (dur) cache[v.videoId] = dur; + } + }; + const workers = []; + for (let i = 0; i < Math.min(6, missing.length); i++) workers.push(worker()); + await Promise.all(workers); + try { await ytStorage.setDurationCache(cache); } catch (_) {} + } + + videos.forEach((v) => { + if (cache[v.videoId]) v.duration = cache[v.videoId]; + }); + } + + // Limited-concurrency map over subscriptions. + // Returns { videos: [...all], diagnostics: [{ name, ucid, count, error }] }. + async function fetchAllFeeds(subs, includeBackfill = false) { + const videos = []; + const diagnostics = []; + let index = 0; + async function worker() { + while (index < subs.length) { + const i = index++; + const result = await fetchChannelFeed(subs[i]); + let channelVideos = result.videos.slice(); + let backfillError = null; + let backfillCount = 0; + + if (includeBackfill && result.ucid) { + const existingIds = new Set(channelVideos.map((video) => video.videoId).filter(Boolean)); + const backfill = await feedCore.fetchChannelBackfill(result.sub, result.ucid, existingIds); + backfillError = backfill.error; + if (backfill.metadataById) { + channelVideos = channelVideos.map((video) => { + const metadata = backfill.metadataById[video.videoId]; + return metadata ? { ...video, ...metadata } : video; + }); + } + if (backfill.videos.length > 0) { + backfillCount = backfill.videos.length; + channelVideos = channelVideos.concat(backfill.videos); + } + } + + videos.push(...channelVideos); + diagnostics.push({ + name: result.sub.channelName || result.sub.id, + ucid: result.ucid || null, + count: channelVideos.length, + rssCount: result.videos.length, + backfillCount, + error: result.error || null, + backfillError + }); + } + } + const workers = []; + const workerCount = includeBackfill ? CHANNEL_BACKFILL_CONCURRENCY : FETCH_CONCURRENCY; + for (let i = 0; i < Math.min(workerCount, subs.length); i++) { + workers.push(worker()); + } + await Promise.all(workers); + return { videos, diagnostics }; + } + + // Refresh the aggregated feed and persist it to the cache. + async function refreshFeed(force = false) { + if (isRefreshing) return; + isRefreshing = true; + try { + const settings = await getFeedSettings(); + let subs = await ytStorage.getSubscriptionList(); + + if (subs.length === 0) { + await ytStorage.setFeedCache({ updatedAt: Date.now(), videos: [], policy: FEED_CACHE_POLICY }); + return; + } + + if (!force) { + const cache = await ytStorage.getFeedCache(); + const ageMs = Date.now() - (cache.updatedAt || 0); + if (cache.videos && cache.videos.length > 0 && + ageMs < settings.feedRefreshMinutes * 60 * 1000) { + return; // cache still fresh + } + } + + // Show the loading spinner right away (keeps existing videos on + // screen; the cache isn't replaced until the fetch completes). + if (isHomePage()) renderHomeFeed(); + + // Repair subscriptions. On a forced refresh, re-pull every channel's + // id/avatar/name from its own page (overwrite) to fix any stale or + // cross-contaminated data from earlier saves. Otherwise only fill in + // subscriptions missing an id or avatar. + const toEnrich = force ? subs : subs.filter((s) => !s.ucid || !s.thumbnail); + if (toEnrich.length > 0) { + for (const s of toEnrich) { + // Build the channel URL from the handle/id (reliable), not the + // stored url which could itself be stale/contaminated. + const handle = s.handle || (s.id && s.id.startsWith('@') ? s.id : null); + const url = handle + ? `https://www.youtube.com/${handle}` + : (s.ucid && /^UC[\w-]+$/.test(s.ucid) + ? `https://www.youtube.com/channel/${s.ucid}` + : s.url); + await enrichSubscription({ + channelId: s.id, + ucid: force ? null : s.ucid, // force: re-resolve ucid too + handle, + thumbnail: force ? null : s.thumbnail, + channelName: force ? null : s.channelName, + url + }, force); + } + subs = await ytStorage.getSubscriptionList(); + } + + // Auto-clean: if two subscriptions resolve to the same channel (same + // UC id), drop the redundant one (fixes duplicates from earlier bad + // saves where a subscription picked up the wrong channel's id). + const byUcid = new Map(); + const redundant = []; + for (const s of subs) { + if (s.ucid && /^UC[\w-]+$/.test(s.ucid)) { + if (byUcid.has(s.ucid)) { + redundant.push(s.id); + } else { + byUcid.set(s.ucid, s.id); + } + } + } + if (redundant.length > 0) { + for (const id of redundant) { + await ytStorage.removeSubscription(id); + } + subs = await ytStorage.getSubscriptionList(); + debugLog('Removed redundant duplicate subscriptions:', redundant); + } + + debugLog('Refreshing feed for', subs.length, 'subscriptions', '(including older uploads)'); + const { videos, diagnostics } = await fetchAllFeeds(subs, true); + // Dedupe by videoId (guards against two subs resolving to one channel). + const seen = new Set(); + const unique = videos.filter((v) => { + if (!v || !v.videoId || seen.has(v.videoId)) return false; + seen.add(v.videoId); + return true; + }); + const trimmed = feedCore.selectFeedVideos(unique, TOTAL_FEED_LIMIT); + // RSS has no duration — fetch + cache it so cards show a length badge. + await attachDurations(trimmed); + await ytStorage.setFeedCache({ updatedAt: Date.now(), videos: trimmed, diagnostics, policy: FEED_CACHE_POLICY }); + debugLog('Feed refreshed:', trimmed.length, 'videos', diagnostics); + } catch (e) { + console.error('[ythdb-subs] refreshFeed failed', e); + } finally { + isRefreshing = false; + } + } + + // Coalesce bursts of render requests (e.g. many sub_ writes during an + // import) into a single render to avoid the feed flickering. + let renderDebounceTimer = null; + function renderHomeFeedDebounced() { + if (renderDebounceTimer) clearTimeout(renderDebounceTimer); + renderDebounceTimer = setTimeout(() => { + renderDebounceTimer = null; + if (isHomePage()) renderHomeFeed(); + }, 400); + } + + let refreshTimer = null; + function scheduleFeedRefresh(force) { + if (refreshTimer) clearTimeout(refreshTimer); + refreshTimer = setTimeout(async () => { + await refreshFeed(force); + if (isHomePage()) renderHomeFeed(); + }, 300); + } + + // ------------------------------------------------------------------ + // Home feed rendering + // ------------------------------------------------------------------ + function getHomeAnchor() { + return document.querySelector('ytd-browse[page-subtype="home"] ytd-rich-grid-renderer') + || document.querySelector('ytd-browse[role="main"] ytd-rich-grid-renderer') + || document.querySelector('ytd-rich-grid-renderer'); + } + + function buildFeedCard(video, record) { + // Card is a container (not a single ) so the avatar/channel name can + // link to the channel while the thumbnail/title link to the video. + const card = document.createElement('div'); + card.className = 'ytvht-feed-card'; + + const thumbWrap = document.createElement('div'); + thumbWrap.className = 'ytvht-thumb-wrap'; + const img = document.createElement('img'); + img.loading = 'lazy'; + img.src = video.thumbnail || `https://i.ytimg.com/vi/${video.videoId}/hqdefault.jpg`; + img.alt = ''; + thumbWrap.appendChild(img); + + // Watched overlay: reuse content.js's classes so it matches the user's + // configured overlay colour/size and "viewed" label. + if (record && typeof record.time === 'number') { + const label = document.createElement('div'); + label.className = 'ytvht-viewed-label'; + label.textContent = (cachedSettings && cachedSettings.overlayTitle) || 'viewed'; + thumbWrap.appendChild(label); + + if (record.duration > 0) { + const progress = document.createElement('div'); + progress.className = 'ytvht-progress-bar'; + const pct = Math.max(0, Math.min(100, (record.time / record.duration) * 100)); + progress.style.width = `${pct}%`; + thumbWrap.appendChild(progress); + } + } + + // Duration badge (bottom-right): from the feed's fetched duration, or + // from watch history if available. + const durationSeconds = (video && video.duration > 0) + ? video.duration + : (record && record.duration > 0 ? record.duration : 0); + if (durationSeconds > 0) { + const dur = document.createElement('span'); + dur.className = 'ytvht-card-duration'; + dur.textContent = formatDuration(durationSeconds); + thumbWrap.appendChild(dur); + } + + const thumbLink = document.createElement('a'); + thumbLink.className = 'ytvht-thumb-link'; + thumbLink.href = video.url; + thumbLink.appendChild(thumbWrap); + + // Body: channel avatar (links to channel) + text column. + const body = document.createElement('div'); + body.className = 'ytvht-card-body'; + + const avatarLink = document.createElement('a'); + avatarLink.className = 'ytvht-avatar-link'; + avatarLink.href = video.channelUrl || video.url; + avatarLink.title = video.channelName || ''; + if (video.channelThumbnail) { + const avatar = document.createElement('img'); + avatar.className = 'ytvht-card-avatar'; + avatar.loading = 'lazy'; + avatar.src = video.channelThumbnail; + avatar.alt = ''; + avatarLink.appendChild(avatar); + } else { + const avatar = document.createElement('div'); + avatar.className = 'ytvht-card-avatar ytvht-avatar-placeholder'; + avatar.textContent = (video.channelName || '?').charAt(0).toUpperCase(); + avatarLink.appendChild(avatar); + } + body.appendChild(avatarLink); + + const text = document.createElement('div'); + text.className = 'ytvht-card-text'; + + const titleLink = document.createElement('a'); + titleLink.className = 'ytvht-card-title'; + titleLink.href = video.url; + titleLink.textContent = video.title; + titleLink.title = video.title; + + const channelLink = document.createElement('a'); + channelLink.className = 'ytvht-card-channel'; + channelLink.href = video.channelUrl || video.url; + channelLink.textContent = video.channelName || ''; + + const stats = document.createElement('div'); + stats.className = 'ytvht-card-stats'; + const when = relativeTime(video.published); + const viewsText = formatViews(video.views); + stats.textContent = [viewsText, when].filter(Boolean).join(' • '); + + text.appendChild(titleLink); + text.appendChild(channelLink); + if (stats.textContent) text.appendChild(stats); + body.appendChild(text); + + card.appendChild(thumbLink); + card.appendChild(body); + return card; + } + + function diagnosticStatusText(d) { + if (!d.error) return `OK — ${d.count} video${d.count === 1 ? '' : 's'}`; + if (d.error === 'no-id') return 'could not find channel ID (lookup failed)'; + if (d.error === 'parse') return 'feed could not be read'; + if (d.error.startsWith('http-')) return `feed request failed (HTTP ${d.error.slice(5)})`; + if (d.error.startsWith('fetch:')) return 'network/fetch blocked — ' + d.error.slice(6); + return d.error; + } + + function buildDiagnostics(diagnostics) { + const box = document.createElement('div'); + box.className = 'ytvht-feed-diag'; + const heading = document.createElement('div'); + heading.className = 'ytvht-feed-diag-title'; + heading.textContent = 'Channel status:'; + box.appendChild(heading); + diagnostics.forEach((d) => { + const line = document.createElement('div'); + line.className = d.error ? 'ytvht-diag-fail' : 'ytvht-diag-ok'; + line.textContent = `${d.error ? '✕' : '✓'} ${d.name} — ${diagnosticStatusText(d)}`; + box.appendChild(line); + }); + return box; + } + + // Apply the "clean, account-free" UI toggles via attributes on . + // The CSS (in injectStyles) is scoped so each attribute only affects the + // relevant elements. Runs on every page so account/subscription UI is + // hidden site-wide, not just on the home page. + function applyGlobalUiPreferences(settings) { + if (!settings) return; + const de = document.documentElement; + de.toggleAttribute('ytvht-hide-recs', false); + // Account mode hides sign-in/account chrome, but keeps YouTube's own + // Subscribe/Join controls visible on videos and channel pages. + de.toggleAttribute('ytvht-hide-account', false); + de.toggleAttribute('ytvht-hide-native-subs', false); + } + + // YouTube shows nag dialogs ("Recommendations not quite right?", "Your + // YouTube history is off", "Turn on history") that CSS can't target by text. + // Detect them by their wording and dismiss them when recommendations are + // hidden. Runs periodically. + const NAG_PHRASES = [ + 'recommendations not quite right', + 'turn on watch history', + 'turn on history', + 'your youtube history is off', + 'get the latest videos tailored', + 'more personalized recommendations' + ]; + function dismissNagPopups() { + if (!EMBED_YOUTUBE_HOME_FEED) return; + if (!cachedSettings || !cachedSettings.hideRecommendations) return; + const candidates = document.querySelectorAll( + 'tp-yt-paper-dialog, ytd-mealbar-promo-renderer, yt-confirm-dialog-renderer, ' + + 'ytd-popup-container ytd-popup-renderer, ytd-button-renderer.ytd-popup-container' + ); + let dismissedAny = false; + candidates.forEach((el) => { + const txt = (el.textContent || '').toLowerCase(); + if (!NAG_PHRASES.some((p) => txt.includes(p))) return; + // Prefer a graceful dismiss (keeps history off); else remove it. + const btn = el.querySelector( + '#dismiss-button, #close-button, [aria-label="Close"], [aria-label="Dismiss"], ' + + 'tp-yt-paper-button#cancel, yt-button-renderer#dismiss-button' + ); + if (btn) { + btn.click(); + } else { + el.remove(); + } + dismissedAny = true; + }); + if (dismissedAny) { + // Clear any leftover modal backdrop so the page isn't left dimmed. + document.querySelectorAll('tp-yt-iron-overlay-backdrop').forEach((b) => { + b.classList.remove('opened'); + b.style.display = 'none'; + }); + } + } + + // Serialize renders: renderHomeFeed is triggered by several events that can + // fire at once (navigation, refresh, storage changes). Without a lock, two + // runs can each create the feed section before the other finishes, leaving + // duplicate sections (and duplicated cards). The lock coalesces overlapping + // calls into a single trailing re-render. + let homeRenderRunning = false; + let homeRenderQueued = false; + async function renderHomeFeed() { + if (homeRenderRunning) { + homeRenderQueued = true; + return; + } + homeRenderRunning = true; + try { + await renderHomeFeedImpl(); + } catch (e) { + debugLog('renderHomeFeed error:', e.message); + } finally { + homeRenderRunning = false; + if (homeRenderQueued) { + homeRenderQueued = false; + renderHomeFeed(); + } + } + } + + async function renderHomeFeedImpl() { + const settings = cachedSettings || await getFeedSettings(); + applyGlobalUiPreferences(settings); + + if (!EMBED_YOUTUBE_HOME_FEED || !settings.localFeedEnabled || !isHomePage()) { + const stale = document.getElementById('ytvht-home-feed'); + if (stale) stale.remove(); + return; + } + + const anchor = getHomeAnchor(); + if (!anchor || !anchor.parentNode) return; // grid not ready yet + + // Remove any duplicate sections that may already exist, keep the first. + const existingSections = document.querySelectorAll('#ytvht-home-feed'); + for (let i = 1; i < existingSections.length; i++) { + existingSections[i].remove(); + } + + let section = existingSections[0] || null; + if (!section) { + section = document.createElement('div'); + section.id = 'ytvht-home-feed'; + anchor.parentNode.insertBefore(section, anchor); + } else if (section.nextSibling !== anchor) { + // Keep our section directly above the native grid after re-renders. + anchor.parentNode.insertBefore(section, anchor); + } + + const subs = await ytStorage.getSubscriptionList(); + const cache = await ytStorage.getFeedCache(); + const videos = cache.videos || []; + const diagnostics = cache.diagnostics || []; + if (subs.length > 0 && cache.policy !== FEED_CACHE_POLICY && !isRefreshing) { + scheduleFeedRefresh(true); + } + + // Header is cheap (text + button, no images) so rebuilding it never + // flickers — this is where the live count + loading spinner update. + const newHeader = buildFeedHeader(subs); + const oldHeader = section.querySelector('.ytvht-feed-header'); + if (oldHeader) oldHeader.replaceWith(newHeader); + else section.insertBefore(newHeader, section.firstChild); + + // Body (the grid with images) is rebuilt ONLY when the video set + // actually changes. During a refresh the cached list is unchanged, so + // the grid stays put (no image reload = no flicker); it rebuilds once + // when the new results arrive. + const displayPolicySig = 'newest-first-v5'; + const sig = subs.length === 0 + ? 'nosubs' + : (videos.length === 0 + ? ('empty:' + isRefreshing + ':' + diagnostics.length) + : (videos.map((v) => v.videoId + ':' + (v.channelThumbnail || '') + ':' + (v.duration || 0)).join(',') + + '|f:' + diagnostics.filter((d) => d.error).length + + '|p:' + displayPolicySig)); + + const existingBody = section.querySelector('.ytvht-feed-body'); + if (!existingBody || section.dataset.bodySig !== sig) { + const newBody = await buildFeedBody(subs, videos, diagnostics); + if (existingBody) existingBody.replaceWith(newBody); + else section.appendChild(newBody); + section.dataset.bodySig = sig; + } + } + + function buildFeedHeader(subs) { + const header = document.createElement('div'); + header.className = 'ytvht-feed-header'; + + const titleEl = document.createElement('span'); + titleEl.className = 'ytvht-feed-title'; + titleEl.textContent = 'Local Subscriptions'; + + const countEl = document.createElement('span'); + countEl.className = 'ytvht-feed-count'; + countEl.textContent = subs.length + ? `${subs.length} channel${subs.length === 1 ? '' : 's'}` + : ''; + if (isRefreshing) { + const loading = document.createElement('span'); + loading.className = 'ytvht-feed-loading'; + const spinner = document.createElement('span'); + spinner.className = 'ytvht-feed-spinner'; + loading.appendChild(spinner); + const loadingText = document.createElement('span'); + loadingText.textContent = 'Loading…'; + loading.appendChild(loadingText); + countEl.appendChild(document.createTextNode(' ')); + countEl.appendChild(loading); + } + + const refreshBtn = document.createElement('button'); + refreshBtn.className = 'ytvht-feed-refresh'; + refreshBtn.textContent = isRefreshing ? 'Refreshing…' : 'Refresh'; + refreshBtn.disabled = isRefreshing; + refreshBtn.addEventListener('click', async () => { + await refreshFeed(true); + await renderHomeFeed(); + }); + + header.appendChild(titleEl); + header.appendChild(countEl); + header.appendChild(refreshBtn); + return header; + } + + async function buildFeedBody(subs, videos, diagnostics) { + const body = document.createElement('div'); + body.className = 'ytvht-feed-body'; + + if (subs.length === 0) { + const empty = document.createElement('div'); + empty.className = 'ytvht-feed-empty'; + empty.textContent = 'No local subscriptions yet. Search in the extension and use Subscribe to build your feed.'; + body.appendChild(empty); + return body; + } + + if (videos.length === 0) { + const empty = document.createElement('div'); + empty.className = 'ytvht-feed-empty'; + empty.textContent = isRefreshing + ? 'Loading your subscription feed…' + : 'No videos found yet. Click Refresh to load the latest uploads.'; + body.appendChild(empty); + if (!isRefreshing && diagnostics.length > 0) { + body.appendChild(buildDiagnostics(diagnostics)); + } + if (!isRefreshing) scheduleFeedRefresh(false); + return body; + } + + // Load watch history once so cards can show progress / duration / labels. + const displayVideos = newestFirstVideos(videos); + let watched = {}; + try { + watched = (await ytStorage.getAllVideos()) || {}; + } catch (e) { + debugLog('getAllVideos failed (no watched overlays):', e.message); + } + + const grid = document.createElement('div'); + grid.className = 'ytvht-feed-grid'; + displayVideos.forEach((v) => grid.appendChild(buildFeedCard(v, watched[v.videoId]))); + body.appendChild(grid); + + const failed = diagnostics.filter((d) => d.error); + if (failed.length > 0) { + body.appendChild(buildDiagnostics(diagnostics)); + } + return body; + } + + // ------------------------------------------------------------------ + // Routing — react to YouTube's SPA navigation + // ------------------------------------------------------------------ + async function onRoute() { + const path = window.location.pathname; + lastRoutePath = path; + + await getFeedSettings(); + // Apply account/subscription hiding on every page (site-wide). + applyGlobalUiPreferences(cachedSettings); + + removeSubButton(); + removeHomeFeedIfPresent(); + } + + function removeHomeFeedIfPresent() { + const stale = document.getElementById('ytvht-home-feed'); + if (stale) stale.remove(); + // Recommendation-hiding CSS is route-scoped; preferences own the flag. + } + + let homeRetry = null; + function scheduleHomeRetry() { + if (homeRetry) clearInterval(homeRetry); + let attempts = 0; + homeRetry = setInterval(() => { + attempts++; + if (!isHomePage()) { + clearInterval(homeRetry); + homeRetry = null; + return; + } + if (document.getElementById('ytvht-home-feed') && getHomeAnchor()) { + clearInterval(homeRetry); + homeRetry = null; + return; + } + renderHomeFeed(); + if (attempts > 20) { + clearInterval(homeRetry); + homeRetry = null; + } + }, 500); + } + + // ------------------------------------------------------------------ + // External signals (popup messages, storage changes) + // ------------------------------------------------------------------ + function setupListeners() { + // Messages from the popup. + if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) { + chrome.runtime.onMessage.addListener((message) => { + if (!message || !message.type) return; + if (message.type === 'ytvhtRefreshFeed') { + (async () => { + await refreshFeed(true); + if (EMBED_YOUTUBE_HOME_FEED && isHomePage()) renderHomeFeed(); + })(); + } else if (message.type === 'ytvhtSubsChanged') { + scheduleFeedRefresh(true); + } else if (message.type === 'ytvhtSettingsChanged') { + (async () => { + cachedSettings = null; + await getFeedSettings(); + applyGlobalUiPreferences(cachedSettings); + if (EMBED_YOUTUBE_HOME_FEED && isHomePage()) renderHomeFeed(); + })(); + } + }); + } + + // React to subscription / feed-cache changes from any context. + const onChange = (changes, area) => { + if (area !== 'local') return; + const keys = Object.keys(changes); + const subChanged = keys.some((k) => k.startsWith('sub_')); + const feedChanged = keys.includes('feedCache'); + const settingsChanged = keys.includes('settings'); + if (settingsChanged) { + cachedSettings = null; + getFeedSettings().then(applyGlobalUiPreferences); + } + if (feedChanged && EMBED_YOUTUBE_HOME_FEED && isHomePage()) { + // Final aggregated result — render promptly. + renderHomeFeed(); + } + if (subChanged) { + // Subscriptions can change in rapid bursts (import); debounce. + if (EMBED_YOUTUBE_HOME_FEED && isHomePage()) renderHomeFeedDebounced(); + } + }; + if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.onChanged) { + chrome.storage.onChanged.addListener(onChange); + } else if (typeof browser !== 'undefined' && browser.storage && browser.storage.onChanged) { + browser.storage.onChanged.addListener(onChange); + } + + // SPA navigation hooks (mirrors content.js approach). + window.addEventListener('yt-navigate-finish', () => onRoute()); + window.addEventListener('yt-page-data-updated', () => onRoute()); + window.addEventListener('popstate', () => onRoute()); + + // Fallback: poll for URL changes. + let lastUrl = window.location.href; + setInterval(() => { + if (window.location.href !== lastUrl) { + lastUrl = window.location.href; + onRoute(); + } + }, 700); + + // Dismiss YouTube's recommendation / watch-history nag popups. + setInterval(dismissNagPopups, 1000); + } + + // ------------------------------------------------------------------ + // Init + // ------------------------------------------------------------------ + async function init() { + await waitForStorage(); + await getFeedSettings(); + injectStyles(); + applyGlobalUiPreferences(cachedSettings); + setupListeners(); + // Pre-warm the feed cache for the extension feed when enabled. + if (EMBED_YOUTUBE_HOME_FEED && isHomePage()) { + scheduleFeedRefresh(false); + } + onRoute(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // Test hook + if (typeof window !== 'undefined' && window.__YTVHT_TEST__) { + window.__YTVHT_TEST__.subscriptions = { + parseFeedEntry, + relativeTime, + getChannelPageInfo, + getWatchOwnerInfo + }; + } +})(); diff --git a/src/content-thumbnails.js b/src/content-thumbnails.js new file mode 100644 index 0000000..47f11f7 --- /dev/null +++ b/src/content-thumbnails.js @@ -0,0 +1,683 @@ +(function() { + 'use strict'; + + function createContentThumbnailHelpers(dependencies) { + const log = dependencies.log; + const getStorage = dependencies.getStorage; + const getCurrentSettings = dependencies.getCurrentSettings; + const updateOverlayCSS = dependencies.updateOverlayCSS; + const overlayColors = dependencies.overlayColors; + const overlayLabelSizeMap = dependencies.overlayLabelSizeMap; + const getAccentOverlayColor = dependencies.getAccentOverlayColor; + const pendingOperations = dependencies.pendingOperations; + + function extractVideoIdFromText(value) { + const text = String(value || ''); + if (!text) return null; + const watchMatch = text.match(/[?&]v=([^&/#]+)/); + if (watchMatch) return watchMatch[1]; + const shortsMatch = text.match(/\/shorts\/([^/?#]+)/); + if (shortsMatch) return shortsMatch[1]; + const imageMatch = text.match(/\/vi(?:_webp)?\/([a-zA-Z0-9_-]{11})\//); + if (imageMatch) return imageMatch[1]; + return null; + } + + function getVideoIdFromUrlElement(element) { + if (!element) return null; + return extractVideoIdFromText(element.href) || + extractVideoIdFromText(element.getAttribute?.('href')) || + extractVideoIdFromText(element.getAttribute?.('data-url')) || + extractVideoIdFromText(element.getAttribute?.('aria-label')) || + extractVideoIdFromText(element.getAttribute?.('title')); + } + + function getVideoIdFromThumbnail(thumbnail) { + if (thumbnail.tagName === 'YTD-PLAYLIST-PANEL-VIDEO-RENDERER' || thumbnail.closest('ytd-playlist-panel-video-renderer')) { + const videoLink = thumbnail.querySelector('a#wc-endpoint[href*="watch?v="]'); + if (videoLink) { + return getVideoIdFromUrlElement(videoLink); + } + + const thumbnailLink = thumbnail.querySelector('a#thumbnail[href*="watch?v="]'); + if (thumbnailLink) { + return getVideoIdFromUrlElement(thumbnailLink); + } + } + + if (thumbnail.tagName === 'YTD-PLAYLIST-VIDEO-RENDERER' || thumbnail.closest('ytd-playlist-video-renderer')) { + const videoId = thumbnail.getAttribute('data-video-id') || thumbnail.getAttribute('video-id'); + if (videoId) return videoId; + + const playlistLink = thumbnail.querySelector('a#video-title[href*="watch?v="], a#thumbnail[href*="watch?v="]'); + if (playlistLink) { + return getVideoIdFromUrlElement(playlistLink); + } + } + + if (thumbnail.tagName === 'YTD-COMPACT-VIDEO-RENDERER' || thumbnail.closest('ytd-compact-video-renderer')) { + const videoId = thumbnail.getAttribute('video-id'); + if (videoId) return videoId; + + const compactLink = thumbnail.querySelector('a#thumbnail[href*="watch?v="]'); + if (compactLink) { + return getVideoIdFromUrlElement(compactLink); + } + } + + if (thumbnail.tagName === 'YT-LOCKUP-VIEW-MODEL' || thumbnail.closest('yt-lockup-view-model')) { + const lockupLink = thumbnail.querySelector('a[href*="watch?v="]'); + if (lockupLink) { + return getVideoIdFromUrlElement(lockupLink); + } + } + + let anchor = thumbnail.querySelector('a#thumbnail[href*="watch?v="], a#video-title[href*="watch?v="]'); + if (anchor) { + return getVideoIdFromUrlElement(anchor); + } + + anchor = thumbnail.querySelector('a[href*="/watch?v="]'); + if (anchor) { + return getVideoIdFromUrlElement(anchor); + } + + const dataVideoId = thumbnail.getAttribute('data-video-id') || + thumbnail.getAttribute('data-context-item-id') || + thumbnail.getAttribute('data-content-id'); + if (dataVideoId && /^[a-zA-Z0-9_-]{11}$/.test(dataVideoId)) { + return dataVideoId; + } + + if (thumbnail.tagName === 'A' && (thumbnail.id === 'thumbnail' || thumbnail.id === 'video-title')) { + const videoId = getVideoIdFromUrlElement(thumbnail); + if (videoId) return videoId; + } + + anchor = thumbnail.querySelector('a[href*="/shorts/"]'); + if (anchor) { + return getVideoIdFromUrlElement(anchor); + } + + const images = thumbnail.querySelectorAll?.('img') || []; + for (const image of images) { + const videoId = extractVideoIdFromText(image.currentSrc) || + extractVideoIdFromText(image.src) || + extractVideoIdFromText(image.srcset) || + extractVideoIdFromText(image.getAttribute('src')) || + extractVideoIdFromText(image.getAttribute('srcset')); + if (videoId) return videoId; + } + + return null; + } + + function removeYtvhtOverlayNodes(targetElement) { + targetElement?.querySelectorAll('.ytvht-viewed-label, .ytvht-progress-mask, .ytvht-progress-bar, .ytvht-native-progress-line, .ytvht-remove-button') + .forEach((node) => node.remove()); + } + + function removeYtvhtOverlay(targetElement) { + removeYtvhtOverlayNodes(targetElement); + clearNativePseudoOverlay(targetElement); + } + + function clearNativePseudoOverlay(element) { + if (!element) return; + element.classList?.remove('ytvht-native-overlay-target'); + element.classList?.remove('ytvht-native-page-overlay'); + element.classList?.remove('ytvht-native-overlay-no-progress'); + if (element.dataset?.ytvhtLabel) delete element.dataset.ytvhtLabel; + if (element.style) { + element.style.removeProperty('--ytvht-overlay-color'); + element.style.removeProperty('--ytvht-label-font-size'); + element.style.removeProperty('--ytvht-label-padding'); + element.style.removeProperty('--ytvht-progress-height'); + element.style.removeProperty('--ytvht-progress-width'); + } + } + + function applyNativePseudoOverlay(targetElement, settings, size, color, progressPercent, showProgress = true) { + removeYtvhtOverlay(targetElement); + targetElement.classList.add('ytvht-native-overlay-target'); + targetElement.classList.toggle('ytvht-native-overlay-no-progress', !showProgress); + targetElement.dataset.ytvhtLabel = settings.overlayTitle || 'Viewed'; + targetElement.style.setProperty('--ytvht-overlay-color', color); + targetElement.style.setProperty('--ytvht-label-font-size', `${size.fontSize}px`); + targetElement.style.setProperty('--ytvht-label-padding', `${size.fontSize / 2}px 4px`); + targetElement.style.setProperty('--ytvht-progress-height', `${size.bar}px`); + targetElement.style.setProperty('--ytvht-progress-width', `${progressPercent}%`); + } + + function removeDuplicateOverlays(targetElement, selectors) { + selectors.forEach((selector) => { + targetElement.querySelectorAll(selector).forEach((node, index) => { + if (index > 0) node.remove(); + }); + }); + } + + function removeStaleThumbnailOverlays(thumbnailElement, targetElement) { + thumbnailElement.querySelectorAll('.ytvht-viewed-label, .ytvht-progress-mask, .ytvht-progress-bar, .ytvht-remove-button') + .forEach((node) => { + if (!targetElement.contains(node)) node.remove(); + }); + } + + function clearNativeYouTubeOverlayState(thumbnailElement) { + thumbnailElement.querySelectorAll('.ytvht-viewed-label, .ytvht-progress-mask, .ytvht-progress-bar, .ytvht-remove-button') + .forEach((node) => node.remove()); + thumbnailElement.querySelectorAll('.ytvht-has-overlay') + .forEach((node) => { + node.classList.remove('ytvht-has-overlay'); + if (node.dataset?.ytvhtVideoId) delete node.dataset.ytvhtVideoId; + }); + thumbnailElement.classList?.remove('ytvht-has-overlay'); + if (thumbnailElement.dataset?.ytvhtVideoId) delete thumbnailElement.dataset.ytvhtVideoId; + } + + function clearAllNativeYouTubeOverlayState() { + document.querySelectorAll('.ytvht-viewed-label, .ytvht-progress-mask, .ytvht-progress-bar, .ytvht-remove-button') + .forEach((node) => node.remove()); + document.querySelectorAll('.ytvht-has-overlay, [data-ytvht-video-id]') + .forEach((node) => { + node.classList.remove('ytvht-has-overlay'); + if (node.dataset?.ytvhtVideoId) delete node.dataset.ytvhtVideoId; + }); + } + + function clearNativeOverlayTree(root) { + if (!root) return; + const nodes = [root]; + if (typeof root.querySelectorAll === 'function') { + nodes.push(...root.querySelectorAll('.ytvht-has-overlay, .ytvht-native-page-overlay, .ytvht-native-overlay-target, [data-ytvht-video-id], [data-ytvht-label]')); + } + + nodes.forEach((node) => { + removeYtvhtOverlayNodes(node); + clearNativePseudoOverlay(node); + node.classList?.remove('ytvht-has-overlay', 'ytvht-native-page-overlay'); + if (node.dataset?.ytvhtVideoId) delete node.dataset.ytvhtVideoId; + }); + } + + function isExtensionFeedPage() { + return !!document.getElementById('ytvht-home-feed'); + } + + function isBlockedNativeOverlaySurface(element) { + if (!element || isExtensionFeedPage()) return false; + return !!element.closest([ + 'ytd-notification-renderer', + 'ytd-notification-container-renderer', + 'ytd-notification-topbar-button-renderer', + 'ytd-multi-page-menu-renderer', + 'ytd-popup-container', + 'ytd-menu-popup-renderer', + 'tp-yt-iron-dropdown', + 'iron-dropdown', + '[role="menu"]', + '[aria-label="Notifications"]' + ].join(', ')); + } + + function clearBlockedNativeOverlaySurfaces(root = document) { + if (isExtensionFeedPage()) return; + root.querySelectorAll?.([ + 'ytd-notification-renderer', + 'ytd-notification-container-renderer', + 'ytd-notification-topbar-button-renderer', + 'ytd-multi-page-menu-renderer', + 'ytd-popup-container', + 'ytd-menu-popup-renderer', + 'tp-yt-iron-dropdown', + 'iron-dropdown', + '[role="menu"]', + '[aria-label="Notifications"]' + ].join(', ')).forEach((surface) => clearNativeOverlayTree(surface)); + } + + function removeNativePageProgressBars(root = document) { + if (isExtensionFeedPage()) return; + root.querySelectorAll?.('.ytvht-progress-mask, .ytvht-progress-bar') + .forEach((node) => node.remove()); + } + + function updateNativeOverlayMode() { + const de = document.documentElement; + const extensionFeed = isExtensionFeedPage(); + de.toggleAttribute('ytvht-extension-feed', extensionFeed); + de.toggleAttribute('ytvht-native-badge-only', !extensionFeed); + removeNativePageProgressBars(); + } + + function findThumbnailOverlayTarget(thumbnailElement) { + const image = thumbnailElement.querySelector('.ytThumbnailViewModelImage, .yt-thumbnail-view-model__image'); + return image?.parentElement || + thumbnailElement.querySelector('.yt-lockup-view-model-wiz__content-image img')?.parentElement || + thumbnailElement.querySelector('.yt-lockup-view-model-wiz__content-image') || + thumbnailElement.querySelector('#thumbnail') || + thumbnailElement.querySelector('a#thumbnail[href*="/watch?v="]') || + thumbnailElement.querySelector('a[href*="/watch?v="]') || + thumbnailElement.querySelector('yt-thumbnail-view-model') || + thumbnailElement.querySelector('.ytThumbnailViewModelHost') || + thumbnailElement.querySelector('ytd-thumbnail') || + thumbnailElement.querySelector('a[href*="/watch?v="]'); + } + + function findNativeThumbnailOverlayTarget(thumbnailElement) { + if (!thumbnailElement) return null; + if (thumbnailElement.matches?.('ytd-thumbnail, yt-thumbnail-view-model, .ytThumbnailViewModelHost, a#thumbnail')) { + return thumbnailElement; + } + return thumbnailElement.querySelector?.([ + 'ytd-thumbnail', + 'yt-thumbnail-view-model', + '.ytThumbnailViewModelHost', + 'a#thumbnail[href*="/watch?v="]', + 'a#thumbnail[href*="/shorts/"]' + ].join(', ')) || null; + } + + function renderNativeDomOverlay(targetElement, settings, size, color, progressPercent, showProgress = true) { + removeYtvhtOverlay(targetElement); + clearNativePseudoOverlay(targetElement); + targetElement.style.position = 'relative'; + + const label = document.createElement('div'); + label.className = 'ytvht-viewed-label ytvht-native-dom-label'; + label.dataset.ytvhtOverlayNode = 'true'; + label.textContent = `${Math.round(progressPercent)}%`; + label.style.setProperty('background-color', color, 'important'); + label.style.setProperty('display', 'block', 'important'); + label.style.setProperty('font-size', `${Math.max(11, size.fontSize - 4)}px`, 'important'); + label.style.setProperty('line-height', '1.2', 'important'); + label.style.setProperty('padding', '4px 6px', 'important'); + label.style.setProperty('border-radius', '6px', 'important'); + label.style.setProperty('z-index', '2147483647', 'important'); + targetElement.appendChild(label); + + if (showProgress) { + const progress = document.createElement('div'); + progress.className = 'ytvht-native-progress-line'; + progress.dataset.ytvhtOverlayNode = 'true'; + progress.style.setProperty('background-color', color, 'important'); + progress.style.setProperty('height', `${size.bar}px`, 'important'); + progress.style.setProperty('width', `${progressPercent}%`, 'important'); + targetElement.appendChild(progress); + } + } + + function addViewedLabelToThumbnail(thumbnailElement, videoId) { + if (!thumbnailElement || !videoId) return; + + const currentSettings = getCurrentSettings(); + const isCompact = !!(thumbnailElement.tagName === 'YTD-PLAYLIST-PANEL-VIDEO-RENDERER' || + thumbnailElement.tagName === 'YTD-COMPACT-VIDEO-RENDERER' || + thumbnailElement.tagName === 'YTD-COMPACT-RADIO-RENDERER' || + thumbnailElement.closest('ytd-playlist-panel-video-renderer, ytd-compact-video-renderer, ytd-compact-radio-renderer')); + const isNativeYouTubePage = !isExtensionFeedPage(); + const isLockupCard = !!(thumbnailElement.tagName === 'YT-LOCKUP-VIEW-MODEL' || + thumbnailElement.closest('yt-lockup-view-model')); + updateNativeOverlayMode(); + + if (isNativeYouTubePage && isBlockedNativeOverlaySurface(thumbnailElement)) { + clearNativeOverlayTree(thumbnailElement); + return; + } + + let targetElement = thumbnailElement; + + if (isLockupCard) { + const thumbnailContainer = findThumbnailOverlayTarget(thumbnailElement); + targetElement = thumbnailContainer || thumbnailElement; + } else if (thumbnailElement.tagName === 'YTD-PLAYLIST-PANEL-VIDEO-RENDERER' || thumbnailElement.closest('ytd-playlist-panel-video-renderer')) { + const thumbnailContainer = thumbnailElement.querySelector('#thumbnail-container ytd-thumbnail') || + thumbnailElement.querySelector('ytd-thumbnail') || + thumbnailElement.querySelector('#thumbnail-container'); + if (thumbnailContainer) { + targetElement = thumbnailContainer; + } else { + return; + } + } else if (thumbnailElement.tagName === 'YTD-PLAYLIST-VIDEO-RENDERER' || thumbnailElement.closest('ytd-playlist-video-renderer')) { + const thumbnailContainer = thumbnailElement.querySelector('ytd-thumbnail') || + thumbnailElement.querySelector('a#thumbnail'); + if (thumbnailContainer) { + targetElement = thumbnailContainer; + } else { + return; + } + } else if (thumbnailElement.tagName !== 'YTD-THUMBNAIL' && !(thumbnailElement.tagName === 'A' && thumbnailElement.id === 'thumbnail')) { + const image = thumbnailElement.querySelector('.ytThumbnailViewModelImage, .yt-thumbnail-view-model__image'); + const inner = image?.parentElement || + thumbnailElement.querySelector('.yt-lockup-view-model-wiz__content-image img')?.parentElement || + thumbnailElement.querySelector('.yt-lockup-view-model-wiz__content-image, a#thumbnail, a[href*="/watch?v="], yt-thumbnail-view-model, .ytThumbnailViewModelHost, ytd-thumbnail'); + targetElement = inner || thumbnailElement; + } + + if (isNativeYouTubePage) { + const nativeTarget = findNativeThumbnailOverlayTarget(thumbnailElement); + if (!nativeTarget) { + clearNativeOverlayTree(thumbnailElement); + return; + } + targetElement = nativeTarget; + } + + if (isNativeYouTubePage && isBlockedNativeOverlaySurface(targetElement)) { + clearNativeOverlayTree(targetElement); + return; + } + + targetElement.style.position = 'relative'; + removeStaleThumbnailOverlays(thumbnailElement, targetElement); + if (targetElement.dataset.ytvhtVideoId && targetElement.dataset.ytvhtVideoId !== videoId) { + removeYtvhtOverlay(targetElement); + } + targetElement.dataset.ytvhtVideoId = videoId; + thumbnailElement.dataset.ytvhtVideoId = videoId; + const overlayHosts = Array.from(new Set([ + targetElement, + thumbnailElement, + targetElement.closest('yt-thumbnail-view-model, .ytThumbnailViewModelHost'), + targetElement.closest('yt-lockup-view-model, ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-rich-grid-media, ytd-video-renderer, ytd-compact-video-renderer, ytd-compact-radio-renderer, ytd-playlist-video-renderer, ytd-playlist-panel-video-renderer'), + targetElement.closest('a[href*="/watch"], a[href*="/shorts/"]') + ].filter(Boolean))); + const setOverlayHostClass = (enabled) => { + overlayHosts.forEach((host) => { + host.classList.toggle('ytvht-has-overlay', enabled); + host.classList.toggle('ytvht-native-page-overlay', enabled && isNativeYouTubePage); + }); + }; + + let label = targetElement.querySelector('.ytvht-viewed-label'); + let progressMask = targetElement.querySelector('.ytvht-progress-mask'); + let progress = targetElement.querySelector('.ytvht-progress-bar'); + let removeBtn = targetElement.querySelector('.ytvht-remove-button'); + + getStorage().getVideo(videoId).then(record => { + if (!targetElement.isConnected || targetElement.dataset.ytvhtVideoId !== videoId) { + return; + } + + if (record) { + removeDuplicateOverlays(targetElement, [ + '.ytvht-viewed-label', + '.ytvht-progress-mask', + '.ytvht-progress-bar', + '.ytvht-remove-button' + ]); + setOverlayHostClass(true); + const settings = getCurrentSettings(); + const size = overlayLabelSizeMap[settings.overlayLabelSize] || overlayLabelSizeMap.medium; + const color = getAccentOverlayColor + ? getAccentOverlayColor(settings) + : (overlayColors[settings.accentColor] || overlayColors[settings.overlayColor] || overlayColors.blue); + + updateOverlayCSS(size, color); + + const duration = Number(record.duration || 0); + const time = Number(record.time || 0); + const progressPercent = duration > 0 + ? Math.max(0, Math.min(100, (time / duration) * 100)) + : 100; + + if (isNativeYouTubePage) { + overlayHosts.forEach((host) => removeYtvhtOverlayNodes(host)); + clearNativePseudoOverlay(targetElement); + renderNativeDomOverlay(targetElement, settings, size, color, progressPercent, true); + return; + } + + if (!label) { + label = document.createElement('div'); + label.className = 'ytvht-viewed-label'; + label.dataset.ytvhtOverlayNode = 'true'; + targetElement.appendChild(label); + } + label.classList.toggle('ytvht-compact', isCompact); + + if (label.textContent !== settings.overlayTitle) { + label.textContent = settings.overlayTitle; + } + + if (!progressMask) { + progressMask = document.createElement('div'); + progressMask.className = 'ytvht-progress-mask'; + progressMask.dataset.ytvhtOverlayNode = 'true'; + targetElement.appendChild(progressMask); + } + progressMask.classList.toggle('ytvht-compact', isCompact); + + if (!progress) { + progress = document.createElement('div'); + progress.className = 'ytvht-progress-bar'; + progress.dataset.ytvhtOverlayNode = 'true'; + targetElement.appendChild(progress); + } + progress.classList.toggle('ytvht-compact', isCompact); + + const newWidth = `${progressPercent}%`; + if (progress.style.width !== newWidth) { + progress.style.width = newWidth; + } + + if (!removeBtn) { + removeBtn = document.createElement('button'); + removeBtn.className = 'ytvht-remove-button'; + removeBtn.dataset.ytvhtOverlayNode = 'true'; + removeBtn.setAttribute('type', 'button'); + removeBtn.setAttribute('title', 'Remove from YT re:Watch history'); + removeBtn.textContent = '×'; + removeBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + getStorage().removeVideo(videoId).then(() => { + label?.remove(); + progress?.remove(); + removeBtn?.remove(); + }).catch(() => { + // no-op: silent fail + }); + }, { once: false }); + targetElement.appendChild(removeBtn); + } + } else { + setOverlayHostClass(false); + removeYtvhtOverlay(targetElement); + delete targetElement.dataset.ytvhtVideoId; + delete thumbnailElement.dataset.ytvhtVideoId; + } + }).catch(error => { + log('[Error] Failed to process thumbnail', { videoId, error }); + setOverlayHostClass(false); + removeYtvhtOverlay(targetElement); + delete targetElement.dataset.ytvhtVideoId; + delete thumbnailElement.dataset.ytvhtVideoId; + }); + } + + function processExistingThumbnails() { + updateNativeOverlayMode(); + if (!isExtensionFeedPage()) { + clearBlockedNativeOverlaySurfaces(); + document.querySelectorAll('.ytvht-native-overlay-target') + .forEach((element) => { + if (!element.closest('ytd-playlist-panel-video-renderer, ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-rich-grid-media, ytd-compact-video-renderer, ytd-compact-radio-renderer, ytd-video-renderer, yt-lockup-view-model')) { + clearNativeOverlayTree(element); + } + }); + } + const selectors = [ + 'ytd-playlist-panel-video-renderer', + 'ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-rich-grid-media', + 'ytd-compact-video-renderer, ytd-compact-radio-renderer', + 'ytd-video-renderer, yt-lockup-view-model' + ]; + + selectors.forEach(selector => { + document.querySelectorAll(selector).forEach(element => processVideoElement(element)); + }); + } + + function processVideoElement(element) { + const currentSettings = getCurrentSettings(); + if (!element || !element.isConnected) { + if (currentSettings?.debug) log('[Overlay] Skipping invalid or disconnected element'); + return; + } + + if (!isExtensionFeedPage() && isBlockedNativeOverlaySurface(element)) { + clearNativeOverlayTree(element); + return; + } + + if (pendingOperations.has(element)) { + const ops = pendingOperations.get(element); + if (ops.timeout) { + if (currentSettings?.debug) log('[Overlay] Clearing existing timeout for element'); + clearTimeout(ops.timeout); + } + if (ops.rafId) { + if (currentSettings?.debug) log('[Overlay] Cancelling existing animation frame for element'); + cancelAnimationFrame(ops.rafId); + } + pendingOperations.delete(element); + } + + const ops = {}; + + const process = (retryCount = 0) => { + const settings = getCurrentSettings(); + if (!element.isConnected) { + if (settings?.debug) log('[Overlay] Element no longer connected, aborting'); + return; + } + + const videoId = getVideoIdFromThumbnail(element); + if (videoId) { + addViewedLabelToThumbnail(element, videoId); + return; + } + + if (retryCount < 2) { + const delay = 100 * (retryCount + 1); + ops.timeout = setTimeout(() => { + ops.timeout = null; + process(retryCount + 1); + }, delay); + pendingOperations.set(element, ops); + } + }; + + ops.rafId = requestAnimationFrame(() => { + ops.rafId = null; + process(); + }); + + pendingOperations.set(element, ops); + } + + const thumbnailObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes') { + const target = mutation.target; + if (!isExtensionFeedPage() && isBlockedNativeOverlaySurface(target)) { + clearNativeOverlayTree(target); + return; + } + if (target.tagName === 'IMG' && target.id === 'img') { + const videoElement = target.closest('ytd-playlist-panel-video-renderer, ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-rich-grid-media, ytd-compact-video-renderer, ytd-compact-radio-renderer, ytd-video-renderer, yt-lockup-view-model'); + if (videoElement) { + processVideoElement(videoElement); + } + } + return; + } + + const repairTarget = mutation.target?.closest?.('[data-ytvht-video-id]'); + if (repairTarget && + !isBlockedNativeOverlaySurface(repairTarget) && + !repairTarget.classList.contains('ytvht-native-overlay-target') && + !repairTarget.querySelector('.ytvht-viewed-label')) { + const videoElement = repairTarget.closest('ytd-playlist-panel-video-renderer, ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-rich-grid-media, ytd-compact-video-renderer, ytd-compact-radio-renderer, ytd-video-renderer, yt-lockup-view-model'); + if (videoElement) { + processVideoElement(videoElement); + } + } + + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + removeNativePageProgressBars(node); + if (!isExtensionFeedPage()) { + clearBlockedNativeOverlaySurfaces(node); + if (isBlockedNativeOverlaySurface(node)) { + clearNativeOverlayTree(node); + return; + } + } + + if (node.tagName && ( + node.tagName === 'YTD-PLAYLIST-PANEL-VIDEO-RENDERER' || + node.tagName === 'YTD-RICH-ITEM-RENDERER' || + node.tagName === 'YTD-GRID-VIDEO-RENDERER' || + node.tagName === 'YTD-RICH-GRID-MEDIA' || + node.tagName === 'YTD-VIDEO-RENDERER' || + node.tagName === 'YT-LOCKUP-VIEW-MODEL' || + node.tagName === 'YTD-COMPACT-VIDEO-RENDERER' || + node.tagName === 'YTD-COMPACT-RADIO-RENDERER' + )) { + processVideoElement(node); + } + + const videoElements = node.querySelectorAll('ytd-playlist-panel-video-renderer, ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-rich-grid-media, ytd-compact-video-renderer, ytd-compact-radio-renderer, ytd-video-renderer, yt-lockup-view-model'); + if (videoElements.length > 0) { + videoElements.forEach(element => processVideoElement(element)); + } + } + }); + }); + }); + + function startRemovedElementCleanupObserver() { + if (typeof MutationObserver === 'undefined' || window.ytvhtCleanupObserver) return; + + window.ytvhtCleanupObserver = new MutationObserver((mutations) => { + if (!getCurrentSettings()?.debug) return; + + mutations.forEach((mutation) => { + mutation.removedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const elements = [node, ...node.querySelectorAll('*')]; + elements.forEach(el => { + if (pendingOperations.has(el)) { + const ops = pendingOperations.get(el); + if (ops.timeout) clearTimeout(ops.timeout); + if (ops.rafId) cancelAnimationFrame(ops.rafId); + pendingOperations.delete(el); + } + }); + } + }); + }); + }); + + window.ytvhtCleanupObserver.observe(document.body, { + childList: true, + subtree: true + }); + } + + return { + thumbnailObserver, + processExistingThumbnails, + processVideoElement, + startRemovedElementCleanupObserver + }; + } + + window.YTVHTContentThumbnails = { + create: createContentThumbnailHelpers + }; +})(); diff --git a/src/content-url.js b/src/content-url.js new file mode 100644 index 0000000..93e6fc0 --- /dev/null +++ b/src/content-url.js @@ -0,0 +1,189 @@ +(function() { + 'use strict'; + + function createContentUrlHelpers(dependencies) { + const log = dependencies.log; + const getStorage = dependencies.getStorage; + + function getVideoId() { + const url = window.location.href; + + const urlParams = new URLSearchParams(window.location.search); + const videoId = urlParams.get('v'); + if (videoId) return videoId; + + const patterns = [ + /(?:youtube\.com\/watch\/([^\/\?]+))/i, + /(?:youtube\.com\/embed\/([^\/\?]+))/i, + /(?:youtube\.com\/v\/([^\/\?]+))/i, + /(?:youtu\.be\/([^\/\?]+))/i, + /(?:youtube\.com\/shorts\/([^\/\?]+))/i + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match && match[1]) { + return match[1]; + } + } + + const pathSegments = window.location.pathname.split('/').filter(Boolean); + if (pathSegments.length > 0) { + const lastSegment = pathSegments[pathSegments.length - 1]; + if (/^[a-zA-Z0-9_-]{11}$/.test(lastSegment)) { + return lastSegment; + } + } + + log('Could not extract video ID from URL:', url); + return null; + } + + function getCleanVideoUrl() { + const videoId = getVideoId(); + if (!videoId) return null; + return `https://www.youtube.com/watch?v=${videoId}`; + } + + function cleanVideoUrl(url) { + if (!url) return url; + + let absoluteUrl = url; + if (url.startsWith('/')) { + absoluteUrl = 'https://www.youtube.com' + url; + } else if (!url.startsWith('http://') && !url.startsWith('https://')) { + if (url.includes('youtube.com') || url.includes('youtu.be')) { + absoluteUrl = 'https://' + url.replace(/^https?:\/\//, ''); + } else { + return url; + } + } + + try { + const urlObj = new URL(absoluteUrl); + const videoId = urlObj.searchParams.get('v') || + (urlObj.pathname.includes('/shorts/') ? urlObj.pathname.split('/shorts/')[1]?.split('/')[0] : null); + + if (!videoId) return url; + + if (urlObj.pathname.includes('/shorts/')) { + return `https://www.youtube.com/shorts/${videoId}`; + } + return `https://www.youtube.com/watch?v=${videoId}`; + } catch (e) { + const videoIdMatch = absoluteUrl.match(/[?&]v=([^&]+)/) || absoluteUrl.match(/\/shorts\/([^\/\?]+)/); + if (videoIdMatch) { + const videoId = videoIdMatch[1]; + if (absoluteUrl.includes('/shorts/')) { + return `https://www.youtube.com/shorts/${videoId}`; + } + return `https://www.youtube.com/watch?v=${videoId}`; + } + return url; + } + } + + function addTimestampToUrl(url, timeSeconds) { + if (!url || !timeSeconds || timeSeconds <= 0) return url; + + try { + const cleanUrl = cleanVideoUrl(url); + const urlToUse = cleanUrl || url; + + try { + const urlObj = new URL(urlToUse); + urlObj.searchParams.set('t', Math.floor(timeSeconds) + 's'); + return urlObj.toString(); + } catch (e) { + if (urlToUse.includes('watch?v=') || urlToUse.includes('/shorts/')) { + const separator = urlToUse.includes('?') ? '&' : '?'; + return `${urlToUse}${separator}t=${Math.floor(timeSeconds)}s`; + } + return urlToUse; + } + } catch (error) { + log(`[Content] Failed to add timestamp to URL: ${error} (${url})`); + return url; + } + } + + async function addTimestampToLink(anchor) { + const href = anchor.getAttribute('href'); + if (!href) return; + + log(`[Link Intercept] Processing anchor with href: ${href}`); + + let videoId = null; + if (href.includes('watch?v=')) { + const match = href.match(/[?&]v=([^&]+)/); + videoId = match ? match[1] : null; + } else if (href.includes('/shorts/')) { + const match = href.match(/\/shorts\/([^\/\?]+)/); + videoId = match ? match[1] : null; + } + + if (!videoId) { + log(`[Link Intercept] No video ID found in href: ${href}`); + return; + } + + log(`[Link Intercept] Found video ID: ${videoId}`); + + try { + const record = await getStorage().getVideo(videoId); + log(`[Link Intercept] Retrieved record for ${videoId}:`, record); + + if (record && record.time && record.time > 0) { + const newUrl = addTimestampToUrl(href, record.time); + log(`[Link Intercept] Original URL: ${href}`); + log(`[Link Intercept] Modified URL: ${newUrl}`); + + if (newUrl !== href) { + anchor.setAttribute('href', newUrl); + log(`[Link Intercept] Added timestamp ${record.time}s to video ${videoId}`); + } else { + log('[Link Intercept] URL unchanged, timestamp not added'); + } + } else { + log(`[Link Intercept] No saved progress found for video ${videoId}`); + } + } catch (error) { + log(`[Link Intercept] Failed to check video ${videoId}:`, error); + } + } + + function interceptVideoLinkClicks() { + document.addEventListener('click', async (e) => { + let anchor = e.target.closest('a[href*="watch?v="], a[href*="/shorts/"]'); + if (!anchor) { + const overlayElement = e.target.closest('.ytvht-viewed-label, .ytvht-progress-bar'); + if (overlayElement) { + anchor = overlayElement.closest('a[href*="watch?v="], a[href*="/shorts/"]'); + log(`[Click Intercept] Click on overlay, found anchor: ${anchor ? anchor.href : 'none'}`); + } + } + + if (!anchor) { + log('[Click Intercept] No anchor found for click target:', e.target); + return; + } + + log(`[Click Intercept] Found anchor for video link: ${anchor.href}`); + await addTimestampToLink(anchor); + }, true); + } + + return { + getVideoId, + getCleanVideoUrl, + cleanVideoUrl, + addTimestampToUrl, + addTimestampToLink, + interceptVideoLinkClicks + }; + } + + window.YTVHTContentUrls = { + create: createContentUrlHelpers + }; +})(); diff --git a/src/content.js b/src/content.js index 637fc31..4fc909b 100644 --- a/src/content.js +++ b/src/content.js @@ -1,7 +1,7 @@ (function() { 'use strict'; - // Browser detection and cross-browser storage wrapper - safer approach + // Cross-browser wrapper for storage calls made directly from the content script. const isFirefox = (function() { try { return typeof browser !== 'undefined' && typeof chrome !== 'undefined' && browser !== chrome; @@ -20,7 +20,7 @@ return new Promise((resolve, reject) => { chrome.storage.local.get(keys, (result) => { if (chrome.runtime.lastError) { - // Handle extension context invalidated error + // YouTube tabs can outlive a reloaded extension context. if (chrome.runtime.lastError.message.includes('Extension context invalidated')) { log('[STORAGE] Extension context invalidated during get operation, returning empty result'); resolve({}); @@ -50,7 +50,7 @@ return new Promise((resolve, reject) => { chrome.storage.local.set(data, () => { if (chrome.runtime.lastError) { - // Handle extension context invalidated error + // YouTube tabs can outlive a reloaded extension context. if (chrome.runtime.lastError.message.includes('Extension context invalidated')) { log('[STORAGE] Extension context invalidated during set operation, ignoring'); resolve(); @@ -76,25 +76,30 @@ const DB_NAME = 'YouTubeHistoryDB'; const DB_VERSION = 3; const STORE_NAME = 'videoHistory'; - const EXTENSION_VERSION = chrome.runtime.getManifest().version; // Get version from manifest - const SAVE_INTERVAL = 5000; // Save every 5 seconds + const EXTENSION_VERSION = chrome.runtime.getManifest().version; + const SAVE_INTERVAL = 5000; const DEFAULT_SETTINGS = { - autoCleanPeriod: 90, // days + autoCleanPeriod: 'forever', paginationCount: 10, overlayTitle: 'viewed', + accentColor: 'blue', overlayColor: 'blue', overlayLabelSize: 'medium', - debug: false, // Add debug setting + debug: false, pauseHistoryInPlaylists: false, - version: EXTENSION_VERSION // Add version to settings + localFeedEnabled: true, + hideAccountUI: false, + hideRecommendations: true, + feedRefreshMinutes: 60, + version: EXTENSION_VERSION }; const OVERLAY_COLORS = { - blue: '#4285f4', - red: '#ea4335', - green: '#34a853', - purple: '#9c27b0', - orange: '#ff9800' + blue: '#3ea6ff', + red: '#ff4e45', + green: '#2ecc71', + purple: '#a970ff', + orange: '#ff9f2f' }; const OVERLAY_LABEL_SIZE_MAP = { small: { fontSize: 12, bar: 2 }, @@ -114,6 +119,11 @@ // Track event listeners for cleanup const videoEventListeners = new WeakMap(); + function getAccentOverlayColor(settings = currentSettings) { + const colorName = settings?.accentColor || settings?.overlayColor || 'blue'; + return OVERLAY_COLORS[colorName] || OVERLAY_COLORS.blue; + } + // Track MutationObservers for cleanup const videoObservers = new WeakMap(); @@ -127,36 +137,34 @@ let playlistNavigationCheckInterval = null; let historyApiTimeout = null; - // Track thumbnail processing state + // Batches thumbnail overlay work when many YouTube cards load at once. let isProcessingThumbnails = false; let thumbnailProcessingQueue = new Set(); let processingTimeout = null; - // Track YouTube's content loading state + // Tracks batches of newly inserted YouTube content during SPA rendering. let contentLoadingBatch = new Set(); let batchProcessingTimeout = null; - // Track pending operations - const pendingOperations = new Map(); // Map + // Debounces thumbnail overlay retries keyed by the YouTube card element. + const pendingOperations = new Map(); + const ENABLE_NATIVE_THUMBNAIL_OVERLAYS = true; + let processExistingThumbnails = null; - // Track the last processed video ID to handle SPA navigation + // Last video handled by the tracker; prevents duplicate SPA setup. let lastProcessedVideoId = null; - // Track the last time SPA navigation occurred + // Used to decide whether a timestamp restore belongs to a fresh navigation. let lastSpaNavigationTime = 0; - // Track current URL for navigation detection + // Fallback for route changes YouTube does not report through events. let lastUrl = window.location.href; - // DRY-RUN LOGGING: Event-driven content change detection - let simulatedLastContentChangeTime = 0; - - // Track video changes in YouTube's SPA + // YouTube often reuses the document and swaps video elements in-place. let videoObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { - // Check if this node is a video or contains a video const videos = [ ...(node.tagName === 'VIDEO' ? [node] : []), ...node.querySelectorAll('video') @@ -171,7 +179,6 @@ } }); - // Handle removed videos mutation.removedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { const videos = [ @@ -197,6 +204,47 @@ log('YouTube Video History Tracker script is running.'); + function startNativeThumbnailOverlays() { + if (!ENABLE_NATIVE_THUMBNAIL_OVERLAYS) { + clearNativeThumbnailOverlayArtifacts(); + return; + } + if (!ENABLE_NATIVE_THUMBNAIL_OVERLAYS || !document.body || !thumbnailObserver || !processExistingThumbnails) { + return; + } + + try { + thumbnailObserver.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['src', 'href', 'data-visibility-tracking'] + }); + } catch (error) { + log('[Overlay] Thumbnail observer already active or failed to start', error); + } + + processExistingThumbnails(); + setTimeout(processExistingThumbnails, 2000); + } + + function clearNativeThumbnailOverlayArtifacts() { + document.querySelectorAll('.ytvht-viewed-label, .ytvht-progress-mask, .ytvht-progress-bar, .ytvht-native-progress-line, .ytvht-remove-button') + .forEach((node) => node.remove()); + document.querySelectorAll('.ytvht-has-overlay, .ytvht-native-page-overlay, .ytvht-native-overlay-target, [data-ytvht-video-id], [data-ytvht-label]') + .forEach((node) => { + node.classList.remove('ytvht-has-overlay', 'ytvht-native-page-overlay', 'ytvht-native-overlay-target', 'ytvht-native-overlay-no-progress'); + if (node.dataset?.ytvhtVideoId) delete node.dataset.ytvhtVideoId; + if (node.dataset?.ytvhtLabel) delete node.dataset.ytvhtLabel; + node.style?.removeProperty('--ytvht-overlay-color'); + node.style?.removeProperty('--ytvht-label-font-size'); + node.style?.removeProperty('--ytvht-label-padding'); + node.style?.removeProperty('--ytvht-progress-height'); + node.style?.removeProperty('--ytvht-progress-width'); + }); + document.documentElement.removeAttribute('ytvht-native-badge-only'); + } + // Enhanced cleanup function function cleanup() { log('Cleaning up resources...'); @@ -302,7 +350,7 @@ log('Cleaned up event listeners and observers for video:', video); } - // Helper function to add tracked event listeners + // Register listeners so cleanup can detach them on page unload/reuse. function addTrackedEventListener(video, event, handler) { if (!videoEventListeners.has(video)) { videoEventListeners.set(video, []); @@ -311,7 +359,7 @@ video.addEventListener(event, handler); } - // Helper function to add tracked observers + // Keep per-video observers together with the video they belong to. function addTrackedObserver(video, observer) { if (!videoObservers.has(video)) { videoObservers.set(video, []); @@ -322,479 +370,28 @@ // Use 'pagehide' for reliable cleanup on page unload window.addEventListener('pagehide', cleanup); - // Inject CSS to avoid CSP issues with inline styles - function injectCSS() { - if (document.getElementById('ytvht-styles')) return; // Already injected - - const style = document.createElement('style'); - style.id = 'ytvht-styles'; - style.textContent = ` - .ytvht-viewed-label { - position: absolute !important; - top: 0 !important; - left: 0 !important; - padding: 8px 4px !important; - background-color: #4285f4 !important; - color: #fff !important; - font-size: 16px !important; - font-weight: bold !important; - z-index: 9999 !important; - border-radius: 0 0 4px 0 !important; - pointer-events: none !important; - } - .ytvht-progress-bar { - position: absolute !important; - bottom: 0 !important; - left: 0 !important; - height: 3px !important; - background-color: #4285f4 !important; - z-index: 9999 !important; - pointer-events: none !important; - } - .ytvht-remove-button { - position: absolute !important; - bottom: 10px !important; - right: 10px !important; - width: 26px !important; - height: 26px !important; - line-height: 26px !important; - text-align: center !important; - font-size: 18px !important; - font-weight: 700 !important; - color: #fff !important; - background: #4285f4 !important; - border: none !important; - border-radius: 50% !important; - cursor: pointer !important; - z-index: 10000 !important; - pointer-events: auto !important; - opacity: 0 !important; - transition: opacity 0.15s ease-in-out !important; - user-select: none !important; - } - ytd-thumbnail:hover .ytvht-remove-button, - a#thumbnail:hover .ytvht-remove-button, - ytd-playlist-video-renderer:hover .ytvht-remove-button, - ytd-playlist-panel-video-renderer:hover .ytvht-remove-button, - yt-lockup-view-model:hover .ytvht-remove-button, - ytd-video-renderer:hover .ytvht-remove-button, - ytd-rich-item-renderer:hover .ytvht-remove-button, - ytd-grid-video-renderer:hover .ytvht-remove-button { - opacity: 0.95 !important; - } - .ytvht-info { - position: absolute !important; - top: -120px !important; - right: 0 !important; - background: var(--yt-spec-brand-background-primary, #0f0f0f) !important; - border: 1px solid var(--yt-spec-text-secondary, #aaa) !important; - border-radius: 8px !important; - padding: 12px !important; - width: 300px !important; - z-index: 9999 !important; - color: var(--yt-spec-text-primary, #fff) !important; - font-size: 14px !important; - box-shadow: 0 4px 8px rgba(0,0,0,0.1) !important; - } - .ytvht-info-content { - display: flex !important; - align-items: start !important; - gap: 12px !important; - } - .ytvht-info-text { - flex-grow: 1 !important; - } - .ytvht-info-title { - font-weight: 500 !important; - margin-bottom: 8px !important; - color: #fff !important; - } - .ytvht-info-description { - color: #aaa !important; - line-height: 1.4 !important; - } - .ytvht-info-highlight { - color: #fff !important; - background: rgba(255,255,255,0.1) !important; - padding: 2px 6px !important; - border-radius: 4px !important; - } - .ytvht-close { - background: none !important; - border: none !important; - padding: 4px 8px !important; - cursor: pointer !important; - color: #aaa !important; - font-size: 20px !important; - opacity: 0.8 !important; - transition: opacity 0.2s !important; - } - .ytvht-close:hover { - opacity: 1 !important; - } - .ytvht-ignore-toggle { - position: absolute !important; - top: 8px !important; - right: 8px !important; - background: #4285f4 !important; - color: #fff !important; - border: none !important; - border-radius: 14px !important; - font-size: 12px !important; - line-height: 1 !important; - padding: 6px 10px !important; - cursor: pointer !important; - z-index: 10001 !important; - opacity: 0.9 !important; - } - .ytvht-ignore-toggle[aria-pressed="true"] { - background: #666 !important; - } - .ytvht-ignore-row { - margin-top: 8px !important; - } - .ytvht-ignore-toggle.header { - position: static !important; - display: inline-flex !important; - } - .ytvht-ignore-toggle.action { - position: static !important; - display: inline-flex !important; - margin-left: 8px !important; - } - `; - document.head.appendChild(style); - } - - // Update overlay CSS with current settings to avoid inline styles - function updateOverlayCSS(size, color) { - let styleElement = document.getElementById('ytvht-dynamic-styles'); - if (!styleElement) { - styleElement = document.createElement('style'); - styleElement.id = 'ytvht-dynamic-styles'; - document.head.appendChild(styleElement); - } - - styleElement.textContent = ` - .ytvht-viewed-label { - padding: ${size.fontSize / 2}px 4px !important; - background-color: ${color} !important; - font-size: ${size.fontSize}px !important; - } - .ytvht-progress-bar { - height: ${size.bar}px !important; - background-color: ${color} !important; - } - .ytvht-remove-button { - background: ${color} !important; - } - .ytvht-ignore-toggle { - background: ${color} !important; - } - `; - } - - // Extract video ID from any YouTube URL format - function getVideoId() { - const url = window.location.href; - - // Try to get from URL parameters first - const urlParams = new URLSearchParams(window.location.search); - const videoId = urlParams.get('v'); - if (videoId) return videoId; - - // Try to match various URL patterns - const patterns = [ - /(?:youtube\.com\/watch\/([^\/\?]+))/i, // youtube.com/watch/VIDEO_ID - /(?:youtube\.com\/embed\/([^\/\?]+))/i, // youtube.com/embed/VIDEO_ID - /(?:youtube\.com\/v\/([^\/\?]+))/i, // youtube.com/v/VIDEO_ID - /(?:youtu\.be\/([^\/\?]+))/i, // youtu.be/VIDEO_ID - /(?:youtube\.com\/shorts\/([^\/\?]+))/i // youtube.com/shorts/VIDEO_ID - ]; - - for (const pattern of patterns) { - const match = url.match(pattern); - if (match && match[1]) { - return match[1]; - } - } - - // If no pattern matches, try the last path segment - const pathSegments = window.location.pathname.split('/').filter(Boolean); - if (pathSegments.length > 0) { - const lastSegment = pathSegments[pathSegments.length - 1]; - // Only return if it looks like a video ID (typically 11 characters) - if (/^[a-zA-Z0-9_-]{11}$/.test(lastSegment)) { - return lastSegment; - } - } - - log('Could not extract video ID from URL:', url); - return null; - } - - // Get clean video URL without any parameters - function getCleanVideoUrl() { - const videoId = getVideoId(); - if (!videoId) return null; - return `https://www.youtube.com/watch?v=${videoId}`; - } - - /** - * Clean YouTube URL by removing timestamp and other parameters - * @param {string} url - YouTube URL - * @returns {string} Clean URL with only video ID - */ - function cleanVideoUrl(url) { - if (!url) return url; - - // Handle relative URLs by making them absolute - let absoluteUrl = url; - if (url.startsWith('/')) { - absoluteUrl = 'https://www.youtube.com' + url; - } else if (!url.startsWith('http://') && !url.startsWith('https://')) { - // If it's not absolute and not relative, assume it's a YouTube URL - if (url.includes('youtube.com') || url.includes('youtu.be')) { - absoluteUrl = 'https://' + url.replace(/^https?:\/\//, ''); - } else { - // Can't parse, return as-is - return url; - } - } - - try { - const urlObj = new URL(absoluteUrl); - const videoId = urlObj.searchParams.get('v') || - (urlObj.pathname.includes('/shorts/') ? urlObj.pathname.split('/shorts/')[1]?.split('/')[0] : null); - - if (!videoId) return url; // Return original if we can't extract video ID - - // Return clean URL - if (urlObj.pathname.includes('/shorts/')) { - return `https://www.youtube.com/shorts/${videoId}`; - } else { - return `https://www.youtube.com/watch?v=${videoId}`; - } - } catch (e) { - // If URL parsing fails, try to extract video ID manually - const videoIdMatch = absoluteUrl.match(/[?&]v=([^&]+)/) || absoluteUrl.match(/\/shorts\/([^\/\?]+)/); - if (videoIdMatch) { - const videoId = videoIdMatch[1]; - if (absoluteUrl.includes('/shorts/')) { - return `https://www.youtube.com/shorts/${videoId}`; - } else { - return `https://www.youtube.com/watch?v=${videoId}`; - } - } - // If all else fails, return original URL - return url; - } - } - - /** - * Add timestamp parameter to YouTube URL - * @param {string} url - YouTube URL - * @param {number} timeSeconds - Time in seconds - * @returns {string} URL with timestamp parameter - */ - function addTimestampToUrl(url, timeSeconds) { - if (!url || !timeSeconds || timeSeconds <= 0) return url; - - try { - // First clean the URL to remove any existing timestamp - const cleanUrl = cleanVideoUrl(url); - - // If cleaning failed or returned original, use original URL - const urlToUse = cleanUrl || url; - - try { - const urlObj = new URL(urlToUse); - // Add 't' parameter (YouTube accepts both 't=123' and 't=123s') - urlObj.searchParams.set('t', Math.floor(timeSeconds) + 's'); - return urlObj.toString(); - } catch (e) { - // If URL parsing fails, try simple string manipulation - if (urlToUse.includes('watch?v=') || urlToUse.includes('/shorts/')) { - const separator = urlToUse.includes('?') ? '&' : '?'; - return `${urlToUse}${separator}t=${Math.floor(timeSeconds)}s`; - } - return urlToUse; - } - } catch (error) { - // If anything fails, return original URL without timestamp - log(`[Content] Failed to add timestamp to URL: ${error} (${url})`); - return url; - } - } - - /** - * Add timestamp to a video link if we have saved progress - */ - async function addTimestampToLink(anchor) { - const href = anchor.getAttribute('href'); - if (!href) return; - - log(`[Link Intercept] Processing anchor with href: ${href}`); - - // Extract video ID from URL - let videoId = null; - if (href.includes('watch?v=')) { - const match = href.match(/[?&]v=([^&]+)/); - videoId = match ? match[1] : null; - } else if (href.includes('/shorts/')) { - const match = href.match(/\/shorts\/([^\/\?]+)/); - videoId = match ? match[1] : null; - } - - if (!videoId) { - log(`[Link Intercept] No video ID found in href: ${href}`); - return; - } - - log(`[Link Intercept] Found video ID: ${videoId}`); - - // Check if we have saved progress for this video - try { - const record = await ytStorage.getVideo(videoId); - log(`[Link Intercept] Retrieved record for ${videoId}:`, record); - - if (record && record.time && record.time > 0) { - // Modify the href to include timestamp - const newUrl = addTimestampToUrl(href, record.time); - log(`[Link Intercept] Original URL: ${href}`); - log(`[Link Intercept] Modified URL: ${newUrl}`); - - if (newUrl !== href) { - anchor.setAttribute('href', newUrl); - log(`[Link Intercept] ✅ Added timestamp ${record.time}s to video ${videoId}`); - } else { - log(`[Link Intercept] URL unchanged, timestamp not added`); - } - } else { - log(`[Link Intercept] No saved progress found for video ${videoId}`); - } - } catch (error) { - // Silently fail - don't break navigation if storage check fails - log(`[Link Intercept] ❌ Failed to check video ${videoId}:`, error); - } - } - - /** - * Intercept clicks on video links and add timestamp if we have saved progress - */ - function interceptVideoLinkClicks() { - // Intercept clicks on video links - document.addEventListener('click', async (e) => { - // Find the closest anchor element - let anchor = e.target.closest('a[href*="watch?v="], a[href*="/shorts/"]'); - if (!anchor) { - // If no anchor found, check if we're clicking on an overlay element - // and try to find the anchor in the parent hierarchy - const overlayElement = e.target.closest('.ytvht-viewed-label, .ytvht-progress-bar'); - if (overlayElement) { - anchor = overlayElement.closest('a[href*="watch?v="], a[href*="/shorts/"]'); - log(`[Click Intercept] Click on overlay, found anchor: ${anchor ? anchor.href : 'none'}`); - } - } - - if (!anchor) { - log(`[Click Intercept] No anchor found for click target:`, e.target); - return; - } - - log(`[Click Intercept] Found anchor for video link: ${anchor.href}`); - await addTimestampToLink(anchor); - }, true); // Use capture phase to intercept before YouTube's handlers - - // Also process links when thumbnails are processed (for dynamically loaded content) - // This is handled by the existing thumbnail processing logic - } - - // Get playlist info from URL with better title detection - function getPlaylistInfo() { - const urlParams = new URLSearchParams(window.location.search); - const playlistId = urlParams.get('list'); - if (!playlistId) { - log('No playlist ID found in URL'); - return null; - } - - log('Found playlist ID:', playlistId); - - // Try multiple selectors for playlist title with fallback - const selectors = [ - 'ytd-playlist-panel-renderer #playlist-title yt-formatted-string', - 'ytd-playlist-panel-renderer #playlist-name yt-formatted-string', - 'ytd-playlist-panel-renderer .title yt-formatted-string', - '.ytd-watch-flexy[playlist] .playlist-title', - '#secondary .title.ytd-playlist-panel-renderer', - 'ytd-playlist-metadata-header-renderer yt-formatted-string.title', - 'h3.ytd-playlist-panel-renderer', - '#playlist-title', - '#playlist-name', - // Additional selectors for newer YouTube layouts - 'ytd-playlist-panel-renderer h3 yt-formatted-string', - 'ytd-playlist-panel-renderer .title', - '#secondary-inner ytd-playlist-panel-renderer .title', - 'ytd-playlist-header-renderer h1.ytd-playlist-header-renderer', - '.playlist-title yt-formatted-string', - '.ytd-playlist-panel-renderer .index-message + .title', - // New page header-based layouts - 'yt-page-header-view-model h1.dynamicTextViewModelH1 span', - 'yt-page-header-view-model .yt-page-header-view-model__page-header-title h1 span', - 'yt-dynamic-text-view-model h1.dynamicTextViewModelH1 span' - ]; - - let playlistTitle = null; - for (const selector of selectors) { - const element = document.querySelector(selector); - if (element) { - playlistTitle = element.textContent?.trim(); - log(`Tried selector "${selector}": "${playlistTitle}"`); - if (playlistTitle && playlistTitle !== 'Unknown Playlist' && playlistTitle.length > 0) { - log('Found valid playlist title:', playlistTitle); - break; - } - } - } - - if (!playlistTitle || playlistTitle === 'Unknown Playlist') { - log('No valid playlist title found'); - return null; - } - - const playlistInfo = { - playlistId, - title: playlistTitle, - url: `https://www.youtube.com/playlist?list=${playlistId}`, - timestamp: Date.now() - }; - - log('Created playlist info:', playlistInfo); - return playlistInfo; - } - - // Save playlist info (merge with existing to preserve flags) - async function savePlaylistInfo(playlistInfo = null) { - const info = playlistInfo || getPlaylistInfo(); - if (!info) return; + const { injectCSS, updateOverlayCSS } = window.YTVHTContentCss; - log('Saving playlist info:', info); + const { + getVideoId, + getCleanVideoUrl, + interceptVideoLinkClicks + } = window.YTVHTContentUrls.create({ + log, + getStorage: () => ytStorage + }); - try { - const existing = await ytStorage.getPlaylist(info.playlistId); - const merged = { - ...(existing || {}), - ...info, - lastUpdated: Date.now() - }; - // Ensure we don't drop custom flags like ignoreVideos from existing - await ytStorage.setPlaylist(info.playlistId, merged); - log('Playlist info saved successfully:', merged); - } catch (error) { - log('Error saving playlist info:', error); + const { + tryToSavePlaylist, + ensurePlaylistIgnoreToggles + } = window.YTVHTContentPlaylists.create({ + log, + getStorage: () => ytStorage, + getPlaylistRetryTimeout: () => playlistRetryTimeout, + setPlaylistRetryTimeout: (timeoutId) => { + playlistRetryTimeout = timeoutId; } - } + }); // Load settings from browser.storage.local async function loadSettings() { @@ -809,6 +406,10 @@ updated = true; } } + if (settings.autoCleanPeriod === 90 || settings.autoCleanPeriod === '90') { + settings.autoCleanPeriod = 'forever'; + updated = true; + } // Save updated settings if needed if (updated) { @@ -824,83 +425,6 @@ } } - // Load and set the saved timestamp with retries - async function loadTimestamp() { - const videoId = getVideoId(); - if (!videoId) { - log('No video ID found in URL.'); - return; - } - - log(`Attempting to load timestamp for video ID: ${videoId} from URL: ${window.location.href}`); - - try { - const record = await ytStorage.getVideo(videoId); - if (record) { - const video = document.querySelector('video'); - if (video) { - log(`Found record for video ID ${videoId}:`, record); - - // Wait for video to be ready with retries - const setTime = async (retryCount = 0) => { - const maxRetries = 10; - const retryDelay = 500; - - if (retryCount >= maxRetries) { - log(`Failed to set timestamp after ${maxRetries} retries`); - return; - } - - // Check if video duration is available and valid - if (video.duration && !isNaN(video.duration) && video.duration > 0) { - if (record.time > 0 && record.time < video.duration) { - // Ensure we're not too close to the end - const timeToSet = Math.min(record.time, video.duration - 1); - video.currentTime = timeToSet; - log(`Timestamp set for video ID ${videoId}: ${timeToSet} (duration: ${video.duration})`); - - // Verify the time was actually set - setTimeout(() => { - if (Math.abs(video.currentTime - timeToSet) > 1) { - log('Time was not set correctly, retrying...'); - setTime(retryCount + 1); - } - }, 100); - } else { - log(`Invalid timestamp ${record.time} for video duration ${video.duration}, skipping`); - } - } else { - log(`Video duration not ready (${video.duration}), retrying in ${retryDelay}ms...`); - setTimeout(() => setTime(retryCount + 1), retryDelay); - } - }; - - // Try to set time immediately if video is ready - if (video.readyState >= 1) { - setTime(); - } else { - // Wait for metadata and then try - log('Video not ready, waiting for loadedmetadata event'); - video.addEventListener('loadedmetadata', () => setTime(), { once: true }); - - // Also set up a backup timeout in case the event doesn't fire - setTimeout(() => { - if (video.readyState >= 1) { - setTime(); - } - }, 1000); - } - } else { - log('No video element found.'); - } - } else { - log('No record found for video ID:', videoId); - } - } catch (error) { - log('Error loading timestamp:', error); - } - } - // Save the current video timestamp (regular videos) async function saveTimestamp() { if (window.location.pathname.startsWith('/shorts/')) { @@ -1143,10 +667,9 @@ }); } - // Update cleanupOldRecords to use currentSettings.autoCleanPeriod + // Apply the user's retention setting to local watch history. async function cleanupOldRecords() { try { - // Skip cleanup if set to "forever" if (currentSettings.autoCleanPeriod === 'forever') { log('Auto-clean disabled - keeping all records forever'); return; @@ -1167,16 +690,16 @@ } } - // Start periodic saving with shorter interval + // Start periodic progress saves while playback is active. function startSaveInterval() { if (saveIntervalId) { clearInterval(saveIntervalId); } - saveTimestamp(); // Save immediately - saveIntervalId = setInterval(saveTimestamp, 5000); // Changed from SAVE_INTERVAL to fixed 5000ms + saveTimestamp(); + saveIntervalId = setInterval(saveTimestamp, SAVE_INTERVAL); } - // Debounce helper function + // Coalesce rapid media events into one storage write. function debounce(func, wait) { let timeout; return function executedFunction(...args) { @@ -1189,7 +712,7 @@ }; } - // Helper function for waiting for events with timeout + // Resolve when the media element fires an event, or fail fast if it stalls. function waitForEvent(target, event, timeout = 1000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -1216,6 +739,21 @@ let lastSaveTime = 0; const MIN_SAVE_INTERVAL = 1000; + // Once the user manually moves the playhead, stop auto-restoring the saved + // position. Without this, seeking backward (e.g. to rewatch a video that was + // saved near its end) gets yanked forward again, trapping the user in a loop. + let userInteracted = false; + let lastProgrammaticSeekAt = 0; + // The video ID this closure last set up restoration for; used to detect when a + // reused