diff options
| author | qixing-jk <street-anime-olive@duck.com> | 2025-11-03 09:28:05 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-03 01:28:05 +0000 |
| commit | f0b0959efe380f879b2c673b06de1e4b87d31dd6 (patch) | |
| tree | 8f0bdf727b52a52fbef9e1904705710530f84877 /apps/browser-extension/src | |
| parent | 2056582cf6589d5491267f36aa06b1cd7d764673 (diff) | |
| download | karakeep-f0b0959efe380f879b2c673b06de1e4b87d31dd6.tar.zst | |
feat(extension): add tab bookmark badge indicator (#1745)
* feat: add tab bookmark count badge indicator
- implement getApiClient function to create and cache TRPC client
- add tab activation listener to check bookmark count
- display badge with count and appropriate color based on results
- handle errors by showing error indicator in badge
* feat: add show count badge setting to extension
- Add showCountBadge setting to settings schema and default values
- Implement toggle button in OptionsPage for count badge visibility
- Modify background script to respect showCountBadge setting
- Add logging for archive count check in background script
Closes #486
* feat(background): refactor tab badge update logic
- Extract badge setting logic into reusable `setBadge` function
- Create `getTabCount` function to fetch bookmark count and existence
- Implement `checkAndUpdateIcon` function to centralize icon update logic
- Add support for tab updates via `chrome.tabs.onUpdated` listener
- Improve error handling with consistent badge display
* feat(background): implement badge caching system
- Add badge cache initialization and periodic cleanup
- Implement cache get/set operations for badge status
- Update setBadge function to use cached values when available
- Modify badge display logic to check cache before making API calls
* feat(badgeCache): add debug logging for cache operations
- Add console logs to track cache initialization, purging, and operations
- Move debug log in checkAndUpdateIcon to better position
* feat: add badge refresh on bookmark creation and deletion
- implement message types for badge refresh communication
- update background script to handle REFRESH_BADGE messages
- modify SavePage to send message on successful bookmark creation
- modify BookmarkSavedPage to send message on successful deletion
- add clearBadgeStatusSWR utility function import and usage
* perf(badge-cache): decrease purge alarm interval
The badge cache purge alarm was previously set to run every 60
minutes. This change reduces the interval to 10 minutes to ensure
more frequent cache cleanup and better memory management.
* feat(background): clean up API client and badge cache on invalid settings
- add cleanupApiClient function to trpc utils
- modify checkSettingsState to handle async operations
- clear API client and badge status when settings are invalid
- update settings subscription to handle async operations
* feat: reset settings to include showCountBadge flag when logout
When deleting API key, ensure showCountBadge is also reset to false to
maintain consistent state in the options page configuration.
* refactor: use BOOKMARK_REFRESH_BADGE instead of separate created/deleted types
* perf(badgeCache): replace alarm-based purge with on-demand expiration check
- Remove periodic alarm system for cache purging
- Add lastPurgeTimestamp tracking for efficient cache maintenance
- Update manifest to remove unnecessary alarms permission
- Modify background script to use new on-demand purge mechanism
- Clean up related alarm listener and initialization code
* feat: Replace count badge button with toggle switch
- add new Switch component based on Radix UI
- update OptionsPage to use Switch for count badge setting
* feat: Add horizontal rule separator in options page
- Add `<hr />` element between sections for better visual separation
- Improve UI organization in the OptionsPage component
* feat(badgeCache): implement persistent last purge timestamp storage
- Replace in-memory timestamp with chrome.storage persistence
- Add getter/setter functions for last purge timestamp
- Update checkAndPurgeIfNeeded to use persistent storage
* refactor(badgeCache): Improve type safety and storage utilities
- Add BadgeCacheEntry and BadgeCacheStorage types
- Extract storage operations to dedicated utility functions
- Improve code organization and documentation
- Enhance type safety throughout badge cache operations
* feat(extension): add context menu options to clear cache
- introduce new context menu items for cache management
- add clear current site cache functionality
- add clear all cache functionality
- conditionally show cache menu items based on showCountBadge setting
- pass settings to context menu registration/removal functions
* feat(extension): add configurable badge cache expiration time
- introduce `badgeCacheExpireMs` setting with 1-hour default
- update settings schema and default values
- add input field in OptionsPage for cache time configuration
- modify badgeCache to use dynamic expiration time from settings
- improve cache logging for better debugging visibility
* Revert "feat: reset settings to include showCountBadge flag when logout"
This reverts commit cf071e9dd50f1a1ac0a8dd3b68a4359ecc30c783.
# Conflicts:
# apps/browser-extension/src/OptionsPage.tsx
* refactor(extension): extract badge cache expiration constant
Extract DEFAULT_BADGE_CACHE_EXPIRE_MS constant to central location in
settings.ts and reuse it across badgeCache.ts and OptionsPage.tsx. Remove
duplicate constant definitions and ensure consistent default value usage.
* refactor(extension): standardize showCountBadge default via shared constant
- introduce DEFAULT_SHOW_COUNT_BADGE in settings schema
- replace hardcoded defaults with constant in schema and OptionsPage
- maintain backward compatibility with existing boolean type
* feat(extension): simplify context menu removal logic
- remove redundant `settings` parameter from `removeContextMenus`
- replace selective menu removal with `chrome.contextMenus.removeAll()`
- update function call to match new signature
* refactor(extension): rename site-related cache terms to page for clarity
- update context menu title from "Clear Current Site Cache" to "Clear Current
Page Cache"
- rename `clearCurrentSiteCache()` to `clearCurrentPageCache()`
- adjust related comments, logs, and docstrings for consistency
* refactor(extension): improve tab event handler parameter naming
- rename `activeInfo` to `tabActiveInfo` in `onActivated` listener
- rename `changeInfo` to `tabId` in `onUpdated` listener
* refactor(extension): add isHttpUrl utility and refactor server address validation
- introduce reusable `isHttpUrl` function in new `url.ts` utility module
- replace inline protocol check with utility call in NotConfiguredPage
* feat(extension): filter out non-HTTP URLs from badge count updates
- add `isHttpUrl` import and check in badge update validation
- skip badge updates for non-HTTP URLs to prevent invalid requests
* feat(extension): filter out non-HTTP URLs from bookmark creation
Add validation using isHttpUrl utility to ensure only HTTP/HTTPS
URLs are processed when creating bookmarks from current tab.
* chore: remove .ts extension from internal import statements
* feat(extension): validate and trim server address input in NotConfiguredPage
- use trimmed input for validation and state updates
- ensure consistent handling of trailing slashes in server address
* feat(extension): prevent non-HTTP(S) URL bookmark creation with validation
- Add URL validation in background script to filter out non-HTTP(S) URLs
before creating link-type bookmarks, logging warnings for invalid URLs.
- Enhance SavePage component with explicit error messages for:
- Missing tab URLs
- Unsupported URL schemes (non-HTTP(S))
- Improve error display by rendering messages immediately when errors occur
instead of waiting for status changes.
* feat(extension): clear existing context menus before registration
This ensures no duplicate menu items are created when the extension
reinitializes or settings are updated.
* feat(extension): display "+" suffix for default bookmarks per page count
Add logic to append "+" suffix when badge count matches
DEFAULT_NUM_BOOKMARKS_PER_PAGE to indicate potential overflow.
* fix(extension): ensure API client cleanup on settings state change
Clean up API client before handling settings state to prevent stale
connections when address or API key is missing.
* fix(extension): clear badge text when tab is invalid or incomplete
Ensure badge is reset when URL is non-HTTP or tab status is not complete.
* feat(extension): improve badge revalidation for stale cached data
- simplify badge setting from cached data
- add background revalidation for non-fresh cache
- ensure badge updates when stale cache is detected
* feat(extension): refresh active tab badges after cache clearance
Added logic to update badge icons for all active tabs when clearing
the badge status cache, ensuring UI consistency across the extension.
* feat: add null checks for tab URL and ID in REFRESH_BADGE handler
Add defensive programming to prevent runtime errors when processing
REFRESH_BADGE messages with missing or invalid tab properties.
* feat(extension): replace badge status SWR clear with full cache refresh
Replace selective SWR badge status clearing with a complete cache refresh
to ensure consistent badge state across all active tabs. Updates the
`clearBadgeStatusSWR()` call to use the more comprehensive `clearAllCache()`
method and improves related documentation.
* feat(extension): add validation & error handling for API client
- add null check and error throw in `getTabCount` when API client fails
- ensure badge is cleared when API client is unavailable
- refactor `getApiClient` to use destructured settings
- fix potential undefined access in TRPC client initialization
* feat(extension): replace isExisted with exactMatch for precise bookmark info
- modify getTabCount to return exactMatch instead of isExisted
- update BadgeCacheEntry type to use ZBookmark|null for exactMatch
- adjust setBadge and setBadgeStatusSWR to handle new exactMatch format
- ensure backward compatibility by converting exactMatch to boolean
* refactor(background): rename getTabCount to getBookmarkStatusForUrl
- improve function naming to better reflect return value
- update JSDoc to clarify return object structure
- adjust all function calls to use new name
* feat(extension): add "View page in Karakeep" context menu option
- introduce new `VIEW_PAGE_IN_KARAKEEP` menu item for extension action
- extend `handleContextMenuClick` to support optional tab parameter
- add `searchCurrentUrl` function to handle URL-based searches
- implement exact match and search fallback logic with proper URL encoding
- ensure error handling and validation for invalid URLs
* feat(extension): reorder menu items and extend page context support
- Move 'Add to Karakeep' menu creation before conditional block
- Add 'page' context to 'View this page in Karakeep' menu item
* feat(extension): add toggle to enable/disable badge caching
- introduce `useBadgeCache` setting in schema and defaults
- add switch control in OptionsPage to toggle cache usage
- conditionally render cache expire time input based on toggle
- make badge cache operations respect the toggle setting
- hide cache-related context menu items when caching disabled
- skip cache operations in background when disabled
- prevent cache checks and purges when caching is off
* fix(extension): handle cache miss in bookmark search flow
Previously, the search flow would silently fail (no-op) when the cache missed.
Now, it falls back to fetching the bookmark status directly from the server
when no cached data is available. Also refactored to avoid redundant settings
fetch and improve code readability.
* refactor(badge): implement generic cache utility and refactor badge cache
* Remove dedicated cache type definitions in favor of generic solution
* Create reusable cache utility with L1/L2 (memory/storage) support
* Simplify badge cache implementation using new generic cache
* Remove redundant SWR-specific functions and consolidate logic
* Improve type safety and error handling in cache operations
* Eliminate deprecated cache management functions and types
* refactor(extension): replace custom cache with TanStack Query for badge status
- Remove custom SWR cache implementation
- Add TanStack Query persistence with Chrome storage
- Update badge status logic to use QueryClient
- Add storage persister utility for Chrome
- Update TRPC client initialization with QueryClient
- Add required TanStack Query persistence dependencies
* fix(extension): add missing `useBadgeCache` config and refactor cache logic
- restore accidentally removed `useBadgeCache` setting from refactor
- update cache invalidation logic to include `useBadgeCache` changes
- modify QueryClient config to respect `useBadgeCache` toggle
- improve settings comparison logic for better reuse detection
* fix(extension): use dynamic key for TanStack query cache persistence
Previously used hardcoded string 'TANSTACK_QUERY_CACHE_KEY' instead of the
defined constant, which could lead to inconsistent storage access.
* fix(cache): propagate fetch errors to avoid caching failed badge status
Previously, API errors would return an empty status, causing cache to store
invalid results. Now errors are thrown so the cache treats them as misses.
* fix(extension): guard chrome storage and respect cache disable setting
- use `globalThis.chrome` to avoid runtime errors in non-extension contexts
- skip persistence when `useBadgeCache=false` and clear existing cache
- add `maxAge` and `buster` to prevent stale/incompatible data restoration
* refactor(badgeCache): remove redundant null check for searchBookmarks data
Remove unnecessary if (!data) guard since searchBookmarks query is guaranteed
to return a non-nullable response due to its Zod schema. Use direct access to
data.bookmarks and simplify empty state check with bookmarksLength.
* fix(badgeCache): throw error on misconfiguration instead of caching empty
Prevent cache poisoning by throwing when API client is not configured.
This ensures setup errors are visible rather than silently cached as 0.
* fix(badgeCache): add fallback to fetchBadgeStatus when QueryClient missing
Prevents null returns by falling back to direct fetch when QueryClient
is unavailable, ensuring badge remains responsive.
* feat(extension): add yellow badge for URLs ignoring anchor and trailing slash
- implement partial URL matching logic in badge status calculation
- introduce `BadgeMatchType` enum for different match scenarios
- update badge color scheme (green=exact, yellow=partial, red=none)
- refactor badge status interface and setBadge function signature
- handle edge cases for null badge status in error scenarios
* feat: support partial bookmark matches in badge status check
- use partial match fallback when exact match is unavailable
- update target URL generation to handle both match types
- ensure consistent bookmark ID reference in preview URL
* refactor: improve error handling and badge status logic
* wrap `removeContextMenus()` in try-catch block to prevent crashes
when context menus API fails
* simplify badge setting logic by removing redundant status check
* fix `getBadgeStatus()` return type to always resolve with `BadgeStatus`
* update import path for `urlsMatchIgnoringAnchorAndTrailingSlash`
* clarify comments for URL matching logic and edge cases
* perf: parallelize active-tab badge refresh after cache clear
Avoid serial waits across windows/tabs by using Promise.all to process
active tab IDs concurrently. Replaces nested loops with flatMap and
filter for cleaner extraction of active tab IDs.
* fix(badge): isolate cache config to badge queries only
Move cache time configuration from global QueryClient to badge-specific
queries to prevent unintended caching in other query operations.
* fix: Invalid configuration check should verify if either field is missing.
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* fix(trpc): include address in cache buster to isolate persisted caches
Prevents restoring stale data from a different server after switching
address by scoping the cache buster to the current backend address.
* feat(trpc): ensure proper client cleanup on config changes
- add client removal from persistent storage when address/apiKey invalid
- ensure cache wipe when switching context with address/apiKey changes
- simplify persister creation by using default chrome.storage.local
- make storage parameter optional in createChromeStorage
* fix bad merge
* simplify badge by dropping the count
* more fixes
* more fix
---------
Co-authored-by: Mohamed Bassem <me@mbassem.com>
Diffstat (limited to 'apps/browser-extension/src')
| -rw-r--r-- | apps/browser-extension/src/BookmarkSavedPage.tsx | 11 | ||||
| -rw-r--r-- | apps/browser-extension/src/NotConfiguredPage.tsx | 11 | ||||
| -rw-r--r-- | apps/browser-extension/src/OptionsPage.tsx | 53 | ||||
| -rw-r--r-- | apps/browser-extension/src/SavePage.tsx | 44 | ||||
| -rw-r--r-- | apps/browser-extension/src/background/background.ts | 481 | ||||
| -rw-r--r-- | apps/browser-extension/src/components/ui/switch.tsx | 27 | ||||
| -rw-r--r-- | apps/browser-extension/src/utils/badgeCache.ts | 82 | ||||
| -rw-r--r-- | apps/browser-extension/src/utils/settings.ts | 9 | ||||
| -rw-r--r-- | apps/browser-extension/src/utils/storagePersister.ts | 56 | ||||
| -rw-r--r-- | apps/browser-extension/src/utils/trpc.ts | 116 | ||||
| -rw-r--r-- | apps/browser-extension/src/utils/type.ts | 3 | ||||
| -rw-r--r-- | apps/browser-extension/src/utils/url.ts | 41 |
12 files changed, 792 insertions, 142 deletions
diff --git a/apps/browser-extension/src/BookmarkSavedPage.tsx b/apps/browser-extension/src/BookmarkSavedPage.tsx index 380ba4d2..67e6f753 100644 --- a/apps/browser-extension/src/BookmarkSavedPage.tsx +++ b/apps/browser-extension/src/BookmarkSavedPage.tsx @@ -12,6 +12,7 @@ import { Button, buttonVariants } from "./components/ui/button"; import Spinner from "./Spinner"; import { cn } from "./utils/css"; import usePluginSettings from "./utils/settings"; +import { MessageType } from "./utils/type"; export default function BookmarkSavedPage() { const { bookmarkId } = useParams(); @@ -19,7 +20,15 @@ export default function BookmarkSavedPage() { const [error, setError] = useState(""); const { mutate: deleteBookmark, isPending } = useDeleteBookmark({ - onSuccess: () => { + onSuccess: async () => { + const [currentTab] = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true, + }); + await chrome.runtime.sendMessage({ + type: MessageType.BOOKMARK_REFRESH_BADGE, + currentTab: currentTab, + }); navigate("/bookmarkdeleted"); }, onError: (e) => { diff --git a/apps/browser-extension/src/NotConfiguredPage.tsx b/apps/browser-extension/src/NotConfiguredPage.tsx index fdda480e..ed8cbeb4 100644 --- a/apps/browser-extension/src/NotConfiguredPage.tsx +++ b/apps/browser-extension/src/NotConfiguredPage.tsx @@ -5,6 +5,7 @@ import { Button } from "./components/ui/button"; import { Input } from "./components/ui/input"; import Logo from "./Logo"; import usePluginSettings from "./utils/settings"; +import { isHttpUrl } from "./utils/url"; export default function NotConfiguredPage() { const navigate = useNavigate(); @@ -18,21 +19,19 @@ export default function NotConfiguredPage() { }, [settings.address]); const onSave = () => { - if (serverAddress == "") { + const input = serverAddress.trim(); + if (input == "") { setError("Server address is required"); return; } // Add URL protocol validation - if ( - !serverAddress.startsWith("http://") && - !serverAddress.startsWith("https://") - ) { + if (!isHttpUrl(input)) { setError("Server address must start with http:// or https://"); return; } - setSettings((s) => ({ ...s, address: serverAddress.replace(/\/$/, "") })); + setSettings((s) => ({ ...s, address: input.replace(/\/$/, "") })); navigate("/signin"); }; diff --git a/apps/browser-extension/src/OptionsPage.tsx b/apps/browser-extension/src/OptionsPage.tsx index ef51bc02..cac32eff 100644 --- a/apps/browser-extension/src/OptionsPage.tsx +++ b/apps/browser-extension/src/OptionsPage.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "./components/ui/button"; +import { Input } from "./components/ui/input"; import { Select, SelectContent, @@ -9,9 +10,12 @@ import { SelectTrigger, SelectValue, } from "./components/ui/select"; +import { Switch } from "./components/ui/switch"; import Logo from "./Logo"; import Spinner from "./Spinner"; -import usePluginSettings from "./utils/settings"; +import usePluginSettings, { + DEFAULT_BADGE_CACHE_EXPIRE_MS, +} from "./utils/settings"; import { useTheme } from "./utils/ThemeProvider"; import { api } from "./utils/trpc"; @@ -64,6 +68,53 @@ export default function OptionsPage() { <Logo /> <span className="text-lg">Settings</span> <hr /> + <div className="flex items-center justify-between gap-2"> + <span className="text-sm font-medium">Show count badge</span> + <Switch + checked={settings.showCountBadge} + onCheckedChange={(checked) => + setSettings((s) => ({ ...s, showCountBadge: checked })) + } + /> + </div> + {settings.showCountBadge && ( + <> + <div className="flex items-center justify-between gap-2"> + <span className="text-sm font-medium">Use badge cache</span> + <Switch + checked={settings.useBadgeCache} + onCheckedChange={(checked) => + setSettings((s) => ({ ...s, useBadgeCache: checked })) + } + /> + </div> + {settings.useBadgeCache && ( + <> + <div className="flex items-center justify-between gap-2"> + <span className="text-sm font-medium"> + Badge cache expire time (second) + </span> + <Input + type="number" + min="1" + step="1" + value={settings.badgeCacheExpireMs / 1000} + onChange={(e) => + setSettings((s) => ({ + ...s, + badgeCacheExpireMs: + parseInt(e.target.value) * 1000 || + DEFAULT_BADGE_CACHE_EXPIRE_MS, + })) + } + className="w-32" + /> + </div> + </> + )} + </> + )} + <hr /> <div className="flex gap-2"> <span className="my-auto">Server Address:</span> {settings.address} diff --git a/apps/browser-extension/src/SavePage.tsx b/apps/browser-extension/src/SavePage.tsx index d3d9458a..b4b9ce95 100644 --- a/apps/browser-extension/src/SavePage.tsx +++ b/apps/browser-extension/src/SavePage.tsx @@ -10,6 +10,8 @@ import { import { NEW_BOOKMARK_REQUEST_KEY_NAME } from "./background/protocol"; import Spinner from "./Spinner"; import { api } from "./utils/trpc"; +import { MessageType } from "./utils/type"; +import { isHttpUrl } from "./utils/url"; export default function SavePage() { const [error, setError] = useState<string | undefined>(undefined); @@ -22,8 +24,18 @@ export default function SavePage() { onError: (e) => { setError("Something went wrong: " + e.message); }, + onSuccess: async () => { + // After successful creation, update badge cache and notify background + const [currentTab] = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true, + }); + await chrome.runtime.sendMessage({ + type: MessageType.BOOKMARK_REFRESH_BADGE, + currentTab: currentTab, + }); + }, }); - useEffect(() => { async function getNewBookmarkRequestFromBackgroundScriptIfAny(): Promise<ZNewBookmarkRequest | null> { const { [NEW_BOOKMARK_REQUEST_KEY_NAME]: req } = @@ -44,17 +56,24 @@ export default function SavePage() { active: true, lastFocusedWindow: true, }); - if (currentTab?.url) { - newBookmarkRequest = { - type: BookmarkTypes.LINK, - url: currentTab.url, - title: currentTab.title, - source: "extension", - }; - } else { - setError("Couldn't find the URL of the current tab"); + if (!currentTab.url) { + setError("Current tab has no URL to bookmark."); return; } + + if (!isHttpUrl(currentTab.url)) { + setError( + "Cannot bookmark this type of URL. Only HTTP/HTTPS URLs are supported.", + ); + return; + } + + newBookmarkRequest = { + type: BookmarkTypes.LINK, + title: currentTab.title, + url: currentTab.url, + source: "extension", + }; } createBookmark({ @@ -62,9 +81,14 @@ export default function SavePage() { source: newBookmarkRequest.source || "extension", }); } + runSave(); }, [createBookmark]); + if (error) { + return <div className="text-red-500">{error}</div>; + } + switch (status) { case "error": { return <div className="text-red-500">{error}</div>; diff --git a/apps/browser-extension/src/background/background.ts b/apps/browser-extension/src/background/background.ts index 3d3c1032..04089cc8 100644 --- a/apps/browser-extension/src/background/background.ts +++ b/apps/browser-extension/src/background/background.ts @@ -1,124 +1,357 @@ -import {
- BookmarkTypes,
- ZNewBookmarkRequest,
-} from "@karakeep/shared/types/bookmarks.ts";
-
-import {
- getPluginSettings,
- Settings,
- subscribeToSettingsChanges,
-} from "../utils/settings.ts";
-import { NEW_BOOKMARK_REQUEST_KEY_NAME } from "./protocol.ts";
-
-const OPEN_KARAKEEP_ID = "open-karakeep";
-const ADD_LINK_TO_KARAKEEP_ID = "add-link";
-
-function checkSettingsState(settings: Settings) {
- if (settings?.address) {
- registerContextMenus();
- } else {
- removeContextMenus();
- }
-}
-
-function removeContextMenus() {
- chrome.contextMenus.remove(OPEN_KARAKEEP_ID);
- chrome.contextMenus.remove(ADD_LINK_TO_KARAKEEP_ID);
-}
-
-/**
- * Registers
- * * a context menu button to open a tab with the currently configured karakeep instance
- * * a context menu button to add a link to karakeep without loading the page
- */
-function registerContextMenus() {
- chrome.contextMenus.create({
- id: OPEN_KARAKEEP_ID,
- title: "Open Karakeep",
- contexts: ["action"],
- });
- chrome.contextMenus.create({
- id: ADD_LINK_TO_KARAKEEP_ID,
- title: "Add to Karakeep",
- contexts: ["link", "page", "selection", "image"],
- });
-}
-
-/**
- * Reads the current settings and opens a new tab with karakeep
- * @param info the information about the click in the context menu
- */
-async function handleContextMenuClick(info: chrome.contextMenus.OnClickData) {
- const { menuItemId, selectionText, srcUrl, linkUrl, pageUrl } = info;
- if (menuItemId === OPEN_KARAKEEP_ID) {
- getPluginSettings().then((settings: Settings) => {
- chrome.tabs.create({ url: settings.address, active: true });
- });
- } else if (menuItemId === ADD_LINK_TO_KARAKEEP_ID) {
- addLinkToKarakeep({ selectionText, srcUrl, linkUrl, pageUrl });
-
- // NOTE: Firefox only allows opening context menus if it's triggered by a user action.
- // awaiting on any promise before calling this function will lose the "user action" context.
- await chrome.action.openPopup();
- }
-}
-
-function addLinkToKarakeep({
- selectionText,
- srcUrl,
- linkUrl,
- pageUrl,
-}: {
- selectionText?: string;
- srcUrl?: string;
- linkUrl?: string;
- pageUrl?: string;
-}) {
- let newBookmark: ZNewBookmarkRequest | null = null;
- if (selectionText) {
- newBookmark = {
- type: BookmarkTypes.TEXT,
- text: selectionText,
- sourceUrl: pageUrl,
- };
- } else if (srcUrl ?? linkUrl ?? pageUrl) {
- newBookmark = {
- type: BookmarkTypes.LINK,
- url: srcUrl ?? linkUrl ?? pageUrl ?? "",
- };
- }
- if (newBookmark) {
- chrome.storage.session.set({
- [NEW_BOOKMARK_REQUEST_KEY_NAME]: newBookmark,
- });
- }
-}
-
-getPluginSettings().then((settings: Settings) => {
- checkSettingsState(settings);
-});
-
-subscribeToSettingsChanges((settings) => {
- checkSettingsState(settings);
-});
-
-// eslint-disable-next-line @typescript-eslint/no-misused-promises -- Manifest V3 allows async functions for all callbacks
-chrome.contextMenus.onClicked.addListener(handleContextMenuClick);
-
-function handleCommand(command: string, tab: chrome.tabs.Tab) {
- if (command === ADD_LINK_TO_KARAKEEP_ID) {
- addLinkToKarakeep({
- selectionText: undefined,
- srcUrl: undefined,
- linkUrl: undefined,
- pageUrl: tab?.url,
- });
-
- // now try to open the popup
- chrome.action.openPopup();
- } else {
- console.warn(`Received unknown command: ${command}`);
- }
-}
-
-chrome.commands.onCommand.addListener(handleCommand);
+import { + BookmarkTypes, + ZNewBookmarkRequest, +} from "@karakeep/shared/types/bookmarks"; + +import { clearBadgeStatus, getBadgeStatus } from "../utils/badgeCache"; +import { + getPluginSettings, + Settings, + subscribeToSettingsChanges, +} from "../utils/settings"; +import { getApiClient, initializeClients } from "../utils/trpc"; +import { MessageType } from "../utils/type"; +import { isHttpUrl } from "../utils/url"; +import { NEW_BOOKMARK_REQUEST_KEY_NAME } from "./protocol"; + +const OPEN_KARAKEEP_ID = "open-karakeep"; +const ADD_LINK_TO_KARAKEEP_ID = "add-link"; +const CLEAR_CURRENT_CACHE_ID = "clear-current-cache"; +const CLEAR_ALL_CACHE_ID = "clear-all-cache"; +const SEPARATOR_ID = "separator-1"; +const VIEW_PAGE_IN_KARAKEEP = "view-page-in-karakeep"; + +/** + * Check the current settings state and register or remove context menus accordingly. + * @param settings The current plugin settings. + */ +async function checkSettingsState(settings: Settings) { + await initializeClients(); + if (settings?.address && settings?.apiKey) { + registerContextMenus(settings); + } else { + removeContextMenus(); + await clearAllCache(); + } +} + +/** + * Remove context menus from the browser. + */ +function removeContextMenus() { + try { + chrome.contextMenus.removeAll(); + } catch (error) { + console.error("Failed to remove context menus:", error); + } +} + +/** + * Register context menus in the browser. + * * A context menu button to open a tab with the currently configured karakeep instance. + * * * If the "show count badge" setting is enabled, add context menu buttons to clear the cache for the current page or all pages. + * * A context menu button to add a link to karakeep without loading the page. + * @param settings The current plugin settings. + */ +function registerContextMenus(settings: Settings) { + removeContextMenus(); + chrome.contextMenus.create({ + id: OPEN_KARAKEEP_ID, + title: "Open Karakeep", + contexts: ["action"], + }); + + chrome.contextMenus.create({ + id: ADD_LINK_TO_KARAKEEP_ID, + title: "Add to Karakeep", + contexts: ["link", "page", "selection", "image"], + }); + + if (settings?.showCountBadge) { + chrome.contextMenus.create({ + id: VIEW_PAGE_IN_KARAKEEP, + title: "View this page in Karakeep", + contexts: ["action", "page"], + }); + if (settings?.useBadgeCache) { + // Add separator + chrome.contextMenus.create({ + id: SEPARATOR_ID, + type: "separator", + contexts: ["action"], + }); + + chrome.contextMenus.create({ + id: CLEAR_CURRENT_CACHE_ID, + title: "Clear Current Page Cache", + contexts: ["action"], + }); + + chrome.contextMenus.create({ + id: CLEAR_ALL_CACHE_ID, + title: "Clear All Cache", + contexts: ["action"], + }); + } + } +} + +/** + * Handle context menu clicks by opening a new tab with karakeep or adding a link to karakeep. + * @param info Information about the context menu click event. + * @param tab The current tab. + */ +async function handleContextMenuClick( + info: chrome.contextMenus.OnClickData, + tab?: chrome.tabs.Tab, +) { + const { menuItemId, selectionText, srcUrl, linkUrl, pageUrl } = info; + if (menuItemId === OPEN_KARAKEEP_ID) { + getPluginSettings().then((settings: Settings) => { + chrome.tabs.create({ url: settings.address, active: true }); + }); + } else if (menuItemId === CLEAR_CURRENT_CACHE_ID) { + await clearCurrentPageCache(); + } else if (menuItemId === CLEAR_ALL_CACHE_ID) { + await clearAllCache(); + } else if (menuItemId === ADD_LINK_TO_KARAKEEP_ID) { + addLinkToKarakeep({ + selectionText, + srcUrl, + linkUrl, + pageUrl, + title: tab?.title, + }); + + // NOTE: Firefox only allows opening context menus if it's triggered by a user action. + // awaiting on any promise before calling this function will lose the "user action" context. + await chrome.action.openPopup(); + } else if (menuItemId === VIEW_PAGE_IN_KARAKEEP) { + if (tab) { + await searchCurrentUrl(tab.url); + } + } +} + +/** + * Add a link to karakeep based on the provided information. + * @param options An object containing information about the link to add. + */ +function addLinkToKarakeep({ + selectionText, + srcUrl, + linkUrl, + pageUrl, + title, +}: { + selectionText?: string; + srcUrl?: string; + linkUrl?: string; + pageUrl?: string; + title?: string; +}) { + let newBookmark: ZNewBookmarkRequest | null = null; + if (selectionText) { + newBookmark = { + type: BookmarkTypes.TEXT, + text: selectionText, + sourceUrl: pageUrl, + source: "extension", + }; + } else { + const finalUrl = srcUrl ?? linkUrl ?? pageUrl; + + if (finalUrl && isHttpUrl(finalUrl)) { + newBookmark = { + type: BookmarkTypes.LINK, + url: finalUrl, + source: "extension", + title, + }; + } else { + console.warn("Invalid URL, bookmark not created:", finalUrl); + } + } + if (newBookmark) { + chrome.storage.session.set({ + [NEW_BOOKMARK_REQUEST_KEY_NAME]: newBookmark, + }); + } +} + +/** + * Search current URL and open appropriate page. + */ +async function searchCurrentUrl(tabUrl?: string) { + try { + if (!tabUrl || !isHttpUrl(tabUrl)) { + console.warn("Invalid URL, cannot search:", tabUrl); + return; + } + console.log("Searching bookmarks for URL:", tabUrl); + + const settings = await getPluginSettings(); + const serverAddress = settings.address; + + const matchedBookmarkId = await getBadgeStatus(tabUrl); + let targetUrl: string; + if (matchedBookmarkId) { + // Found exact match, open bookmark details page + targetUrl = `${serverAddress}/dashboard/preview/${matchedBookmarkId}`; + console.log("Opening bookmark details page:", targetUrl); + } else { + // No exact match, open search results page + const searchQuery = encodeURIComponent(`url:${tabUrl}`); + targetUrl = `${serverAddress}/dashboard/search?q=${searchQuery}`; + console.log("Opening search results page:", targetUrl); + } + await chrome.tabs.create({ url: targetUrl, active: true }); + } catch (error) { + console.error("Failed to search current URL:", error); + } +} + +/** + * Clear badge cache for the current active page. + */ +async function clearCurrentPageCache() { + try { + // Get the active tab + const [activeTab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + + if (activeTab.url && activeTab.id) { + console.log("Clearing cache for current page:", activeTab.url); + await clearBadgeStatus(activeTab.url); + + // Refresh the badge for the current tab + await checkAndUpdateIcon(activeTab.id); + } + } catch (error) { + console.error("Failed to clear current page cache:", error); + } +} + +/** + * Clear all badge cache and refresh badges for all active tabs. + */ +async function clearAllCache() { + try { + console.log("Clearing all badge cache"); + await clearBadgeStatus(); + } catch (error) { + console.error("Failed to clear all cache:", error); + } +} + +getPluginSettings().then(async (settings: Settings) => { + await checkSettingsState(settings); +}); + +subscribeToSettingsChanges(async (settings) => { + await checkSettingsState(settings); +}); + +// eslint-disable-next-line @typescript-eslint/no-misused-promises -- Manifest V3 allows async functions for all callbacks +chrome.contextMenus.onClicked.addListener(handleContextMenuClick); + +/** + * Handle command events, such as adding a link to karakeep. + * @param command The command to handle. + * @param tab The current tab. + */ +function handleCommand(command: string, tab: chrome.tabs.Tab) { + if (command === ADD_LINK_TO_KARAKEEP_ID) { + addLinkToKarakeep({ + selectionText: undefined, + srcUrl: undefined, + linkUrl: undefined, + pageUrl: tab?.url, + }); + + // now try to open the popup + chrome.action.openPopup(); + } else { + console.warn(`Received unknown command: ${command}`); + } +} + +chrome.commands.onCommand.addListener(handleCommand); + +/** + * Set the badge text and color based on the provided information. + * @param badgeStatus + * @param tabId The ID of the tab to update. + */ +export async function setBadge(badgeStatus: string | null, tabId?: number) { + if (!tabId) return; + + if (badgeStatus) { + return await Promise.all([ + chrome.action.setBadgeText({ tabId, text: ` ` }), + chrome.action.setBadgeBackgroundColor({ + tabId, + color: "#4CAF50", + }), + ]); + } else { + await chrome.action.setBadgeText({ tabId, text: `` }); + } +} + +/** + * Check and update the badge icon for a given tab ID. + * @param tabId The ID of the tab to update. + */ +async function checkAndUpdateIcon(tabId: number) { + const tabInfo = await chrome.tabs.get(tabId); + const { showCountBadge } = await getPluginSettings(); + const api = await getApiClient(); + if ( + !api || + !showCountBadge || + !tabInfo.url || + !isHttpUrl(tabInfo.url) || + tabInfo.status !== "complete" + ) { + await chrome.action.setBadgeText({ tabId, text: "" }); + return; + } + console.log("Tab activated", tabId, tabInfo); + + try { + const status = await getBadgeStatus(tabInfo.url); + await setBadge(status, tabId); + } catch (error) { + console.error("Archive check failed:", error); + await setBadge(null, tabId); + } +} + +chrome.tabs.onActivated.addListener(async (tabActiveInfo) => { + await checkAndUpdateIcon(tabActiveInfo.tabId); +}); + +chrome.tabs.onUpdated.addListener(async (tabId) => { + await checkAndUpdateIcon(tabId); +}); + +// Listen for REFRESH_BADGE messages from popup and update badge accordingly +chrome.runtime.onMessage.addListener(async (msg) => { + if (msg && msg.type) { + if (msg.currentTab && msg.type === MessageType.BOOKMARK_REFRESH_BADGE) { + console.log( + "Received REFRESH_BADGE message for tab:", + msg.currentTab.url, + ); + if (msg.currentTab.url) { + await clearBadgeStatus(msg.currentTab.url); + } + if (typeof msg.currentTab.id === "number") { + await checkAndUpdateIcon(msg.currentTab.id); + } + } + } +}); diff --git a/apps/browser-extension/src/components/ui/switch.tsx b/apps/browser-extension/src/components/ui/switch.tsx new file mode 100644 index 00000000..f2016ed0 --- /dev/null +++ b/apps/browser-extension/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; + +import { cn } from "../../utils/css"; + +const Switch = React.forwardRef< + React.ElementRef<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> +>(({ className, ...props }, ref) => ( + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className, + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0", + )} + /> + </SwitchPrimitives.Root> +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/apps/browser-extension/src/utils/badgeCache.ts b/apps/browser-extension/src/utils/badgeCache.ts new file mode 100644 index 00000000..caa4231d --- /dev/null +++ b/apps/browser-extension/src/utils/badgeCache.ts @@ -0,0 +1,82 @@ +// Badge count cache helpers +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +import { getPluginSettings } from "./settings"; +import { getApiClient, getQueryClient } from "./trpc"; +import { urlsMatchIgnoringAnchorAndTrailingSlash } from "./url"; + +/** + * Fetches the bookmark status for a given URL from the API. + * This function will be used by our cache as the "fetcher". + * @param url The URL to check. + * @returns The bookmark id if found, null if not found. + */ +async function fetchBadgeStatus(url: string): Promise<string | null> { + const api = await getApiClient(); + if (!api) { + // This case should ideally not happen if settings are correct + throw new Error("[badgeCache] API client not configured"); + } + try { + const data = await api.bookmarks.searchBookmarks.query({ + text: "url:" + url, + }); + const bookmarks = data.bookmarks; + const bookmarksLength = bookmarks.length; + if (bookmarksLength === 0) { + return null; + } + + // First check the exact match (including anchor points) + const exactMatch = + bookmarks.find( + (b) => + b.content.type === BookmarkTypes.LINK && + urlsMatchIgnoringAnchorAndTrailingSlash(url, b.content.url), + ) || null; + + return exactMatch ? exactMatch.id : null; + } catch (error) { + console.error(`[badgeCache] Failed to fetch status for ${url}:`, error); + // In case of API error, return a non-cacheable empty status + // Propagate so cache treats this as a miss and doesn’t store + throw error; + } +} + +/** + * Get badge status for a URL using the SWR cache. + * @param url The URL to get the status for. + */ +export async function getBadgeStatus(url: string): Promise<string | null> { + const { useBadgeCache, badgeCacheExpireMs } = await getPluginSettings(); + if (!useBadgeCache) return fetchBadgeStatus(url); + + const queryClient = await getQueryClient(); + if (!queryClient) return fetchBadgeStatus(url); + + return await queryClient.fetchQuery({ + queryKey: ["badgeStatus", url], + queryFn: () => fetchBadgeStatus(url), + // Keep in memory for twice as long as stale time + gcTime: badgeCacheExpireMs * 2, + // Use the user-configured cache expire time + staleTime: badgeCacheExpireMs, + }); +} + +/** + * Clear badge status cache for a specific URL or all URLs. + * @param url The URL to clear. If not provided, clears the entire cache. + */ +export async function clearBadgeStatus(url?: string): Promise<void> { + const queryClient = await getQueryClient(); + if (!queryClient) return; + + if (url) { + await queryClient.invalidateQueries({ queryKey: ["badgeStatus", url] }); + } else { + await queryClient.invalidateQueries({ queryKey: ["badgeStatus"] }); + } + console.log(`[badgeCache] Invalidated cache for: ${url || "all"}`); +} diff --git a/apps/browser-extension/src/utils/settings.ts b/apps/browser-extension/src/utils/settings.ts index 523699b4..c3ac50d2 100644 --- a/apps/browser-extension/src/utils/settings.ts +++ b/apps/browser-extension/src/utils/settings.ts @@ -1,17 +1,26 @@ import React from "react"; import { z } from "zod"; +export const DEFAULT_BADGE_CACHE_EXPIRE_MS = 60 * 60 * 1000; // 1 hour +export const DEFAULT_SHOW_COUNT_BADGE = false; + const zSettingsSchema = z.object({ apiKey: z.string(), apiKeyId: z.string().optional(), address: z.string(), theme: z.enum(["light", "dark", "system"]).optional().default("system"), + showCountBadge: z.boolean().default(DEFAULT_SHOW_COUNT_BADGE), + useBadgeCache: z.boolean().default(true), + badgeCacheExpireMs: z.number().min(0).default(DEFAULT_BADGE_CACHE_EXPIRE_MS), }); const DEFAULT_SETTINGS: Settings = { apiKey: "", address: "", theme: "system", + showCountBadge: DEFAULT_SHOW_COUNT_BADGE, + useBadgeCache: true, + badgeCacheExpireMs: DEFAULT_BADGE_CACHE_EXPIRE_MS, }; export type Settings = z.infer<typeof zSettingsSchema>; diff --git a/apps/browser-extension/src/utils/storagePersister.ts b/apps/browser-extension/src/utils/storagePersister.ts new file mode 100644 index 00000000..186faa4d --- /dev/null +++ b/apps/browser-extension/src/utils/storagePersister.ts @@ -0,0 +1,56 @@ +import { + PersistedClient, + Persister, +} from "@tanstack/react-query-persist-client"; + +export const TANSTACK_QUERY_CACHE_KEY = "tanstack-query-cache-key"; + +// Declare chrome namespace for TypeScript +declare const chrome: { + storage: { + local: { + set: (items: Record<string, string>) => Promise<void>; + get: (keys: string | string[]) => Promise<Record<string, string>>; + remove: (keys: string | string[]) => Promise<void>; + }; + }; +}; + +/** + * Creates an AsyncStorage-like interface for Chrome's extension storage. + * + * @param storage The Chrome storage area to use (e.g., `chrome.storage.local`). + * @returns An object that mimics the AsyncStorage interface. + */ +export const createChromeStorage = ( + storage: typeof chrome.storage.local = globalThis.chrome?.storage?.local, +): Persister => { + // Check if we are in a Chrome extension environment + if (typeof chrome === "undefined" || !chrome.storage) { + // Return a noop persister for non-extension environments + return { + persistClient: async () => { + return; + }, + restoreClient: async () => undefined, + removeClient: async () => { + return; + }, + }; + } + + return { + persistClient: async (client: PersistedClient) => { + await storage.set({ [TANSTACK_QUERY_CACHE_KEY]: JSON.stringify(client) }); + }, + restoreClient: async () => { + const result = await storage.get(TANSTACK_QUERY_CACHE_KEY); + return result[TANSTACK_QUERY_CACHE_KEY] + ? JSON.parse(result[TANSTACK_QUERY_CACHE_KEY]) + : undefined; + }, + removeClient: async () => { + await storage.remove(TANSTACK_QUERY_CACHE_KEY); + }, + }; +}; diff --git a/apps/browser-extension/src/utils/trpc.ts b/apps/browser-extension/src/utils/trpc.ts index e56968b8..76534bcb 100644 --- a/apps/browser-extension/src/utils/trpc.ts +++ b/apps/browser-extension/src/utils/trpc.ts @@ -1,5 +1,121 @@ +import { QueryClient } from "@tanstack/react-query"; +import { persistQueryClient } from "@tanstack/react-query-persist-client"; +import { createTRPCClient, httpBatchLink } from "@trpc/client"; import { createTRPCReact } from "@trpc/react-query"; +import superjson from "superjson"; import type { AppRouter } from "@karakeep/trpc/routers/_app"; +import { getPluginSettings } from "./settings"; +import { createChromeStorage } from "./storagePersister"; + export const api = createTRPCReact<AppRouter>(); + +let apiClient: ReturnType<typeof createTRPCClient<AppRouter>> | null = null; +let queryClient: QueryClient | null = null; +let currentSettings: { + address: string; + apiKey: string; + badgeCacheExpireMs: number; + useBadgeCache: boolean; +} | null = null; + +export async function initializeClients() { + const { address, apiKey, badgeCacheExpireMs, useBadgeCache } = + await getPluginSettings(); + + if (currentSettings) { + const addressChanged = currentSettings.address !== address; + const apiKeyChanged = currentSettings.apiKey !== apiKey; + const cacheTimeChanged = + currentSettings.badgeCacheExpireMs !== badgeCacheExpireMs; + const useBadgeCacheChanged = + currentSettings.useBadgeCache !== useBadgeCache; + + if (!address || !apiKey) { + // Invalid configuration, clean + const persisterForCleanup = createChromeStorage(); + await persisterForCleanup.removeClient(); + cleanupApiClient(); + return; + } + + if (addressChanged || apiKeyChanged) { + // Switch context completely → discard the old instance and wipe persisted cache + const persisterForCleanup = createChromeStorage(); + await persisterForCleanup.removeClient(); + cleanupApiClient(); + } else if ((cacheTimeChanged || useBadgeCacheChanged) && queryClient) { + // Change the cache policy only → Clean up the data, but reuse the instance + queryClient.clear(); + } + + // If there is already existing and there is no major change in settings, reuse it + if ( + queryClient && + apiClient && + currentSettings && + !addressChanged && + !apiKeyChanged && + !cacheTimeChanged && + !useBadgeCacheChanged + ) { + return; + } + } + + if (address && apiKey) { + // Store current settings + currentSettings = { address, apiKey, badgeCacheExpireMs, useBadgeCache }; + + // Create new QueryClient with updated settings + queryClient = new QueryClient(); + + const persister = createChromeStorage(); + if (useBadgeCache) { + persistQueryClient({ + queryClient, + persister, + // Avoid restoring very old data and bust on policy changes + maxAge: badgeCacheExpireMs * 2, + buster: `badge:${address}:${badgeCacheExpireMs}`, + }); + } else { + // Ensure disk cache is cleared when caching is disabled + await persister.removeClient(); + } + + apiClient = createTRPCClient<AppRouter>({ + links: [ + httpBatchLink({ + url: `${address}/api/trpc`, + headers() { + return { + Authorization: `Bearer ${apiKey}`, + }; + }, + transformer: superjson, + }), + ], + }); + } +} + +export async function getApiClient() { + if (!apiClient) { + await initializeClients(); + } + return apiClient; +} + +export async function getQueryClient() { + // Check if settings have changed and reinitialize if needed + await initializeClients(); + return queryClient; +} + +export function cleanupApiClient() { + apiClient = null; + queryClient = null; + currentSettings = null; +} diff --git a/apps/browser-extension/src/utils/type.ts b/apps/browser-extension/src/utils/type.ts new file mode 100644 index 00000000..ba5c025b --- /dev/null +++ b/apps/browser-extension/src/utils/type.ts @@ -0,0 +1,3 @@ +export const enum MessageType { + BOOKMARK_REFRESH_BADGE = 1, +} diff --git a/apps/browser-extension/src/utils/url.ts b/apps/browser-extension/src/utils/url.ts new file mode 100644 index 00000000..9ed423a0 --- /dev/null +++ b/apps/browser-extension/src/utils/url.ts @@ -0,0 +1,41 @@ +/** + * Check if a URL is an HTTP or HTTPS URL. + * @param url The URL to check. + * @returns True if the URL starts with "http://" or "https://", false otherwise. + */ +export function isHttpUrl(url: string) { + const lower = url.toLowerCase(); + return lower.startsWith("http://") || lower.startsWith("https://"); +} + +/** + * Normalize a URL by removing the hash and trailing slash. + * @param url The URL to process. + * @param base Optional base URL for relative URLs. + * @returns Normalized URL as string. + */ +export function normalizeUrl(url: string, base?: string): string { + const u = new URL(url, base); + u.hash = ""; // Remove hash fragment + let pathname = u.pathname; + if (pathname.endsWith("/") && pathname !== "/") { + pathname = pathname.slice(0, -1); // Remove trailing slash except for root "/" + } + u.pathname = pathname; + return u.toString(); +} + +/** + * Compare two URLs ignoring hash and trailing slash. + * @param url1 First URL. + * @param url2 Second URL. + * @param base Optional base URL for relative URLs. + * @returns True if URLs match after normalization. + */ +export function urlsMatchIgnoringAnchorAndTrailingSlash( + url1: string, + url2: string, + base?: string, +): boolean { + return normalizeUrl(url1, base) === normalizeUrl(url2, base); +} |
