import { name } from "../../package.json"; import { addLogEntry } from "./logEntries"; import { getSearchTokenHash } from "./searchTokenHash"; import type { ImageSearchResults, TextSearchResults } from "./types"; /** * Creates a cached version of a search function using IndexedDB for storage. * * @param fn - The original search function to be cached. * @returns A new function that wraps the original, adding caching functionality. * * This function implements a caching mechanism for search results using IndexedDB. * It stores search results with a 15-minute time-to-live (TTL) to improve performance * for repeated searches. The cache is automatically cleaned of expired entries. * * The returned function behaves as follows: * 1. Checks IndexedDB for a cached result matching the query. * 2. If a valid (non-expired) cached result exists, it is returned immediately. * 3. Otherwise, the original search function is called, and its result is both * returned and stored in the cache for future use. * * If IndexedDB is not available, the function falls back to using the original * search function without caching. */ function cacheSearchWithIndexedDB< T extends ImageSearchResults | TextSearchResults, >( fn: (query: string, limit?: number) => Promise, storeName: string, ): (query: string, limit?: number) => Promise { const databaseVersion = 2; const timeToLive = 15 * 60 * 1000; async function openDB(): Promise { return new Promise((resolve, reject) => { let request = indexedDB.open(name, databaseVersion); request.onerror = () => reject(request.error); request.onsuccess = () => { const db = request.result; if ( !db.objectStoreNames.contains("textSearches") || !db.objectStoreNames.contains("imageSearches") ) { db.close(); request = indexedDB.open(name, databaseVersion); request.onupgradeneeded = createStores; request.onsuccess = () => { const upgradedDb = request.result; cleanExpiredCache(upgradedDb); resolve(upgradedDb); }; request.onerror = () => reject(request.error); } else { cleanExpiredCache(db); resolve(db); } }; request.onupgradeneeded = createStores; }); } function createStores(event: IDBVersionChangeEvent): void { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains("textSearches")) { db.createObjectStore("textSearches"); } if (!db.objectStoreNames.contains("imageSearches")) { db.createObjectStore("imageSearches"); } } async function cleanExpiredCache(db: IDBDatabase): Promise { const transaction = db.transaction(storeName, "readwrite"); const store = transaction.objectStore(storeName); const currentTime = Date.now(); return new Promise((resolve) => { const request = store.openCursor(); request.onsuccess = (event) => { const cursor = (event.target as IDBRequest).result; if (cursor) { if (currentTime - cursor.value.timestamp >= timeToLive) { cursor.delete(); } cursor.continue(); } else { resolve(); } }; }); } /** * Generates a hash for a given query string. * * This function implements a simple hash algorithm: * 1. It iterates through each character in the query string. * 2. For each character, it updates the hash value using bitwise operations. * 3. The final hash is converted to a 32-bit integer. * 4. The result is returned as a base-36 string representation. * * @param query - The input string to be hashed. * @returns A string representation of the hash in base-36. */ function hashQuery(query: string): string { return query .split("") .reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0) .toString(36); } const dbPromise = openDB(); return async (query: string, limit?: number): Promise => { if (!indexedDB) return fn(query, limit); const db = await dbPromise; const transaction = db.transaction(storeName, "readwrite"); const store = transaction.objectStore(storeName); const key = hashQuery(query); const cachedResult = await new Promise< | { results: T; timestamp: number; } | undefined >((resolve) => { const request = store.get(key); request.onerror = () => resolve(undefined); request.onsuccess = () => resolve(request.result); }); if (cachedResult && Date.now() - cachedResult.timestamp < timeToLive) { addLogEntry( `IndexedDB ${storeName}: Search cache hit, returning cached results containing ${cachedResult.results.length} items`, ); return cachedResult.results; } addLogEntry( `IndexedDB ${storeName}: Search cache miss, fetching new results`, ); const results = await fn(query, limit); const writeTransaction = db.transaction(storeName, "readwrite"); const writeStore = writeTransaction.objectStore(storeName); writeStore.put({ results, timestamp: Date.now() }, key); addLogEntry( `IndexedDB ${storeName}: Search completed with ${results.length} items`, ); return results; }; } async function performSearch( endpoint: "text" | "images", query: string, limit?: number, ): Promise { const searchUrl = new URL(`/search/${endpoint}`, self.location.origin); searchUrl.searchParams.set("q", query); searchUrl.searchParams.set("token", await getSearchTokenHash()); if (limit) searchUrl.searchParams.set("limit", limit.toString()); const response = await fetch(searchUrl.toString()); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } export const searchText = cacheSearchWithIndexedDB( async (query: string, limit?: number): Promise => { try { return performSearch("text", query, limit); } catch (error) { addLogEntry( `Text search failed: ${error instanceof Error ? error.message : error}`, ); return []; } }, "textSearches", ); export const searchImages = cacheSearchWithIndexedDB( async (query: string, limit?: number): Promise => { try { return performSearch("images", query, limit); } catch (error) { addLogEntry( `Image search failed: ${error instanceof Error ? error.message : error}`, ); return []; } }, "imageSearches", );