diff options
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); +} |
