[obj mult] A Better Book Highlighter (Integrated Read Extractor)

Highlights tiered lists and fades books read by extracting per-pet read-lists from books_read pages (no network requests). Tampermonkey/Violentmonkey compatible. Includes export/import using IndexedDB for reliable storage.

// ==UserScript==
// @name         [obj mult] A Better Book Highlighter (Integrated Read Extractor)
// @version      2.0
// @description  Highlights tiered lists and fades books read by extracting per-pet read-lists from books_read pages (no network requests). Tampermonkey/Violentmonkey compatible. Includes export/import using IndexedDB for reliable storage.
// @namespace    https://github.com/uxillary/neo-qol
// @author       valkryie1248
// @license      MIT
// @match        https://www.neopets.com/objects.phtml?type=shop&obj_type=7
// @match        https://www.neopets.com/objects.phtml?obj_type=7&type=shop
// @match        https://www.neopets.com/objects.phtml?*obj_type=38*
// @match        https://www.neopets.com/objects.phtml?*obj_type=51*
// @match        https://www.neopets.com/objects.phtml?*obj_type=70*
// @match        https://www.neopets.com/objects.phtml?*obj_type=77*
// @match        https://www.neopets.com/objects.phtml?*obj_type=92*
// @match        https://www.neopets.com/objects.phtml?*obj_type=106*
// @match        https://www.neopets.com/objects.phtml?*obj_type=114*
// @match        https://www.neopets.com/books_read.phtml?pet_name=*
// @match        https://www.neopets.com/moon/books_read.phtml?pet_name=*
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// @run-at       document-end
// ==/UserScript==
(() => {
  /* Safe error logging */
  function safeLogError(err) {
    try {
      console.error("[BM Script Error]", err);
    } catch (e) {}
  }

  /* ---------------------- CONSTANTS ----------------------- */
  const CSS_CLASSES = {
    OVERLAY: "ddg-img-overlay",
    READ_HIGHLIGHT: "ListRead-highlight",
    LIST1: "List1-highlight",
    LIST2: "List2-highlight",
    LIST3: "List3-highlight",
    LIST4: "List4-highlight",
  };

  /* ---------------------- CSS + UI key ----------------------- */
  const css = `
.ddg-img-overlay { position: relative; }
.ddg-img-overlay::after{
    content: "";
    position: absolute;
    inset: 0;
    background: rgba(0,255,255,0.25);
    pointer-events: none;
    transition: opacity .15s;
    opacity: 1;
}
.ddg-img-overlay.no-overlay::after { opacity: 0; }
.${CSS_CLASSES.LIST4}{ color: red; font-weight: 800; text-decoration: underline;}
:has(>.${CSS_CLASSES.LIST4}) {border: 5px solid red; padding: 5px; box-shadow: 0 4px 8px rgba(255, 0, 0, 0.2); }
.${CSS_CLASSES.LIST3}{ color:red; }
:has(> .${CSS_CLASSES.LIST3}) { border: 2px dashed red; padding-top: 5px; box-shadow: 0 4px 8px rgba(255, 0, 0, 0.1);}
.${CSS_CLASSES.LIST2} { color: orange; }
:has(> .${CSS_CLASSES.LIST2}) { border:2px solid orange; padding-top:5px; box-shadow:0 4px 8px rgba(255,165,0,0.2);}
.${CSS_CLASSES.LIST1} { color:green; }
:has(> .${CSS_CLASSES.LIST1}) { border:1px dotted green; padding-top:5px; box-shadow:0 4px 8px rgba(0,255,0,0.2);}
.${CSS_CLASSES.READ_HIGHLIGHT} { color:grey; text-decoration: line-through;}
:has(> .${CSS_CLASSES.READ_HIGHLIGHT}) { padding-top:5px; box-shadow:0 4px 8px rgba(0,255,255,0.6);}
:where(.item-name) b { font-weight: normal; }`;
  const style = document.createElement("style");
  style.textContent = css;
  document.head.appendChild(style);

  /* Price/key UI setup */
  let keyDiv;
  try {
    keyDiv = document.createElement("div");
    keyDiv.style.position = "fixed";
    keyDiv.style.top = "180px";
    keyDiv.style.right = "3px";
    keyDiv.style.backgroundColor = "rgba(255, 255, 255, 0.9)";
    keyDiv.style.border = "1px solid #ccc";
    keyDiv.style.padding = "10px";
    keyDiv.style.fontSize = "11px";
    keyDiv.style.fontFamily = "Arial, sans-serif";
    keyDiv.style.zIndex = "1000";
    keyDiv.style.width = "110px";
    keyDiv.style.maxHeight = "90vh";
    keyDiv.style.overflowY = "auto";
    keyDiv.style.opacity = "0.4";
    keyDiv.style.transition = "opacity 0.3s";
    keyDiv.onmouseenter = () => (keyDiv.style.opacity = "1.0");
    keyDiv.onmouseleave = () => (keyDiv.style.opacity = "0.4");
    // Inner HTML is set later by updateKeyUI()
  } catch (err) {
    safeLogError(err);
  }

  // Function to dynamically build and update the Key UI based on non-empty lists
  function updateKeyUI() {
    if (!keyDiv) return;

    const listDefinitions = [
      {
        name: "List 4 (100k+ NP)",
        style: "border: 5px solid red;",
        check: NORM_LIST4,
        margin: true,
      },
      {
        name: "List 3 (50k-100k NP)",
        style: "border: 2px dashed red;",
        check: NORM_LIST3,
        margin: false,
      },
      {
        name: "List 2 (25k-50k NP)",
        style: "border: 2px solid orange;",
        check: NORM_LIST2,
        margin: false,
      },
      {
        name: "List 1 (10k-25k NP)",
        style: "border: 1px dotted green;",
        check: NORM_LIST1,
        margin: false,
      },
    ];

    let html = `
    <div style="display: flex; align-items: center;">
        <strong>- Highlight Key -</strong><br><br>
    </div>
    <div style="display: flex; align-items: center; margin-bottom: 6px;">
        <div style="width: 12px; height: 12px; background-color: rgba(0,255,255,0.25); border: 1px solid #00ffff; margin-right: 5px;"></div>
        Read!
    </div>`;

    for (const def of listDefinitions) {
      if (def.check.size > 0) {
        const marginTop = def.margin ? "margin-top: 5px;" : "";
        html += `
            <div style="display: flex; align-items: center; margin-bottom: 6px;">
                <div style="width: 12px; height: 12px; ${def.style} margin-right: 5px; ${marginTop}"></div>
                ${def.name}
            </div>`;
      }
    }

    keyDiv.innerHTML = html;
  }

  /* ---------------------- Normalizer ------------------------- */
  const normalize = (s) =>
    (s || "")
      .toString()
      .toLowerCase()
      .normalize("NFKD")
      .replace(/\p{Diacritic}/gu, "")
      .replace(/[.,`'’"():/–—\-_]/g, " ")
      .replace(/\b(the|a|an|of|and|&)\b/g, " ")
      .replace(/\s+/g, " ")
      .trim();

  /* ---------------------- Lists (empty Sets for runtime data) ------------------------ */
  const NORM_LISTBOOKTASTICREAD = new Set();
  const NORM_LISTREAD = new Set();

  // Tiered Highlight Lists: List 0 was removed as it had no special styling.
  // Add your purchase preferences (ideally based on JN estimated values) directly into these sets
  const NORM_LIST1 = new Set();
  const NORM_LIST2 = new Set();
  const NORM_LIST3 = new Set();
  const NORM_LIST4 = new Set();

  /* ---------------------- Storage helpers (IndexedDB for Read Lists) --------------------- */
  const TIER_LISTS_KEY = "bm_tier_lists_v1";
  const DB_NAME = "BookMarkerDB";
  const STORE_NAME = "PetReadLists";

  function openDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(DB_NAME, 1);
      request.onerror = (event) => {
        console.error(
          "[IDB Error] Failed to open IndexedDB:",
          event.target.error
        );
        reject(new Error("IndexedDB error"));
      };
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        if (!db.objectStoreNames.contains(STORE_NAME)) {
          db.createObjectStore(STORE_NAME, { keyPath: "pet_name" });
        }
      };
      request.onsuccess = (event) => {
        resolve(event.target.result);
      };
    });
  }

  async function loadReadListsForPet(petName) {
    try {
      const db = await openDB();
      const transaction = db.transaction([STORE_NAME], "readonly");
      const store = transaction.objectStore(STORE_NAME);
      return new Promise((resolve) => {
        const request = store.get(petName);
        request.onsuccess = (event) => {
          const result = event.target.result || {
            booktastic: [],
            regular: [],
            pet_name: petName,
          };
          resolve(result);
        };
        request.onerror = (event) => {
          console.error("[IDB Load Error]", event.target.error);
          resolve({ booktastic: [], regular: [], pet_name: petName });
        };
      });
    } catch (e) {
      console.error("IndexedDB initialization failed during load:", e);
      return { booktastic: [], regular: [], pet_name: petName };
    }
  }

  async function saveReadListsForPet(petName, payload) {
    try {
      const db = await openDB();
      const transaction = db.transaction([STORE_NAME], "readwrite");
      const store = transaction.objectStore(STORE_NAME);
      const dataToStore = {
        pet_name: petName,
        booktastic: payload.booktastic,
        regular: payload.regular,
      };
      return new Promise((resolve, reject) => {
        const request = store.put(dataToStore);
        request.onsuccess = () => resolve();
        request.onerror = (event) => {
          console.error("[IDB Save Error]", event.target.error);
          reject(new Error("Failed to save data to IndexedDB."));
        };
      });
    } catch (e) {
      console.error("IndexedDB initialization failed during save:", e);
      throw new Error("Could not access database for saving.");
    }
  }

  // Gets all records from IndexedDB
  async function getAllReadLists() {
    try {
      const db = await openDB();
      const transaction = db.transaction([STORE_NAME], "readonly");
      const store = transaction.objectStore(STORE_NAME);
      return new Promise((resolve) => {
        const request = store.getAll();
        request.onsuccess = (event) => {
          resolve(event.target.result || []);
        };
        request.onerror = (event) => {
          console.error("[IDB Load All Error]", event.target.error);
          resolve([]);
        };
      });
    } catch (e) {
      safeLogError(e);
      return [];
    }
  }

  // Clears all records from IndexedDB
  async function clearAllReadLists() {
    if (
      !confirm(
        "WARNING: This will permanently DELETE ALL stored pet read lists (both regular and booktastic). Are you sure?"
      )
    ) {
      return;
    }
    try {
      // Clears all records from the IndexedDB store
      const db = await openDB();
      const transaction = db.transaction([STORE_NAME], "readwrite");
      const store = transaction.objectStore(STORE_NAME);
      await new Promise((resolve, reject) => {
        const request = store.clear();
        request.onsuccess = () => resolve();
        request.onerror = (event) => reject(event.target.error);
      });

      alert(
        "All stored pet read lists have been deleted. Please reload the shop page."
      );
      console.log("[BM Debug Log] Successfully cleared all read lists.");
    } catch (e) {
      safeLogError(e);
      alert(`Failed to clear read lists. Error: ${e.message}`);
    }
  }

  // Imports multiple records into IndexedDB
  async function importAllReadLists(data) {
    try {
      const db = await openDB();
      const transaction = db.transaction([STORE_NAME], "readwrite");
      const store = transaction.objectStore(STORE_NAME);
      return new Promise((resolve, reject) => {
        const promises = data.map((item) => {
          return new Promise((res, rej) => {
            const request = store.put(item);
            request.onsuccess = () => res();
            request.onerror = () => rej();
          });
        });

        Promise.all(promises)
          .then(() => resolve())
          .catch((e) => {
            console.error("[IDB Import Error]", e);
            reject(new Error("Failed to import all data into IndexedDB."));
          });
      });
    } catch (e) {
      safeLogError(e);
      throw new Error("Could not access database for importing.");
    }
  }

  /* ---------------------- Matching / apply classes --------------------- */
  const applyListClass = (element, nameNorm) => {
    try {
      if (!element) return;
      element.classList.remove(
        CSS_CLASSES.LIST4,
        CSS_CLASSES.LIST3,
        CSS_CLASSES.LIST2,
        CSS_CLASSES.LIST1,
        CSS_CLASSES.READ_HIGHLIGHT
      );

      // Tiered Highlighting is applied in reverse order (List 4 first)
      if (NORM_LIST4.has(nameNorm)) element.classList.add(CSS_CLASSES.LIST4);
      else if (NORM_LIST3.has(nameNorm))
        element.classList.add(CSS_CLASSES.LIST3);
      else if (NORM_LIST2.has(nameNorm))
        element.classList.add(CSS_CLASSES.LIST2);
      // List 0 check is absent; books not in List 1-4 receive default unstyled display.
      else if (NORM_LIST1.has(nameNorm))
        element.classList.add(CSS_CLASSES.LIST1);

      // Read Highlighting
      if (
        NORM_LISTREAD.has(nameNorm) ||
        NORM_LISTBOOKTASTICREAD.has(nameNorm)
      ) {
        element.classList.add(CSS_CLASSES.READ_HIGHLIGHT);
        if (element.parentNode)
          element.parentNode.classList.add(CSS_CLASSES.OVERLAY);
      } else {
        if (element.parentNode)
          element.parentNode.classList.remove(CSS_CLASSES.OVERLAY);
      }
    } catch (e) {
      safeLogError(e);
    }
  };

  const applyBooktasticByTitle = () => {
    try {
      const divs = document.querySelectorAll("div[title]");
      for (const d of divs) {
        try {
          const title = (d.getAttribute("title") || "").trim();
          if (!title) continue;
          const titleNorm = normalize(title);
          if (NORM_LISTBOOKTASTICREAD.has(titleNorm)) {
            const target = d.querySelector(".item-name") || d;
            target.classList.add(CSS_CLASSES.READ_HIGHLIGHT);
            if (target.parentNode)
              target.parentNode.classList.add(CSS_CLASSES.OVERLAY);
          }
        } catch (inner) {
          safeLogError(inner);
        }
      }
    } catch (err) {
      safeLogError(err);
    }
  };

  function applyStylesToItems() {
    try {
      const itemNames = document.getElementsByClassName("item-name");
      for (let i = 0; i < itemNames.length; i++) {
        try {
          const raw = (itemNames[i].textContent || "").trim();
          if (!raw) continue;
          const nameNorm = normalize(raw);
          applyListClass(itemNames[i], nameNorm);
        } catch (inner) {
          safeLogError(inner);
        }
      }
      applyBooktasticByTitle();
    } catch (err) {
      safeLogError(err);
    }
  }

  /* ---------------------- Extraction logic for books_read pages -------------------- */
  function getQueryParam(name) {
    try {
      const u = new URL(location.href);
      return u.searchParams.get(name);
    } catch (e) {
      return null;
    }
  }

  function extractFromBooksReadDOM() {
    try {
      const log = [];
      const booktasticRaw = [];
      const tdsA = document.querySelectorAll('td[align="center"]');
      for (const td of tdsA) {
        if (td.querySelector("i") && !td.hasAttribute("style")) {
          const txt = (td.textContent || "").trim();
          if (txt) {
            booktasticRaw.push(txt);
            log.push({
              source: "Variant A (Booktastic)",
              raw: txt,
              key: normalize(txt),
            });
          }
        }
      }

      const regularRaw = [];
      const tdsB = document.querySelectorAll(
        'td[align="center"][style="border:1px solid black;"]'
      );
      for (const td of tdsB) {
        const raw = (td.textContent || "").trim();
        if (!raw || !raw.includes(":")) continue;

        let title = raw;
        const split = raw.split(/:\s*\u00A0*\s*/);
        if (split && split.length > 1) {
          title = split[0].trim();
        } else {
          title = raw.trim();
        }

        if (title) {
          regularRaw.push(title);
          log.push({
            source: "Variant B (Regular)",
            raw: raw,
            key: normalize(title),
          });
        }
      }

      const seen = new Map();
      booktasticRaw.forEach((o) => {
        const key = normalize(o);
        if (key) seen.set(key, { orig: o, key, classification: "booktastic" });
      });

      regularRaw.forEach((o) => {
        const key = normalize(o);
        if (key && !seen.has(key)) {
          seen.set(key, { orig: o, key, classification: "regular" });
        }
      });

      const booktastic = [];
      const regular = [];
      for (const item of seen.values()) {
        if (item.classification === "booktastic") {
          booktastic.push({ orig: item.orig, key: item.key });
        } else {
          regular.push({ orig: item.orig, key: item.key });
        }
      }

      return { booktastic, regular, log };
    } catch (e) {
      safeLogError(e);
      return { booktastic: [], regular: [], log: [{ error: e.message }] };
    }
  }

  async function handleBooksReadPage() {
    try {
      const petName = getQueryParam("pet_name");
      if (!petName) {
        console.warn(
          "[BM Debug Log] Could not find 'pet_name' query parameter. Aborting read page handling."
        );
        return;
      }
      const extracted = extractFromBooksReadDOM();
      const extractedBooktasticCount = extracted.booktastic.length || 0;
      const extractedRegularCount = extracted.regular.length || 0;

      let pageType = "Unknown";
      let booksFound = 0;

      if (extractedBooktasticCount > 0 && extractedRegularCount === 0) {
        pageType = "Booktastic";
        booksFound = extractedBooktasticCount;
      } else if (extractedRegularCount > 0 && extractedBooktasticCount === 0) {
        pageType = "Regular";
        booksFound = extractedRegularCount;
      } else {
        const isBooktasticPage = location.href.includes(
          "booktastic_read.phtml"
        );
        pageType = isBooktasticPage ? "Booktastic" : "Regular";
        booksFound = isBooktasticPage
          ? extractedBooktasticCount
          : extractedRegularCount;
      }

      console.groupCollapsed(
        `[BM Debug Log] Extracted ${
          extractedBooktasticCount + extractedRegularCount
        } books for ${petName} (${pageType} Page). Click to expand raw data.`
      );
      console.table(extracted.log);
      console.log("Extracted Booktastic List:", extracted.booktastic);
      console.log("Extracted Regular List:", extracted.regular);
      console.groupEnd();

      if (booksFound === 0) {
        console.warn(
          `[BM Debug Log] Found 0 relevant books (${pageType}). Aborting save.`
        );
        return;
      }

      const existing = await loadReadListsForPet(petName);
      const existingBooktastic = new Map(
        (existing.booktastic || []).map((e) => [e.key, e.orig])
      );
      const existingRegular = new Map(
        (existing.regular || []).map((e) => [e.key, e.orig])
      );

      if (pageType === "Booktastic") {
        existingBooktastic.clear();
        extracted.booktastic.forEach((e) =>
          existingBooktastic.set(e.key, e.orig)
        );
      } else if (pageType === "Regular") {
        existingRegular.clear();
        extracted.regular.forEach((e) => existingRegular.set(e.key, e.orig));
      }

      const shouldSave = confirm(
        `Detected ${booksFound} ${pageType} entries on this page. ` +
          `This will REPLACE the stored ${pageType} list for pet "${petName}". Save new lists?`
      );
      if (!shouldSave) return;

      const payload = {
        booktastic: Array.from(existingBooktastic.entries()).map(
          ([key, orig]) => ({ orig, key })
        ),
        regular: Array.from(existingRegular.entries()).map(([key, orig]) => ({
          orig,
          key,
        })),
      };

      await saveReadListsForPet(petName, payload);
      alert(
        `Saved ${payload.booktastic.length} Booktastic and ${payload.regular.length} Regular entries for pet "${petName}".`
      );
    } catch (e) {
      console.error("[BM Script Fatal Error on books_read.phtml]", e);
      safeLogError(e);
    }
  }

  /* ---------------------- Load stored lists into runtime Sets -------------------- */
  async function loadStoredListsToSetsForPet(petName) {
    try {
      NORM_LISTBOOKTASTICREAD.clear();
      NORM_LISTREAD.clear();
      if (!petName) return;
      const raw = await loadReadListsForPet(petName);
      (raw.booktastic || []).forEach((item) =>
        NORM_LISTBOOKTASTICREAD.add(item.key)
      );
      (raw.regular || []).forEach((item) => NORM_LISTREAD.add(item.key));
    } catch (e) {
      safeLogError(e);
    }
  }

  /* ---------------------- Tiered List Management (GM Storage) -------------------- */
  async function loadTieredListsToSets() {
    try {
      const defaultLists = {
        List1: [],
        List2: [],
        List3: [],
        List4: [],
      };

      const raw = await GM.getValue(TIER_LISTS_KEY, defaultLists);

      // Clear and populate runtime sets
      NORM_LIST1.clear();
      NORM_LIST2.clear();
      NORM_LIST3.clear();
      NORM_LIST4.clear();
      (raw.List1 || []).forEach((item) => NORM_LIST1.add(normalize(item)));
      (raw.List2 || []).forEach((item) => NORM_LIST2.add(normalize(item)));
      (raw.List3 || []).forEach((item) => NORM_LIST3.add(normalize(item)));
      (raw.List4 || []).forEach((item) => NORM_LIST4.add(normalize(item)));
    } catch (e) {
      safeLogError(e);
    }
  }

  async function manageTieredLists() {
    try {
      const currentData = await GM.getValue(TIER_LISTS_KEY, {
        List1: [],
        List2: [],
        List3: [],
        List4: [],
      });
      const json = JSON.stringify(currentData, null, 2);

      const promptText =
        `Paste your complete tiered highlight lists in JSON format below. This will REPLACE the existing lists.\n\n` +
        `Lists are typically based on Neopoint (NP) value:\n` +
        `List1: 10k-25k NP (Lowest Priority)\n` +
        `List2: 25k-50k NP\n` +
        `List3: 50k-100k NP\n` +
        `List4: 100k+ NP (Highest Priority)\n\n` +
        `Format example (use arrays of raw book names):\n` +
        `{\n  "List1": ["Book Title A", "Book Title B"],\n  "List2": ["Book Title C"],\n  "List3": [],\n  "List4": []\n}`;

      const input = prompt(promptText, json);
      if (input === null || input === json) return;

      const parsed = JSON.parse(input);
      if (
        !Array.isArray(parsed.List1) ||
        !Array.isArray(parsed.List2) ||
        !Array.isArray(parsed.List3) ||
        !Array.isArray(parsed.List4)
      ) {
        alert(
          "Invalid JSON structure. Must contain keys: List1, List2, List3, List4, each with an array value."
        );
        return;
      }

      await GM.setValue(TIER_LISTS_KEY, parsed);
      await loadTieredListsToSets();
      applyStylesToItems();
      updateKeyUI(); // Update key after saving/loading new lists
      alert("Tiered highlight lists updated successfully!");
    } catch (e) {
      safeLogError(e);
      alert(`Failed to parse or save lists. Error: ${e.message}`);
    }
  }

  /* ---------------------- Menu Command Logic (Import/Export/Clear) -------------------- */

  async function exportReadLists() {
    try {
      const allData = await getAllReadLists();
      if (allData.length === 0) {
        alert("No read list data found to export.");
        return;
      }

      const json = JSON.stringify(allData, null, 2);

      // Use prompt as a temporary text area for easy copying
      prompt(
        `Copy the complete JSON data below (Contains all pet read lists):`,
        json
      );
    } catch (e) {
      safeLogError(e);
      alert(`Failed to export read lists. Error: ${e.message}`);
    }
  }

  async function importReadLists() {
    try {
      const input = prompt(
        "Paste the complete JSON export data (containing all pet read lists) below. This will REPLACE existing data for matching pet names:",
        ""
      );
      if (!input) return;

      const parsed = JSON.parse(input);
      if (!Array.isArray(parsed)) {
        alert("Invalid JSON format. Expected an array of pet objects.");
        return;
      }

      // Basic validation: ensure each item has required keys
      const validData = parsed.filter(
        (item) =>
          item.pet_name &&
          Array.isArray(item.regular) &&
          Array.isArray(item.booktastic)
      );

      if (validData.length === 0) {
        alert("No valid pet list data found in the imported JSON.");
        return;
      }

      const shouldProceed = confirm(
        `Found ${validData.length} valid pet list(s) to import. This will OVERWRITE existing lists. Proceed?`
      );
      if (!shouldProceed) return;

      await importAllReadLists(validData);
      alert(
        `Successfully imported ${validData.length} pet read list(s). Please reload the shop page to see the changes.`
      );
    } catch (e) {
      safeLogError(e);
      alert(`Failed to import read lists. Error: ${e.message}`);
    }
  }

  try {
    GM.registerMenuCommand(
      "Manage Tiered Highlight Lists (JSON)",
      manageTieredLists
    );
    GM.registerMenuCommand("Export ALL Pet Read Lists (JSON)", exportReadLists);
    GM.registerMenuCommand("Import Pet Read Lists (JSON)", importReadLists);
    GM.registerMenuCommand(
      "Clear ALL Pet Read Lists (IndexedDB)",
      clearReadLists
    );
  } catch (e) {
    /* Ignore if GM functions are not supported */
  }

  /* ---------------------- Observe shop pages for dynamic content -------------------- */
  let shopObserver;
  let shopDebounce = null;

  function initShopObserver() {
    try {
      if (shopObserver) return;
      shopObserver = new MutationObserver((mutations) => {
        if (shopDebounce) clearTimeout(shopDebounce);
        shopDebounce = setTimeout(() => {
          applyStylesToItems();
        }, 120);
      });
      shopObserver.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: false,
      });
    } catch (e) {
      safeLogError(e);
    }
  }

  /* ---------------------- On load: route behavior -------------------- */
  (async () => {
    try {
      const url = location.href;
      if (url.includes("books_read.phtml")) {
        await handleBooksReadPage();
      } else if (url.includes("objects.phtml")) {
        let petName = getQueryParam("pet_name"); // (1) URL param (highest priority)

        if (!petName) {
          // (2) Selector for H5 Profile Dropdown
          const petLookupLink = document.querySelector(
            '.nav-profile-dropdown-text a[href*="petlookup.phtml?pet="]'
          );
          if (petLookupLink) {
            // Extract from the href's query parameter (parameter name is 'pet')
            const lookupURL = new URL(petLookupLink.href, location.origin);
            petName = lookupURL.searchParams.get("pet");
          }
        }

        if (!petName) {
          // (3) Fallback to previous selectors (H5 Status Name, Pet Image Alt, User Name)
          petName =
            document
              .querySelector(".active-pet-status .active-pet-name")
              ?.textContent?.trim() ||
            document
              .querySelector(".active-pet-status img[alt]")
              ?.alt?.split(/\s+/)?.[0] ||
            document
              .querySelector(".user")
              ?.textContent?.trim()
              ?.split(/\s+/)?.[0] ||
            null;
        }

        if (!petName) {
          console.warn(
            "[BM Debug Log] FAILED to detect active pet name on shop page. Cannot load read list."
          );
        } else {
          console.log(
            `[BM Debug Log] Detected active pet name: ${petName}. Loading read list.`
          );
        }

        // Load both Read Lists (using petName) and Highlight Lists (global)
        await loadStoredListsToSetsForPet(petName);
        await loadTieredListsToSets();

        applyStylesToItems();
        applyBooktasticByTitle();
        updateKeyUI(); // Generates dynamic Key content based on loaded lists
        initShopObserver();
        if (keyDiv) document.body.appendChild(keyDiv);
      }
    } catch (e) {
      safeLogError(e);
    }
  })();
})();