aboutsummaryrefslogtreecommitdiffstats
path: root/apps/browser-extension/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/browser-extension/src')
-rw-r--r--apps/browser-extension/src/BookmarkSavedPage.tsx11
-rw-r--r--apps/browser-extension/src/NotConfiguredPage.tsx11
-rw-r--r--apps/browser-extension/src/OptionsPage.tsx53
-rw-r--r--apps/browser-extension/src/SavePage.tsx44
-rw-r--r--apps/browser-extension/src/background/background.ts481
-rw-r--r--apps/browser-extension/src/components/ui/switch.tsx27
-rw-r--r--apps/browser-extension/src/utils/badgeCache.ts82
-rw-r--r--apps/browser-extension/src/utils/settings.ts9
-rw-r--r--apps/browser-extension/src/utils/storagePersister.ts56
-rw-r--r--apps/browser-extension/src/utils/trpc.ts116
-rw-r--r--apps/browser-extension/src/utils/type.ts3
-rw-r--r--apps/browser-extension/src/utils/url.ts41
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);
+}