All files / src/helpers excludedProducts.ts

100% Statements 64/64
100% Branches 15/15
100% Functions 5/5
100% Lines 64/64

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 1791x 1x 1x 1x   1x                                                               1x                             1x 1x 32x 32x   1x                         1x 1x 6x 6x 6x   1x                             1x 1x 7x 7x 7x 9x 9x 7x 7x   1x                                           1x 1x 13x 13x 13x   13x 13x 13x 12x 12x 13x 13x 13x 13x 13x 13x 13x 1x 1x 1x 1x 13x 13x   1x                                 1x 1x 3x 3x 3x 2x 2x 1x 1x 1x 1x 1x 3x  
import { getProductIdentityKey } from '@/helpers/productIdentity';
import { getExcludedProducts, putExcludedProducts } from '@/utils/idbCache';
import { Logger } from '@/utils/Logger';
import { getErrorMessage } from '@/helpers/exceptions';
 
const logger = new Logger('excludedProducts');
 
/**
 * Minimal metadata stored for each excluded product so the user can review
 * (and eventually un-exclude) their ignore list from settings.
 * @category Helpers
 * @source
 */
export interface ExcludedProductEntry {
  /**
   * Supplier's stable product identity (`SupplierBase.getUniqueProductKey`),
   * used with the supplier name to derive the exclusion key.
   */
  identity?: string;
  /** Canonical product URL, kept for display. */
  url?: string;
  /** Supplier name used to derive the exclusion key. */
  supplier: string;
  /** Last-known product title, purely for display. */
  title?: string;
  /** Epoch ms at which the product was excluded. */
  excludedAt: number;
}
 
/**
 * Map of exclusion-key → excluded product entry, as persisted in the
 * `excludedProducts` IndexedDB object store (via `@/utils/idbCache`).
 * @category Helpers
 * @source
 */
export type ExcludedProductsMap = Record<string, ExcludedProductEntry>;
 
/**
 * Load the excluded-products map from IndexedDB. Returns an empty object if
 * the store is empty or the read fails, so callers can treat the result as
 * always-valid.
 *
 * @category Helpers
 * @returns The persisted exclusions map (never `null`/`undefined`).
 * @example
 * ```ts
 * const excluded = await loadExcludedProducts();
 * if (excluded[key]) {
 *   // product is ignored
 * }
 * ```
 * @source
 */
export async function loadExcludedProducts(): Promise<ExcludedProductsMap> {
  return getExcludedProducts();
}
 
/**
 * Load just the set of exclusion keys — convenient for hot-path membership
 * checks (e.g. inside `SupplierBase.getProductData`) where the full metadata
 * is not needed.
 *
 * @category Helpers
 * @returns A `Set` of exclusion keys (empty on read failure).
 * @example
 * ```ts
 * const keys = await loadExcludedProductKeys();
 * if (keys.has(cacheKey)) return undefined; // skip this product
 * ```
 * @source
 */
export async function loadExcludedProductKeys(): Promise<Set<string>> {
  const map = await loadExcludedProducts();
  return new Set(Object.keys(map));
}
 
/**
 * Count how many excluded entries were ignored from a given supplier. Used by
 * `SupplierBase.execute` to over-fetch from `queryProducts` so that dropping
 * previously-ignored rows still leaves the caller with `limit` survivors
 * instead of a short list.
 *
 * @category Helpers
 * @param supplierName - Supplier name to match against entry metadata.
 * @returns Count of entries whose `supplier` field equals `supplierName`.
 * @example
 * ```ts
 * const extra = await countExcludedProductsForSupplier("Loudwolf");
 * const results = await this.queryProductsWithCache(query, limit + extra);
 * ```
 * @source
 */
export async function countExcludedProductsForSupplier(supplierName: string): Promise<number> {
  const map = await loadExcludedProducts();
  let count = 0;
  for (const entry of Object.values(map)) {
    if (entry.supplier === supplierName) count++;
  }
  return count;
}
 
/**
 * Add a product to the excluded list in IndexedDB, keyed by the supplier's
 * stable product identity ({@link getProductIdentityKey}) — the same key that
 * keys the product-detail cache. Idempotent: re-excluding an already-ignored
 * product just refreshes its `excludedAt` and last-known title. Returns the
 * exclusion key for the caller's logging convenience.
 *
 * @category Helpers
 * @param identity - The product's unique identity (its `cacheKey`), or the URL
 *   as a fallback when no identity was stamped.
 * @param supplierName - Supplier name.
 * @param meta - Optional extra metadata: display title and canonical URL (kept
 *   for display and legacy-key matching).
 * @returns The exclusion key that was written.
 * @example
 * ```ts
 * await addExcludedProduct(product.cacheKey ?? product.url, product.supplier, {
 *   title: product.title,
 *   url: product.url,
 * });
 * ```
 * @source
 */
export async function addExcludedProduct(
  identity: string,
  supplierName: string,
  meta?: { title?: string; url?: string },
): Promise<string> {
  const key = getProductIdentityKey(identity, supplierName);
  try {
    const map = await loadExcludedProducts();
    map[key] = {
      identity,
      url: meta?.url,
      supplier: supplierName,
      title: meta?.title,
      excludedAt: Date.now(),
    };
    await putExcludedProducts(map);
  } catch (error) {
    logger.warn(`Failed to persist excluded product to IndexedDB: ${getErrorMessage(error)}`, {
      error,
    });
  }
  return key;
}
 
/**
 * Remove a single entry from the excluded-products map in IndexedDB.
 * Load → delete → put, matching `addExcludedProduct`'s read-modify-write
 * shape so a caller holding a stale map cannot clobber concurrent writes.
 * No-op (and no write) when the key is absent, so repeated removals are
 * safe. Errors are logged, not thrown — callers don't need to try/catch.
 * @category Helpers
 * @param key - Exclusion key, as produced by `getProductExclusionKey`.
 * @returns Resolves once the write completes.
 * @example
 * ```ts
 * const key = getProductExclusionKey(product.url, product.supplier);
 * await removeExcludedProduct(key);
 * // entry for `key` is gone from IndexedDB; next search may surface the
 * // product again (subject to any cached supplier results).
 * ```
 * @source
 */
export async function removeExcludedProduct(key: string): Promise<void> {
  try {
    const map = await loadExcludedProducts();
    if (map[key] === undefined) return;
    delete map[key];
    await putExcludedProducts(map);
  } catch (error) {
    logger.warn(`Failed to remove excluded product from IndexedDB: ${getErrorMessage(error)}`, {
      error,
    });
  }
}