aboutsummaryrefslogtreecommitdiffstats
path: root/apps/browser-extension/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'apps/browser-extension/src/utils')
-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
6 files changed, 307 insertions, 0 deletions
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);
+}