diff options
Diffstat (limited to 'apps/browser-extension/src/utils')
| -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 |
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); +} |
