Greasy Fork is available in English.
Track faction armory purchases, deposits, and display case items for reimbursement management
// ==UserScript==
// @name Purchase Tracker
// @namespace https://torn.com
// @version 3.0.0
// @description Track faction armory purchases, deposits, and display case items for reimbursement management
// @author Oatshead [3487562]
// @match https://www.torn.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @connect api.torn.com
// @connect script.google.com
// @connect script.googleusercontent.com
// @connect *.google.com
// @connect *.googleusercontent.com
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// ============================================
// CONFIGURATION
// ============================================
const CONFIG = {
POLL_INTERVAL_MS: 60000,
LOG_TYPES: {
ITEM_MARKET_BUY: 1112,
BAZAAR_BUY: 1225,
FACTION_DEPOSIT: 6728,
DISPLAY_ADD: 1302,
DISPLAY_REMOVE: 1303,
},
VERSION: "2.0.0",
SYNC_MODES: {
PERSONAL_ONLY: "personal",
SHARED_ONLY: "shared",
BOTH: "both",
},
};
// ============================================
// STORAGE UTILITIES
// ============================================
const Storage = {
get: function (key, defaultValue = null) {
try {
if (typeof GM_getValue !== "undefined") {
const value = GM_getValue(key, null);
return value !== null ? value : defaultValue;
}
const stored = localStorage.getItem("purchaseTracker_" + key);
return stored !== null ? JSON.parse(stored) : defaultValue;
} catch (e) {
return defaultValue;
}
},
set: function (key, value) {
try {
if (typeof GM_setValue !== "undefined") {
GM_setValue(key, value);
} else {
localStorage.setItem("purchaseTracker_" + key, JSON.stringify(value));
}
} catch (e) {
console.error("[PurchaseTracker] Storage.set error:", e);
}
},
delete: function (key) {
try {
if (typeof GM_deleteValue !== "undefined") {
GM_deleteValue(key);
} else {
localStorage.removeItem("purchaseTracker_" + key);
}
} catch (e) {
console.error("[PurchaseTracker] Storage.delete error:", e);
}
},
};
// ============================================
// MIGRATION: Old single whitelist -> split whitelists
// ============================================
function migrateWhitelist() {
const oldWhitelist = Storage.get("whitelist", null);
if (oldWhitelist !== null && Array.isArray(oldWhitelist) && oldWhitelist.length > 0) {
console.log("[PurchaseTracker] Migrating old whitelist (" + oldWhitelist.length + " items) to split whitelists");
const existingPersonal = Storage.get("personalWhitelist", null);
const existingShared = Storage.get("sharedWhitelist", null);
if (existingPersonal === null) {
Storage.set("personalWhitelist", [...oldWhitelist]);
}
if (existingShared === null) {
Storage.set("sharedWhitelist", [...oldWhitelist]);
}
Storage.delete("whitelist");
console.log("[PurchaseTracker] Migration complete");
} else if (oldWhitelist !== null) {
// Old key exists but is empty — clean it up
Storage.delete("whitelist");
}
}
// Run migration before State initializes
migrateWhitelist();
// ============================================
// STATE MANAGEMENT
// ============================================
const State = {
sheetsSectionCollapsed: Storage.get("sheetsSectionCollapsed", false),
apiKey: Storage.get("apiKey", ""),
userId: Storage.get("userId", null),
userName: Storage.get("userName", ""),
// NEW: Split whitelists
personalWhitelist: Storage.get("personalWhitelist", []),
sharedWhitelist: Storage.get("sharedWhitelist", []),
// NEW: Remember which whitelist user was editing
lastViewedWhitelist: Storage.get("lastViewedWhitelist", "shared"),
lastSyncTimestamp: Storage.get("lastSyncTimestamp", 0),
processedLogIds: Storage.get("processedLogIds", []),
itemCache: Storage.get("itemCache", {}),
itemCacheTimestamp: Storage.get("itemCacheTimestamp", 0),
sellerCache: Storage.get("sellerCache", {}),
isPolling: false,
pollIntervalId: null,
autoSyncEnabled: Storage.get("autoSyncEnabled", false),
togglePosition: Storage.get("togglePosition", { x: null, y: 100 }),
personalSheetUrl: Storage.get("personalSheetUrl", ""),
sharedSheetUrl: Storage.get("sharedSheetUrl", ""),
syncMode: Storage.get("syncMode", CONFIG.SYNC_MODES.SHARED_ONLY),
save: function () {
Storage.set("apiKey", this.apiKey);
Storage.set("userId", this.userId);
Storage.set("userName", this.userName);
Storage.set("personalWhitelist", this.personalWhitelist);
Storage.set("sharedWhitelist", this.sharedWhitelist);
Storage.set("lastViewedWhitelist", this.lastViewedWhitelist);
Storage.set("lastSyncTimestamp", this.lastSyncTimestamp);
Storage.set("processedLogIds", this.processedLogIds);
Storage.set("itemCache", this.itemCache);
Storage.set("itemCacheTimestamp", this.itemCacheTimestamp);
Storage.set("sellerCache", this.sellerCache);
},
saveSheetConfig: function () {
Storage.set("personalSheetUrl", this.personalSheetUrl);
Storage.set("sharedSheetUrl", this.sharedSheetUrl);
Storage.set("syncMode", this.syncMode);
},
saveSheetsSectionState: function () {
Storage.set("sheetsSectionCollapsed", this.sheetsSectionCollapsed);
},
saveAutoSync: function () {
Storage.set("autoSyncEnabled", this.autoSyncEnabled);
},
saveTogglePosition: function () {
Storage.set("togglePosition", this.togglePosition);
},
isAuthenticated: function () {
return !!(this.apiKey && this.userId);
},
hasValidSheetConfig: function () {
switch (this.syncMode) {
case CONFIG.SYNC_MODES.PERSONAL_ONLY:
return !!this.personalSheetUrl;
case CONFIG.SYNC_MODES.SHARED_ONLY:
return !!this.sharedSheetUrl;
case CONFIG.SYNC_MODES.BOTH:
return !!this.personalSheetUrl && !!this.sharedSheetUrl;
default:
return false;
}
},
getActiveSheetUrls: function () {
const urls = [];
switch (this.syncMode) {
case CONFIG.SYNC_MODES.PERSONAL_ONLY:
if (this.personalSheetUrl)
urls.push({ url: this.personalSheetUrl, type: "personal" });
break;
case CONFIG.SYNC_MODES.SHARED_ONLY:
if (this.sharedSheetUrl)
urls.push({ url: this.sharedSheetUrl, type: "shared" });
break;
case CONFIG.SYNC_MODES.BOTH:
if (this.personalSheetUrl)
urls.push({ url: this.personalSheetUrl, type: "personal" });
if (this.sharedSheetUrl)
urls.push({ url: this.sharedSheetUrl, type: "shared" });
break;
}
return urls;
},
// NEW: Get the whitelist for a specific sheet type
getWhitelistForSheetType: function (sheetType) {
if (sheetType === "personal") return this.personalWhitelist;
if (sheetType === "shared") return this.sharedWhitelist;
return [];
},
// NEW: Get the whitelist the user is currently editing in the UI
getCurrentWhitelist: function () {
if (this.syncMode === CONFIG.SYNC_MODES.PERSONAL_ONLY) {
return this.personalWhitelist;
}
if (this.syncMode === CONFIG.SYNC_MODES.SHARED_ONLY) {
return this.sharedWhitelist;
}
// "both" mode: use lastViewedWhitelist to determine which one
if (this.lastViewedWhitelist === "personal") {
return this.personalWhitelist;
}
return this.sharedWhitelist;
},
// NEW: Get the type string of the currently-edited whitelist
getCurrentWhitelistType: function () {
if (this.syncMode === CONFIG.SYNC_MODES.PERSONAL_ONLY) return "personal";
if (this.syncMode === CONFIG.SYNC_MODES.SHARED_ONLY) return "shared";
return this.lastViewedWhitelist || "shared";
},
clearProcessedLogs: function () {
if (this.processedLogIds.length > 1000) {
this.processedLogIds = this.processedLogIds.slice(-500);
this.save();
}
},
};
// ============================================
// DEVICE DETECTION
// ============================================
const Device = {
isTornPDA: function () {
return (
typeof window.PDA !== "undefined" ||
navigator.userAgent.includes("TornPDA") ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
)
);
},
isMobile: function () {
return window.innerWidth <= 768 || this.isTornPDA();
},
};
// ============================================
// API UTILITIES
// ============================================
const API = {
isTornPDA: function () {
return typeof GM_xmlhttpRequest === "undefined";
},
tornRequest: async function (endpoint, selections) {
if (!State.apiKey) {
throw new Error("API key not set");
}
const url = `https://api.torn.com/${endpoint}?selections=${selections}&key=${State.apiKey}`;
if (typeof GM_xmlhttpRequest === "undefined") {
return this.fetchRequest(url);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
responseType: "json",
onload: function (response) {
try {
let data = response.response;
if (!data && response.responseText) {
data = JSON.parse(response.responseText);
}
if (!data) {
reject(new Error("Empty response from Torn API"));
return;
}
if (data.error) {
reject(new Error(`Torn API Error ${data.error.code}: ${data.error.error}`));
} else {
resolve(data);
}
} catch (e) {
reject(new Error("Failed to parse Torn API response: " + e.message));
}
},
onerror: function () {
reject(new Error("Network error contacting Torn API"));
},
ontimeout: function () {
reject(new Error("Torn API request timed out"));
},
});
});
},
fetchRequest: async function (url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const text = await response.text();
if (!text) {
throw new Error("Empty response from Torn API");
}
const data = JSON.parse(text);
if (data.error) {
throw new Error(`Torn API Error ${data.error.code}: ${data.error.error}`);
}
return data;
} catch (e) {
throw e;
}
},
// Send request to a specific sheet URL
sheetsRequest: async function (sheetUrl, action, data) {
const payload = {
action: action,
userId: State.userId,
userName: State.userName,
data: data,
};
if (typeof GM_xmlhttpRequest === "undefined") {
return this.fetchPostRequest(sheetUrl, payload);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: sheetUrl,
headers: { "Content-Type": "text/plain" },
data: JSON.stringify(payload),
anonymous: false,
onload: function (response) {
try {
const text = response.responseText || "";
const status = response.status;
console.log("[PurchaseTracker] Sheet response status:", status);
console.log("[PurchaseTracker] Sheet response (first 300 chars):", text.substring(0, 300));
if (text.trim().startsWith("<") || text.trim().startsWith("<!DOCTYPE")) {
console.error("[PurchaseTracker] Got HTML instead of JSON");
reject(new Error("Sheet returned HTML instead of JSON. The request was not processed. Try updating Tampermonkey."));
return;
}
if (!text.trim()) {
console.error("[PurchaseTracker] Empty response from sheet");
reject(new Error("Empty response from sheet — data may not have been saved"));
return;
}
const responseData = JSON.parse(text);
if (responseData.success === false) {
console.error("[PurchaseTracker] Sheet returned error:", responseData.error);
reject(new Error(responseData.error || "Sheet reported failure"));
return;
}
if (responseData.message && responseData.message.includes("Web App is running")) {
console.error("[PurchaseTracker] Got doGet response — POST was converted to GET");
reject(new Error("Request was redirected as GET instead of POST. Data was not saved."));
return;
}
console.log("[PurchaseTracker] Sheet response valid:", JSON.stringify(responseData));
resolve(responseData);
} catch (e) {
console.error("[PurchaseTracker] Failed to parse sheet response:", e.message);
reject(new Error("Failed to parse sheet response: " + e.message));
}
},
onerror: function (error) {
console.error("[PurchaseTracker] Sheet request network error:", error);
reject(new Error("Network error contacting Sheets"));
},
ontimeout: function () {
reject(new Error("Sheet request timed out"));
},
});
});
},
// NEW: Send to a single specific sheet (replaces sendToAllSheets for sync)
sendToSheet: async function (sheetUrl, action, data) {
return await this.sheetsRequest(sheetUrl, action, data);
},
fetchPostRequest: async function (url, body) {
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify(body),
redirect: "follow",
});
const text = await response.text();
if (!text || !text.trim()) {
throw new Error("Empty response from sheet");
}
if (text.trim().startsWith("<") || text.trim().startsWith("<!DOCTYPE")) {
console.log("[PurchaseTracker] Got HTML redirect, attempting manual redirect...");
return await this.handleRedirectManually(url, body, text);
}
const data = JSON.parse(text);
if (data.success === false) {
throw new Error(data.error || "Sheet reported failure");
}
if (data.message && data.message.includes("Web App is running")) {
console.warn("[PurchaseTracker] Got doGet response — POST was redirected as GET");
return {
success: true,
added: -1,
warning: "Redirect detected — data may have been saved. Verify in sheet.",
redirected: true,
};
}
return data;
} catch (e) {
if (e.message && (e.message.includes("redirect") || e.message.includes("HTML"))) {
console.warn("[PurchaseTracker] TornPDA redirect issue:", e.message);
return {
success: true,
added: -1,
warning: e.message,
redirected: true,
};
}
throw e;
}
},
handleRedirectManually: async function (originalUrl, body, htmlResponse) {
const match = htmlResponse.match(/https:\/\/script\.googleusercontent\.com[^"'\s<>]+/);
if (match) {
console.log("[PurchaseTracker] Found redirect URL, POSTing directly...");
try {
const response = await fetch(match[0], {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify(body),
});
const text = await response.text();
if (text && !text.trim().startsWith("<")) {
return JSON.parse(text);
}
} catch (e) {
console.warn("[PurchaseTracker] Manual redirect failed:", e.message);
}
}
return {
success: true,
added: -1,
warning: "Could not confirm data was saved (redirect issue)",
redirected: true,
};
},
testSheetUrl: async function (url) {
if (typeof GM_xmlhttpRequest !== "undefined") {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
timeout: 10000,
onload: function (response) {
try {
const text = response.responseText;
console.log("[PurchaseTracker] Test response:", text.substring(0, 200));
if (text.includes('"success"') || text.includes('"configured"') || text.includes('"message"')) {
const data = JSON.parse(text);
if (data.success === false) {
resolve({ valid: false, error: data.error || data.message || "Sheet returned an error" });
} else {
resolve({ valid: true, data });
}
} else if (text.trim().startsWith("<")) {
resolve({ valid: false, error: "Received HTML instead of JSON. Check deployment settings." });
} else {
resolve({ valid: false, error: "Unexpected response format" });
}
} catch (e) {
console.error("[PurchaseTracker] Test parse error:", e);
resolve({ valid: false, error: "Failed to parse response: " + e.message });
}
},
onerror: function (error) {
console.error("[PurchaseTracker] Test connection error:", error);
resolve({ valid: false, error: "Connection failed. Check the URL." });
},
ontimeout: function () {
resolve({ valid: false, error: "Connection timed out" });
},
});
});
} else {
try {
const response = await fetch(url);
const text = await response.text();
if (text.includes('"success"') || text.includes('"configured"')) {
const data = JSON.parse(text);
if (data.success === false) {
return { valid: false, error: data.error || data.message || "Sheet returned an error" };
}
return { valid: true, data };
}
return { valid: false, error: "Invalid response from sheet" };
} catch (e) {
return { valid: false, error: e.message };
}
}
},
};
// ============================================
// AUTHENTICATION
// ============================================
const Auth = {
validateApiKey: async function (apiKey) {
State.apiKey = apiKey;
try {
const data = await API.tornRequest("user", "basic");
State.userId = data.player_id;
State.userName = data.name;
State.save();
return { success: true, user: data };
} catch (e) {
State.apiKey = "";
State.save();
return { success: false, error: e.message };
}
},
};
// ============================================
// ITEM CACHE MANAGEMENT
// ============================================
const ItemCache = {
CACHE_DURATION: 24 * 60 * 60 * 1000,
needsRefresh: function () {
return (
!State.itemCacheTimestamp ||
Date.now() - State.itemCacheTimestamp > this.CACHE_DURATION ||
Object.keys(State.itemCache).length === 0
);
},
refresh: async function () {
try {
UI.setStatus("Refreshing item cache...");
const data = await API.tornRequest("torn", "items");
if (data.items) {
State.itemCache = {};
for (const [id, item] of Object.entries(data.items)) {
State.itemCache[id] = { name: item.name, type: item.type };
}
State.itemCacheTimestamp = Date.now();
State.save();
UI.setStatus("Item cache updated (" + Object.keys(State.itemCache).length + " items)", "success");
return true;
}
} catch (e) {
UI.setStatus("Failed to refresh item cache: " + e.message, "error");
}
return false;
},
getItemName: function (itemId) {
const item = State.itemCache[String(itemId)];
return item ? item.name : `Item #${itemId}`;
},
getItemType: function (itemId) {
const item = State.itemCache[String(itemId)];
return item ? item.type : "Unknown";
},
searchByName: function (searchTerm) {
const searchLower = searchTerm.toLowerCase().trim();
const results = [];
for (const [id, item] of Object.entries(State.itemCache)) {
if (item.name.toLowerCase().includes(searchLower)) {
results.push({ id: parseInt(id), name: item.name, type: item.type });
}
}
results.sort((a, b) => {
const aExact = a.name.toLowerCase() === searchLower;
const bExact = b.name.toLowerCase() === searchLower;
if (aExact && !bExact) return -1;
if (bExact && !aExact) return 1;
return a.name.localeCompare(b.name);
});
return results;
},
};
// ============================================
// SELLER CACHE
// ============================================
const SellerCache = {
getSellerName: async function (sellerId) {
if (!sellerId) return "Unknown";
if (State.sellerCache[sellerId]) {
return State.sellerCache[sellerId];
}
try {
const data = await API.tornRequest(`user/${sellerId}`, "basic");
if (data.name) {
State.sellerCache[sellerId] = data.name;
State.save();
return data.name;
}
} catch (e) {
console.error("[PurchaseTracker] Failed to fetch seller name:", e);
}
return `User #${sellerId}`;
},
};
// ============================================
// SYNC LOGIC (REWRITTEN for split whitelists)
// ============================================
const Sync = {
isRunning: false,
// Extract raw purchase data from a log entry WITHOUT whitelist filtering
extractRawPurchase: async function (logId, entry) {
const data = entry.data;
if (!data || !data.items || data.items.length === 0) return null;
const sellerName = await SellerCache.getSellerName(data.seller);
const records = [];
for (const item of data.items) {
records.push({
logId: logId,
timestamp: entry.timestamp,
source: entry.log === CONFIG.LOG_TYPES.ITEM_MARKET_BUY ? "ItemMarket" : "Bazaar",
itemId: item.id,
itemName: ItemCache.getItemName(item.id),
itemType: ItemCache.getItemType(item.id),
quantity: item.qty,
unitPrice: data.cost_each,
totalSpent: item.qty * data.cost_each,
sellerId: data.seller,
sellerName: sellerName,
});
}
return records;
},
// Extract raw deposit data from a log entry WITHOUT whitelist filtering
extractRawDeposit: function (logId, entry) {
const data = entry.data;
if (!data || !data.items || data.items.length === 0) return null;
const records = [];
for (const item of data.items) {
records.push({
logId: logId,
timestamp: entry.timestamp,
factionId: data.faction,
itemId: item.id,
itemName: ItemCache.getItemName(item.id),
quantity: item.qty,
});
}
return records;
},
// Filter purchase records by a specific whitelist
filterPurchasesByWhitelist: function (rawPurchases, whitelist) {
if (whitelist.length === 0) return rawPurchases; // empty = track all
return rawPurchases.filter((p) => whitelist.includes(p.itemId));
},
// Filter deposit records by a specific whitelist
filterDepositsByWhitelist: function (rawDeposits, whitelist) {
if (whitelist.length === 0) return rawDeposits; // empty = track all
return rawDeposits.filter((d) => whitelist.includes(d.itemId));
},
// Flatten purchases into the format the sheet expects
flattenPurchases: function (purchases) {
return purchases.map((item) => ({
logId: item.logId,
timestamp: item.timestamp,
buyerId: State.userId,
buyerName: State.userName,
source: item.source,
itemId: item.itemId,
itemName: item.itemName,
itemType: item.itemType,
qty: item.quantity,
unitPrice: item.unitPrice,
totalSpent: item.totalSpent,
sellerId: item.sellerId,
sellerName: item.sellerName,
}));
},
// Flatten deposits into the format the sheet expects
flattenDeposits: function (deposits) {
return deposits.map((item) => ({
depositLogId: item.logId,
timestamp: item.timestamp,
depositorId: State.userId,
depositorName: State.userName,
factionId: item.factionId,
itemId: item.itemId,
itemName: item.itemName,
qty: item.quantity,
}));
},
run: async function () {
if (this.isRunning) {
console.log("[PurchaseTracker] Sync already running, skipping");
return;
}
if (!State.isAuthenticated()) {
UI.setStatus("Not authenticated. Please enter your API key.", "error");
return;
}
if (!State.hasValidSheetConfig()) {
UI.setStatus("Please configure at least one sheet URL", "error");
return;
}
this.isRunning = true;
UI.setStatus("Syncing...");
try {
if (ItemCache.needsRefresh()) {
await ItemCache.refresh();
}
const logsData = await API.tornRequest("user", "log");
const logs = logsData.log || {};
// ---- PHASE 1: Extract ALL raw data (no whitelist filtering) ----
const rawPurchases = []; // Each element is an array of records from one log entry
const rawDeposits = []; // Each element is an array of records from one log entry
const relevantLogIds = new Set(); // All log IDs that had purchase or deposit data
for (const [logId, entry] of Object.entries(logs)) {
if (State.processedLogIds.includes(logId)) continue;
if (entry.timestamp < State.lastSyncTimestamp - 300) continue;
if (
entry.log === CONFIG.LOG_TYPES.ITEM_MARKET_BUY ||
entry.log === CONFIG.LOG_TYPES.BAZAAR_BUY
) {
const records = await this.extractRawPurchase(logId, entry);
if (records && records.length > 0) {
rawPurchases.push(...records);
relevantLogIds.add(logId);
}
}
if (entry.log === CONFIG.LOG_TYPES.FACTION_DEPOSIT) {
const records = this.extractRawDeposit(logId, entry);
if (records && records.length > 0) {
rawDeposits.push(...records);
relevantLogIds.add(logId);
}
}
}
if (relevantLogIds.size === 0) {
State.lastSyncTimestamp = Math.floor(Date.now() / 1000);
State.save();
const timeStr = new Date().toLocaleTimeString();
UI.setStatus(`No new events (${timeStr})`, "success");
this.isRunning = false;
return;
}
// ---- PHASE 2: Build per-sheet payloads using respective whitelists ----
const activeSheets = State.getActiveSheetUrls();
// Track success/failure per sheet type, and which logIds each sheet touches
const sheetResults = {}; // { "personal": { success: true/false }, "shared": { ... } }
const logIdToSheetTypes = {}; // { "logId123": Set(["personal", "shared"]) }
let totalSent = 0;
let failCount = 0;
// Process each sheet SEQUENTIALLY
for (const { url, type } of activeSheets) {
const whitelist = State.getWhitelistForSheetType(type);
// Filter raw data through this sheet's whitelist
const filteredPurchases = this.filterPurchasesByWhitelist(rawPurchases, whitelist);
const filteredDeposits = this.filterDepositsByWhitelist(rawDeposits, whitelist);
// Track which logIds this sheet cares about
const sheetLogIds = new Set();
for (const p of filteredPurchases) sheetLogIds.add(p.logId);
for (const d of filteredDeposits) sheetLogIds.add(d.logId);
// Register this sheet type against its logIds
for (const lid of sheetLogIds) {
if (!logIdToSheetTypes[lid]) logIdToSheetTypes[lid] = new Set();
logIdToSheetTypes[lid].add(type);
}
if (sheetLogIds.size === 0) {
// No items for this sheet after filtering
console.log(`[PurchaseTracker] No relevant items for ${type} sheet after whitelist filter`);
sheetResults[type] = { success: true, sent: 0 };
continue;
}
let sheetSuccess = true;
// Send purchases to this sheet
if (filteredPurchases.length > 0) {
const flatPurchases = this.flattenPurchases(filteredPurchases);
UI.setStatus(`Sending ${flatPurchases.length} purchase(s) to ${type} sheet...`);
try {
const result = await API.sendToSheet(url, "addPurchases", flatPurchases);
if (result && result.success === true && (result.added !== undefined || result.skipped !== undefined)) {
totalSent += flatPurchases.length;
if (result.redirected) {
console.warn(`[PurchaseTracker] ${type} sheet: redirect detected, data may have been saved`);
} else {
console.log(`[PurchaseTracker] ${type} sheet confirmed purchases: added=${result.added}, skipped=${result.skipped || 0}`);
}
} else {
sheetSuccess = false;
failCount++;
console.error(`[PurchaseTracker] ${type} sheet purchase response invalid:`, result);
}
} catch (e) {
sheetSuccess = false;
failCount++;
console.error(`[PurchaseTracker] Error sending purchases to ${type} sheet:`, e.message);
}
}
// Send deposits to this sheet
if (filteredDeposits.length > 0) {
const flatDeposits = this.flattenDeposits(filteredDeposits);
UI.setStatus(`Sending ${flatDeposits.length} deposit(s) to ${type} sheet...`);
try {
const result = await API.sendToSheet(url, "processDeposits", flatDeposits);
if (result && result.success === true) {
totalSent += flatDeposits.length;
console.log(`[PurchaseTracker] ${type} sheet confirmed deposits`);
} else {
sheetSuccess = false;
failCount++;
console.error(`[PurchaseTracker] ${type} sheet deposit response invalid:`, result);
}
} catch (e) {
sheetSuccess = false;
failCount++;
console.error(`[PurchaseTracker] Error sending deposits to ${type} sheet:`, e.message);
}
}
sheetResults[type] = { success: sheetSuccess, sent: filteredPurchases.length + filteredDeposits.length };
}
// ---- PHASE 3: Mark logIds as processed only if ALL required sheets succeeded ----
let markedCount = 0;
let retriedCount = 0;
for (const [logId, requiredSheetTypes] of Object.entries(logIdToSheetTypes)) {
let allSucceeded = true;
for (const st of requiredSheetTypes) {
if (!sheetResults[st] || !sheetResults[st].success) {
allSucceeded = false;
break;
}
}
if (allSucceeded) {
if (!State.processedLogIds.includes(logId)) {
State.processedLogIds.push(logId);
markedCount++;
}
} else {
retriedCount++;
console.warn(`[PurchaseTracker] Log ${logId} NOT marked as processed — will retry next sync`);
}
}
// Also mark logIds that had data but matched NO whitelist on any sheet
// These are truly irrelevant and should be marked to avoid re-processing
for (const logId of relevantLogIds) {
if (!logIdToSheetTypes[logId]) {
// This log had raw data but nothing passed any whitelist filter
if (!State.processedLogIds.includes(logId)) {
State.processedLogIds.push(logId);
}
}
}
console.log(`[PurchaseTracker] Marked ${markedCount} logIds as processed, ${retriedCount} will retry`);
State.lastSyncTimestamp = Math.floor(Date.now() / 1000);
State.clearProcessedLogs();
State.save();
const timeStr = new Date().toLocaleTimeString();
const sheetInfo = `(${activeSheets.length} sheet${activeSheets.length > 1 ? "s" : ""})`;
if (failCount > 0) {
UI.setStatus(
`Synced with ${failCount} error(s) ${sheetInfo} — ${retriedCount} log(s) will retry. Check console (F12)`,
"error",
);
} else {
UI.setStatus(
totalSent > 0
? `Synced ${totalSent} item(s) ${sheetInfo} at ${timeStr}`
: `No whitelisted items to sync (${timeStr})`,
"success",
);
}
} catch (e) {
console.error("[PurchaseTracker] Sync error:", e);
UI.setStatus("Sync failed: " + e.message, "error");
} finally {
this.isRunning = false;
}
},
// NEW: Display case sync with split payloads
syncDisplayCase: async function () {
if (!State.isAuthenticated()) {
UI.setStatus("Not authenticated", "error");
return;
}
if (!State.hasValidSheetConfig()) {
UI.setStatus("Please configure at least one sheet URL", "error");
return;
}
UI.setStatus("Syncing display case...");
try {
const data = await API.tornRequest("user", "display");
if (!data.display || data.display.length === 0) {
UI.setStatus("Display case is empty", "success");
return;
}
// Build raw display items (no filtering)
const rawDisplayItems = data.display.map((item) => ({
itemId: item.ID,
itemName: item.name,
itemType: item.type,
quantity: item.quantity,
marketPrice: item.market_price,
}));
const activeSheets = State.getActiveSheetUrls();
let successCount = 0;
let totalItems = 0;
// Send to each sheet sequentially with its own whitelist filter
for (const { url, type } of activeSheets) {
const whitelist = State.getWhitelistForSheetType(type);
const filteredItems = whitelist.length === 0
? rawDisplayItems
: rawDisplayItems.filter((item) => whitelist.includes(item.itemId));
if (filteredItems.length === 0) {
console.log(`[PurchaseTracker] No whitelisted display items for ${type} sheet`);
successCount++; // Nothing to send is still a success
continue;
}
try {
UI.setStatus(`Sending ${filteredItems.length} display item(s) to ${type} sheet...`);
const result = await API.sendToSheet(url, "updateDisplayCase", filteredItems);
if (result && result.success) {
successCount++;
totalItems += filteredItems.length;
console.log(`[PurchaseTracker] ${type} sheet display case updated`);
} else {
console.error(`[PurchaseTracker] ${type} sheet display case update failed:`, result);
}
} catch (e) {
console.error(`[PurchaseTracker] Error sending display case to ${type} sheet:`, e.message);
}
}
if (successCount === activeSheets.length) {
UI.setStatus(
`Display case synced to ${successCount} sheet(s): ${totalItems} item(s)`,
"success",
);
} else {
UI.setStatus(
`Display case: ${successCount}/${activeSheets.length} sheets updated`,
"error",
);
}
} catch (e) {
UI.setStatus("Display case sync failed: " + e.message, "error");
}
},
startPolling: function () {
if (State.pollIntervalId) {
this.stopPolling();
}
State.isPolling = true;
State.autoSyncEnabled = true;
State.saveAutoSync();
State.pollIntervalId = setInterval(() => this.run(), CONFIG.POLL_INTERVAL_MS);
UI.updatePollingButton();
UI.updateToggleIndicator();
},
stopPolling: function () {
if (State.pollIntervalId) {
clearInterval(State.pollIntervalId);
State.pollIntervalId = null;
}
State.isPolling = false;
State.autoSyncEnabled = false;
State.saveAutoSync();
UI.updatePollingButton();
UI.updateToggleIndicator();
},
resumePollingIfEnabled: function () {
if (State.autoSyncEnabled && State.isAuthenticated() && State.hasValidSheetConfig()) {
console.log("[PurchaseTracker] Resuming auto-sync from previous session");
this.startPolling();
this.run();
}
},
};
// ============================================
// USER INTERFACE
// ============================================
const UI = {
panel: null,
statusEl: null,
toggleBtn: null,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
dragStartPosX: 0,
dragStartPosY: 0,
hasMoved: false,
init: function () {
this.addStyles();
this.createPanel();
this.createToggleButton();
if (typeof GM_registerMenuCommand !== "undefined") {
GM_registerMenuCommand("Open Purchase Tracker", () => this.togglePanel());
}
},
addStyles: function () {
const isMobile = Device.isMobile();
const css = `
#purchase-tracker-toggle {
position: fixed;
z-index: 999999;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
padding: ${isMobile ? "6px 10px" : "8px 12px"};
border-radius: 6px;
cursor: ${isMobile ? "pointer" : "move"};
font-family: Arial, sans-serif;
font-size: ${isMobile ? "10px" : "11px"};
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
border: 1px solid #0f3460;
transition: box-shadow 0.2s ease;
user-select: none;
touch-action: none;
white-space: nowrap;
}
#purchase-tracker-toggle:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
#purchase-tracker-toggle.dragging {
opacity: 0.8;
cursor: grabbing;
}
#purchase-tracker-toggle .pt-sync-indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: 5px;
background: #666;
}
#purchase-tracker-toggle .pt-sync-indicator.active {
background: #2ecc71;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
#purchase-tracker-panel {
position: fixed;
top: 50px;
right: 20px;
width: ${isMobile ? "90vw" : "420px"};
max-width: 420px;
max-height: 85vh;
z-index: 999998;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e6e6e6;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
font-family: Arial, sans-serif;
font-size: 13px;
display: none;
overflow: hidden;
border: 1px solid #0f3460;
}
#purchase-tracker-panel.visible {
display: block;
}
.pt-header {
background: linear-gradient(135deg, #0f3460 0%, #1a1a2e 100%);
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #0f3460;
}
.pt-header h3 {
margin: 0;
font-size: 16px;
color: #fff;
}
.pt-close {
background: none;
border: none;
color: #e94560;
font-size: 20px;
cursor: pointer;
padding: 0 5px;
}
.pt-body {
padding: 15px;
max-height: calc(85vh - 60px);
overflow-y: auto;
}
.pt-section {
margin-bottom: 15px;
background: rgba(255,255,255,0.03);
border-radius: 8px;
padding: 12px;
}
.pt-section-title {
font-weight: bold;
margin-bottom: 10px;
color: #e94560;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.pt-section-title .pt-toggle-icon {
transition: transform 0.3s;
}
.pt-section-title.collapsed .pt-toggle-icon {
transform: rotate(-90deg);
}
.pt-section-content {
overflow: hidden;
transition: max-height 0.3s ease;
}
.pt-section-content.collapsed {
max-height: 0 !important;
padding: 0;
margin: 0;
}
.pt-input-group {
margin-bottom: 10px;
}
.pt-input-group label {
display: block;
margin-bottom: 5px;
color: #aaa;
font-size: 11px;
}
.pt-input-group input[type="text"],
.pt-input-group input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #0f3460;
border-radius: 6px;
background: rgba(0,0,0,0.3);
color: #fff;
font-size: 13px;
box-sizing: border-box;
}
.pt-input-group input:focus {
outline: none;
border-color: #e94560;
}
.pt-input-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pt-input-group select {
width: 100%;
padding: 10px;
border: 1px solid #0f3460;
border-radius: 6px;
background: rgba(0,0,0,0.3);
color: #fff;
font-size: 13px;
box-sizing: border-box;
}
.pt-input-group select:focus {
outline: none;
border-color: #e94560;
}
.pt-btn {
padding: 10px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
transition: all 0.3s ease;
margin-right: 8px;
margin-bottom: 8px;
}
.pt-btn-primary {
background: linear-gradient(135deg, #e94560 0%, #c73e54 100%);
color: #fff;
}
.pt-btn-primary:hover {
background: linear-gradient(135deg, #c73e54 0%, #a33547 100%);
}
.pt-btn-secondary {
background: linear-gradient(135deg, #0f3460 0%, #16213e 100%);
color: #fff;
border: 1px solid #0f3460;
}
.pt-btn-secondary:hover {
background: linear-gradient(135deg, #16213e 0%, #1a1a2e 100%);
}
.pt-btn-success {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: #fff;
}
.pt-btn-success:hover {
background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
}
.pt-btn-danger {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: #fff;
}
.pt-btn-danger:hover {
background: linear-gradient(135deg, #c0392b 0%, #a93226 100%);
}
.pt-btn-small {
padding: 6px 12px;
font-size: 11px;
}
.pt-status {
background: rgba(0,0,0,0.3);
padding: 10px;
border-radius: 6px;
font-size: 12px;
color: #aaa;
margin-top: 10px;
border-left: 3px solid #e94560;
}
.pt-status.success {
border-left-color: #2ecc71;
color: #2ecc71;
}
.pt-status.error {
border-left-color: #e74c3c;
color: #e74c3c;
}
.pt-whitelist {
max-height: 150px;
overflow-y: auto;
background: rgba(0,0,0,0.2);
border-radius: 6px;
padding: 8px;
margin-top: 10px;
}
.pt-whitelist-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
background: rgba(255,255,255,0.05);
border-radius: 4px;
margin-bottom: 4px;
}
.pt-whitelist-item:last-child {
margin-bottom: 0;
}
.pt-whitelist-item span {
flex: 1;
font-size: 12px;
}
.pt-whitelist-item button {
background: #e74c3c;
border: none;
color: #fff;
padding: 3px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 10px;
}
.pt-whitelist-item button:hover {
background: #c0392b;
}
.pt-info {
font-size: 11px;
color: #888;
margin-top: 5px;
margin-bottom: 10px;
}
.pt-user-info {
background: rgba(46, 204, 113, 0.15);
border: 1px solid rgba(46, 204, 113, 0.4);
border-radius: 6px;
padding: 10px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.pt-user-info .pt-check {
color: #2ecc71;
font-size: 18px;
}
.pt-user-info strong {
color: #2ecc71;
}
.pt-btn-group {
display: flex;
flex-wrap: wrap;
}
.pt-search-results {
background: rgba(0,0,0,0.3);
border-radius: 6px;
max-height: 120px;
overflow-y: auto;
margin-top: 5px;
display: none;
}
.pt-search-results.visible {
display: block;
}
.pt-search-result {
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.1);
font-size: 12px;
}
.pt-search-result:hover {
background: rgba(233, 69, 96, 0.2);
}
.pt-search-result:last-child {
border-bottom: none;
}
.pt-search-result .pt-item-type {
color: #888;
font-size: 10px;
}
.pt-sheet-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 4px;
margin-top: 5px;
font-size: 11px;
}
.pt-sheet-status.valid {
background: rgba(46, 204, 113, 0.15);
border: 1px solid rgba(46, 204, 113, 0.4);
color: #2ecc71;
}
.pt-sheet-status.invalid {
background: rgba(231, 76, 60, 0.15);
border: 1px solid rgba(231, 76, 60, 0.4);
color: #e74c3c;
}
.pt-sheet-status.pending {
background: rgba(241, 196, 15, 0.15);
border: 1px solid rgba(241, 196, 15, 0.4);
color: #f1c40f;
}
.pt-mode-description {
font-size: 10px;
color: #666;
margin-top: 5px;
padding: 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
}
/* NEW: Whitelist selector dropdown styling */
.pt-whitelist-selector {
margin-bottom: 10px;
padding: 8px;
background: rgba(0,0,0,0.2);
border-radius: 6px;
border: 1px solid rgba(233, 69, 96, 0.3);
}
.pt-whitelist-selector label {
display: block;
margin-bottom: 5px;
color: #e94560;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.pt-whitelist-selector select {
width: 100%;
padding: 8px 10px;
border: 1px solid #0f3460;
border-radius: 6px;
background: rgba(0,0,0,0.3);
color: #fff;
font-size: 13px;
box-sizing: border-box;
}
.pt-whitelist-selector select:focus {
outline: none;
border-color: #e94560;
}
.pt-whitelist-label {
font-size: 11px;
color: #aaa;
margin-bottom: 8px;
padding: 4px 8px;
background: rgba(233, 69, 96, 0.1);
border-radius: 4px;
display: inline-block;
}
`;
if (typeof GM_addStyle !== "undefined") {
GM_addStyle(css);
} else {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
}
},
createToggleButton: function () {
const btn = document.createElement("div");
btn.id = "purchase-tracker-toggle";
const isMobile = Device.isMobile();
btn.innerHTML = `📦 ${isMobile ? "PT" : "Tracker"}<span class="pt-sync-indicator ${State.autoSyncEnabled ? "active" : ""}"></span>`;
const pos = State.togglePosition;
if (pos.x !== null) {
btn.style.left = pos.x + "px";
btn.style.top = pos.y + "px";
} else {
btn.style.right = "10px";
btn.style.top = pos.y + "px";
}
this.setupDraggable(btn);
document.body.appendChild(btn);
this.toggleBtn = btn;
},
setupDraggable: function (element) {
const self = this;
element.addEventListener("mousedown", startDrag);
document.addEventListener("mousemove", doDrag);
document.addEventListener("mouseup", endDrag);
element.addEventListener("touchstart", startDrag, { passive: false });
document.addEventListener("touchmove", doDrag, { passive: false });
document.addEventListener("touchend", endDrag);
function startDrag(e) {
self.isDragging = true;
self.hasMoved = false;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
self.dragStartX = clientX;
self.dragStartY = clientY;
const rect = element.getBoundingClientRect();
self.dragStartPosX = rect.left;
self.dragStartPosY = rect.top;
element.classList.add("dragging");
if (e.touches) e.preventDefault();
}
function doDrag(e) {
if (!self.isDragging) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const deltaX = clientX - self.dragStartX;
const deltaY = clientY - self.dragStartY;
if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
self.hasMoved = true;
}
let newX = self.dragStartPosX + deltaX;
let newY = self.dragStartPosY + deltaY;
const maxX = window.innerWidth - element.offsetWidth - 10;
const maxY = window.innerHeight - element.offsetHeight - 10;
newX = Math.max(10, Math.min(newX, maxX));
newY = Math.max(10, Math.min(newY, maxY));
element.style.left = newX + "px";
element.style.top = newY + "px";
element.style.right = "auto";
if (e.touches) e.preventDefault();
}
function endDrag() {
if (!self.isDragging) return;
self.isDragging = false;
element.classList.remove("dragging");
if (self.hasMoved) {
const rect = element.getBoundingClientRect();
State.togglePosition = { x: rect.left, y: rect.top };
State.saveTogglePosition();
} else {
self.togglePanel();
}
}
},
updateToggleIndicator: function () {
if (this.toggleBtn) {
const indicator = this.toggleBtn.querySelector(".pt-sync-indicator");
if (indicator) {
if (State.autoSyncEnabled || State.isPolling) {
indicator.classList.add("active");
} else {
indicator.classList.remove("active");
}
}
}
},
createPanel: function () {
const panel = document.createElement("div");
panel.id = "purchase-tracker-panel";
const isAuthenticated = State.isAuthenticated();
const isBothMode = State.syncMode === CONFIG.SYNC_MODES.BOTH;
panel.innerHTML = `
<div class="pt-header">
<h3>📦 Purchase Tracker</h3>
<button class="pt-close">×</button>
</div>
<div class="pt-body">
<!-- Auth Section -->
<div class="pt-section" id="pt-auth-section">
<div class="pt-section-title ${isAuthenticated ? "collapsed" : ""}" id="pt-auth-title">
<span>🔐 Authentication</span>
<span class="pt-toggle-icon">▼</span>
</div>
<div class="pt-section-content ${isAuthenticated ? "collapsed" : ""}" id="pt-auth-content">
<div id="pt-user-info" class="pt-user-info" style="display:${isAuthenticated ? "flex" : "none"};">
<span class="pt-check">✓</span>
<span>Authenticated as <strong id="pt-username">${State.userName || ""}</strong></span>
</div>
<div class="pt-input-group">
<label>Torn API Key (Full Access)</label>
<input type="password" id="pt-api-key" placeholder="Enter your API key" ${isAuthenticated ? "disabled" : ""}>
</div>
<div class="pt-btn-group">
<button class="pt-btn pt-btn-primary" id="pt-btn-auth" style="display:${isAuthenticated ? "none" : "inline-block"}">Authenticate</button>
<button class="pt-btn pt-btn-danger pt-btn-small" id="pt-btn-logout" style="display:${isAuthenticated ? "inline-block" : "none"}">Logout</button>
</div>
</div>
</div>
<!-- Sheet Configuration Section -->
<div class="pt-section" id="pt-sheets-section">
<div class="pt-section-title ${State.sheetsSectionCollapsed ? "collapsed" : ""}" id="pt-sheets-title">
<span>📊 Sheet Configuration</span>
<span class="pt-toggle-icon">▼</span>
</div>
<div class="pt-section-content ${State.sheetsSectionCollapsed ? "collapsed" : ""}" id="pt-sheets-content">
<div class="pt-input-group">
<label>Sync Mode</label>
<select id="pt-sync-mode">
<option value="shared" ${State.syncMode === "shared" ? "selected" : ""}>Shared Sheet Only (Faction)</option>
<option value="personal" ${State.syncMode === "personal" ? "selected" : ""}>Personal Sheet Only</option>
<option value="both" ${State.syncMode === "both" ? "selected" : ""}>Both Sheets</option>
</select>
<div class="pt-mode-description" id="pt-mode-description">
${this.getSyncModeDescription(State.syncMode)}
</div>
</div>
<div class="pt-input-group" id="pt-shared-sheet-group" style="display:${State.syncMode !== "personal" ? "block" : "none"}">
<label>Shared/Faction Sheet URL</label>
<input type="text" id="pt-shared-sheet-url" placeholder="Paste your faction's Apps Script URL" value="${State.sharedSheetUrl}">
<div id="pt-shared-sheet-status" class="pt-sheet-status" style="display:none;"></div>
</div>
<div class="pt-input-group" id="pt-personal-sheet-group" style="display:${State.syncMode !== "shared" ? "block" : "none"}">
<label>Personal Sheet URL</label>
<input type="text" id="pt-personal-sheet-url" placeholder="Paste your personal Apps Script URL" value="${State.personalSheetUrl}">
<div id="pt-personal-sheet-status" class="pt-sheet-status" style="display:none;"></div>
</div>
<div class="pt-btn-group">
<button class="pt-btn pt-btn-secondary pt-btn-small" id="pt-btn-test-sheets">Test Connection(s)</button>
<button class="pt-btn pt-btn-primary pt-btn-small" id="pt-btn-save-sheets">Save Sheet Config</button>
</div>
<p class="pt-info">
💡 To create your own sheet: Copy the master template, deploy your own Apps Script, and paste the URL above.
</p>
</div>
</div>
<!-- Whitelist Section -->
<div class="pt-section" id="pt-whitelist-section">
<div class="pt-section-title" id="pt-whitelist-title">
<span>📋 Item Whitelist</span>
<span class="pt-toggle-icon">▼</span>
</div>
<div class="pt-section-content" id="pt-whitelist-content">
<!-- NEW: Whitelist selector dropdown - only visible in "both" mode -->
<div class="pt-whitelist-selector" id="pt-whitelist-selector" style="display:${isBothMode ? "block" : "none"};">
<label>Editing Whitelist For:</label>
<select id="pt-whitelist-which">
<option value="shared" ${State.lastViewedWhitelist === "shared" ? "selected" : ""}>🏰 Shared/Faction Sheet</option>
<option value="personal" ${State.lastViewedWhitelist === "personal" ? "selected" : ""}>📝 Personal Sheet</option>
</select>
</div>
<!-- Label showing which whitelist is being edited (shown in all modes) -->
<div class="pt-whitelist-label" id="pt-whitelist-active-label">
${this.getWhitelistLabel()}
</div>
<p class="pt-info" id="pt-whitelist-info">${this.getWhitelistInfoText()}</p>
<div class="pt-input-group">
<label>Add Item (ID or Name)</label>
<input type="text" id="pt-whitelist-input" placeholder="e.g., 67 or First Aid Kit">
<div class="pt-search-results" id="pt-search-results"></div>
</div>
<div class="pt-btn-group">
<button class="pt-btn pt-btn-secondary pt-btn-small" id="pt-btn-add-whitelist">Add Item</button>
<button class="pt-btn pt-btn-secondary pt-btn-small" id="pt-btn-clear-whitelist">Clear This List</button>
</div>
<div class="pt-whitelist" id="pt-whitelist-container">
<em style="color:#666;">No items in whitelist (tracking all)</em>
</div>
</div>
</div>
<!-- Sync Section -->
<div class="pt-section" id="pt-sync-section">
<div class="pt-section-title">🔄 Sync Controls</div>
<div class="pt-btn-group">
<button class="pt-btn pt-btn-primary" id="pt-btn-sync-now">Sync Now</button>
<button class="pt-btn pt-btn-success" id="pt-btn-toggle-polling">Start Auto-Sync</button>
</div>
<div class="pt-btn-group" style="margin-top: 5px;">
<button class="pt-btn pt-btn-secondary pt-btn-small" id="pt-btn-sync-display">Sync Display Case</button>
</div>
<p class="pt-info">Auto-sync runs every minute and persists across page changes.</p>
<div class="pt-status" id="pt-status">Ready</div>
</div>
<!-- Settings Section -->
<div class="pt-section">
<div class="pt-section-title">⚙️ Settings</div>
<div class="pt-btn-group">
<button class="pt-btn pt-btn-secondary pt-btn-small" id="pt-btn-refresh-items">Refresh Item Cache</button>
<button class="pt-btn pt-btn-secondary pt-btn-small" id="pt-btn-reset-position">Reset Button Position</button>
<button class="pt-btn pt-btn-danger pt-btn-small" id="pt-btn-reset">Reset All Data</button>
</div>
<p class="pt-info">
Version: ${CONFIG.VERSION} | Item cache: ${Object.keys(State.itemCache).length} items<br>
Active sheets: ${State.getActiveSheetUrls().length}
</p>
</div>
</div>
`;
document.body.appendChild(panel);
this.panel = panel;
this.statusEl = panel.querySelector("#pt-status");
this.bindEvents();
this.updateWhitelistUI();
this.updatePollingButton();
},
// NEW: Get a human-readable label for the currently active whitelist
getWhitelistLabel: function () {
const type = State.getCurrentWhitelistType();
if (State.syncMode === CONFIG.SYNC_MODES.BOTH) {
return type === "personal"
? "📝 Editing: Personal Sheet Whitelist"
: "🏰 Editing: Shared/Faction Sheet Whitelist";
}
if (State.syncMode === CONFIG.SYNC_MODES.PERSONAL_ONLY) {
return "📝 Personal Sheet Whitelist";
}
return "🏰 Shared/Faction Sheet Whitelist";
},
// NEW: Get info text for the whitelist section based on mode
getWhitelistInfoText: function () {
if (State.syncMode === CONFIG.SYNC_MODES.BOTH) {
return "Each sheet has its own whitelist. Use the dropdown above to switch between them. Only purchases of whitelisted items will be sent to each respective sheet. Leave a list empty to track all items for that sheet.";
}
return "Only purchases of these items will be tracked. Leave empty to track all.";
},
getSyncModeDescription: function (mode) {
switch (mode) {
case "personal":
return "📝 Logs will only be saved to your personal sheet.";
case "shared":
return "👥 Logs will only be saved to the shared faction sheet.";
case "both":
return "📝👥 Logs will be saved to BOTH your personal sheet AND the shared faction sheet. Each sheet can have its own item whitelist.";
default:
return "";
}
},
bindEvents: function () {
const panel = this.panel;
panel.querySelector(".pt-close").addEventListener("click", () => this.togglePanel());
panel.querySelector("#pt-auth-title").addEventListener("click", () => this.toggleSection("auth"));
panel.querySelector("#pt-sheets-title").addEventListener("click", () => this.toggleSection("sheets"));
panel.querySelector("#pt-whitelist-title").addEventListener("click", () => this.toggleSection("whitelist"));
panel.querySelector("#pt-btn-auth").addEventListener("click", () => this.handleAuth());
panel.querySelector("#pt-btn-logout").addEventListener("click", () => this.handleLogout());
// Sheet configuration events
panel.querySelector("#pt-sync-mode").addEventListener("change", (e) => this.handleSyncModeChange(e.target.value));
panel.querySelector("#pt-btn-test-sheets").addEventListener("click", () => this.handleTestSheets());
panel.querySelector("#pt-btn-save-sheets").addEventListener("click", () => this.handleSaveSheets());
// NEW: Whitelist selector change event
panel.querySelector("#pt-whitelist-which").addEventListener("change", (e) => {
State.lastViewedWhitelist = e.target.value;
Storage.set("lastViewedWhitelist", State.lastViewedWhitelist);
this.updateWhitelistUI();
});
// Whitelist events
const whitelistInput = panel.querySelector("#pt-whitelist-input");
whitelistInput.addEventListener("input", (e) => this.handleWhitelistSearch(e.target.value));
whitelistInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") this.handleAddWhitelist();
});
whitelistInput.addEventListener("focus", (e) => {
if (e.target.value.length >= 2) {
this.handleWhitelistSearch(e.target.value);
}
});
document.addEventListener("click", (e) => {
if (!e.target.closest("#pt-whitelist-input") && !e.target.closest("#pt-search-results")) {
panel.querySelector("#pt-search-results").classList.remove("visible");
}
});
panel.querySelector("#pt-btn-add-whitelist").addEventListener("click", () => this.handleAddWhitelist());
panel.querySelector("#pt-btn-clear-whitelist").addEventListener("click", () => this.handleClearWhitelist());
panel.querySelector("#pt-btn-sync-now").addEventListener("click", () => Sync.run());
panel.querySelector("#pt-btn-toggle-polling").addEventListener("click", () => this.handleTogglePolling());
panel.querySelector("#pt-btn-sync-display").addEventListener("click", () => Sync.syncDisplayCase());
panel.querySelector("#pt-btn-refresh-items").addEventListener("click", () => ItemCache.refresh());
panel.querySelector("#pt-btn-reset-position").addEventListener("click", () => this.handleResetPosition());
panel.querySelector("#pt-btn-reset").addEventListener("click", () => this.handleReset());
},
togglePanel: function () {
this.panel.classList.toggle("visible");
},
toggleSection: function (section) {
const titleEl = this.panel.querySelector(`#pt-${section}-title`);
const contentEl = this.panel.querySelector(`#pt-${section}-content`);
titleEl.classList.toggle("collapsed");
contentEl.classList.toggle("collapsed");
if (section === "sheets") {
State.sheetsSectionCollapsed = titleEl.classList.contains("collapsed");
State.saveSheetsSectionState();
}
},
setStatus: function (message, type = "") {
if (this.statusEl) {
this.statusEl.textContent = message;
this.statusEl.className = "pt-status " + type;
}
},
handleSyncModeChange: function (mode) {
State.syncMode = mode;
// Update visibility of URL inputs
const sharedGroup = this.panel.querySelector("#pt-shared-sheet-group");
const personalGroup = this.panel.querySelector("#pt-personal-sheet-group");
const descriptionEl = this.panel.querySelector("#pt-mode-description");
sharedGroup.style.display = mode !== "personal" ? "block" : "none";
personalGroup.style.display = mode !== "shared" ? "block" : "none";
descriptionEl.innerHTML = this.getSyncModeDescription(mode);
// NEW: Show/hide whitelist selector based on mode
const whitelistSelector = this.panel.querySelector("#pt-whitelist-selector");
whitelistSelector.style.display = mode === "both" ? "block" : "none";
// When switching to a single-sheet mode, auto-set the lastViewedWhitelist
// so getCurrentWhitelist returns the correct one
if (mode === "personal") {
State.lastViewedWhitelist = "personal";
} else if (mode === "shared") {
State.lastViewedWhitelist = "shared";
}
// In "both" mode, keep whatever was last selected
Storage.set("lastViewedWhitelist", State.lastViewedWhitelist);
// Refresh the whitelist UI to show the correct list
this.updateWhitelistUI();
},
handleTestSheets: async function () {
this.setStatus("Testing sheet connection(s)...");
const mode = this.panel.querySelector("#pt-sync-mode").value;
const sharedUrl = this.panel.querySelector("#pt-shared-sheet-url").value.trim();
const personalUrl = this.panel.querySelector("#pt-personal-sheet-url").value.trim();
let testsRun = 0;
let testsPassed = 0;
if (mode !== "personal" && sharedUrl) {
testsRun++;
const statusEl = this.panel.querySelector("#pt-shared-sheet-status");
statusEl.style.display = "flex";
statusEl.className = "pt-sheet-status pending";
statusEl.textContent = "Testing...";
const result = await API.testSheetUrl(sharedUrl);
if (result.valid) {
statusEl.className = "pt-sheet-status valid";
statusEl.textContent = `✓ Connected: ${result.data.sheetName || "Sheet OK"}`;
testsPassed++;
} else {
statusEl.className = "pt-sheet-status invalid";
statusEl.textContent = `✗ Error: ${result.error}`;
}
}
if (mode !== "shared" && personalUrl) {
testsRun++;
const statusEl = this.panel.querySelector("#pt-personal-sheet-status");
statusEl.style.display = "flex";
statusEl.className = "pt-sheet-status pending";
statusEl.textContent = "Testing...";
const result = await API.testSheetUrl(personalUrl);
if (result.valid) {
statusEl.className = "pt-sheet-status valid";
statusEl.textContent = `✓ Connected: ${result.data.sheetName || "Sheet OK"}`;
testsPassed++;
} else {
statusEl.className = "pt-sheet-status invalid";
statusEl.textContent = `✗ Error: ${result.error}`;
}
}
if (testsRun === 0) {
this.setStatus("No sheet URLs to test", "error");
} else if (testsPassed === testsRun) {
this.setStatus(`All ${testsRun} sheet(s) connected successfully!`, "success");
} else {
this.setStatus(`${testsPassed}/${testsRun} sheets connected`, "error");
}
},
handleSaveSheets: function () {
const mode = this.panel.querySelector("#pt-sync-mode").value;
const sharedUrl = this.panel.querySelector("#pt-shared-sheet-url").value.trim();
const personalUrl = this.panel.querySelector("#pt-personal-sheet-url").value.trim();
if (mode === "shared" && !sharedUrl) {
this.setStatus("Please enter a shared sheet URL", "error");
return;
}
if (mode === "personal" && !personalUrl) {
this.setStatus("Please enter a personal sheet URL", "error");
return;
}
if (mode === "both" && (!sharedUrl || !personalUrl)) {
this.setStatus("Please enter both sheet URLs", "error");
return;
}
State.syncMode = mode;
State.sharedSheetUrl = sharedUrl;
State.personalSheetUrl = personalUrl;
State.saveSheetConfig();
this.setStatus("Sheet configuration saved!", "success");
// Refresh whitelist UI to reflect mode change
this.updateWhitelistUI();
setTimeout(() => {
const sheetsTitle = this.panel.querySelector("#pt-sheets-title");
const sheetsContent = this.panel.querySelector("#pt-sheets-content");
sheetsTitle.classList.add("collapsed");
sheetsContent.classList.add("collapsed");
State.sheetsSectionCollapsed = true;
State.saveSheetsSectionState();
}, 1500);
},
updateAuthUI: function (showSuccess = false) {
const apiKeyInput = this.panel.querySelector("#pt-api-key");
const authBtn = this.panel.querySelector("#pt-btn-auth");
const logoutBtn = this.panel.querySelector("#pt-btn-logout");
const userInfo = this.panel.querySelector("#pt-user-info");
const usernameEl = this.panel.querySelector("#pt-username");
const authTitle = this.panel.querySelector("#pt-auth-title");
const authContent = this.panel.querySelector("#pt-auth-content");
if (State.isAuthenticated()) {
apiKeyInput.value = "••••••••••••••••";
apiKeyInput.disabled = true;
authBtn.style.display = "none";
logoutBtn.style.display = "inline-block";
userInfo.style.display = "flex";
usernameEl.textContent = State.userName;
if (showSuccess) {
setTimeout(() => {
authTitle.classList.add("collapsed");
authContent.classList.add("collapsed");
}, 1500);
}
} else {
apiKeyInput.value = State.apiKey || "";
apiKeyInput.disabled = false;
authBtn.style.display = "inline-block";
logoutBtn.style.display = "none";
userInfo.style.display = "none";
authTitle.classList.remove("collapsed");
authContent.classList.remove("collapsed");
}
},
// REWRITTEN: Update whitelist UI to show the correct list based on mode and selection
updateWhitelistUI: function () {
const container = this.panel.querySelector("#pt-whitelist-container");
const selectorEl = this.panel.querySelector("#pt-whitelist-selector");
const labelEl = this.panel.querySelector("#pt-whitelist-active-label");
const infoEl = this.panel.querySelector("#pt-whitelist-info");
const whichSelect = this.panel.querySelector("#pt-whitelist-which");
const isBothMode = State.syncMode === CONFIG.SYNC_MODES.BOTH;
// Show/hide the dropdown selector
selectorEl.style.display = isBothMode ? "block" : "none";
// Update the dropdown to reflect current selection
if (whichSelect) {
whichSelect.value = State.lastViewedWhitelist;
}
// Update labels and info
labelEl.innerHTML = this.getWhitelistLabel();
infoEl.textContent = this.getWhitelistInfoText();
// Get the currently active whitelist
const currentWhitelist = State.getCurrentWhitelist();
if (currentWhitelist.length === 0) {
container.innerHTML = '<em style="color:#666;">No items in whitelist (tracking all)</em>';
return;
}
container.innerHTML = currentWhitelist
.map((itemId) => {
const itemName = ItemCache.getItemName(itemId);
return `
<div class="pt-whitelist-item">
<span>${itemName} (#${itemId})</span>
<button data-item-id="${itemId}">✕</button>
</div>
`;
})
.join("");
container.querySelectorAll("button").forEach((btn) => {
btn.addEventListener("click", () => {
const itemId = parseInt(btn.getAttribute("data-item-id"));
// Remove from the currently active whitelist
const wl = State.getCurrentWhitelist();
const idx = wl.indexOf(itemId);
if (idx > -1) {
wl.splice(idx, 1);
State.save();
this.updateWhitelistUI();
this.setStatus(`Removed item #${itemId} from ${State.getCurrentWhitelistType()} whitelist`);
}
});
});
},
updatePollingButton: function () {
const btn = this.panel.querySelector("#pt-btn-toggle-polling");
if (State.isPolling || State.autoSyncEnabled) {
btn.textContent = "Stop Auto-Sync";
btn.classList.remove("pt-btn-success");
btn.classList.add("pt-btn-danger");
} else {
btn.textContent = "Start Auto-Sync";
btn.classList.remove("pt-btn-danger");
btn.classList.add("pt-btn-success");
}
},
handleAuth: async function () {
const apiKey = this.panel.querySelector("#pt-api-key").value.trim();
if (!apiKey) {
this.setStatus("Please enter your API key", "error");
return;
}
this.setStatus("Validating API key...");
const apiResult = await Auth.validateApiKey(apiKey);
if (!apiResult.success) {
this.setStatus("Invalid API key: " + apiResult.error, "error");
return;
}
this.setStatus(`✓ Authenticated as ${State.userName}!`, "success");
this.updateAuthUI(true);
if (ItemCache.needsRefresh()) {
await ItemCache.refresh();
}
},
handleLogout: function () {
Sync.stopPolling();
State.apiKey = "";
State.userId = null;
State.userName = "";
State.save();
this.updateAuthUI();
this.setStatus("Logged out");
},
handleWhitelistSearch: function (value) {
const resultsContainer = this.panel.querySelector("#pt-search-results");
if (!value || value.length < 2) {
resultsContainer.classList.remove("visible");
return;
}
if (/^\d+$/.test(value)) {
resultsContainer.classList.remove("visible");
return;
}
if (Object.keys(State.itemCache).length === 0) {
resultsContainer.innerHTML = '<div class="pt-search-result" style="color:#e94560;">Item cache empty. Click "Refresh Item Cache" first.</div>';
resultsContainer.classList.add("visible");
return;
}
const results = ItemCache.searchByName(value);
if (results.length === 0) {
resultsContainer.innerHTML = '<div class="pt-search-result" style="color:#888;">No items found</div>';
resultsContainer.classList.add("visible");
return;
}
const displayResults = results.slice(0, 10);
resultsContainer.innerHTML = displayResults
.map(
(item) => `
<div class="pt-search-result" data-item-id="${item.id}">
<strong>${item.name}</strong> (#${item.id})<br>
<span class="pt-item-type">${item.type}</span>
</div>
`,
)
.join("");
resultsContainer.querySelectorAll(".pt-search-result").forEach((el) => {
el.addEventListener("click", () => {
const itemId = parseInt(el.getAttribute("data-item-id"));
const currentWhitelist = State.getCurrentWhitelist();
const whitelistType = State.getCurrentWhitelistType();
if (itemId && !currentWhitelist.includes(itemId)) {
currentWhitelist.push(itemId);
State.save();
this.updateWhitelistUI();
this.setStatus(`Added ${ItemCache.getItemName(itemId)} to ${whitelistType} whitelist`, "success");
} else if (itemId) {
this.setStatus(`${ItemCache.getItemName(itemId)} already in ${whitelistType} whitelist`);
}
this.panel.querySelector("#pt-whitelist-input").value = "";
resultsContainer.classList.remove("visible");
});
});
resultsContainer.classList.add("visible");
},
handleAddWhitelist: async function () {
const input = this.panel.querySelector("#pt-whitelist-input");
const value = input.value.trim();
if (!value) return;
const currentWhitelist = State.getCurrentWhitelist();
const whitelistType = State.getCurrentWhitelistType();
if (/^\d+$/.test(value)) {
const itemId = parseInt(value);
if (!currentWhitelist.includes(itemId)) {
currentWhitelist.push(itemId);
State.save();
this.updateWhitelistUI();
this.setStatus(`Added ${ItemCache.getItemName(itemId)} (#${itemId}) to ${whitelistType} whitelist`, "success");
} else {
this.setStatus(`Item #${itemId} already in ${whitelistType} whitelist`);
}
input.value = "";
this.panel.querySelector("#pt-search-results").classList.remove("visible");
return;
}
if (Object.keys(State.itemCache).length === 0) {
this.setStatus('Item cache empty. Click "Refresh Item Cache" first.', "error");
return;
}
const results = ItemCache.searchByName(value);
if (results.length === 0) {
this.setStatus('No items found matching "' + value + '"', "error");
return;
}
const item = results[0];
if (!currentWhitelist.includes(item.id)) {
currentWhitelist.push(item.id);
State.save();
this.updateWhitelistUI();
this.setStatus(`Added ${item.name} (#${item.id}) to ${whitelistType} whitelist`, "success");
} else {
this.setStatus(`${item.name} already in ${whitelistType} whitelist`);
}
input.value = "";
this.panel.querySelector("#pt-search-results").classList.remove("visible");
},
// UPDATED: Only clears the currently active whitelist
handleClearWhitelist: function () {
const whitelistType = State.getCurrentWhitelistType();
const label = whitelistType === "personal" ? "Personal" : "Shared/Faction";
if (confirm(`Clear all items from the ${label} whitelist? This will track ALL purchases for that sheet.`)) {
if (whitelistType === "personal") {
State.personalWhitelist = [];
} else {
State.sharedWhitelist = [];
}
State.save();
this.updateWhitelistUI();
this.setStatus(`${label} whitelist cleared`);
}
},
handleTogglePolling: function () {
if (State.isPolling || State.autoSyncEnabled) {
Sync.stopPolling();
this.setStatus("Auto-sync stopped");
} else {
if (!State.isAuthenticated()) {
this.setStatus("Please authenticate first", "error");
return;
}
if (!State.hasValidSheetConfig()) {
this.setStatus("Please configure sheet URL(s) first", "error");
return;
}
Sync.startPolling();
this.setStatus("Auto-sync started (persists across pages)");
Sync.run();
}
},
handleResetPosition: function () {
State.togglePosition = { x: null, y: 100 };
State.saveTogglePosition();
if (this.toggleBtn) {
this.toggleBtn.style.left = "auto";
this.toggleBtn.style.right = "10px";
this.toggleBtn.style.top = "100px";
}
this.setStatus("Button position reset", "success");
},
// UPDATED: Clean up all new storage keys on reset
handleReset: function () {
if (confirm("This will delete ALL local data including API key, sheet URLs, whitelists, and caches. Continue?")) {
Sync.stopPolling();
Storage.delete("apiKey");
Storage.delete("userId");
Storage.delete("userName");
Storage.delete("whitelist"); // old key, just in case
Storage.delete("personalWhitelist");
Storage.delete("sharedWhitelist");
Storage.delete("lastViewedWhitelist");
Storage.delete("lastSyncTimestamp");
Storage.delete("processedLogIds");
Storage.delete("itemCache");
Storage.delete("itemCacheTimestamp");
Storage.delete("sellerCache");
Storage.delete("autoSyncEnabled");
Storage.delete("togglePosition");
Storage.delete("personalSheetUrl");
Storage.delete("sharedSheetUrl");
Storage.delete("syncMode");
Storage.delete("sheetsSectionCollapsed");
location.reload();
}
},
};
// ============================================
// INITIALIZATION
// ============================================
function init() {
console.log("[PurchaseTracker] Initializing v" + CONFIG.VERSION);
UI.init();
setTimeout(() => {
Sync.resumePollingIfEnabled();
}, 1000);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();