// API INTEGRATION FUNCTIONS
// =============================================================================
// Track provider connection status for settings UI
const providerStatuses = {
STAKTRAKR: "disconnected",
METALS_DEV: "disconnected",
METALS_API: "disconnected",
METAL_PRICE_API: "disconnected",
CUSTOM: "disconnected",
};
/** Check whether a provider requires an API key */
const providerRequiresKey = (prov) => API_PROVIDERS[prov]?.requiresKey !== false;
/**
* Fetch spot prices from StakTrakr hourly JSON files.
* Walks back up to 6 hours from the current UTC hour to find data.
*/
const fetchStaktrakrPrices = async (selectedMetals) => {
const baseUrl = API_PROVIDERS.STAKTRAKR.hourlyBaseUrl;
const now = new Date();
for (let offset = 0; offset <= 6; offset++) {
const target = new Date(now.getTime() - offset * 3600000);
const yyyy = target.getUTCFullYear();
const mm = String(target.getUTCMonth() + 1).padStart(2, '0');
const dd = String(target.getUTCDate()).padStart(2, '0');
const hh = String(target.getUTCHours()).padStart(2, '0');
const url = `${baseUrl}/${yyyy}/${mm}/${dd}/${hh}.json`;
try {
const resp = await fetch(url, { mode: 'cors' });
if (!resp.ok) continue;
const data = await resp.json();
const { current } = API_PROVIDERS.STAKTRAKR.parseBatchResponse(data);
const results = {};
selectedMetals.forEach(metal => {
if (current[metal] > 0) results[metal] = current[metal];
});
if (Object.keys(results).length > 0) {
// Track usage for STAKTRAKR
const cfg = loadApiConfig();
if (cfg.usage?.STAKTRAKR) {
cfg.usage.STAKTRAKR.used++;
saveApiConfig(cfg);
}
return results;
}
} catch { continue; }
}
throw new Error('No hourly data available from StakTrakr API');
};
/**
* Fetches hourly spot data from StakTrakr for a configurable number of hours.
* Skips hours already present in spotHistory to avoid duplicates.
* @param {number} hoursBack - Number of hours to look back
* @returns {Promise<{newCount: number, fetchCount: number}>} Counts of new entries and successful fetches
*/
const fetchStaktrakrHourlyRange = async (hoursBack) => {
const baseUrl = API_PROVIDERS.STAKTRAKR.hourlyBaseUrl;
const now = new Date();
// Build list of UTC hours as Date objects
const hours = [];
for (let i = 0; i < hoursBack; i++) {
hours.push(new Date(now.getTime() - i * 3600000));
}
// Purge once, then build dedup set for batch append (avoids N×save)
purgeSpotHistory();
const existingKeys = new Set(
spotHistory.map(e => `${e.timestamp}|${e.metal}`)
);
// Fetch hours in batches of 6
let newCount = 0;
let fetchCount = 0;
const batchSize = 6;
const providerName = API_PROVIDERS.STAKTRAKR.name;
for (let i = 0; i < hours.length; i += batchSize) {
const batch = hours.slice(i, i + batchSize);
const results = await Promise.all(batch.map(async (h) => {
const yyyy = h.getUTCFullYear();
const mm = String(h.getUTCMonth() + 1).padStart(2, '0');
const dd = String(h.getUTCDate()).padStart(2, '0');
const hh = String(h.getUTCHours()).padStart(2, '0');
const url = `${baseUrl}/${yyyy}/${mm}/${dd}/${hh}.json`;
try {
const resp = await fetch(url, { mode: 'cors' });
if (!resp.ok) return null;
const data = await resp.json();
const { current } = API_PROVIDERS.STAKTRAKR.parseBatchResponse(data);
// Use ISO-format UTC timestamp so recordSpot normalizes consistently
return { current, timestamp: `${yyyy}-${mm}-${dd}T${hh}:00:00Z` };
} catch { return null; }
}));
results.forEach(result => {
if (!result) return;
fetchCount++;
Object.entries(result.current).forEach(([metalKey, spot]) => {
if (spot <= 0) return;
const metalConfig = Object.values(METALS).find(m => m.key === metalKey);
if (!metalConfig) return;
const entryTimestamp = result.timestamp.replace("T", " ").replace("Z", "");
const isDuplicate = existingKeys.has(`${entryTimestamp}|${metalConfig.name}`);
if (!isDuplicate) {
spotHistory.push({
spot, metal: metalConfig.name, source: "api-hourly",
provider: providerName, timestamp: entryTimestamp,
});
existingKeys.add(`${entryTimestamp}|${metalConfig.name}`);
newCount++;
}
});
});
}
if (newCount > 0) {
saveSpotHistory();
console.log(`[StakTrakr] Added ${newCount} hourly entries (${fetchCount} files fetched)`);
}
return { newCount, fetchCount };
};
/**
* Backfills the last 24 hours of hourly spot data from StakTrakr into spotHistory.
* Only runs when STAKTRAKR is the primary provider (rank 1) and sync succeeded.
* @returns {Promise<number>} Count of new entries added
*/
const backfillStaktrakrHourly = async () => {
const { newCount, fetchCount } = await fetchStaktrakrHourlyRange(24);
// Track usage per file fetched (each file = 1 API request)
if (fetchCount > 0) {
const config = loadApiConfig();
if (config.usage?.STAKTRAKR) {
config.usage.STAKTRAKR.used += fetchCount;
saveApiConfig(config);
}
}
return newCount;
};
/**
* Handles user-initiated hourly history pull for STAKTRAKR.
* Reads days from dropdown, confirms, fetches, and updates UI.
*/
const handleStaktrakrHistoryPull = async () => {
const daysSelect = document.getElementById('historyPullDays_STAKTRAKR');
const totalDays = daysSelect ? parseInt(daysSelect.value, 10) : 7;
const totalHours = totalDays * 24;
const proceed = confirm(
`Pull ${totalDays} day${totalDays > 1 ? 's' : ''} of hourly history from StakTrakr.\n\n` +
`This will fetch up to ${totalHours} hourly files (skipping already-fetched hours).\n\nProceed?`
);
if (!proceed) return;
// Disable button during pull
const btn = document.querySelector('.api-history-btn[data-provider="STAKTRAKR"]');
const origText = btn ? btn.textContent : "";
if (btn) { btn.textContent = "Pulling..."; btn.disabled = true; }
try {
const { newCount, fetchCount } = await fetchStaktrakrHourlyRange(totalHours);
// Track usage
if (fetchCount > 0) {
const config = loadApiConfig();
if (config.usage?.STAKTRAKR) {
config.usage.STAKTRAKR.used += fetchCount;
saveApiConfig(config);
}
}
alert(
`History pull complete!\n\n` +
`Added ${newCount} new entries from ${fetchCount} hourly files.`
);
updateProviderHistoryTables();
if (typeof updateAllSparklines === "function") updateAllSparklines();
} catch (err) {
console.error("StakTrakr history pull failed:", err);
alert("History pull failed: " + err.message);
} finally {
if (btn) { btn.textContent = origText; btn.disabled = false; }
}
};
/**
* Renders a status summary row in the header for all configured API providers.
* Displays connection status (connected/disconnected/cached) and last sync time.
*/
const renderApiStatusSummary = () => {
const container = document.getElementById("apiHeaderStatusRow");
if (!container) return;
// Build provider list: Numista first, then metals providers
const items = [];
// Numista status
let numistaStatus = "disconnected";
try {
if (typeof catalogConfig !== "undefined" && catalogConfig.getNumistaConfig) {
const nc = catalogConfig.getNumistaConfig();
numistaStatus = nc.apiKey ? "connected" : "disconnected";
}
} catch (e) { /* ignore */ }
items.push({ name: "Numista", status: numistaStatus, provider: "NUMISTA" });
// PCGS status
let pcgsStatus = "disconnected";
try {
if (typeof catalogConfig !== "undefined" && catalogConfig.isPcgsEnabled) {
pcgsStatus = catalogConfig.isPcgsEnabled() ? "connected" : "disconnected";
}
} catch (e) { /* ignore */ }
items.push({ name: "PCGS", status: pcgsStatus, provider: "PCGS" });
// Metals providers
Object.keys(API_PROVIDERS).forEach((prov) => {
const status = Object.hasOwn(providerStatuses, prov) ? providerStatuses[prov] : "disconnected"; const providerConfig = Object.hasOwn(API_PROVIDERS, prov) ? API_PROVIDERS[prov] : null; if (!providerConfig) return;
const name = providerConfig.name;
const statusClass = status === "cached" ? "connected" : status;
const lastSync = typeof getLastProviderSyncTime === "function" ? getLastProviderSyncTime(prov) : null;
let tsLabel = "";
if (lastSync) {
const d = new Date(lastSync);
tsLabel = typeof formatTimestamp === 'function' ? formatTimestamp(d, { year: undefined }) : d.toLocaleString();
}
items.push({ name, status: statusClass, tsLabel, provider: prov });
});
container.textContent = '';
items.forEach(item => {
const span = document.createElement('span');
span.className = 'api-header-status-item ' + item.status;
const dot = document.createElement('span');
dot.className = 'status-dot';
const nameEl = document.createElement('span');
nameEl.className = 'status-name';
nameEl.textContent = item.name;
span.append(dot, nameEl);
if (item.tsLabel) {
const ts = document.createElement('span');
ts.className = 'status-timestamp';
ts.textContent = item.tsLabel;
span.appendChild(ts);
}
container.appendChild(span);
});
};
/** @type {Array<Object>} In-memory buffer for API history log entries */
let apiHistoryEntries = [];
/** @type {string} Current sort column for the API history table */
let apiHistorySortColumn = "";
/** @type {boolean} Sort direction for the API history table */
let apiHistorySortAsc = true;
/** @type {string} Active filter text for searching API history */
let apiHistoryFilterText = "";
/**
* Loads Metals API configuration from localStorage
* @returns {Object|null} Metals API configuration or null if not set
*/
const loadApiConfig = () => {
try {
const stored = localStorage.getItem(API_KEY_STORAGE_KEY);
if (stored) {
const config = JSON.parse(stored);
if (config.keys) {
Object.keys(config.keys).forEach((p) => {
if (config.keys[p]) {
config.keys[p] = atob(config.keys[p]);
}
});
} else if (config.apiKey && config.provider) {
// Legacy format migration
config.keys = { [config.provider]: atob(config.apiKey) };
}
const usage = config.usage || {};
const metals = config.metals || {};
const historyDays = config.historyDays || {};
const historyTimes = config.historyTimes || {};
const currentMonth = currentMonthKey();
const savedMonth = config.usageMonth;
Object.keys(API_PROVIDERS).forEach((p) => {
if (!usage[p]) usage[p] = {
quota: providerRequiresKey(p) ? DEFAULT_API_QUOTA : 5000,
used: 0,
};
if (!metals[p])
metals[p] = {
silver: true,
gold: true,
platinum: true,
palladium: true,
};
else {
["silver", "gold", "platinum", "palladium"].forEach((m) => {
if (typeof metals[p][m] === "undefined") metals[p][m] = true;
});
}
if (typeof historyDays[p] !== "number") {
historyDays[p] = p === "METALS_DEV" ? 29 : 30;
} else if (p === "METALS_DEV" && historyDays[p] > 30) {
historyDays[p] = 30;
}
if (!Array.isArray(historyTimes[p])) historyTimes[p] = [];
});
let needsSave = false;
if (savedMonth !== currentMonth) {
Object.keys(usage).forEach((p) => (usage[p].used = 0));
needsSave = true;
}
// Reconstruct per-provider cache timeouts, defaulting to global cacheHours or 24
const cacheTimeouts = config.cacheTimeouts || {};
const globalCache = Number.isFinite(config.cacheHours) ? config.cacheHours : 24;
Object.keys(API_PROVIDERS).forEach((p) => {
if (!Number.isFinite(cacheTimeouts[p]) || cacheTimeouts[p] < 0) {
cacheTimeouts[p] = globalCache;
}
});
const result = {
provider: config.provider || "",
// Clone keys object to prevent accidental cross-provider references
keys: { ...(config.keys || {}) },
cacheHours:
typeof config.cacheHours === "number" ? config.cacheHours : 24,
cacheTimeouts,
customConfig: config.customConfig || {
baseUrl: "",
endpoint: "",
format: "symbol",
},
metals,
usage,
historyDays,
historyTimes,
syncMode: config.syncMode || {},
usageMonth: currentMonth,
};
if (needsSave) {
saveApiConfig(result);
}
return result;
}
} catch (error) {
console.error("Error loading API config:", error);
}
const usage = {};
const metals = {};
const historyDays = {};
const historyTimes = {};
const defaultCacheTimeouts = {};
Object.keys(API_PROVIDERS).forEach((p) => {
usage[p] = {
quota: providerRequiresKey(p) ? DEFAULT_API_QUOTA : 5000,
used: 0,
};
metals[p] = { silver: true, gold: true, platinum: true, palladium: true };
historyDays[p] = p === "METALS_DEV" ? 29 : 30;
historyTimes[p] = [];
defaultCacheTimeouts[p] = 24;
});
return {
provider: "",
keys: {},
cacheHours: 24,
cacheTimeouts: defaultCacheTimeouts,
customConfig: { baseUrl: "", endpoint: "", format: "symbol" },
metals,
usage,
historyDays,
historyTimes,
syncMode: {},
usageMonth: currentMonthKey(),
};
};
/**
* Saves Metals API configuration to localStorage
* @param {Object} config - Metals API configuration object
*/
const saveApiConfig = (config) => {
try {
const configToSave = {
provider: config.provider || "",
keys: {},
cacheHours:
typeof config.cacheHours === "number" ? config.cacheHours : 24,
cacheTimeouts: config.cacheTimeouts || {},
customConfig: config.customConfig || {
baseUrl: "",
endpoint: "",
format: "symbol",
},
metals: config.metals || {},
usage: config.usage || {},
historyDays: config.historyDays || {},
historyTimes: config.historyTimes || {},
syncMode: config.syncMode || {},
usageMonth: config.usageMonth || currentMonthKey(),
};
Object.keys(config.keys || {}).forEach((p) => {
if (config.keys[p]) {
configToSave.keys[p] = btoa(config.keys[p]);
}
});
localStorage.setItem(API_KEY_STORAGE_KEY, JSON.stringify(configToSave));
// Store a cloned copy in memory to avoid shared references
apiConfig = {
...config,
keys: { ...(config.keys || {}) },
};
updateSyncButtonStates();
} catch (error) {
console.error("Error saving API config:", error);
}
};
/**
* Clears Metals API configuration
*/
const clearApiConfig = () => {
localStorage.removeItem(API_KEY_STORAGE_KEY);
localStorage.removeItem(API_CACHE_KEY);
apiConfig = {
provider: "",
keys: {},
cacheHours: 24,
customConfig: { baseUrl: "", endpoint: "", format: "symbol" },
};
apiCache = null;
Object.keys(providerStatuses).forEach((p) =>
setProviderStatus(p, "disconnected"),
);
updateSyncButtonStates();
};
/**
* Clears only the API cache, keeping the configuration
*/
const clearApiCache = () => {
localStorage.removeItem(API_CACHE_KEY);
apiCache = null;
clearApiHistory(true);
alert(
"API cache and history cleared. Next sync will pull fresh data from the API.",
);
};
/**
* Gets cache duration in milliseconds
* @returns {number} Cache duration
*/
const getCacheDurationMs = (provider) => {
let hours;
if (provider && Number.isFinite(apiConfig?.cacheTimeouts?.[provider])) {
hours = apiConfig.cacheTimeouts[provider];
} else {
hours = apiConfig?.cacheHours ?? 24;
}
return (Number.isFinite(hours) && hours >= 0 ? hours : 24) * 60 * 60 * 1000;
};
/**
* Sets connection status for a provider in the settings UI
* @param {string} provider
* @param {"connected"|"disconnected"|"error"|"cached"} status
*/
const setProviderStatus = (provider, status) => {
providerStatuses[provider] = status;
renderApiStatusSummary();
const block = document.querySelector(
`.api-provider[data-provider="${provider}"] .provider-status`,
);
if (!block) return;
block.classList.remove(
"status-connected",
"status-disconnected",
"status-error",
"status-cached",
);
block.classList.add(
status === "cached" ? "status-connected" : `status-${status}`,
);
const text = block.querySelector(".status-text");
if (text) {
text.textContent =
status === "connected"
? "Connected"
: status === "cached"
? "Connected (cached)"
: status === "error"
? "Error"
: "Disconnected";
}
// Update last-used timestamp in provider card
const lastUsed = block.querySelector(".status-last-used");
if (lastUsed && typeof getLastProviderSyncTime === "function") {
const ts = getLastProviderSyncTime(provider);
if (ts) {
const d = new Date(ts);
lastUsed.textContent = "Last: " + (typeof formatTimestamp === 'function' ? formatTimestamp(d, { year: undefined }) : d.toLocaleString());
} else {
lastUsed.textContent = "";
}
}
};
/**
* Updates the visual cost indicator for a history pull from a given provider.
* Displays total API calls or file fetches expected based on current settings.
*
* @param {string} provider - The unique key of the API provider
*/
const updateHistoryPullCost = (provider) => {
const config = loadApiConfig();
const providerConfig = API_PROVIDERS[provider];
const costEl = document.getElementById(`historyPullCost_${provider}`);
if (!costEl || !providerConfig) return;
const daysSelect = document.getElementById(`historyPullDays_${provider}`);
const totalDays = daysSelect ? parseInt(daysSelect.value, 10) : 30;
// STAKTRAKR: show hourly file count instead of API calls
if (provider === 'STAKTRAKR') {
const hours = totalDays * 24;
costEl.textContent = `${totalDays}d = ${hours} hourly files`;
return;
}
const selected = config.metals?.[provider] || {};
const selectedMetals = Object.keys(selected).filter(metal => selected[metal] !== false);
// Check for hourly toggle (MetalPriceAPI)
const hourlyToggle = document.getElementById(`hourlyPull_${provider}`);
if (hourlyToggle && hourlyToggle.checked) {
const calls = selectedMetals.length;
costEl.textContent = `${totalDays}d \u00D7 ${selectedMetals.length} metals = ${calls} API calls (hourly)`;
return;
}
const maxPerReq = providerConfig.maxHistoryDays || 30;
const chunks = Math.ceil(totalDays / maxPerReq);
let calls;
if (providerConfig.symbolsPerRequest === 1) {
calls = chunks * selectedMetals.length;
costEl.textContent = `${totalDays}d \u00D7 ${selectedMetals.length} metals = ${calls} API calls`;
} else {
calls = chunks;
costEl.textContent = `${totalDays}d = ${calls} API call${calls > 1 ? "s" : ""}`;
}
};
/**
* Updates persistent provider settings (like cache duration) from form inputs.
* Persists the updated configuration to localStorage.
*
* @param {string} provider - The unique key of the API provider
*/
const updateProviderSettings = (provider) => {
const config = loadApiConfig();
// Update cache timeout
const cacheSelect = document.getElementById(`cacheTimeout_${provider}`);
if (cacheSelect) {
if (!config.cacheTimeouts) config.cacheTimeouts = {};
config.cacheTimeouts[provider] = parseFloat(cacheSelect.value);
}
saveApiConfig(config);
};
/**
* Attaches DOM event listeners to the settings controls for a specific provider.
* Handles cache changes, history pull parameters, and metal selection checkboxes.
*
* @param {string} provider - The unique key of the API provider
*/
const setupProviderSettingsListeners = (provider) => {
// Cache timeout change
const cacheSelect = document.getElementById(`cacheTimeout_${provider}`);
if (cacheSelect) {
cacheSelect.addEventListener('change', () => updateProviderSettings(provider));
}
// History pull days dropdown — update cost indicator
const pullDaysSelect = document.getElementById(`historyPullDays_${provider}`);
if (pullDaysSelect) {
pullDaysSelect.addEventListener('change', () => updateHistoryPullCost(provider));
}
// History pull button
const pullBtn = document.querySelector(`.api-history-btn[data-provider="${provider}"]`);
if (pullBtn) {
pullBtn.addEventListener('click', () => handleHistoryPull(provider));
}
// Hourly toggle — cap days dropdown and update cost
const hourlyToggle = document.getElementById(`hourlyPull_${provider}`);
if (hourlyToggle) {
hourlyToggle.addEventListener('change', () => {
const daysEl = document.getElementById(`historyPullDays_${provider}`);
if (daysEl && hourlyToggle.checked) {
const maxDays = API_PROVIDERS[provider]?.maxHourlyDays || 7;
if (parseInt(daysEl.value, 10) > maxDays) {
daysEl.value = String(maxDays);
}
}
updateHistoryPullCost(provider);
});
}
// Metal selection changes
document.querySelectorAll(`.provider-metal[data-provider="${provider}"]`).forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const config = loadApiConfig();
const metalKey = e.target.dataset.metal;
if (!config.metals[provider]) config.metals[provider] = {};
config.metals[provider][metalKey] = e.target.checked;
saveApiConfig(config);
updateHistoryPullCost(provider);
});
});
};
/**
* Renders the API usage/quota visualization (progress bars) for each provider.
* Displays usage vs quota and handles clicks for quota adjustment modals.
*/
const updateProviderHistoryTables = () => {
const config = loadApiConfig();
Object.keys(API_PROVIDERS).forEach((prov) => {
const container = document.querySelector(
`.api-provider[data-provider="${prov}"] .provider-settings .provider-history`,
);
if (!container) return;
const usage = config.usage?.[prov] || {
quota: DEFAULT_API_QUOTA,
used: 0,
};
const usedPercent = Math.min((usage.used / usage.quota) * 100, 100);
const remainingPercent = 100 - usedPercent;
const warning = usage.used / usage.quota >= 0.9;
const safeProv = sanitizeHtml(prov);
const usageHtml = `<div class="api-usage" data-quota-provider="${safeProv}" style="cursor:pointer" title="Click to edit quota"><div class="usage-bar"><div class="used" style="width:${usedPercent}%"></div><div class="remaining" style="width:${remainingPercent}%"></div></div><div class="usage-text">${usage.used}/${usage.quota} calls${warning ? " 🚩" : ""}</div></div>`;
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
container.innerHTML = usageHtml;
// Make quota bar clickable
const usageEl = container.querySelector('.api-usage[data-quota-provider]');
if (usageEl) {
usageEl.addEventListener('click', () => {
const modal = document.getElementById('apiQuotaModal');
const input = document.getElementById('apiQuotaInput');
if (modal && input) {
const cfg = loadApiConfig();
const u = cfg.usage?.[prov] || { quota: DEFAULT_API_QUOTA, used: 0 };
input.value = u.quota;
// Store provider for the save handler
modal.dataset.quotaProvider = prov;
if (window.openModalById) openModalById('apiQuotaModal');
else modal.style.display = 'flex';
}
});
}
});
};
/**
* Periodically refreshes connection status icons based on key presence and cache age.
* Determines if a provider is fully connected, cached (needs sync), or disconnected.
*/
const refreshProviderStatuses = () => {
const config = loadApiConfig();
let cache = null;
try {
const stored = localStorage.getItem(API_CACHE_KEY);
cache = stored ? JSON.parse(stored) : null;
} catch (err) {
console.error("Error reading API cache for status check:", err);
}
const now = Date.now();
Object.keys(API_PROVIDERS).forEach((prov) => {
const duration = getCacheDurationMs(prov);
if (config.keys[prov] || !providerRequiresKey(prov)) {
// API key is stored (or provider is keyless)
if (cache && cache.provider === prov && cache.timestamp) {
const age = now - cache.timestamp;
if (age <= duration) {
setProviderStatus(prov, "connected"); // Recently used with fresh data
} else {
setProviderStatus(prov, "cached"); // Key stored but data is old
}
} else if (!providerRequiresKey(prov)) {
// Keyless provider: check last sync time instead of cache object
const lastSync = getLastProviderSyncTime(prov);
if (lastSync && (now - lastSync) <= duration) {
setProviderStatus(prov, "connected");
} else if (lastSync) {
setProviderStatus(prov, "cached");
} else {
setProviderStatus(prov, "connected"); // Keyless, always available
}
} else {
setProviderStatus(prov, "cached"); // Key stored but no recent usage
}
} else {
setProviderStatus(prov, "disconnected"); // No API key stored
}
});
};
/**
* Automatically selects the primary API provider based on priority and availability.
* The highest-priority provider that has a stored API key is selected as default.
*/
const autoSelectDefaultProvider = () => {
const config = loadApiConfig();
const keys = config.keys || {};
// Read tab order from localStorage, fall back to default order
let order;
try {
const stored = localStorage.getItem("apiProviderOrder");
order = stored ? JSON.parse(stored) : null;
} catch (e) { order = null; }
if (!Array.isArray(order) || order.length === 0) {
order = Object.keys(API_PROVIDERS);
}
// Select first provider with a key (or keyless) as default
const active = order.filter((p) => keys[p] || !providerRequiresKey(p));
if (active.length > 0 && config.provider !== active[0]) {
config.provider = active[0];
saveApiConfig(config);
} else if (active.length === 0 && config.provider) {
config.provider = "";
saveApiConfig(config);
}
};
// Backward-compatible alias
const updateDefaultProviderButtons = autoSelectDefaultProvider;
/**
* Returns the effective priority order for API providers.
* Merges user-defined priority with legacy order and hardcoded defaults.
*
* @returns {string[]} Ordered list of provider keys
*/
const getProviderOrder = () => {
try {
const stored = localStorage.getItem("providerPriority");
if (stored) {
const priorities = JSON.parse(stored);
if (typeof priorities === 'object' && priorities !== null) {
return Object.entries(priorities)
.filter(([, p]) => p > 0)
.sort((a, b) => a[1] - b[1])
.map(([prov]) => prov);
}
}
} catch (e) { /* ignore */ }
// Legacy fallback
try {
const stored = localStorage.getItem("apiProviderOrder");
const order = stored ? JSON.parse(stored) : null;
if (Array.isArray(order) && order.length > 0) return order;
} catch (e) { /* ignore */ }
return Object.keys(API_PROVIDERS);
};
/**
* Determines the default synchronization behavior for a provider.
* Higher priority providers default to 'always', others to 'backup'.
*
* @param {string} provider - The unique key of the API provider
* @returns {"always"|"backup"} Recommended sync mode
*/
const getDefaultSyncMode = (provider) => {
const order = getProviderOrder();
const config = loadApiConfig();
const firstActive = order.find(p => config.keys?.[p] || !providerRequiresKey(p));
return provider === firstActive ? "always" : "backup";
};
/**
* Renders API history table with filtering, sorting and pagination
*/
const renderApiHistoryTable = () => {
const table = document.getElementById("apiHistoryTable");
if (!table) return;
let data = [...apiHistoryEntries];
if (apiHistoryFilterText) {
const f = apiHistoryFilterText.toLowerCase();
data = data.filter((e) =>
Object.values(e).some((v) => String(v).toLowerCase().includes(f)),
);
}
if (apiHistorySortColumn) {
data.sort((a, b) => {
const valA = a[apiHistorySortColumn];
const valB = b[apiHistorySortColumn];
if (valA < valB) return apiHistorySortAsc ? -1 : 1;
if (valA > valB) return apiHistorySortAsc ? 1 : -1;
return 0;
});
}
if (!apiHistorySortColumn) {
data.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
}
let html =
"<tr><th data-column=\"timestamp\">Time</th><th data-column=\"metal\">Metal</th><th data-column=\"spot\">Price</th><th data-column=\"provider\">Source</th></tr>";
data.forEach((e) => {
const sourceLabel = e.source === "api-hourly"
? `${e.provider || ""} (hourly)`
: (e.provider || "");
html += `<tr><td>${e.timestamp}</td><td>${e.metal}</td><td>${formatCurrency(
e.spot,
)}</td><td>${sourceLabel}</td></tr>`;
});
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
table.innerHTML = html;
table.querySelectorAll("th").forEach((th) => {
th.addEventListener("click", () => {
const col = th.dataset.column;
if (apiHistorySortColumn === col) {
apiHistorySortAsc = !apiHistorySortAsc;
} else {
apiHistorySortColumn = col;
apiHistorySortAsc = true;
}
renderApiHistoryTable();
});
});
};
/**
* Opens the API history modal and populates it with filtered spot history data.
* Displays only 'api', 'api-hourly', and 'seed' entries in the log.
*/
const showApiHistoryModal = () => {
const modal = document.getElementById("apiHistoryModal");
if (!modal) return;
loadSpotHistory();
apiHistoryEntries = spotHistory.filter((e) => e.source === "api" || e.source === "api-hourly" || e.source === "seed");
apiHistorySortColumn = "";
apiHistorySortAsc = true;
apiHistoryFilterText = "";
const filterInput = document.getElementById("apiHistoryFilter");
const clearFilterBtn = document.getElementById("apiHistoryClearFilterBtn");
if (filterInput) {
filterInput.value = "";
filterInput.oninput = (e) => {
apiHistoryFilterText = e.target.value;
renderApiHistoryTable();
};
}
if (clearFilterBtn) {
clearFilterBtn.onclick = () => {
apiHistoryFilterText = "";
if (filterInput) filterInput.value = "";
renderApiHistoryTable();
};
}
renderApiHistoryTable();
modal.style.display = "flex";
};
/**
* Closes the API history modal.
*/
const hideApiHistoryModal = () => {
const modal = document.getElementById("apiHistoryModal");
if (modal) modal.style.display = "none";
};
/**
* Opens the API provider selection modal (redirects to the API section of Settings).
*/
const showApiProvidersModal = () => {
// Redirect to Settings modal API section
if (typeof showSettingsModal === "function") {
showSettingsModal('api');
}
};
/**
* Closes the API providers modal (legacy wrapper for hideSettingsModal).
*/
const hideApiProvidersModal = () => {
// Legacy — Settings modal handles its own close
if (typeof hideSettingsModal === "function") {
hideSettingsModal();
}
};
/**
* Clears all stored spot price history from localStorage and re-renders UI.
*
* @param {boolean} [silent=false] - If true, suppresses reopening the history modal
*/
const clearApiHistory = (silent = false) => {
spotHistory = [];
saveSpotHistory();
updateProviderHistoryTables();
if (!silent) {
showApiHistoryModal();
}
};
/**
* Updates the active API provider in configuration.
* Validates that the provider has a key (if required) before switching.
*
* @param {string} provider - The unique key of the API provider
*/
const setDefaultProvider = (provider) => {
const config = loadApiConfig();
if (!config.keys[provider] && providerRequiresKey(provider)) {
alert("Please enter your API key first");
return;
}
config.provider = provider;
saveApiConfig(config);
updateDefaultProviderButtons();
updateSyncButtonStates();
};
/**
* Removes the stored API key for a given provider from configuration.
* Also handles fallback to other available providers if necessary.
*
* @param {string} provider - The unique key of the API provider
*/
const clearApiKey = (provider) => {
const config = loadApiConfig();
delete config.keys[provider];
if (config.provider === provider) {
config.provider = "";
}
const active = Object.keys(API_PROVIDERS).filter((p) => config.keys[p] || !providerRequiresKey(p));
if (active.length === 1) {
config.provider = active[0];
}
saveApiConfig(config);
const input = document.getElementById(`apiKey_${provider}`);
if (input) input.value = "";
if (provider === "CUSTOM") {
config.customConfig = { baseUrl: "", endpoint: "", format: "symbol" };
const base = document.getElementById("apiBase_CUSTOM");
const endpoint = document.getElementById("apiEndpoint_CUSTOM");
const format = document.getElementById("apiFormat_CUSTOM");
if (base) base.value = "";
if (endpoint) endpoint.value = "";
if (format) format.value = "symbol";
saveApiConfig(config);
}
setProviderStatus(provider, "disconnected");
updateDefaultProviderButtons();
updateProviderHistoryTables();
};
/**
* Force-refreshes all spot price displays using the most recent cached data.
* Does not make external network requests.
*
* @returns {boolean} True if display was successfully updated from cache
*/
const refreshFromCache = () => {
const cache = loadApiCache();
if (!cache || !cache.data) {
return false;
}
let updatedCount = 0;
Object.entries(cache.data).forEach(([metal, price]) => {
const metalConfig = Object.values(METALS).find((m) => m.key === metal);
if (metalConfig && price > 0) {
// Save to localStorage
localStorage.setItem(metalConfig.spotKey, price.toString());
spotPrices[metal] = price;
// Update display
elements.spotPriceDisplay[metal].textContent = formatCurrency(price);
updateSpotCardColor(metal, price);
// Record in history as 'cached' to distinguish from fresh API calls
recordSpot(
price,
"cached",
metalConfig.name,
API_PROVIDERS[cache.provider]?.name,
);
const ts = document.getElementById(`spotTimestamp${metalConfig.name}`);
if (ts) {
updateSpotTimestamp(metalConfig.name);
}
updatedCount++;
}
});
if (updatedCount > 0) {
// Update summary calculations
updateSummary();
if (typeof updateAllSparklines === "function") {
updateAllSparklines();
}
if (typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
return true;
}
return false;
};
/**
* Retrieves valid cached API response data from localStorage.
* Checks against the provider's specific cache duration before returning.
*
* @returns {Object|null} Cached response or null if expired/not found
*/
const loadApiCache = () => {
try {
const stored = localStorage.getItem(API_CACHE_KEY);
if (stored) {
const cache = JSON.parse(stored);
const now = new Date().getTime();
const duration = getCacheDurationMs(cache.provider);
if (cache.timestamp && now - cache.timestamp < duration) {
return cache;
} else {
// Cache expired, remove it
localStorage.removeItem(API_CACHE_KEY);
}
}
} catch (error) {
console.error("Error loading API cache:", error);
}
return null;
};
/**
* Persists API response data to the local browser cache.
* Uses provider-specific cache duration settings.
*
* @param {Object} data - Standardized price data object
* @param {string} provider - Key of the data provider
*/
const saveApiCache = (data, provider) => {
try {
const duration = getCacheDurationMs(provider);
if (duration === 0) {
localStorage.removeItem(API_CACHE_KEY);
apiCache = null;
return;
}
const cacheObject = {
timestamp: new Date().getTime(),
data: data,
provider,
};
localStorage.setItem(API_CACHE_KEY, JSON.stringify(cacheObject));
apiCache = cacheObject;
} catch (error) {
console.error("Error saving API cache:", error);
}
};
/**
* Triggers an automatic background spot price synchronization.
* Only runs if API keys are configured and local data is stale.
*
* @returns {Promise<void>} Resolves when background sync process ends
*/
const autoSyncSpotPrices = async () => {
const config = loadApiConfig();
const hasAnyKey = Object.values(config.keys || {}).some(k => k);
const hasKeylessProvider = Object.keys(API_PROVIDERS).some(p => !providerRequiresKey(p));
if (!hasAnyKey && !hasKeylessProvider) return;
await syncProviderChain({ showProgress: false, forceSync: false });
updateSyncButtonStates();
};
/**
* Scans the spot history log to find the most recent successful sync for a provider.
*
* @param {string} provider - The unique key of the API provider
* @returns {number|null} Millisecond timestamp of last sync, or null
*/
const getLastProviderSyncTime = (provider) => {
try {
const providerName = API_PROVIDERS[provider]?.name;
if (!providerName || !spotHistory || !spotHistory.length) return null;
// Find most recent API entry from this provider
for (let i = spotHistory.length - 1; i >= 0; i--) {
const entry = spotHistory[i];
if ((entry.source === "api" || entry.source === "api-hourly") && entry.provider === providerName) {
// Parse timestamp string "YYYY-MM-DD HH:MM:SS" to ms
const ts = new Date(entry.timestamp).getTime();
if (!isNaN(ts)) return ts;
}
}
} catch (e) {
console.warn("Error checking provider sync time:", e);
}
return null;
};
/**
* Calculates the expected API usage (call count) for a given sync operation.
* Accounts for batch support and historical data backfill.
*
* @param {string[]} selectedMetals - Array of metal keys to fetch
* @param {number} [historyDays=0] - Number of days of history to include
* @param {boolean} [batchSupported=false] - Whether the provider supports batch calls
* @returns {Object} Usage breakdown including calls, type, and potential savings
*/
const calculateApiUsage = (selectedMetals, historyDays = 0, batchSupported = false) => {
if (batchSupported && selectedMetals.length > 1) {
return {
calls: 1,
type: 'batch',
metals: selectedMetals.length,
days: historyDays,
saved: selectedMetals.length - 1 + (historyDays > 0 ? selectedMetals.length * historyDays : 0)
};
} else {
const currentPriceCalls = selectedMetals.length;
const historicalCalls = historyDays > 0 ? selectedMetals.length * historyDays : 0;
return {
calls: currentPriceCalls + historicalCalls,
type: 'individual',
metals: selectedMetals.length,
days: historyDays,
saved: 0
};
}
};
/**
* Fetches the most recent spot prices for selected metals using individual endpoints.
* Optimized for low-cost, real-time updates without full history backfill.
*
* @param {string} provider - The unique key of the API provider
* @param {string} apiKey - The API key for the provider
* @param {string[]} selectedMetals - Array of metal keys to fetch
* @returns {Promise<Object<string, number>>} Map of metal keys to spot prices
*/
const fetchLatestPrices = async (provider, apiKey, selectedMetals) => {
const providerConfig = API_PROVIDERS[provider];
if (!providerConfig) throw new Error("Invalid API provider");
const config = loadApiConfig();
const usage = config.usage?.[provider] || { quota: DEFAULT_API_QUOTA, used: 0 };
const results = {};
// metals.dev supports a batch /latest endpoint returning all metals in one call
if (provider === "METALS_DEV" && providerConfig.latestBatchEndpoint) {
try {
const url = providerConfig.baseUrl + providerConfig.latestBatchEndpoint.replace("{API_KEY}", apiKey);
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
// Safe: URL constructed from hardcoded API_PROVIDERS config (latestBatchEndpoint)
const response = await fetch(url, { method: "GET", headers, mode: "cors" });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
usage.used++;
const parsed = providerConfig.parseLatestBatchResponse(data);
selectedMetals.forEach((metal) => {
if (parsed[metal] && parsed[metal] > 0) results[metal] = parsed[metal];
});
} catch (err) {
console.warn("Batch latest failed for METALS_DEV, falling back to individual:", err.message);
// Fall through to individual requests below
}
}
// Individual requests for remaining metals (or all metals for non-batch providers)
if (Object.keys(results).length < selectedMetals.length) {
const remaining = selectedMetals.filter((m) => !results[m]);
if (provider === "CUSTOM") {
const custom = config.customConfig || {};
const base = custom.baseUrl || "";
const pattern = custom.endpoint || "";
const format = custom.format || "symbol";
// Validate custom API base URL before use
try {
const validated = new URL(base);
if (validated.protocol !== 'https:') {
throw new Error('Custom API base must use HTTPS');
}
} catch (urlErr) {
console.warn('Invalid custom API base URL:', base, urlErr.message);
return results;
}
const metalCodes = {
silver: format === "symbol" ? "XAG" : "silver",
gold: format === "symbol" ? "XAU" : "gold",
platinum: format === "symbol" ? "XPT" : "platinum",
palladium: format === "symbol" ? "XPD" : "palladium",
};
for (const metal of remaining) {
try {
const endpoint = pattern.replace("{API_KEY}", apiKey).replace("{METAL}", metalCodes[metal]);
const url = `${base}${endpoint}`;
const response = await fetch(url, { method: "GET", headers: { "Content-Type": "application/json" }, mode: "cors" });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
usage.used++;
const price = providerConfig.parseResponse(data, metal);
if (price && price > 0) results[metal] = price;
} catch (err) {
console.warn(`Latest fetch failed for ${metal}:`, err.message);
}
}
} else {
for (const metal of remaining) {
const endpoint = providerConfig.endpoints[metal];
if (!endpoint) continue;
try {
// Safe: URL constructed from hardcoded API_PROVIDERS config (baseUrl + endpoints)
const url = `${providerConfig.baseUrl}${endpoint.replace("{API_KEY}", apiKey)}`;
const headers = { "Content-Type": "application/json" };
if (provider === "METALS_DEV" && apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const response = await fetch(url, { method: "GET", headers, mode: "cors" });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
usage.used++;
const price = providerConfig.parseResponse(data, metal);
if (price && price > 0) results[metal] = price;
} catch (err) {
console.warn(`Latest fetch failed for ${metal}:`, err.message);
}
}
}
}
if (Object.keys(results).length === 0) {
throw new Error("No valid prices retrieved from latest endpoints");
}
config.usage[provider] = usage;
saveApiConfig(config);
return results;
};
/**
* Executes a batch API request to retrieve spot prices for multiple metals simultaneously.
* Supports historical data range requests if provided by the underlying API.
*
* @param {string} provider - The unique key of the API provider
* @param {string} apiKey - The API key for the provider
* @param {string[]} selectedMetals - Array of metal keys to fetch
* @param {number} [historyDays=0] - Number of days of history to include
* @param {string[]} [historyTimes=[]] - Array of HH:MM times for granular history
* @returns {Promise<Object<string, number>>} Map of metal keys to spot prices
*/
const fetchBatchSpotPrices = async (provider, apiKey, selectedMetals, historyDays = 0, historyTimes = []) => {
const providerConfig = API_PROVIDERS[provider];
if (!providerConfig || !providerConfig.batchSupported) {
throw new Error("Provider does not support batch requests");
}
if (provider === "METALS_DEV" && historyDays > 30) historyDays = 30;
const config = loadApiConfig();
const usage = config.usage?.[provider] || { quota: DEFAULT_API_QUOTA, used: 0 };
try {
let url = providerConfig.baseUrl + providerConfig.batchEndpoint;
// Replace placeholders based on provider specifics
if (provider === 'METALS_DEV') {
url = url.replace('{API_KEY}', apiKey);
} else if (provider === 'METALS_API') {
const symbolMap = { silver: 'XAG', gold: 'XAU', platinum: 'XPT', palladium: 'XPD' };
const symbols = selectedMetals.map(metal => symbolMap[metal]).join(',');
url = url.replace('{API_KEY}', apiKey)
.replace('{SYMBOLS}', symbols);
} else if (provider === 'METAL_PRICE_API') {
const symbolMap = { silver: 'XAG', gold: 'XAU', platinum: 'XPT', palladium: 'XPD' };
const currencies = selectedMetals.map(metal => symbolMap[metal]).join(',');
url = url.replace('{API_KEY}', apiKey)
.replace('{CURRENCIES}', currencies);
}
// Compute start/end dates for timeseries endpoints (all providers)
if (url.includes('{START_DATE}') || url.includes('{END_DATE}')) {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - (historyDays || 29));
const fmt = (d) => d.toISOString().slice(0, 10);
url = url.replace('{START_DATE}', fmt(start))
.replace('{END_DATE}', fmt(end));
}
// Apply historical parameters if supported
if (url.includes('{DAYS}')) {
url = url.replace('{DAYS}', historyDays);
if (Array.isArray(historyTimes) && historyTimes.length) {
const timesParam = historyTimes.map(t => encodeURIComponent(t)).join(',');
if (url.includes('{TIMES}')) {
url = url.replace('{TIMES}', timesParam);
} else {
url += `×=${timesParam}`;
}
}
}
const headers = {
"Content-Type": "application/json",
};
if (provider === "METALS_DEV" && apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
const response = await fetch(url, {
method: "GET",
headers: headers,
mode: "cors",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
usage.used++; // Only increment by 1 for batch request
const { current = {}, history = {} } =
providerConfig.parseBatchResponse(data) || {};
// Filter results to only include selected metals
const filteredResults = {};
selectedMetals.forEach((metal) => {
if (current[metal] && current[metal] > 0) {
filteredResults[metal] = current[metal];
}
});
if (Object.keys(filteredResults).length === 0) {
throw new Error("No valid prices retrieved from batch request");
}
// Record historical data if provided
const providerName = providerConfig.name;
Object.entries(history).forEach(([metal, entries]) => {
const metalConfig = Object.values(METALS).find((m) => m.key === metal);
const metalName = metalConfig?.name || metal;
entries.forEach(({ timestamp, price }) => {
recordSpot(price, "api", metalName, providerName, timestamp);
});
});
if (Object.keys(history).length) {
renderApiHistoryTable();
}
// Update usage
config.usage[provider] = usage;
saveApiConfig(config);
return filteredResults;
} catch (error) {
throw new Error(`Batch request failed: ${error.message}`);
}
};
/**
* Standard interface for fetching spot prices from any supported API provider.
* Automatically chooses between individual latest endpoints or batch calls.
*
* @param {string} provider - The unique key of the API provider
* @param {string} apiKey - The API key for the provider
* @returns {Promise<Object<string, number>>} Map of metal keys to spot prices
*/
const fetchSpotPricesFromApi = async (provider, apiKey) => {
const providerConfig = API_PROVIDERS[provider];
if (!providerConfig) {
throw new Error("Invalid API provider");
}
const config = loadApiConfig();
const selected = config.metals?.[provider] || {};
// Get selected metals
const selectedMetals = Object.keys(selected).filter(
(metal) => selected[metal] !== false,
);
if (selectedMetals.length === 0) {
throw new Error("No metals selected for sync");
}
// StakTrakr uses its own hourly JSON fetch instead of generic provider logic
if (provider === 'STAKTRAKR') {
return await fetchStaktrakrPrices(selectedMetals);
}
// Latest-only: no history backfill on regular sync
return await fetchLatestPrices(provider, apiKey, selectedMetals);
};
// =============================================================================
// BATCHED HISTORY PULL
// =============================================================================
/**
* Splits a requested historical time range into smaller date chunks.
* Ensures each request stays within the provider's maximum allowed days per call.
*
* @param {number} totalDays - Total number of days to fetch
* @param {number} maxPerRequest - Maximum days allowed per API request
* @returns {Array<{start: Date, end: Date}>} Array of date range objects, newest first
*/
const getDateChunks = (totalDays, maxPerRequest) => {
const chunks = [];
const today = new Date();
let remaining = totalDays;
let endDate = new Date(today);
while (remaining > 0) {
const chunkSize = Math.min(remaining, maxPerRequest);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - chunkSize);
chunks.push({ start: new Date(startDate), end: new Date(endDate) });
endDate = new Date(startDate);
remaining -= chunkSize;
}
return chunks;
};
/**
* Orchestrates a series of batched API requests to backfill historical spot price data.
* Automates the chunking process and parses results for multiple metals.
*
* @param {string} provider - The unique key of the API provider
* @param {string} apiKey - The API key for the provider
* @param {string[]} selectedMetals - Array of metal keys to fetch
* @param {number} totalDays - Total number of days of history to pull
* @returns {Promise<{totalEntries: number, callsMade: number}>} Summary of the batch operation
*/
const fetchHistoryBatched = async (provider, apiKey, selectedMetals, totalDays) => {
const providerConfig = API_PROVIDERS[provider];
if (!providerConfig || !providerConfig.batchSupported) {
throw new Error("Provider does not support history requests");
}
const maxPerReq = providerConfig.maxHistoryDays || 30;
const chunks = getDateChunks(totalDays, maxPerReq);
const config = loadApiConfig();
const usage = config.usage?.[provider] || { quota: DEFAULT_API_QUOTA, used: 0 };
const providerName = providerConfig.name;
const fmt = (d) => d.toISOString().slice(0, 10);
// Build symbol groups based on provider capability
let symbolGroups;
if (providerConfig.symbolsPerRequest === 1) {
// One metal per request (e.g., metals-api)
symbolGroups = selectedMetals.map((m) => [m]);
} else {
// All metals in one request
symbolGroups = [selectedMetals];
}
let totalEntries = 0;
let callsMade = 0;
for (const chunk of chunks) {
for (const metals of symbolGroups) {
let url = providerConfig.baseUrl + providerConfig.batchEndpoint;
// Replace API key and currency placeholders
url = url.replace("{API_KEY}", apiKey);
// Replace date placeholders
url = url.replace("{START_DATE}", fmt(chunk.start)).replace("{END_DATE}", fmt(chunk.end));
// Replace symbol/currency placeholders
if (provider === "METALS_API") {
const symbolMap = { silver: "XAG", gold: "XAU", platinum: "XPT", palladium: "XPD" };
const symbols = metals.map((m) => symbolMap[m]).join(",");
url = url.replace("{SYMBOLS}", symbols);
} else if (provider === "METAL_PRICE_API") {
const symbolMap = { silver: "XAG", gold: "XAU", platinum: "XPT", palladium: "XPD" };
const currencies = metals.map((m) => symbolMap[m]).join(",");
url = url.replace("{CURRENCIES}", currencies);
}
const headers = { "Content-Type": "application/json" };
if (provider === "METALS_DEV" && apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
try {
// Safe: URL constructed from hardcoded API_PROVIDERS config (baseUrl + batchEndpoint + templated dates/metals)
const response = await fetch(url, { method: "GET", headers, mode: "cors" });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
callsMade++;
usage.used++;
const { history = {} } = providerConfig.parseBatchResponse(data) || {};
Object.entries(history).forEach(([metal, entries]) => {
if (!selectedMetals.includes(metal)) return;
const metalConfig = Object.values(METALS).find((m) => m.key === metal);
const metalName = metalConfig?.name || metal;
entries.forEach(({ timestamp, price }) => {
recordSpot(price, "api", metalName, providerName, timestamp);
totalEntries++;
});
});
} catch (err) {
console.error(`History batch failed (${fmt(chunk.start)}..${fmt(chunk.end)}):`, err.message);
}
}
}
// Save updated usage
config.usage[provider] = usage;
saveApiConfig(config);
return { totalEntries, callsMade };
};
/**
* Specialized history fetcher for MetalPriceAPI's hourly endpoint.
* Requests granular hourly data for a specific date range.
*
* @param {string} apiKey - The API key for MetalPriceAPI
* @param {string[]} selectedMetals - Array of metal keys to fetch
* @param {number} totalDays - Number of days of history to pull
* @returns {Promise<{totalEntries: number, callsMade: number}>} Summary of the operation
*/
const fetchMetalPriceApiHourly = async (apiKey, selectedMetals, totalDays) => {
const baseUrl = API_PROVIDERS.METAL_PRICE_API.baseUrl;
const symbolMap = { silver: 'XAG', gold: 'XAU', platinum: 'XPT', palladium: 'XPD' };
const config = loadApiConfig();
const usage = config.usage?.METAL_PRICE_API || { quota: DEFAULT_API_QUOTA, used: 0 };
const providerName = API_PROVIDERS.METAL_PRICE_API.name;
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - totalDays);
const fmt = (d) => d.toISOString().slice(0, 10);
// Purge once, then build dedup set for batch append (avoids N×save)
purgeSpotHistory();
const existingKeys = new Set(
spotHistory.map(e => `${e.timestamp}|${e.metal}`)
);
let totalEntries = 0;
let callsMade = 0;
for (const metal of selectedMetals) {
const currency = symbolMap[metal];
if (!currency) continue;
const url = (baseUrl + API_PROVIDERS.METAL_PRICE_API.hourlyEndpoint)
.replace("{API_KEY}", encodeURIComponent(apiKey))
.replace("{CURRENCY}", currency)
.replace("{START_DATE}", fmt(start))
.replace("{END_DATE}", fmt(end));
try {
const resp = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' }, mode: 'cors' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
callsMade++;
usage.used++;
const metalConfig = Object.values(METALS).find(m => m.key === metal);
const metalName = metalConfig?.name || metal;
(data.rates || []).forEach(entry => {
const ts = new Date(entry.timestamp * 1000);
const entryTimestamp = ts.toISOString().replace('T', ' ').slice(0, 19);
const rate = entry.rates?.[currency];
if (!Number.isFinite(rate) || rate === 0) return;
const price = 1 / rate;
const key = `${entryTimestamp}|${metalName}`;
if (!existingKeys.has(key)) {
spotHistory.push({
spot: price, metal: metalName, source: 'api-hourly',
provider: providerName, timestamp: entryTimestamp,
});
existingKeys.add(key);
totalEntries++;
}
});
} catch (err) {
console.warn(`Hourly fetch failed for ${metal}:`, err.message);
}
}
if (totalEntries > 0) {
saveSpotHistory();
}
config.usage.METAL_PRICE_API = usage;
saveApiConfig(config);
return { totalEntries, callsMade };
};
/**
* UI entry point for initiating a historical data pull for a provider.
* Validates requirements, shows cost/quota confirmation, and executes pull.
*
* @param {string} provider - The unique key of the API provider
*/
const handleHistoryPull = async (provider) => {
// STAKTRAKR has its own hourly pull logic (no API key needed)
if (provider === 'STAKTRAKR') {
return handleStaktrakrHistoryPull();
}
const config = loadApiConfig();
const apiKey = config.keys?.[provider];
if (!apiKey) {
alert("No API key configured for this provider. Please save your key first.");
return;
}
const providerConfig = API_PROVIDERS[provider];
if (!providerConfig || !providerConfig.batchSupported) {
alert("This provider does not support history pulls.");
return;
}
const selected = config.metals?.[provider] || {};
const selectedMetals = Object.keys(selected).filter((m) => selected[m] !== false);
if (selectedMetals.length === 0) {
alert("No metals selected. Please select at least one metal to track.");
return;
}
const daysSelect = document.getElementById(`historyPullDays_${provider}`);
let totalDays = daysSelect ? parseInt(daysSelect.value, 10) : 30;
// Check for hourly mode (MetalPriceAPI)
const hourlyToggle = document.getElementById(`hourlyPull_${provider}`);
const isHourly = hourlyToggle && hourlyToggle.checked;
if (isHourly) {
const maxHourly = providerConfig.maxHourlyDays || 7;
totalDays = Math.min(totalDays, maxHourly);
}
// Calculate cost — one request per metal for hourly, chunked batches for daily
const totalCalls = isHourly
? selectedMetals.length
: Math.ceil(totalDays / (providerConfig.maxHistoryDays || 30))
* (providerConfig.symbolsPerRequest === 1 ? selectedMetals.length : 1);
const usage = config.usage?.[provider] || { quota: DEFAULT_API_QUOTA, used: 0 };
const remaining = Math.max(0, usage.quota - usage.used);
const modeLabel = isHourly ? "hourly" : "daily";
const proceed = confirm(
`Pull ${totalDays} days of ${modeLabel} history from ${providerConfig.name}.\n\n` +
`This will use ${totalCalls} API call${totalCalls > 1 ? "s" : ""} ` +
`(${remaining} remaining this month).\n\nProceed?`
);
if (!proceed) return;
// Disable button during pull
const btn = document.querySelector(`.api-history-btn[data-provider="${provider}"]`);
const origText = btn ? btn.textContent : "";
if (btn) { btn.textContent = "Pulling..."; btn.disabled = true; }
try {
let result;
if (isHourly && provider === 'METAL_PRICE_API') {
result = await fetchMetalPriceApiHourly(apiKey, selectedMetals, totalDays);
} else {
result = await fetchHistoryBatched(provider, apiKey, selectedMetals, totalDays);
}
alert(
`History pull complete!\n\n` +
`Pulled ${result.totalEntries} data points using ${result.callsMade} API call${result.callsMade > 1 ? "s" : ""}.`
);
updateProviderHistoryTables();
if (typeof updateAllSparklines === "function") updateAllSparklines();
} catch (err) {
console.error("History pull failed:", err);
alert("History pull failed: " + err.message);
} finally {
if (btn) { btn.textContent = origText; btn.disabled = false; }
}
};
/**
* Initiates the spot price synchronization process across all configured providers.
* Handles user interaction, cache validation prompts, and UI status updates.
*
* @param {boolean} [showProgress=true] - Whether to display alerts and progress UI
* @param {boolean} [forceSync=false] - If true, ignores the local cache and forces API calls
* @returns {Promise<boolean>} True if at least one provider successfully synced prices
*/
const syncSpotPricesFromApi = async (
showProgress = true,
forceSync = false,
) => {
const config = loadApiConfig();
const hasAnyKey = Object.values(config.keys || {}).some(k => k);
const hasKeylessProvider = Object.keys(API_PROVIDERS).some(p => !providerRequiresKey(p));
if (!hasAnyKey && !hasKeylessProvider) {
if (showProgress) {
alert(
"No Metals API configuration found. Please configure an API provider first.",
);
}
return false;
}
// Interactive cache prompt (only when user-initiated with visible UI)
if (showProgress && !forceSync) {
const cache = loadApiCache();
if (cache && cache.data && cache.timestamp) {
const now = Date.now();
const cacheAge = now - cache.timestamp;
const duration = getCacheDurationMs(apiConfig?.provider || config.provider);
if (cacheAge < duration) {
const hoursAgo = Math.floor(cacheAge / (1000 * 60 * 60));
const minutesAgo = Math.floor(cacheAge / (1000 * 60));
const timeText =
hoursAgo > 0
? `${hoursAgo} hours ago`
: `${minutesAgo} minutes ago`;
const override = confirm(
`Cached prices from ${timeText}.\n\nFetch fresh prices from the API?`,
);
if (!override) {
return refreshFromCache();
}
}
}
}
// Delegate to provider chain
const { updatedCount, anySucceeded, results } = await syncProviderChain({
showProgress,
forceSync: forceSync || showProgress, // User-initiated always forces
});
if (showProgress && updatedCount > 0) {
const summary = Object.entries(results)
.filter(([_, status]) => status !== "skipped")
.map(([prov, status]) => `${API_PROVIDERS[prov]?.name || prov}: ${status}`)
.join("\n");
alert(`Synced ${updatedCount} prices.\n\n${summary}`);
} else if (showProgress && !anySucceeded) {
alert("Failed to sync prices from any provider.");
}
return anySucceeded;
};
/**
* Validates an API provider's connectivity by making a lightweight test request.
* Usually attempts to fetch a single metal's price (e.g., silver) to verify the key.
*
* @param {string} provider - The unique key of the API provider
* @param {string} apiKey - The API key to be tested
* @returns {Promise<boolean>} True if the connection test was successful
*/
const testApiConnection = async (provider, apiKey) => {
try {
// Just test one metal (silver) to verify connection
const providerConfig = API_PROVIDERS[provider];
if (!providerConfig) {
throw new Error("Invalid provider");
}
if (provider === 'STAKTRAKR') {
const result = await fetchStaktrakrPrices(['silver']);
return result.silver > 0;
}
let url = "";
const headers = {
"Content-Type": "application/json",
};
if (provider === "CUSTOM") {
const config = loadApiConfig();
const custom = config.customConfig || {};
const metal = custom.format === "word" ? "silver" : "XAG";
url = `${custom.baseUrl || ""}${(custom.endpoint || "")
.replace("{API_KEY}", apiKey)
.replace("{METAL}", metal)}`;
} else {
const endpoint = providerConfig.endpoints.silver;
url = `${providerConfig.baseUrl}${endpoint.replace("{API_KEY}", apiKey)}`;
if (provider === "METALS_DEV" && apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
}
const response = await fetch(url, {
method: "GET",
headers: headers,
mode: "cors",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const price = providerConfig.parseResponse(data, "silver");
return price && price > 0;
} catch (error) {
console.error("API connection test failed:", error);
return false;
}
};
/**
* Handles the UI-triggered synchronization of a single specific provider.
* Useful for per-provider settings cards and troubleshooting.
*
* @param {string} provider - The unique key of the API provider
* @returns {Promise<void>} Resolves when the provider sync attempt completes
*/
const handleProviderSync = async (provider) => {
let apiKey = '';
if (providerRequiresKey(provider)) {
const keyInput = document.getElementById(`apiKey_${provider}`);
if (!keyInput) return;
apiKey = keyInput.value.trim();
if (!apiKey) {
alert("Please enter your API key");
return;
}
}
const config = loadApiConfig();
// Ensure keys object exists and clone to avoid mutating shared references
config.keys = { ...(config.keys || {}) };
if (apiKey) config.keys[provider] = apiKey;
if (provider === "CUSTOM") {
const base = document.getElementById("apiBase_CUSTOM")?.value.trim() || "";
const endpoint =
document.getElementById("apiEndpoint_CUSTOM")?.value.trim() || "";
const format =
document.getElementById("apiFormat_CUSTOM")?.value || "symbol";
if (!base || !endpoint) {
alert("Please enter base URL and endpoint");
return;
}
config.customConfig = { baseUrl: base, endpoint, format };
}
config.timestamp = new Date().getTime();
saveApiConfig(config);
updateDefaultProviderButtons();
updateSyncButtonStates();
setProviderStatus(provider, "disconnected");
// Test connection
const ok = await testApiConnection(provider, apiKey);
if (!ok) {
alert("API connection test failed.");
setProviderStatus(provider, "error");
return;
}
try {
const data = await fetchSpotPricesFromApi(provider, apiKey);
let updatedCount = 0;
Object.entries(data).forEach(([metal, price]) => {
const metalConfig = Object.values(METALS).find((m) => m.key === metal);
if (metalConfig && price > 0) {
localStorage.setItem(metalConfig.spotKey, price.toString());
spotPrices[metal] = price;
elements.spotPriceDisplay[metal].textContent = formatCurrency(price);
updateSpotCardColor(metal, price);
recordSpot(
price,
"api",
metalConfig.name,
API_PROVIDERS[provider].name,
);
const ts = document.getElementById(`spotTimestamp${metalConfig.name}`);
if (ts) {
updateSpotTimestamp(metalConfig.name);
}
updatedCount++;
}
});
if (updatedCount > 0) {
saveApiCache(data, provider);
updateSummary();
// Update Goldback denomination prices BEFORE snapshotting item prices (STAK-108)
if (typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
if (typeof recordAllItemPriceSnapshots === 'function') recordAllItemPriceSnapshots();
if (typeof updateAllSparklines === "function") {
updateAllSparklines();
}
setProviderStatus(provider, "connected");
updateProviderHistoryTables();
alert(
`Successfully synced ${updatedCount} metal prices from ${API_PROVIDERS[provider].name}`,
);
} else {
setProviderStatus(provider, "error");
alert("No valid prices retrieved from API");
}
} catch (error) {
console.error("API sync error:", error);
setProviderStatus(provider, "error");
alert("Failed to sync prices: " + error.message);
}
};
/**
* Triggers a background sync across all providers.
*
* @returns {Promise<number>} Total number of prices updated
*/
const syncAllProviders = async () => {
const { updatedCount } = await syncProviderChain({ showProgress: false, forceSync: true });
updateProviderHistoryTables();
return updatedCount;
};
/**
* Core engine that iterates through configured API providers in priority order.
* Respects sync modes ('always' vs 'backup') and handles cascading failover.
*
* @param {Object} options
* @param {boolean} [options.showProgress=false] - If true, updates sync button UI states
* @param {boolean} [options.forceSync=false] - If true, ignores provider-specific cache durations
* @returns {Promise<{results: Object, updatedCount: number, anySucceeded: boolean}>} Sync operation summary
*/
const syncProviderChain = async ({ showProgress = false, forceSync = false } = {}) => {
const config = loadApiConfig();
const order = getProviderOrder();
const results = {};
let updatedCount = 0;
let anySucceeded = false;
if (showProgress) {
updateSyncButtonStates(true);
}
// Load priorities once outside loop (STACK-90)
const priorities = typeof loadProviderPriorities === 'function'
? loadProviderPriorities() : {};
try {
for (const prov of order) {
const apiKey = config.keys?.[prov];
if (!apiKey && providerRequiresKey(prov)) continue;
// Priority-based sync: priority > 1 are backups, skip if primary succeeded (STACK-90)
if (priorities[prov] === 0) { results[prov] = "disabled"; continue; }
if (priorities[prov] > 1 && anySucceeded) {
results[prov] = "skipped";
continue;
}
// Check per-provider cache unless forcing
if (!forceSync) {
const provDuration = getCacheDurationMs(prov);
const lastSync = getLastProviderSyncTime(prov);
if (lastSync && Date.now() - lastSync < provDuration) {
results[prov] = "cached";
anySucceeded = true;
continue;
}
}
// Attempt fetch
try {
const data = await fetchSpotPricesFromApi(prov, apiKey);
let provUpdated = 0;
Object.entries(data).forEach(([metal, price]) => {
const metalConfig = Object.values(METALS).find((m) => m.key === metal);
if (metalConfig && price > 0) {
localStorage.setItem(metalConfig.spotKey, price.toString());
spotPrices[metal] = price;
elements.spotPriceDisplay[metal].textContent = formatCurrency(price);
updateSpotCardColor(metal, price);
recordSpot(price, "api", metalConfig.name, API_PROVIDERS[prov].name);
const ts = document.getElementById(`spotTimestamp${metalConfig.name}`);
if (ts) updateSpotTimestamp(metalConfig.name);
provUpdated++;
}
});
if (provUpdated > 0) {
saveApiCache(data, prov);
updatedCount += provUpdated;
anySucceeded = true;
results[prov] = "success";
setProviderStatus(prov, "connected");
} else {
results[prov] = "no data";
setProviderStatus(prov, "error");
}
} catch (err) {
console.warn(`Chain sync failed for ${prov}:`, err.message);
results[prov] = "error";
setProviderStatus(prov, "error");
}
}
// Post-sync updates if anything changed
if (updatedCount > 0) {
// Refresh exchange rates alongside spot prices (STACK-50)
if (typeof fetchExchangeRates === 'function') {
fetchExchangeRates().catch(() => {});
}
updateSummary();
// Update Goldback denomination prices BEFORE snapshotting item prices,
// so the retail hierarchy reflects the new gold spot (STAK-108)
if (typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
if (typeof recordAllItemPriceSnapshots === 'function') recordAllItemPriceSnapshots();
if (typeof updateStorageStats === "function") updateStorageStats();
// Backfill hourly data when StakTrakr is rank 1 and sync was fresh
if (results.STAKTRAKR === "success" && priorities.STAKTRAKR === 1) {
try { await backfillStaktrakrHourly(); }
catch (err) { console.warn("Hourly backfill failed:", err.message); }
}
if (typeof updateAllSparklines === "function") updateAllSparklines();
}
} finally {
if (showProgress) {
updateSyncButtonStates(false);
}
}
return { results, updatedCount, anySucceeded };
};
/**
* Updates sync button states based on API availability
* @param {boolean} syncing - Whether sync is in progress
*/
const updateSyncButtonStates = (syncing = false) => {
const hasApi =
apiConfig && apiConfig.provider &&
(apiConfig.keys[apiConfig.provider] || !providerRequiresKey(apiConfig.provider));
Object.values(METALS).forEach((metalConfig) => {
// New sparkline card sync icon
const syncIcon = document.getElementById(`syncIcon${metalConfig.name}`);
if (syncIcon) {
syncIcon.disabled = !hasApi || syncing;
syncIcon.title = hasApi
? syncing
? "Syncing..."
: "Sync from API"
: "Configure API first";
if (syncing) {
syncIcon.classList.add("syncing");
} else {
syncIcon.classList.remove("syncing");
}
}
});
};
/**
* Updates API status display in modal
*/
/**
* Populates the API section fields with current configuration.
* Called when switching to the API section in the Settings modal.
*/
const populateApiSection = () => {
let currentConfig = loadApiConfig() || {
provider: "",
keys: {},
cacheHours: 24,
customConfig: { baseUrl: "", endpoint: "", format: "symbol" },
};
if (!currentConfig.provider) {
currentConfig.provider = Object.keys(API_PROVIDERS)[0];
saveApiConfig(currentConfig);
}
// Populate Numista tab
if (typeof catalogConfig !== "undefined" && catalogConfig.getNumistaConfig) {
const nc = catalogConfig.getNumistaConfig();
const numistaKeyInput = document.getElementById("numistaApiKey");
if (numistaKeyInput) numistaKeyInput.value = nc.apiKey || "";
if (typeof renderNumistaUsageBar === "function") renderNumistaUsageBar();
// Update Numista status indicator
const numistaStatusEl = document.getElementById("numistaProviderStatus");
if (numistaStatusEl) {
numistaStatusEl.classList.remove("status-connected", "status-disconnected");
if (nc.apiKey) {
numistaStatusEl.classList.add("status-connected");
const dot = numistaStatusEl.querySelector(".status-dot");
const text = numistaStatusEl.querySelector(".status-text");
if (dot) dot.style.background = "";
if (text) text.textContent = "Connected";
} else {
numistaStatusEl.classList.add("status-disconnected");
const text = numistaStatusEl.querySelector(".status-text");
if (text) text.textContent = "Disconnected";
}
}
}
// Populate PCGS tab status
if (typeof catalogConfig !== "undefined" && catalogConfig.isPcgsEnabled) {
const pcgsStatusEl = document.getElementById("pcgsProviderStatus");
if (pcgsStatusEl) {
pcgsStatusEl.classList.remove("status-connected", "status-disconnected");
if (catalogConfig.isPcgsEnabled()) {
pcgsStatusEl.classList.add("status-connected");
const dot = pcgsStatusEl.querySelector(".status-dot");
const text = pcgsStatusEl.querySelector(".status-text");
if (dot) dot.style.background = "";
if (text) text.textContent = "Connected";
} else {
pcgsStatusEl.classList.add("status-disconnected");
const text = pcgsStatusEl.querySelector(".status-text");
if (text) text.textContent = "Disconnected";
}
}
}
// Populate metals provider tabs
Object.keys(API_PROVIDERS).forEach((prov) => {
const input = document.getElementById(`apiKey_${prov}`);
if (input) input.value = currentConfig.keys?.[prov] || "";
setProviderStatus(prov, providerStatuses[prov] || "disconnected");
});
renderApiStatusSummary();
const baseInput = document.getElementById("apiBase_CUSTOM");
if (baseInput) baseInput.value = currentConfig.customConfig?.baseUrl || "";
const endpointInput = document.getElementById("apiEndpoint_CUSTOM");
if (endpointInput)
endpointInput.value = currentConfig.customConfig?.endpoint || "";
const formatSelect = document.getElementById("apiFormat_CUSTOM");
if (formatSelect)
formatSelect.value = currentConfig.customConfig?.format || "symbol";
autoSelectDefaultProvider();
updateProviderHistoryTables();
// Initialize provider settings listeners and load saved values
const cfg = loadApiConfig();
Object.keys(API_PROVIDERS).forEach(provider => {
if (typeof setupProviderSettingsListeners === 'function') {
setupProviderSettingsListeners(provider);
}
// Load saved cache timeout
const cacheSelect = document.getElementById(`cacheTimeout_${provider}`);
if (cacheSelect) {
cacheSelect.value = cfg.cacheTimeouts?.[provider] ?? 24;
}
// Initialize history pull cost indicator
if (typeof updateHistoryPullCost === 'function') {
updateHistoryPullCost(provider);
}
// Load saved metal selections
const metals = cfg.metals?.[provider] || {};
['silver', 'gold', 'platinum', 'palladium'].forEach(metal => {
const checkbox = document.querySelector(`.provider-metal[data-provider="${provider}"][data-metal="${metal}"]`);
if (checkbox) {
checkbox.checked = metals[metal] !== false;
}
});
});
// Restore provider priority UI (STACK-90)
if (typeof loadProviderPriorities === 'function' && typeof syncProviderPriorityUI === 'function') {
syncProviderPriorityUI(loadProviderPriorities());
}
if (typeof refreshProviderStatuses === 'function') {
refreshProviderStatuses();
}
// Wire up spot history export/import buttons
if (typeof initSpotHistoryButtons === 'function') {
initSpotHistoryButtons();
}
};
/**
* Legacy showApiModal — redirects to Settings modal API section
*/
const showApiModal = () => {
if (typeof showSettingsModal === "function") {
showSettingsModal('api');
}
};
/**
* Legacy hideApiModal — redirects to hideSettingsModal
*/
const hideApiModal = () => {
if (typeof hideSettingsModal === "function") {
hideSettingsModal();
}
};
/**
* Legacy showFilesModal — redirects to Settings modal Files section
*/
const showFilesModal = () => {
if (typeof showSettingsModal === "function") {
showSettingsModal('system');
}
};
/**
* Legacy hideFilesModal — redirects to hideSettingsModal
*/
const hideFilesModal = () => {
if (typeof hideSettingsModal === "function") {
hideSettingsModal();
} else {
try {
document.body.style.overflow = '';
} catch (e) {
console.warn('Failed to reset body overflow:', e);
}
}
};
/**
* Shows provider information modal
* @param {string} providerKey
*/
const showProviderInfo = (providerKey) => {
const modal = document.getElementById("apiInfoModal");
if (!modal || !API_PROVIDERS[providerKey]) return;
const provider = API_PROVIDERS[providerKey];
const title = document.getElementById("apiInfoTitle");
const body = document.getElementById("apiInfoBody");
if (title) title.textContent = "Provider Information";
if (body) {
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
body.innerHTML = `
<div class="info-provider-name">${provider.name}</div>
<div>Base URL: ${provider.baseUrl}</div>
<div>Metals: Silver, Gold, Platinum, Palladium</div>
<div class="api-key-info">
<div>📋 <strong>API Key Management:</strong></div>
<ul>
<li>Visit the documentation link below to manage your API key</li>
<li>You can view usage, reset, or regenerate your key there</li>
<li>Keep your API key secure and never share it publicly</li>
</ul>
</div>
<a class="btn info-docs-btn" href="${provider.documentation}" target="_blank" rel="noopener">
📄 ${provider.name} Documentation & Key Management
</a>
`;
}
modal.style.display = "flex";
};
/**
* Hides provider information modal
*/
const hideProviderInfo = () => {
const modal = document.getElementById("apiInfoModal");
if (modal) {
modal.style.display = "none";
}
};
// Make modal controls available globally
window.showApiModal = showApiModal;
window.hideApiModal = hideApiModal;
window.showFilesModal = showFilesModal;
window.hideFilesModal = hideFilesModal;
window.populateApiSection = populateApiSection;
window.showProviderInfo = showProviderInfo;
window.hideProviderInfo = hideProviderInfo;
/**
* Saves provider settings (key, cache timeout, history days) without testing or fetching
* @param {string} provider - Provider key
*/
const handleProviderSave = (provider) => {
const keyInput = document.getElementById(`apiKey_${provider}`);
if (!keyInput) return;
const apiKey = keyInput.value.trim();
const config = loadApiConfig();
config.keys = { ...(config.keys || {}) };
if (apiKey) {
config.keys[provider] = apiKey;
}
if (provider === "CUSTOM") {
const base = document.getElementById("apiBase_CUSTOM")?.value.trim() || "";
const endpoint = document.getElementById("apiEndpoint_CUSTOM")?.value.trim() || "";
const format = document.getElementById("apiFormat_CUSTOM")?.value || "symbol";
config.customConfig = { baseUrl: base, endpoint, format };
}
// Persist per-provider settings (cache timeout)
updateProviderSettings(provider);
// Re-load after updateProviderSettings saved, then layer key + CUSTOM config on top
const updated = loadApiConfig();
updated.keys = { ...(updated.keys || {}) };
if (apiKey) updated.keys[provider] = apiKey;
if (provider === "CUSTOM") {
updated.customConfig = {
baseUrl: document.getElementById("apiBase_CUSTOM")?.value.trim() || "",
endpoint: document.getElementById("apiEndpoint_CUSTOM")?.value.trim() || "",
format: document.getElementById("apiFormat_CUSTOM")?.value || "symbol",
};
}
saveApiConfig(updated);
updateDefaultProviderButtons();
updateSyncButtonStates();
// Brief visual confirmation via status indicator
const btn = document.querySelector(`.api-save-btn[data-provider="${provider}"]`);
if (btn) {
const origText = btn.textContent;
btn.textContent = "Saved!";
btn.disabled = true;
setTimeout(() => {
btn.textContent = origText;
btn.disabled = false;
}, 1200);
}
};
window.handleProviderSave = handleProviderSave;
window.handleProviderSync = handleProviderSync;
window.clearApiKey = clearApiKey;
window.clearApiCache = clearApiCache;
window.setDefaultProvider = setDefaultProvider;
window.showApiHistoryModal = showApiHistoryModal;
window.hideApiHistoryModal = hideApiHistoryModal;
window.clearApiHistory = clearApiHistory;
window.syncAllProviders = syncAllProviders;
window.syncProviderChain = syncProviderChain;
window.autoSyncSpotPrices = autoSyncSpotPrices;
window.handleHistoryPull = handleHistoryPull;
window.updateHistoryPullCost = updateHistoryPullCost;
window.fetchHistoryBatched = fetchHistoryBatched;
/**
* Shows manual price input for a specific metal
* @param {string} metal - Metal name (Silver, Gold, etc.)
*/
const showManualInput = (metal) => {
const manualInput = document.getElementById(`manualInput${metal}`);
if (manualInput) {
manualInput.style.display = "block";
// Focus the input field
const input = document.getElementById(`userSpotPrice${metal}`);
if (input) {
input.focus();
}
}
};
/**
* Hides manual price input for a specific metal
* @param {string} metal - Metal name (Silver, Gold, etc.)
*/
const hideManualInput = (metal) => {
const manualInput = document.getElementById(`manualInput${metal}`);
if (manualInput) {
manualInput.style.display = "none";
// Clear the input
const input = document.getElementById(`userSpotPrice${metal}`);
if (input) {
input.value = "";
}
}
};
/**
* Resets spot price to default or API cached value
* @param {string} metal - Metal name (Silver, Gold, etc.)
*/
const resetSpotPrice = (metal) => {
const metalConfig = Object.values(METALS).find((m) => m.name === metal);
if (!metalConfig) return;
let resetPrice = metalConfig.defaultPrice;
let source = "default";
let providerName = null;
// If we have cached API data, use that instead
if (apiCache && apiCache.data && apiCache.data[metalConfig.key]) {
resetPrice = apiCache.data[metalConfig.key];
source = "api";
providerName = API_PROVIDERS[apiCache.provider]?.name || null;
}
// Update price
localStorage.setItem(metalConfig.spotKey, resetPrice.toString());
spotPrices[metalConfig.key] = resetPrice;
// Update display
elements.spotPriceDisplay[metalConfig.key].textContent =
formatCurrency(resetPrice);
updateSpotCardColor(metalConfig.key, resetPrice);
// Record in history
recordSpot(resetPrice, source, metalConfig.name, providerName);
// Update summary
updateSummary();
// Hide manual input if shown
hideManualInput(metal);
};
/**
* Exports backup data including Metals API configuration
* @returns {Object} Complete backup data object
*/
const createBackupData = () => {
const backupData = {
version: APP_VERSION,
timestamp: new Date().toISOString(),
inventory: loadData(LS_KEY, []),
spotHistory: loadData(SPOT_HISTORY_KEY, []),
apiConfig:
apiConfig && apiConfig.provider
? {
provider: apiConfig.provider,
providerName: API_PROVIDERS[apiConfig.provider]?.name || "Unknown",
keyLength: apiConfig.keys[apiConfig.provider]
? apiConfig.keys[apiConfig.provider].length
: 0,
hasKey: !!apiConfig.keys[apiConfig.provider],
timestamp: apiConfig.timestamp,
}
: null,
spotPrices: { ...spotPrices },
};
return backupData;
};
/**
* Downloads complete backup files including inventory and Metals API configuration
*/
const downloadCompleteBackup = async () => {
try {
const timestamp = new Date()
.toISOString()
.slice(0, 19)
.replace(/[T:]/g, "-");
// 1. Create inventory CSV using existing export logic
const inventory = loadDataSync(LS_KEY, []);
if (inventory.length > 0) {
// Create CSV manually for backup instead of calling exportCsv()
const headers = [
"Metal",
"Name",
"Qty",
"Type",
"Weight(oz)",
"Purchase Price",
"Spot Price ($/oz)",
"Premium ($/oz)",
"Total Premium",
"Purchase Location",
"Storage Location",
"Notes",
"Date",
];
const sortedInventory = [...inventory].sort(
(a, b) => new Date(b.date) - new Date(a.date),
);
const rows = sortedInventory.map((item) => [
item.metal || "Silver",
item.name,
item.qty,
item.type,
parseFloat(item.weight).toFixed(4),
formatCurrency(item.price),
formatCurrency(item.spotPriceAtPurchase),
formatCurrency(item.premiumPerOz),
formatCurrency(item.totalPremium),
item.purchaseLocation,
item.storageLocation || "Unknown",
item.notes || "",
item.date,
]);
const inventoryCsv = Papa.unparse([headers, ...rows]);
downloadFile(
`inventory-backup-${timestamp}.csv`,
inventoryCsv,
"text/csv",
);
}
// 2. Create spot history CSV
const spotHistory = loadDataSync(SPOT_HISTORY_KEY, []);
if (spotHistory.length > 0) {
const historyData = [
["Timestamp", "Metal", "Price", "Source"],
...spotHistory.map((entry) => [
entry.timestamp,
entry.metal,
entry.spot,
entry.source,
]),
];
const historyCsv = Papa.unparse(historyData);
downloadFile(
`spot-price-history-${timestamp}.csv`,
historyCsv,
"text/csv",
);
}
// 3. Create complete JSON backup
const completeBackup = {
version: APP_VERSION,
timestamp: new Date().toISOString(),
data: {
inventory: inventory,
spotHistory: spotHistory,
spotPrices: { ...spotPrices },
apiConfig:
apiConfig && apiConfig.provider
? {
provider: apiConfig.provider,
providerName:
API_PROVIDERS[apiConfig.provider]?.name || "Unknown",
hasKey: !!apiConfig.keys[apiConfig.provider],
keyLength: apiConfig.keys[apiConfig.provider]
? apiConfig.keys[apiConfig.provider].length
: 0,
timestamp: apiConfig.timestamp,
}
: null,
},
};
const backupJson = JSON.stringify(completeBackup, null, 2);
downloadFile(
`complete-backup-${timestamp}.json`,
backupJson,
"application/json",
);
// 4. Create API documentation and restoration guide
const backupData = createBackupData();
const apiInfo = `# StakTrakr - Complete Backup
Generated: ${typeof formatTimestamp === 'function' ? formatTimestamp(new Date()) : new Date().toLocaleString()}
Application Version: ${APP_VERSION}
## Backup Contents
1. **inventory-backup-${timestamp}.csv** - Complete inventory data
2. **spot-price-history-${timestamp}.csv** - Historical spot price data
3. **complete-backup-${timestamp}.json** - Full application backup
4. **backup-info-${timestamp}.md** - This documentation file
## Metals API Configuration
${
backupData.apiConfig
? `
- Provider: ${backupData.apiConfig.providerName}
- Has API Key: ${backupData.apiConfig.hasKey}
- Key Length: ${backupData.apiConfig.keyLength} characters
- Configured: ${typeof formatTimestamp === 'function' ? formatTimestamp(backupData.apiConfig.timestamp) : new Date(backupData.apiConfig.timestamp).toLocaleString()}
**⚠️ Security Note:** API keys are not included in backups for security.
After restoring, reconfigure your API key in the API settings.
### API Key Management
${
API_PROVIDERS[apiConfig?.provider]
? `
**${API_PROVIDERS[apiConfig.provider].name}**
- Documentation: ${API_PROVIDERS[apiConfig.provider].documentation}
- If you need to reset your API key, visit the documentation link above
`
: ""
}
`
: "No Metals API configuration found."
}
## Current Data Summary
- Inventory Items: ${inventory.length}
- Spot Price History: ${spotHistory.length} entries
- Silver Price: ${spotPrices.silver || "Not set"}
- Gold Price: ${spotPrices.gold || "Not set"}
- Platinum Price: ${spotPrices.platinum || "Not set"}
- Palladium Price: ${spotPrices.palladium || "Not set"}
## Restoration Instructions
1. Import **inventory-backup-${timestamp}.csv** using the CSV import feature
2. Reconfigure API settings if needed (keys not backed up for security)
3. Use **complete-backup-${timestamp}.json** for full data restoration if needed
*This backup was created by StakTrakr v${APP_VERSION}*
`;
downloadFile(`backup-info-${timestamp}.md`, apiInfo, "text/markdown");
alert(
`Complete backup created! Downloaded files:\n\n✓ Inventory CSV (${inventory.length} items)\n✓ Spot price history (${spotHistory.length} entries)\n✓ Complete JSON backup\n✓ Documentation & restoration guide\n\nCheck your Downloads folder.`,
);
} catch (error) {
console.error("Backup error:", error);
alert("Error creating backup: " + error.message);
}
};
// =============================================================================
// SPOT HISTORY EXPORT/IMPORT
// =============================================================================
/**
* Exports all spot history data as a CSV file
*/
const exportSpotHistory = () => {
loadSpotHistory();
if (!spotHistory.length) {
alert("No spot history to export.");
return;
}
const csv = Papa.unparse([
["Timestamp", "Metal", "Price", "Source", "Provider"],
...spotHistory.map((e) => [
e.timestamp,
e.metal,
e.spot,
e.source,
e.provider || "",
]),
]);
downloadFile(
`spot-history-${new Date().toISOString().slice(0, 10)}.csv`,
csv,
"text/csv",
);
};
/**
* Imports spot history data from a CSV or JSON file
* @param {File} file - File to import
*/
const importSpotHistory = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
let entries = [];
try {
if (file.name.endsWith(".json")) {
const parsed = JSON.parse(e.target.result);
// Support both flat array and { history: [...] } wrapper
entries = Array.isArray(parsed) ? parsed : parsed.history || [];
} else {
const parsed = Papa.parse(e.target.result, { header: true });
entries = parsed.data
.map((row) => ({
timestamp: row.Timestamp,
metal: row.Metal,
spot: parseFloat(row.Price),
source: row.Source || "import",
provider: row.Provider || "import",
}))
.filter((entry) => entry.timestamp && entry.metal && entry.spot > 0);
}
} catch (err) {
alert("Failed to parse file: " + err.message);
return;
}
if (entries.length === 0) {
alert("No valid entries found in file.");
return;
}
loadSpotHistory();
let imported = 0;
entries.forEach((entry) => {
recordSpot(
entry.spot,
entry.source || "import",
entry.metal,
entry.provider || "import",
entry.timestamp,
);
imported++;
});
alert(`Imported ${imported} spot history entries.`);
if (typeof updateAllSparklines === "function") updateAllSparklines();
// Refresh the visible history table after import
apiHistoryEntries = spotHistory.filter((e) => e.source === "api" || e.source === "api-hourly" || e.source === "seed");
renderApiHistoryTable();
};
reader.readAsText(file);
};
/**
* Wires up spot history export/import button event listeners.
* Called during populateApiSection() or init.
*/
const initSpotHistoryButtons = () => {
const exportBtn = document.getElementById("exportSpotHistoryBtn");
if (exportBtn) exportBtn.addEventListener("click", exportSpotHistory);
const importBtn = document.getElementById("importSpotHistoryBtn");
const importFile = document.getElementById("importSpotHistoryFile");
if (importBtn && importFile) {
importBtn.addEventListener("click", () => importFile.click());
importFile.addEventListener("change", (e) => {
if (e.target.files.length > 0) {
importSpotHistory(e.target.files[0]);
e.target.value = ""; // Reset so same file can be re-imported
}
});
}
};
window.exportSpotHistory = exportSpotHistory;
window.importSpotHistory = importSpotHistory;
window.initSpotHistoryButtons = initSpotHistoryButtons;
// =============================================================================