// ==UserScript==
// @name Neopets: A Better Book Highlighter
// @version 3.2
// @description Integrates a per-pet read-list extractor with item highlighting---clearly marking books you've already read across all shops and the Safety Deposit Box.
// @namespace https://git.gay/valkyrie1248/Neopets-Userscripts
// @author valkryie1248 (Refactored with help of Gemini)
// @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=112*
// @match https://www.neopets.com/objects.phtml?*obj_type=114*
// @match https://www.neopets.com/*books_read.phtml*
// @match https://www.neopets.com/safetydeposit.phtml*
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM.listValues
// @grant GM.registerMenuCommand
// @run-at document-end
// ==/UserScript==
/* eslint-disable no-useless-escape */
(function () {
("use strict");
console.log("[BM Debug Log] Starting application lifecycle.");
// --- CONFIGURATION ---
const READ_LIST_KEY_PREFIX = "ReadList_"; // Prefix for pet-specific read lists
const TIERED_LISTS_KEY = "tieredLists"; // Key for global tiered lists
// --- CORE STYLING CONSTANTS (Centralized) ---
const CSS_CLASSES = {
READ_HIGHLIGHT: "ddg-read-highlight",
OVERLAY: "ddg-img-overlay",
LIST1: "ddg-list1",
LIST2: "ddg-list2",
LIST3: "ddg-list3",
LIST4: "ddg-list4",
};
// --- GLOBAL STATE ---
// Sets are used for O(1) lookup speed for normalized item names.
// Add in your own prioritized lists (ideally based on known np values from JellyNeo or ItemDB).
let NORM_LISTREAD = new Set();
let NORM_LISTBOOKTASTICREAD = new Set();
let NORM_LIST1 = new Set();
let NORM_LIST2 = new Set();
let NORM_LIST3 = new Set();
let NORM_LIST4 = new Set();
// UI elements
let keyDiv = null;
// --- UTILITY FUNCTIONS ---
/** Logs errors safely to the console. */
function safeLogError(e) {
console.error(`[Book Highlighter Error]`, e.message, e);
}
/** Extracts a URL query parameter. */
function getQueryParam(key) {
// console.log(`[BM Debug Log] Getting query param: ${key}`);
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(key);
}
/** Non-destructive normalization function for item names. */
function normalize(name) {
// console.log(`[BM Debug Log] Normalizing name: ${name}`);
if (typeof name !== "string") return "";
return name
.toLowerCase() // 1. Standardize case
.trim() // 2. Remove leading/trailing whitespace
.replace(/\s+/g, " "); // 3. Replace multiple spaces/newlines/tabs with a single space
}
/**
* @param {Element} imgDiv The item's image div ([data-name] element).
* @param {string} keyType Specifies which attribute to use ('TITLE' or 'DESCRIPTION').
* @returns {string|null} The normalized text key for list lookup.
*/
function getItemMatchKey(imgDiv, keyType) {
let matchText = null;
if (keyType === "DESCRIPTION") {
// Booktastic matching: Prioritize alt/title (description).
matchText = imgDiv.getAttribute("alt") || imgDiv.getAttribute("title");
// CRITICAL: Do not fall back to data-name (Title) if matching by Description,
// as the Title is not in the extracted read list.
if (!matchText) return null;
} else {
// Regular matching: Use data-name (Title)
matchText = imgDiv.getAttribute("data-name");
}
if (!matchText) return null;
return normalize(matchText);
}
// --- CORE REFACTORED FUNCTIONS (New) ---
/** Registers the Tampermonkey/Greasemonkey menu commands. */
function registerMenuCommands() {
// console.log("[BM Debug Log] Registering GM Menu Commands.");
GM.registerMenuCommand("Export Pet Read Lists", exportReadLists);
GM.registerMenuCommand("Import Pet Read Lists", importReadLists);
GM.registerMenuCommand("Clear ALL Pet Read Lists", clearReadLists);
}
/**
* Searches the DOM and URL for the active pet's name.
* @returns {string|null} The normalized pet name or null if not found.
*/
function findActivePetName() {
let petName = null;
// 1. Prioritize URL parameter (used for 'books_read.phtml?view=PetName')
const viewParam = getQueryParam("view");
if (viewParam) {
petName = viewParam;
// console.log(
// `[BM Debug Log] Pet Name found via 'view' query param: ${petName}.`
// );
return petName;
}
// 2. Check DOM elements (Active Pet Status Bar)
petName =
document
.querySelector(".active-pet-status .active-pet-name") // Original Selector 1
?.textContent?.trim() ||
document
.querySelector(".active-pet-status img[alt]") // Original Selector 2
?.alt?.split(/\s+/)?.[0] ||
document
.querySelector(".nav-profile-dropdown-text .profile-dropdown-link") // New Selector for Navbar
?.textContent?.trim() ||
document
.querySelector(".sidebarHeader a b") // NEW: Selector for SDB/QuickRef sidebar
?.textContent?.trim() ||
null;
if (petName) {
console.log(`[BM Debug Log] Pet Name found via DOM element: ${petName}.`);
} else {
console.warn("[BM Debug Log] FAILED to detect active pet name on page.");
}
return petName;
}
/** Loads all pet-specific and global tiered lists from storage. */
async function loadAllLists(petName) {
console.log("[BM Debug Log] Starting asynchronous list loading...");
await loadStoredListsToSetsForPet(petName);
await loadTieredListsToSets();
console.log(
"[BM Debug Log] Asynchronous list loading complete. Lists are now in global Sets."
);
}
/** Sets up the UI key and the mutation observer. */
function setupUIKeyAndObserver() {
console.log("[BM Debug Log] Setting up UI key and observer.");
updateKeyUI(); // The existing function that creates and populates keyDiv
initShopObserver(); // Observer setup is often considered part of UI initialization
if (keyDiv) {
document.body.appendChild(keyDiv);
}
}
/**
* Displays a non-blocking confirmation modal after successful data extraction and saving.
* @param {string} petName The name of the pet whose lists were saved.
* @param {number} finalRegularCount The final total count of regular books read.
* @param {number} finalBooktasticCount The final total count of booktastic books read.
*/
function displayExtractionSuccessModal(
petName,
finalRegularCount,
finalBooktasticCount
) {
console.log("[BM Debug Log] Entering displayExtractionSuccessModal.");
// 1. Create Modal Overlay (Preserves the current page content)
const modalOverlay = document.createElement("div");
modalOverlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 10000;
padding-top: 50px;
`;
const modalContent = document.createElement("div");
modalContent.style.cssText = `
background: #fff;
padding: 30px 40px;
border-radius: 10px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5);
text-align: center;
font-family: Arial, sans-serif;
color: #333;
max-width: 90%;
min-width: 300px;
`;
// 2. Message showing both counts
const successHeader = document.createElement("h2");
successHeader.innerHTML = `Read Lists for <strong>${petName}</strong> Saved Successfully!`;
successHeader.style.cssText = "margin-bottom: 20px; color: #006400;";
modalContent.appendChild(successHeader);
const countMessage = document.createElement("p");
countMessage.innerHTML = `
Regular Books Total: <strong>${finalRegularCount}</strong><br>
Booktastic Books Total: <strong>${finalBooktasticCount}</strong>
`;
countMessage.style.cssText = "font-size: 1.1em; margin-bottom: 30px;";
modalContent.appendChild(countMessage);
// 3. Close Button
const button = document.createElement("button");
button.textContent = "Continue Viewing List";
button.style.cssText = `
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background-color: #007BFF;
color: white;
border: none;
border-radius: 5px;
box-shadow: 0 4px #0056b3;
transition: background-color 0.1s;
`;
button.onmouseover = () => (button.style.backgroundColor = "#0056b3");
button.onmouseout = () => (button.style.backgroundColor = "#007BFF");
button.onmousedown = () => (button.style.boxShadow = "0 2px #0056b3");
button.onmouseup = () => (button.style.boxShadow = "0 4px #0056b3");
// Action: Close the modal
button.addEventListener("click", () => {
modalOverlay.remove();
});
modalContent.appendChild(button);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
console.log(
"[BM Debug Log] Exiting displayExtractionSuccessModal. Modal appended."
);
}
// --- STORAGE (GM API Abstraction) ---
// Added logging for read/write operations.
/**
* Loads and deserializes data from GM storage.
* @param {string} key The key under which the data is stored.
* @param {object|array|null} defaultValue The default value to return.
* @returns {Promise<any>} The deserialized data or the default value.
*/
async function loadData(key, defaultValue = null) {
console.log(
`[BM Debug Log] [Storage] Attempting to load data for key: ${key}`
);
try {
const storedValue = await GM.getValue(key);
if (storedValue) {
const data = JSON.parse(storedValue);
console.log(
`[BM Debug Log] [Storage] Successfully loaded and parsed data for key: ${key}.`
);
return data;
} else {
console.warn(
`[BM Debug Log] [Storage] Key not found or empty: ${key}. Returning default value.`
);
}
} catch (e) {
safeLogError({
message: `[Storage] Failed to load or parse data for key: ${key}. Data may be corrupted. Returning default.`,
stack: e.stack,
});
}
return defaultValue;
}
/**
* Serializes and saves data to GM storage.
* @param {string} key The key under which the data should be stored.
* @param {any} data The data (object or array) to be stored.
* @returns {Promise<void>}
*/
async function saveData(key, data) {
console.log(
`[BM Debug Log] [Storage] Attempting to save data for key: ${key}`
);
try {
await GM.setValue(key, JSON.stringify(data));
console.log(
`[BM Debug Log] [Storage] Data saved successfully for key: ${key}.`
);
} catch (e) {
safeLogError({
message: `[Storage] Failed to save data for key: ${key}`,
stack: e.stack,
});
}
}
/**
* Loads the pet-specific read lists from storage.
* @param {string} petName The name of the pet (used for the storage key).
*/
async function loadStoredListsToSetsForPet(petName) {
console.log(
`[BM Debug Log] Entering loadStoredListsToSetsForPet for pet: ${petName}.`
);
if (!petName) {
console.warn(
`[BM Debug Log] Cannot load pet lists: petName is null or undefined.`
);
return;
}
const key = `${READ_LIST_KEY_PREFIX}${petName}`;
const defaultPayload = {
petName: petName,
readBooks: [],
readBooktastic: [],
};
const data = await loadData(key, defaultPayload);
if (data && data.readBooks && Array.isArray(data.readBooks)) {
NORM_LISTREAD = new Set(data.readBooks);
console.log(
`[BM Debug Log] Loaded ${NORM_LISTREAD.size} regular books read by ${petName}.`
);
} else {
NORM_LISTREAD = new Set();
console.log(
`[BM Debug Log] No regular read books found for ${petName}. Initializing empty Set.`
);
}
if (data && data.readBooktastic && Array.isArray(data.readBooktastic)) {
NORM_LISTBOOKTASTICREAD = new Set(data.readBooktastic);
console.log(
`[BM Debug Log] Loaded ${NORM_LISTBOOKTASTICREAD.size} booktastic books read by ${petName}.`
);
} else {
NORM_LISTBOOKTASTICREAD = new Set();
console.log(
`[BM Debug Log] No booktastic books found for ${petName}. Initializing empty Set.`
);
}
console.log(`[BM Debug Log] Exiting loadStoredListsToSetsForPet.`);
}
/** Loads the global tiered lists from GM storage. */
async function loadTieredListsToSets() {
console.log(
`[BM Debug Log] Entering loadTieredListsToSets (Global Tiered Lists).`
);
const data = await loadData(TIERED_LISTS_KEY, {});
if (data) {
NORM_LIST1 = new Set(data.list1 || []);
NORM_LIST2 = new Set(data.list2 || []);
NORM_LIST3 = new Set(data.list3 || []);
NORM_LIST4 = new Set(data.list4 || []);
console.log(
`[BM Debug Log] Loaded Tiered Lists summary: L1:${NORM_LIST1.size}, L2:${NORM_LIST2.size}, L3:${NORM_LIST3.size}, L4:${NORM_LIST4.size}.`
);
} else {
NORM_LIST1 = new Set();
NORM_LIST2 = new Set();
NORM_LIST3 = new Set();
NORM_LIST4 = new Set();
console.log(
`[BM Debug Log] No Tiered Lists found in storage. Initializing empty sets.`
);
}
console.log(`[BM Debug Log] Exiting loadTieredListsToSets.`);
}
// --- STYLING LOGIC ---
/** Core function to apply the "Read Status" style and overlay. */
function applyReadStatus(element, nameNorm) {
// Keeping this function quiet to prevent console spam during DOM iteration.
try {
const isRead =
NORM_LISTREAD.has(nameNorm) || NORM_LISTBOOKTASTICREAD.has(nameNorm);
const container = element.closest(
".shop-item, .item-container, tr[bgcolor]"
);
if (isRead) {
element.classList.add(CSS_CLASSES.READ_HIGHLIGHT);
if (container) {
container.classList.add(CSS_CLASSES.OVERLAY);
container.classList.remove("no-overlay");
}
} else {
element.classList.remove(CSS_CLASSES.READ_HIGHLIGHT);
if (container) {
container.classList.remove(CSS_CLASSES.OVERLAY);
}
}
} catch (e) {
safeLogError(e);
}
}
/** Core function to apply the "Tiered List" highlight style. */
function applyTieredHighlight(element, nameNorm) {
// Keeping this function quiet to prevent console spam during DOM iteration.
try {
element.classList.remove(
CSS_CLASSES.LIST1,
CSS_CLASSES.LIST2,
CSS_CLASSES.LIST3,
CSS_CLASSES.LIST4
);
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);
} else if (NORM_LIST1.has(nameNorm)) {
element.classList.add(CSS_CLASSES.LIST1);
}
} catch (e) {
safeLogError(e);
}
}
/**
* Renamed from applyStylesToItems.
* Locates all shop/inventory items,
* extracts normalized match keys (Title/Description) using getItemMatchKey,
* and initiates the final styling calls for each item.
* @param {Document} doc - The document to search (defaults to document).
* @param {string} keyType - 'TITLE' or 'DESCRIPTION', passed from initializeAndRoute.
*/
function processShopItems(doc = document, keyType) {
console.log(
`[BM Debug Log] Entering processShopItems with keyType: ${keyType}.`
);
try {
// 1. Locate Items: Use the robust universal selector for the item image div
const itemContainers = doc.querySelectorAll("[data-name]");
if (itemContainers.length === 0) {
console.warn(
"[BM Debug Log] processShopItems: No items found with selector '[data-name]'."
);
return;
}
console.log(
`[BM Debug Log] processShopItems: Found ${itemContainers.length} items to process.`
);
itemContainers.forEach((imgDiv) => {
try {
// 2. Extract Match Key: Call the helper to get the normalized Title or Description
const nameNorm = getItemMatchKey(imgDiv, keyType);
if (!nameNorm) return;
// 3. Find the Styling Targets: Locate the <b> element for the text highlight.
const parentItemContainer = imgDiv.closest(
".shop-item, .item-container"
);
const bElement = parentItemContainer?.querySelector("b");
if (bElement) {
// 4. Initiate Final Styling (applyReadStatus handles both the text highlight and the fade overlay)
applyReadStatus(bElement, nameNorm);
applyTieredHighlight(bElement, nameNorm);
}
} catch (inner) {
safeLogError(inner);
}
});
console.log(
`[BM Debug Log] Exiting processShopItems. All items processed.`
);
} catch (err) {
safeLogError(err);
}
}
// --- CONTEXT HANDLERS ---
/**
* Executes logic for the Safety Deposit Box page.
* Uses the SDB's unique table structure to extract item info.
*/
function handleSDBPage(doc = document) {
console.log("[BM Debug Log] Entering handleSDBPage context handler.");
try {
// 1. Find all SDB item rows (the old-school table rows)
const itemRows = doc.querySelectorAll(
'tr[bgcolor="#F6F6F6"], tr[bgcolor="#FFFFFF"]'
);
if (itemRows.length === 0) {
console.warn("[BM Debug Log] handleSDBPage: No item rows found.");
return;
}
itemRows.forEach((row) => {
const cells = row.cells;
if (cells.length < 5) return;
const itemTypeCell = cells[3]; // The cell containing "Booktastic Book" or similar
const titleCell = cells[1]; // The cell containing the item Title (with the <b> tag)
const descriptionCell = cells[2]; // The cell containing the Description (for Booktastic)
// The element we will apply the text highlight to (the title <b> tag)
const bElement = titleCell.querySelector("b");
if (!bElement) return;
let rawMatchText = null;
// 2. Determine the Item Type and Set the Match Key
const itemTypeText = itemTypeCell.textContent;
let isRegularBook =
itemTypeText.includes("Book") ||
itemTypeText.includes("Qasalan Tablets") ||
itemTypeText.includes("Desert Scroll") ||
itemTypeText.includes("Neovian Press");
if (itemTypeText.includes("Booktastic Book")) {
// Booktastic: The extracted list uses the Description (3rd column).
rawMatchText = descriptionCell.textContent.trim();
} else if (isRegularBook) {
// Regular Book: The extracted list uses the Title (2nd column).
// Use firstChild.textContent to extract ONLY the clean Title text
// and ignore subsequent <br> and <span> elements inside the <b> tag.
if (
bElement.firstChild &&
bElement.firstChild.nodeType === Node.TEXT_NODE
) {
rawMatchText = bElement.firstChild.textContent.trim();
} else {
// Fallback to the whole text content, but this is less reliable
rawMatchText = bElement.textContent.trim();
}
} else {
return; // Not a book, skip.
}
if (!rawMatchText) return; // Skip if no text was found
const nameNorm = normalize(rawMatchText);
// 3. Apply Styles using the correct match key
applyReadStatus(bElement, nameNorm);
applyTieredHighlight(bElement, nameNorm);
});
console.log(
"[BM Debug Log] Exiting handleSDBPage context handler (SDB styling complete)."
);
} catch (err) {
safeLogError(err);
}
}
/**
* Extracts and normalizes book titles from the Regular Books Read page.
* Handles the 'Title: Description' format and isolates the title.
* @returns {string[]} An array of normalized Regular book titles.
*/
function extractRegularBooks() {
// Select all relevant table data elements that contain book information.
const bookTdsNodeList = document.querySelectorAll(
'td[align="center"][style*="border:1px solid black;"]'
);
// Skip the first two elements, which are known table headers.
const bookTds = Array.from(bookTdsNodeList).slice(2);
const books = [];
bookTds.forEach((td) => {
let rawText = td.textContent?.trim() || "";
// Skip TDs containing only the pet's reading count (e.g., "(1007)").
if (
rawText.startsWith("(") &&
rawText.endsWith(")") &&
rawText.length < 10
) {
return;
}
// Create a unique separator (':::') by replacing the Title/Description divider
// (a colon followed by whitespace/non-breaking spaces). This handles titles
// that contain colons.
rawText = rawText.replace(/:\s*(\u00A0| )+/gi, ":::");
const separator = ":::";
const separatorIndex = rawText.indexOf(separator);
let title;
if (separatorIndex !== -1) {
// Isolate the title by taking only the text before the unique separator.
title = rawText.substring(0, separatorIndex).trim();
} else {
// Default to the entire text if the expected format is not found.
title = rawText;
}
// Normalize the title for consistent storage and lookup.
if (title) {
books.push(normalize(title));
}
});
return books;
}
/**
* Extracts normalized descriptions (used as IDs) from the Booktastic Books Read page.
* @returns {string[]} Array of normalized Booktastic descriptions.
*/
function extractBooktasticBooks() {
// Selector based on the original script's logic for Booktastic (e.g., has <i> tag, no style)
const bookTds = document.querySelectorAll(
'td[align="center"]:not([style]) i'
);
const books = [];
bookTds.forEach((iTag) => {
// We capture the text content of the parent <td> element
// (or the i tag's content if that is the identifier)
const rawText = iTag.closest("td")?.textContent?.trim() || "";
if (rawText) {
// The entire description text is the identifier, so we normalize the whole thing.
books.push(normalize(rawText));
}
});
return books;
}
/** Extracts read lists from the 'Books Read' page and saves them to storage. */
async function handleBooksReadPage() {
console.log("[BM Debug Log] Entering handleBooksReadPage (EXTRACTOR).");
try {
const petName = getQueryParam("pet_name");
if (!petName) {
console.error(
"[BM Debug Log] Extractor Error: pet_name is missing from URL. Aborting extraction."
);
return;
}
// RESILIENT TYPE IDENTIFICATION (using the /moon/ path)
const isMoonPage = location.href.includes("/moon/");
const pageType = isMoonPage ? "Booktastic" : "Regular";
let extractedList = [];
// DELEGATION: Call the specialized extractor (Information Hiding)
if (isMoonPage) {
extractedList = extractBooktasticBooks();
} else {
extractedList = extractRegularBooks();
}
const booksFound = extractedList.length;
if (booksFound === 0) {
console.warn(
`[BM Debug Log] Extractor found 0 ${pageType} books. Proceeding with save to ensure data is cleared.`
);
}
console.log(
`[BM Debug Log] Extractor Success: Extracted ${extractedList.length} books.`
);
const key = `${READ_LIST_KEY_PREFIX}${petName}`;
const defaultPayload = {
petName: petName,
readBooks: [],
readBooktastic: [],
};
// Step 1: Load existing data
const existingData = await loadData(key, defaultPayload);
// Step 2: Update the specific list
if (isMoonPage) {
existingData.readBooktastic = extractedList;
} else {
existingData.readBooks = extractedList;
}
// ... (omitted logging for brevity) ...
// Step 3: Save the updated payload
await saveData(key, existingData);
console.log(
`[BM Debug Log] Extractor: Read list for ${petName} successfully persisted.`
);
// --- Call Dedicated UI Function (SoC Principle) ---
const finalRegularCount = existingData.readBooks.length;
const finalBooktasticCount = existingData.readBooktastic.length;
displayExtractionSuccessModal(
petName,
finalRegularCount,
finalBooktasticCount
);
} catch (e) {
safeLogError(e);
}
console.log("[BM Debug Log] Exiting handleBooksReadPage.");
}
// --- MENU COMMANDS ---
/** Exports all stored pet read lists into a single JSON file. */
async function exportReadLists() {
console.log(
"[BM Debug Log] [Menu Command] Starting exportReadLists process."
);
let allKeys = [];
let allData = [];
try {
console.log(
"[BM Debug Log] [Menu Command] Listing all GM storage keys..."
);
allKeys = await GM.listValues();
const readListKeys = allKeys.filter((key) =>
key.startsWith(READ_LIST_KEY_PREFIX)
);
console.log(
`[BM Debug Log] [Menu Command] Found ${readListKeys.length} pet read list keys to export.`
);
for (const key of readListKeys) {
const data = await loadData(key, null);
if (data) {
allData.push(data);
console.log(
`[BM Debug Log] [Menu Command] Loaded data for pet key: ${key}.`
);
}
}
const exportPayload = {
version: 4,
dataType: "NeopetsBookHighlighterReadLists",
readLists: allData,
};
console.log(
`[BM Debug Log] [Menu Command] Final export payload generated with ${allData.length} lists.`
);
const dataStr =
"data:text/json;charset=utf-8," +
encodeURIComponent(JSON.stringify(exportPayload, null, 2));
const downloadAnchorNode = document.createElement("a");
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute(
"download",
`book_highlighter_export_${Date.now()}.json`
);
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
console.log(
`[BM Debug Log] [Menu Command] Successfully finished export.`
);
alert(
`Successfully exported ${allData.length} pet read lists. Check your downloads folder.`
);
} catch (error) {
safeLogError({
message: "[Menu Command] Export failed",
stack: error.stack,
});
alert("Export failed. Check the console for details.");
}
console.log("[BM Debug Log] [Menu Command] Exiting exportReadLists.");
}
/** Imports pet read lists from a JSON file. */
function importReadLists() {
console.log(
"[BM Debug Log] [Menu Command] Starting importReadLists process (File dialogue opening)."
);
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = async (event) => {
console.log("[BM Debug Log] [Menu Command] File change event triggered.");
const file = event.target.files[0];
if (!file) return;
console.log(`[BM Debug Log] [Menu Command] File selected: ${file.name}.`);
try {
const text = await file.text();
const payload = JSON.parse(text);
console.log("[BM Debug Log] [Menu Command] File parsed successfully.");
if (
payload.dataType !== "NeopetsBookHighlighterReadLists" ||
!Array.isArray(payload.readLists)
) {
throw new Error(
"Invalid file format. Does not contain valid book highlighter read lists."
);
}
const importedLists = payload.readLists;
let successfulSaves = 0;
console.log(
`[BM Debug Log] [Menu Command] Found ${importedLists.length} lists to import.`
);
for (const list of importedLists) {
if (list.petName) {
const key = `${READ_LIST_KEY_PREFIX}${list.petName}`;
console.log(
`[BM Debug Log] [Menu Command] Saving list for pet: ${list.petName}.`
);
await saveData(key, list);
successfulSaves++;
}
}
alert(
`Successfully imported ${successfulSaves} pet read lists. Reload the page to see changes.`
);
console.log(
`[BM Debug Log] [Menu Command] Successfully imported ${successfulSaves} pet read lists.`
);
} catch (error) {
safeLogError({
message: "[Menu Command] Import failed",
stack: error.stack,
});
alert("Import failed. Check the console for details.");
}
};
input.click();
console.log(
"[BM Debug Log] [Menu Command] Exiting importReadLists (waiting for file selection)."
);
}
/** Clears all stored pet read lists. */
async function clearReadLists() {
console.log(
"[BM Debug Log] [Menu Command] Starting clearReadLists process."
);
if (
!confirm(
"Are you sure you want to CLEAR ALL stored pet read lists? This cannot be undone."
)
) {
console.log(
"[BM Debug Log] [Menu Command] Clear operation cancelled by user."
);
return;
}
console.log(
"[BM Debug Log] [Menu Command] Clear operation confirmed. Listing keys..."
);
let allKeys = [];
let deletedCount = 0;
try {
allKeys = await GM.listValues();
const readListKeys = allKeys.filter((key) =>
key.startsWith(READ_LIST_KEY_PREFIX)
);
console.log(
`[BM Debug Log] [Menu Command] Found ${readListKeys.length} pet read list keys to delete.`
);
for (const key of readListKeys) {
await GM.deleteValue(key);
deletedCount++;
console.log(`[BM Debug Log] [Menu Command] Deleted key: ${key}.`);
}
alert(
`Successfully cleared ${deletedCount} pet read lists. Reload the page to confirm.`
);
console.log(
`[BM Debug Log] [Menu Command] Successfully cleared ${deletedCount} pet read lists. Operation complete.`
);
} catch (error) {
safeLogError({
message: "[Menu Command] Clear operation failed",
stack: error.stack,
});
alert("Clear operation failed. Check the console for details.");
}
console.log("[BM Debug Log] [Menu Command] Exiting clearReadLists.");
}
// --- OBSERVER & UI ---
function initShopObserver() {
console.log("[BM Debug Log] Entering initShopObserver.");
const targetNode = document.body;
const config = { childList: true, subtree: true };
const callback = function (mutationsList, observer) {
// Optimization: Only run if we are on an objects.phtml page
if (!location.href.includes("objects.phtml")) return;
console.log(
"[BM Debug Log] Observer Callback: Mutation detected. Checking for added nodes."
);
// Determine keyType based on current URL (replicates logic from initializeAndRoute)
const url = new URL(location.href);
const objType = url.searchParams.get("obj_type");
let keyType = "TITLE";
if (objType === "70") {
keyType = "DESCRIPTION";
}
// Iterate mutations, but only process shop items once per cycle if new nodes were added.
for (const mutation of mutationsList) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
console.log(
"[BM Debug Log] Observer: Found new element. Re-processing shop items with keyType:",
keyType
);
// Call the correct processing function on the whole document to re-scan
// all items, including the newly added ones.
processShopItems(document, keyType);
return; // Exit the callback after the first successful shop re-scan
}
}
};
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);
console.log(
"[BM Debug Log] Shop MutationObserver initialized on document body (only runs on objects.phtml)."
);
console.log("[BM Debug Log] Exiting initShopObserver.");
}
function updateKeyUI() {
console.log("[BM Debug Log] Entering updateKeyUI.");
if (!keyDiv) {
keyDiv = document.createElement("div");
keyDiv.id = "book-highlighter-key";
keyDiv.style.cssText = `
position: fixed;
bottom: 10px;
left: 10px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
padding: 10px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
font-family: Arial, sans-serif;
font-size: 12px;
z-index: 9999;
max-width: 250px;
`;
}
let content = "<strong>Book Highlighter Key</strong>";
const totalRead = NORM_LISTREAD.size + NORM_LISTBOOKTASTICREAD.size;
content += `<p style="margin-top: 5px;">Total Books Read: (${totalRead})<br>Read books marked.</p>`;
console.log(`[BM Debug Log] Key UI: Total read books count: ${totalRead}`);
content += `<p style="margin-top: 5px; margin-bottom: 0;"><strong>Tiered Lists:</strong></p>`;
if (NORM_LIST1.size > 0) {
content += `<p style="color: #4CAF50; margin: 0 0 2px 10px;">• List 1 (Top Tier): ${NORM_LIST1.size}</p>`;
}
if (NORM_LIST2.size > 0) {
content += `<p style="color: #FFC107; margin: 0 0 2px 10px;">• List 2: ${NORM_LIST2.size}</p>`;
}
if (NORM_LIST3.size > 0) {
content += `<p style="color: #2196F3; margin: 0 0 2px 10px;">• List 3: ${NORM_LIST3.size}</p>`;
}
if (NORM_LIST4.size > 0) {
content += `<p style="color: #F44336; margin: 0 0 2px 10px;">• List 4 (Low Tier): ${NORM_LIST4.size}</p>`;
}
if (
totalRead === 0 &&
NORM_LIST1.size === 0 &&
NORM_LIST2.size === 0 &&
NORM_LIST3.size === 0 &&
NORM_LIST4.size === 0
) {
content += `<p style="margin: 0 0 2px 10px;">No lists loaded.</p>`;
}
keyDiv.innerHTML = content;
console.log("[BM Debug Log] Exiting updateKeyUI. UI content updated.");
}
// --- DYNAMIC CSS STYLES ---
function injectStyles() {
console.log("[BM Debug Log] Entering injectStyles. Injecting core CSS.");
const style = document.createElement("style");
style.type = "text/css";
const css = `
/* Core Styles for READ Books (FADE) */
.${CSS_CLASSES.READ_HIGHLIGHT} {
/* Text style for read items (optional, but good for visibility) */
text-decoration: line-through;
}
/* Container overlay for read items */
.${CSS_CLASSES.OVERLAY} {
position: relative;
background: rgba(0,255,255,0.6);
opacity: 0.35 !important;
transition: opacity 0.3s ease;
padding-top:5px;
box-shadow:0 4px 8px rgba(0,255,255,0.6);
}
.${CSS_CLASSES.OVERLAY}:hover {
opacity: 0.8 !important; /* Slightly brighter on hover */
}
/* Tier 1 Highlight (Top Tier) */
.${CSS_CLASSES.LIST1} {
color: #4CAF50 !important; /* Bright Green */
font-weight: bold;
text-shadow: 0 0 2px rgba(76, 175, 80, 0.5);
}
/* Tier 2 Highlight */
.${CSS_CLASSES.LIST2} {
color: #FFC107 !important; /* Amber/Yellow */
font-weight: bold;
text-shadow: 0 0 2px rgba(255, 193, 7, 0.5);
}
/* Tier 3 Highlight */
.${CSS_CLASSES.LIST3} {
color: #2196F3 !important; /* Blue */
font-weight: bold;
text-shadow: 0 0 2px rgba(33, 150, 243, 0.5);
}
/* Tier 4 Highlight (Lower Tier/Misc) */
.${CSS_CLASSES.LIST4} {
color: #F44336 !important; /* Red */
font-weight: bold;
text-shadow: 0 0 2px rgba(244, 67, 54, 0.5);
}
`;
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
console.log("[BM Debug Log] Exiting injectStyles. Styles added to <head>.");
}
// --- MAIN INITIALIZATION & ROUTING ---
/**
* The core function that runs the script.
* Determines the current page context and executes the necessary logic.
*/
async function initializeAndRoute() {
console.log(
"[BM Debug Log] Entering initializeAndRoute (Contextual Router)."
);
const currentURL = location.href;
// 1. Check for the Read Book Extractor Page
if (currentURL.includes("books_read.phtml")) {
console.log(
"[BM Debug Log] Routing: Found 'books_read.phtml'. Executing extractor."
);
await handleBooksReadPage();
return; // Stop further execution on extractor page
}
// 2. Inject CSS and Register Menu Commands (Setup Phase)
injectStyles();
registerMenuCommands();
// 3. Detect Pet and Load Lists (Data Phase)
const petName = findActivePetName();
if (petName) {
await loadAllLists(petName);
} else {
console.warn(
"[BM Debug Log] No pet name detected. Loading only Tiered Lists."
);
await loadAllLists(null); // Load tiered lists even without a pet list
}
// 4. Apply Initial Styles (Rendering Phase)
if (currentURL.includes("safetydeposit.phtml")) {
console.log(
"[BM Debug Log] Routing: Found 'safetydeposit.phtml'. Executing SDB handler."
);
handleSDBPage();
} else if (currentURL.includes("objects.phtml")) {
console.log(
"[BM Debug Log] Routing: Found 'objects.phtml'. Determining Match Key Type."
);
// Determine match key type based on url parameters
const url = new URL(currentURL);
const objType = url.searchParams.get("obj_type");
let keyType = "TITLE"; // Default for almost all shops/inventory
// Check for Booktastic Book Shops (obj_type=70)
if (objType === "70") {
keyType = "DESCRIPTION";
console.log(
"[BM Debug Log] Booktastic Shop detected. Using DESCRIPTION for match key."
);
} else {
console.log(
"[BM Debug Log] Regular Shop/Inventory detected. Using TITLE for match key."
);
}
// Call the unified processing function with the determined key type
processShopItems(document, keyType);
}
// 5. Setup UI and Observer (Finalization Phase)
setupUIKeyAndObserver();
// 5. Setup UI and Observer (Finalization Phase)
setupUIKeyAndObserver();
console.log(
"[BM Debug Log] Exiting initializeAndRoute. Main script execution finished."
);
}
// Start the application lifecycle
initializeAndRoute();
})();