i2psnark/res/js/refreshTorrents.js

/**
 * @module refreshTorrents
 * @file refreshTorrents.js - Selectively refreshes torrents and volatile elements in the I2PSnark UI.
 * @description Core refresh module for I2PSnark. Manages periodic AJAX-based updates of the
 * torrent list, screen log, header/footer stats, and file listings. Uses a web worker for
 * fetch operations, caches responses, handles visibility changes, and orchestrates
 * initialization of sub-modules (sorting, pagination, filter bar, lightbox, debug toggle).
 * @author dr|z3d
 * @license AGPL3 or later
 */

import {pageNav} from "./pageNav.js";
import {showBadge} from "./filterBar.js";
import {snarkSort} from "./snarkSort.js";
import {toggleDebug} from "./toggleDebug.js";
import {Lightbox} from "./lightbox.js";
import {MESSAGE_TYPES} from "./messageTypes.js";
import {extractRefreshPayload} from "./refreshPayload.js";
import morphdom from "./morphdom.js";

/**
 * @type {Map<string, Object>}
 * @description In-memory cache for fetched HTML documents, keyed by URL.
 */
const cache = new Map();

/**
 * @type {number}
 * @description Duration in milliseconds before cached documents are considered stale.
 */
const cacheDuration = 5000;

/**
 * @type {?HTMLElement}
 * @description The #debugMode element, used to check if debug mode is active.
 */
const debugMode = document.getElementById("debugMode");

/**
 * @type {?HTMLElement}
 * @description The #dirInfo element for file directory listings.
 */
const files = document.getElementById("dirInfo");

/**
 * @type {?HTMLElement}
 * @description The #filterBar element for torrent status filtering.
 */
const filterbar = document.getElementById("filterBar");

/**
 * @type {?HTMLElement}
 * @description The main navigation link element (.nav_main) in the navbar.
 */
const home = document.querySelector("#navbar #nav_main");

/**
 * @type {boolean}
 * @description Whether the page is running inside an iframe.
 */
const isIframed = document.documentElement.classList.contains("iframed") || window.top !== window.self;

/**
 * @type {boolean}
 * @description Whether the page is running as a standalone I2PSnark instance.
 */
const isStandalone = document.documentElement.classList.contains("standalone");

/**
 * @type {?HTMLElement}
 * @description The #mainsection container element.
 */
const mainsection = document.getElementById("mainsection");

/**
 * @type {?HTMLElement}
 * @description The #noTorrents element shown when no torrents are present.
 */
const noTorrents = document.getElementById("noTorrents");

/**
 * @type {Document}
 * @description Reference to the parent document (for iframe mode).
 */
const parentDoc = window.parent.document;

/**
 * @type {string}
 * @description The current URL query string.
 */
const query = window.location.search;

/**
 * @type {?HTMLElement}
 * @description The #screenlog element for displaying operation messages.
 */
const screenlog = document.getElementById("screenlog");

/**
 * @type {?HTMLElement}
 * @description The #snarkHead element for the torrent table header.
 */
const snarkHead = document.getElementById("snarkHead");

/**
 * @type {?string}
 * @description The stored refresh interval from localStorage.
 */
const storageRefresh = localStorage.getItem("snarkRefresh");

/**
 * @type {?HTMLElement}
 * @description The #torrents container element.
 */
const torrents = document.getElementById("torrents");

/**
 * @type {?HTMLElement}
 * @description The #snarkTbody element containing torrent table rows.
 */
const torrentsBody = document.getElementById("snarkTbody");

/**
 * @type {?HTMLElement}
 * @description The #torrentlist form element.
 */
const torrentForm = document.getElementById("torrentlist");

/**
 * @type {string}
 * @description The active realtime search term appended to AJAX refresh URLs,
 * empty when no search filter is applied.
 */
let activeSearch = "";

/**
 * @type {boolean}
 * @description Whether the chimp image has been preloaded and cached.
 */
let chimpIsCached = false;

/**
 * @type {boolean}
 * @description Whether the server is currently unreachable.
 */
let noConnection = false;

/**
 * @type {?number}
 * @description Interval ID for the main torrent refresh timer.
 */
let snarkRefreshIntervalId;

/**
 * @type {?number}
 * @description Interval ID for the server connectivity check timer.
 */
let serverOKIntervalId;

/**
 * @type {?number}
 * @description Interval ID for the screen log refresh timer.
 */
let screenLogIntervalId;

/**
 * @type {boolean}
 * @description Whether debug logging is enabled.
 */
let debugging = false;

/**
 * @type {boolean}
 * @description Whether initial setup has been completed.
 */
let initialized = false;

/**
 * @type {boolean}
 * @description Whether the document is currently visible (not hidden by tab switch).
 */
let isDocumentVisible = true;

/**
 * @type {number}
 * @description Timestamp of the last server connectivity check.
 */
let lastCheckTime = 0;

/**
 * @function requestAnimationFramePromise
 * @description Wraps requestAnimationFrame in a Promise, executing the callback within
 * the animation frame. Provides a cancel method to abort pending frames.
 * @param {Function} callback - The function to execute within the animation frame.
 * @returns {Promise<void>} A promise that resolves after the callback executes.
 * @example
 * await requestAnimationFramePromise(() => { element.textContent = "updated"; });
 */
const requestAnimationFramePromise = (callback) => {
  let requestId;
  let resolvePromise;
  const promise = new Promise((resolve) => { resolvePromise = resolve; });
  const execCallback = () => {
    try {
      callback();
    } catch (error) {
      if (debugging) console.error(error);
    } finally {
      cancelAnimationFrame(requestId);
      resolvePromise();
    }
  };

  requestId = requestAnimationFrame(execCallback);
  return promise;
};

/**
 * @async
 * @function getRefreshInterval
 * @description Reads the refresh interval from the global snarkRefreshDelay variable,
 * localStorage, or defaults to 5 seconds. Persists the value to localStorage and
 * returns it in milliseconds.
 * @returns {Promise<number>} The refresh interval in milliseconds.
 */
async function getRefreshInterval() {
  const refreshInterval = snarkRefreshDelay || parseInt(localStorage.getItem("snarkRefreshDelay")) || 5;
  localStorage.setItem("snarkRefresh", refreshInterval);
  return refreshInterval * 1000;
}

/**
 * @async
 * @function getURL
 * @description Constructs the AJAX refresh URL by replacing the "/i2psnark/" path
 * segment with "/i2psnark/.ajax/xhr1.html" in the current page URL.
 * @returns {Promise<string>} The AJAX-compatible refresh URL.
 */
async function getURL() {
  const url = new URL(window.location.href);
  url.pathname = url.pathname.replace("/i2psnark/", "/i2psnark/.ajax/xhr1.html");
  if (activeSearch) {url.searchParams.set("search", activeSearch);}
  return url.href;
}

/**
 * @function setActiveSearch
 * @description The active search term appended to AJAX refresh URLs so refreshes
 * keep the realtime search filter applied.
 * @param {string} value - The search term; empty clears the filter.
 * @returns {void}
 */
function setActiveSearch(value) {activeSearch = value;}

/**
 * @async
 * @function setLinks
 * @description Updates the main navigation link href to point to the I2PSnark root,
 * optionally including a query string for filter preservation.
 * @param {string} [query] - The query string to append (e.g., "?filter=active").
 * @returns {Promise<void>}
 */
async function setLinks(query) { if (home) {home.href = query ? `/i2psnark/${query}` : "/i2psnark/";} }

/**
 * @async
 * @function initHandlers
 * @description Initializes sub-module handlers including sort listeners, debug toggle,
 * page navigation, and filter badge display. Uses requestAnimationFrame for smooth UI.
 * @returns {Promise<void>}
 */
async function initHandlers() {
  if (torrents) {
    snarkSort();
    if (debugMode) {toggleDebug();}
  }
  setLinks();
  await requestAnimationFramePromise(async () => {
    if (document.getElementById("pagenavtop")) await pageNav();
    if (filterbar) await showBadge();
    if (debugging) console.log("initHandlers()");
  });
}

/**
 * @type {AbortController}
 * @description Controller for aborting in-progress fetch requests.
 */
let abortController = new AbortController();

/**
 * @type {Map<string, Promise>}
 * @description Map of ongoing fetch promises keyed by URL, used to deduplicate concurrent requests.
 */
const ongoingRequests = new Map();

/**
 * @type {?Worker}
 * @description The snarkWork Web Worker, constructed lazily on the first fetch.
 */
let worker = null;

/**
 * @type {boolean}
 * @description Whether the worker has signaled readiness after startup.
 */
let workerReady = false;

/**
 * @type {number}
 * @description Monotonic counter for correlating worker responses with requests.
 */
let workerRequestId = 0;

/**
 * @type {Map<number, {resolve: Function, reject: Function}>}
 * @description Pending worker requests keyed by requestId.
 */
const workerRequests = new Map();

/**
 * @type {number}
 * @description How long to wait for a worker response before falling back to direct fetching.
 */
const workerTimeout = 30000;

/**
 * @function initWorker
 * @description Constructs the snarkWork worker once. Falls back to direct fetching
 * when Web Workers are unavailable or the worker script cannot be loaded.
 * @returns {void}
 */
function initWorker() {
    if (worker || typeof Worker === "undefined") {return;}
    try {
        worker = new Worker("/i2psnark/.res/js/snarkWork.js", {type: "module"});
        worker.addEventListener("message", (event) => {
            const {type, requestId, payload, message} = event.data || {};
            if (type === MESSAGE_TYPES.READY) {workerReady = true; return;}
            const pending = workerRequests.get(requestId);
            if (!pending) {return;}
            workerRequests.delete(requestId);
            if (type === MESSAGE_TYPES.FETCH_HTML_DOCUMENT_RESPONSE || type === MESSAGE_TYPES.FETCH_TUNNEL_COUNTS_RESPONSE) {pending.resolve(payload);}
            else if (type === MESSAGE_TYPES.FETCH_HTML_DOCUMENT_ERROR || type === MESSAGE_TYPES.FETCH_TUNNEL_COUNTS_ERROR) {pending.reject(new Error(message || "Worker fetch failed"));}
        });
        worker.addEventListener("error", () => {workerFailed();});
    } catch {workerFailed();}
}

/**
 * @function workerFailed
 * @description Marks the worker as unusable and rejects all pending requests so
 * callers fall back to direct fetching.
 * @returns {void}
 */
function workerFailed() {
    worker = null;
    workerReady = false;
    workerRequests.forEach((pending) => {pending.reject(new Error("Worker unavailable"));});
    workerRequests.clear();
}

/**
 * @function fetchViaWorker
 * @description Fetches the URL through the worker, correlating the response by
 * requestId. Rejects immediately when the worker is unavailable; HTTP-level failures
 * arrive as <type>_ERROR messages. Times out after the given duration so a wedged
 * worker cannot stall refreshes indefinitely.
 * @param {string} url - The URL to fetch.
 * @param {AbortSignal} signal - Cancels the in-flight worker request when aborted.
 * @param {string} [type=MESSAGE_TYPES.FETCH_HTML_DOCUMENT] - The worker message type to post.
 * @param {number} [timeout=workerTimeout] - How long to wait for a worker response before timing out.
 * @returns {Promise<Object>} The payload returned by the worker for the request type.
 */
function fetchViaWorker(url, signal, type = MESSAGE_TYPES.FETCH_HTML_DOCUMENT, timeout = workerTimeout) {
    if (!worker || !workerReady) {return Promise.reject(new Error("Worker unavailable"));}
    const requestId = ++workerRequestId;
    return new Promise((resolve, reject) => {
        workerRequests.set(requestId, {resolve, reject});
        const onAbort = () => {
            clearTimeout(timer);
            workerRequests.delete(requestId);
            worker.postMessage({type: MESSAGE_TYPES.ABORT, requestId});
            reject(new DOMException("Aborted", "AbortError"));
        };
        const timer = setTimeout(() => {
            workerRequests.delete(requestId);
            worker.postMessage({type: MESSAGE_TYPES.ABORT, requestId});
            reject(new Error("Worker timeout"));
        }, timeout);
        signal.addEventListener("abort", onAbort, {once: true});
        worker.postMessage({type, requestId, url});
    });
}

/**
 * @function fetchTunnelCounts
 * @description Fetches the snark tunnel counts through the worker, which downloads and
 * parses the tunnel configuration page off the main thread. Rejects when the worker is
 * unavailable so callers can fall back to a direct fetch.
 * @returns {Promise<?Object>} An object with inCount and outCount properties, or null
 * when the page contains neither count.
 */
function fetchTunnelCounts() {
    return fetchViaWorker("/configtunnels", new AbortController().signal, MESSAGE_TYPES.FETCH_TUNNEL_COUNTS, 10000);
}

/**
 * @async
 * @function fetchRefreshPayload
 * @description Fetches the refresh payload for the given URL, preferring the worker so
 * the network request and HTML parsing happen off the main thread. Uses a cache layer to
 * avoid redundant fetches within the cacheDuration window. Deduplicates concurrent
 * requests to the same URL. Supports forced fetches that bypass the cache.
 * @param {string} url - The URL to fetch the refresh payload for.
 * @param {boolean} [forceFetch=false] - If true, bypasses the cache and fetches fresh data.
 * @param {?AbortSignal} [signal=null] - Aborts the in-flight fetch; defaults to the shared refresh signal.
 * @returns {Promise<Object>} The refresh payload.
 * @throws {Error} If the network request fails or returns a non-OK status.
 */
async function fetchRefreshPayload(url, forceFetch = false, signal = null) {
    cleanupCache();
    if (!forceFetch && ongoingRequests.has(url)) {return ongoingRequests.get(url);}
    try {
        if (!forceFetch) {
            const cachedPayload = cache.get(url), now = Date.now();
            if (cachedPayload && (now - cachedPayload.timestamp < cacheDuration)) {return cachedPayload.payload;}
        }
        const activeSignal = signal || abortController.signal, promise = (async () => {
            let payload;
            initWorker();
            if (worker) {
                try {payload = await fetchViaWorker(url, activeSignal);}
                catch (error) {
                    if (error.name === "AbortError") {throw error;}
                    if (debugging) {console.error(error);}
                    payload = extractRefreshPayload(new DOMParser().parseFromString(await fetchDirect(url, activeSignal), "text/html"));
                }
            } else {payload = extractRefreshPayload(new DOMParser().parseFromString(await fetchDirect(url, activeSignal), "text/html"));}
            cache.set(url, { payload, timestamp: Date.now() });
            return payload;
        })();
        ongoingRequests.set(url, promise);
        try {
            const result = await promise;
            return result;
        } finally {
            ongoingRequests.delete(url);
        }
    } catch (error) {
        if (debugging && error.name !== "AbortError") {console.error(error);}
        throw error;
    } finally {abortController = new AbortController();}
}

/**
 * @async
 * @function fetchDirect
 * @description Fetches the URL on the main thread and returns the response text.
 * Used as the fallback when the worker is unavailable or fails.
 * @param {string} url - The URL to fetch.
 * @param {AbortSignal} signal - Cancels the fetch when aborted.
 * @returns {Promise<string>} The fetched HTML text.
 * @throws {Error} If the network request fails or returns a non-OK status.
 */
async function fetchDirect(url, signal) {
    const response = await fetch(url, { signal });
    if (!response.ok) {throw new Error(`Network error: ${response.status} ${response.statusText}`);}
    return response.text();
}

/**
 * @type {Set<string>}
 * @description Set of cache keys that have expired and need removal.
 */
const staleCacheKeys = new Set();

/**
 * @function cleanupCache
 * @description Identifies and removes expired cache entries older than cacheDuration.
 * @returns {void}
 */
function cleanupCache() {
  const now = Date.now();
  for (const [key, value] of cache.entries()) {
    if (now - value.timestamp >= cacheDuration) {staleCacheKeys.add(key);}
  }
  removeStaleCacheKeys();
}

/**
 * @function removeStaleCacheKeys
 * @description Removes all keys tracked in staleCacheKeys from the main cache, then clears the set.
 * @returns {void}
 */
function removeStaleCacheKeys() {
  for (const key of staleCacheKeys) {cache.delete(key);}
  staleCacheKeys.clear();
}

/**
 * @async
 * @function doRefresh
 * @description Main refresh entry point. Fetches the AJAX HTML document for the given URL,
 * refreshes the torrent display, reinitializes handlers, and updates the filter badge.
 * Accepts either an options object or a plain URL string.
 * @param {Object|string} [options={}] - Refresh options, or a URL string to fetch.
 * @param {string} [options.url] - The URL to fetch; defaults to the current AJAX URL.
 * @param {boolean} [options.forceFetch=false] - Whether to bypass the cache.
 * @param {?AbortSignal} [options.signal=null] - Aborts the in-flight fetch.
 * @returns {Promise<void>}
 */
async function doRefresh(options = {}) {
  const url = typeof options === "string" ? options : options.url;
  const forceFetch = typeof options === "string" ? false : Boolean(options.forceFetch);
  const signal = typeof options === "string" ? null : options.signal || null;
  const defaultUrl = await getURL();
  const payload = await fetchRefreshPayload(url || defaultUrl, forceFetch, signal);
  await requestAnimationFramePromise(async () => await refreshTorrents(payload));
  await initHandlers();
  await showBadge();
}

/**
 * @async
 * @function refreshTorrents
 * @description Core refresh function that updates the I2PSnark UI. Detects the current view
 * (torrent list, file directory, or offline state) and delegates to the appropriate update
 * function. Handles iframe detection, initialization delays, and volatile row updates.
 * @param {Object} [payload] - Refresh payload; when absent or incomplete it is fetched.
 * @returns {Promise<void>}
 */
async function refreshTorrents(payload) {
  try {
    if (!payload || typeof payload !== "object" || !("snarkTbody" in payload)) {
      payload = await fetchRefreshPayload(await getURL());
    }
    const control = document.getElementById("torrentInfoControl");
    const dirlist = document.getElementById("dirlist");
    const down = document.getElementById("NotFound") || document.getElementById("down");

    if (!initialized && !down) {
      initialized = true;
      if (window.top !== window.self && !document.documentElement.classList.contains("iframed")) {
        document.documentElement.classList.add("iframed");
      }
      if (!document.getElementById("tnlInCount")) {
        snarkHead.classList.add("initializing");
        await new Promise(resolve => setTimeout(() => {
          snarkHead.classList.remove("initializing");
          resolve();
        }, 3 * 1000));
      }
    }

    if (!storageRefresh) {
      localStorage.setItem("snarkRefresh", await getRefreshInterval());
    }

    await setLinks(query);
    if (torrents) { await requestAnimationFramePromise(async () => await updateVolatile()); }
    else if (dirlist) {await requestAnimationFramePromise(async () => await updateFiles()); }
    else if (down) { await requestAnimationFramePromise(async () => await refreshAll()); }

    markStartingRows();

    /**
     * @function morphTorrentsBody
     * @description Morphs the live torrent tbody to match the response rows, diffing by
     * row key so matching torrents update in place and filters show the correct subset.
     * @param {?string} newTbodyHTML - Inner HTML of the response #snarkTbody.
     * @returns {void}
     */
    function morphTorrentsBody(newTbodyHTML) {
      if (!torrentsBody || newTbodyHTML === null) {return;}
      if (newTbodyHTML === "") {
        if (torrentsBody.children.length > 0) {torrentsBody.innerHTML = "";}
        return;
      }
      const newTbody = document.createElement("tbody");
      newTbody.innerHTML = newTbodyHTML;
      morphdom(torrentsBody, newTbody, {
        getKey: (el) => el.getAttribute("data-name") || el.id || null,
        childrenOnly: true
      });
    }

    /**
     * @async
     * @function refreshAll
     * @description Performs a full refresh of the torrent table from the payload,
     * morphing the tbody and refreshing the header/footer and screen log.
     * @returns {Promise<void>}
     */
    async function refreshAll() {
      try {
        if (payload.snarkTbody !== null) {
          await requestAnimationFramePromise(async () => {
            morphTorrentsBody(payload.snarkTbody);
            refreshHeaderAndFooter();
            refreshScreenLog(undefined);
            if (noTorrents) {noTorrents.remove();}
            if (debugging) {console.log("refreshAll()");}
          });
        } else if (payload.mainsection !== null && mainsection) {
          mainsection.innerHTML = payload.mainsection;
        }
      } catch (error) {
        if (debugging) console.error(error);
      }
    }

    /**
     * @async
     * @function updateVolatile
     * @description Performs an incremental update of the torrent table from the payload.
     * Morphs the tbody so only changed rows and cells are written, refreshes the
     * filter badge, pagination, and DHT debug rows, and syncs the form's hidden nonce
     * from the payload so it never goes stale between page loads.
     * @returns {Promise<void>}
     */
    async function updateVolatile() {
      try {
        if (noTorrents) {noTorrents.remove();}

        const nonceMatch = payload.torrentlist && payload.torrentlist.match(/name=nonce value="([^"]*)/);
        if (nonceMatch && torrentForm) {
          const hidden = torrentForm.querySelector('input[name=nonce]');
          if (hidden && hidden.value !== nonceMatch[1]) {hidden.value = nonceMatch[1];}
        }

        if (filterbar) {
          if (payload.badgeText !== null) {
            const activeBadge = filterbar.querySelector("#filterBar .filter#all .badge");
            if (activeBadge && activeBadge.textContent !== payload.badgeText) {activeBadge.textContent = payload.badgeText;}
          }

          const pagenavtop = document.getElementById("pagenavtop");

          if (!payload.filterBarPresent || (!pagenavtop && payload.pagenavtop !== null)) {
            if (payload.torrentlist !== null && torrentForm) {torrentForm.innerHTML = payload.torrentlist;}
            await initHandlers();
          } else if (pagenavtop && payload.pagenavtop && pagenavtop.outerHTML !== payload.pagenavtop) {
            pagenavtop.outerHTML = payload.pagenavtop;
            await initHandlers();
          }
        }
        morphTorrentsBody(payload.snarkTbody);
        await refreshHeaderAndFooter();

        const dhtRows = document.querySelectorAll("#dhtDebug .dht");
        if (dhtRows.length && payload.dhtDebug.length === dhtRows.length) {
          dhtRows.forEach((row, index) => {
            if (row.outerHTML !== payload.dhtDebug[index]) {row.outerHTML = payload.dhtDebug[index];}
          });
        }
      } catch (error) {
        if (debugging) console.error(error);
      }
    }

    /**
     * @async
     * @function updateFiles
     * @description Updates file listing information by comparing and refreshing
     * incomplete file cells and torrent info stats from the payload.
     * @returns {Promise<void>}
     */
    async function updateFiles() {
      try {
        if (!payload.fileTds || !payload.fileStats) {payload = await fetchRefreshPayload(window.location.href);}
        const selectors = ["#dirInfo tbody tr.incomplete td", "#torrentInfoStats .nowrap"];
        const responseValues = [payload.fileTds, payload.fileStats];
        for (let selectorIndex = 0; selectorIndex < selectors.length; selectorIndex++) {
          const elements = document.querySelectorAll(selectors[selectorIndex]);
          const values = responseValues[selectorIndex];
          if (values.length !== elements.length) {continue;}
          const trimmedValues = values.map(value => value.trim());
          for (let index = 0; index < elements.length; index++) {
            const element = elements[index];
            if (element.innerHTML.trim() !== trimmedValues[index]) {
              element.innerHTML = values[index];
            }
          }
        }
      } catch (error) { if (debugging) console.error(error); }
    }

    /**
     * @async
     * @function refreshHeaderAndFooter
     * @description Refreshes the header and footer table header cells from the payload.
     * Also adjusts sort icon and option box visibility based on the number of torrent rows.
     * @returns {Promise<void>}
     */
    async function refreshHeaderAndFooter() {
      try {
        const snarkFooter = document.getElementById("snarkFoot");
        const snarkHeader = document.getElementById("snarkHead");

        if (snarkFooter) {
          const thElements = snarkFooter.querySelectorAll("th");

          if (thElements.length === payload.footerTH.length) {
            thElements.forEach((th, index) => {
              if (th.innerHTML !== payload.footerTH[index]) {th.innerHTML = payload.footerTH[index];}
            });
          }
        }

        if (snarkHeader) {
          const thElements = snarkHeader.querySelectorAll("th");

          if (thElements.length === payload.headerTH.length) {
            thElements.forEach((th, index) => {
              if (th.innerHTML !== payload.headerTH[index]) {th.innerHTML = payload.headerTH[index];}
            });
          }
          const noload = torrents?.querySelector("#noTorrents");
          if ((torrentsBody && torrentsBody.children.length === 0) || noload) {
            snarkHeader.querySelectorAll("th:nth-child(n+2) .sortIcon, th:nth-child(n+2) .optbox")
                       .forEach(el => el.style.opacity = "0");
          } else if (torrentsBody && torrentsBody.children.length < 2) {
            snarkHeader.querySelectorAll("th:nth-child(n+2) .sortIcon")
                       .forEach(el => el.style.opacity = "0");
          } else {
            snarkHeader.querySelectorAll("th:nth-child(n+2):not(.tAction) img, th:nth-child(n+2):not(.tAction) input")
                       .forEach(el => el.style.opacity = "");
          }
        }
      } catch (error) {}
    }

  } catch (error) {}
}

/**
 * @async
 * @function refreshScreenLog
 * @description Fetches and updates the screen log (#messages) element. Uses a cache with
 * triple the normal duration, keyed on the messages HTML only. Converts URL-encoded
 * spaces only when newly rendered messages contain them. Optionally executes a callback
 * after the update and supports forced fetches to bypass the cache.
 * @param {Function} [callback] - Optional callback to execute after the screen log is updated.
 * @param {boolean} [forceFetch=false] - Whether to bypass the cache and fetch fresh data.
 * @returns {Promise<void>}
 */
async function refreshScreenLog(callback, forceFetch = false) {
  try {
    const screenlog = document.getElementById("messages");
    if (!screenlog || (screenlog.hidden && screenlog.textContent.trim() === "")) {
      return;
    }
    screenlog.removeAttribute("hidden");
    let messages;
    if (!callback && !forceFetch && cache.has("screenlog")) {
      const [cachedMessages, expiry] = cache.get("screenlog");
      if (expiry > Date.now()) {messages = cachedMessages;}
      else {cache.delete("screenlog");}
    }
    if (messages === undefined || forceFetch) {
      const payload = await fetchRefreshPayload("/i2psnark/.ajax/xhrscreenlog.html", forceFetch);
      if (!payload) {return;}
      cache.set("screenlog", [payload.messages, Date.now() + cacheDuration * 3]);
      messages = payload.messages;
    }
    if (messages === null) {return;}
    if (screenlog.innerHTML !== messages) {
      screenlog.innerHTML = messages;
      convertEncodedSpaces();
    }
    if (callback) {callback();}
  } catch (error) {}
}

/**
 * @function convertEncodedSpaces
 * @description Replaces URL-encoded spaces (%20) with regular spaces in screen log message
 * text nodes for cleaner display.
 * @returns {void}
 */
function convertEncodedSpaces() {
  if (!screenlog) {return;}

  /**
   * @function replaceEncodedSpaces
   * @description Recursively traverses DOM nodes, replacing %20 with spaces in text nodes.
   * @param {Node} node - The DOM node to process.
   * @returns {void}
   */
  function replaceEncodedSpaces(node) {
    if (node.nodeType === Node.TEXT_NODE) {
      node.nodeValue = node.nodeValue.replace(/%20/g, " ");
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      Array.from(node.childNodes).forEach(replaceEncodedSpaces);
    }
  }
  const msgElements = screenlog.querySelectorAll("li.msg");
  if (msgElements) { msgElements.forEach(element => replaceEncodedSpaces(element)); }
}

/**
 * @type {?{handler: Function}}
 * @description Pending processForm iframe load waiter for the most recent form submission.
 */
let pendingIframeLoad = null;

/**
 * @function waitForIframeLoad
 * @description Returns a promise that resolves when the processForm iframe next fires
 * load, which is the signal that the submission response has been swallowed by the hidden
 * iframe and the server finished processing. Re-arms on each submission so only the most
 * recent submit triggers a refresh; earlier waiters are discarded.
 * @param {HTMLIFrameElement} iframe - The processForm iframe element.
 * @returns {Promise<void>} Resolves when the iframe fires load.
 */
function waitForIframeLoad(iframe) {
  if (pendingIframeLoad) {iframe.removeEventListener("load", pendingIframeLoad.handler);}
  return new Promise((resolve) => {
    const handler = () => {
      iframe.removeEventListener("load", handler);
      resolve();
    };
    pendingIframeLoad = {handler};
    iframe.addEventListener("load", handler);
  });
}

/**
 * @function invalidateCache
 * @description Clears all cached fetch payloads so the next refresh fetches fresh data.
 * Called on form submission so a concurrent interval refresh cannot re-render the
 * pre-action state.
 * @returns {void}
 */
function invalidateCache() {
  cache.clear();
  staleCacheKeys.clear();
}

/**
 * @function markStartingRows
 * @description Adds a <span class=loading> indicator to the action cell of rows that
 * currently have no action buttons (the transient state while a torrent is starting),
 * and removes it again once the next refresh restores the buttons.
 * @returns {void}
 */
function markStartingRows() {
  if (!torrentsBody) {return;}
  torrentsBody.querySelectorAll("tr").forEach((row) => {
    const cell = row.querySelector(".tAction");
    if (!cell) {return;}
    if (cell.querySelector("input[type=submit]")) {
      cell.querySelector(".loading")?.remove();
    } else if (!cell.querySelector(".loading")) {
      cell.insertAdjacentHTML("beforeend", "<span class=loading></span>");
    }
  });
}

/**
 * @function refreshOnSubmit
 * @description Attaches form submission handlers that refresh the torrent display and
 * screen log once the processForm iframe reports the submission complete, instead of
 * waiting for the next refresh interval. Also handles click delegation for the
 * navigation refresh link.
 * @returns {void}
 */
function refreshOnSubmit() {
  const forms = document.querySelectorAll("form");
  const iframe = document.getElementById("processForm");
  forms.forEach((form) => {
    if (form && iframe && form.id !== "snarkSearch") {
      form.onsubmit = async (event) => {
        const submitter = event.submitter;
        if (!(submitter instanceof HTMLInputElement && submitter.classList) && submitter !== null) {return;}
        invalidateCache();
        try {
          await waitForIframeLoad(iframe);
          await doRefresh({forceFetch: true});
          await refreshScreenLog(undefined, true);
        } catch (error) {
          if (debugging) {console.error(error);}
        }
      };
    }
  });

  document.addEventListener("click", (event) => {
    const clickTarget = event.target;
    const dirlist = document.getElementById("dirlist");
    if (clickTarget.matches("#nav_main:not(.isConfig)") && !dirlist) {
      const navMain = document.querySelector("#nav_main:not(.isConfig)");
      navMain.classList.add("isRefreshing");
      event.preventDefault();
      refreshScreenLog(refreshTorrents, true);
      setTimeout(() => {
        navMain.classList.remove("isRefreshing");
        clickTarget.blur();
      }, 200);
    }
  });
}

/**
 * @async
 * @function initSnarkRefresh
 * @description Initializes the I2PSnark refresh system. Sets up periodic refresh intervals,
 * initializes the lightbox for image viewing, cleans up old event listeners, and preloads
 * the offline indicator image. Called on visibility change to visible state.
 * @returns {Promise<void>}
 */
async function initSnarkRefresh() {
  clearInterval(serverOKIntervalId);
  serverOKIntervalId = setInterval(checkIfUp, 5000);
  clearInterval(snarkRefreshIntervalId);
  document.documentElement.removeAttribute("style");
  const loaded = torrentsBody?.querySelector(".rowEven");
  const noload = torrents?.querySelector("#noTorrents");
  if (loaded && noload) {noload.remove();}
  try {
    snarkRefreshIntervalId = setInterval(async () => {
      try {
        if (isDocumentVisible) {
          await doRefresh();
          await refreshScreenLog();
        }
      } catch (error) {
        if (debugging) console.error(error);
      }
    }, await getRefreshInterval());

    if (files && document.getElementById("lightbox")) {
      try {
        const lightbox = new Lightbox();
        lightbox.load();
      } catch (error) {
        if (debugging) console.error(error);
      }
    }

    const events = document._events?.click || [];
    events.forEach(event => document.removeEventListener("click", event));
    refreshOnSubmit();
  } catch (error) {
    if (debugging) console.error(error);
  }

  if (!chimpIsCached) {
    if (isStandalone) {preloadImage("/i2psnark/.res/themes/snark/midnight/images/chimp.webp");}
    else {preloadImage("/themes/snark/midnight/images/chimp.webp");}
    chimpIsCached = true;
  }
}

/**
 * @function stopSnarkRefresh
 * @description Clears the main torrent refresh interval. Called when the document becomes hidden.
 * @returns {void}
 */
function stopSnarkRefresh() {clearInterval(snarkRefreshIntervalId);}

/**
 * @async
 * @function checkIfUp
 * @description Periodically checks server connectivity by performing a HEAD request. Removes
 * the offline overlay if the server responds OK, or shows the offline screen if the request fails.
 * Respects a minimum delay between checks to avoid excessive requests.
 * @param {number} [minDelay=14000] - Minimum delay in milliseconds between connectivity checks.
 * @returns {Promise<void>}
 */
async function checkIfUp(minDelay = 14000) {
  const currentTime = Date.now();
  if (currentTime - lastCheckTime < minDelay) {return;}
  lastCheckTime = currentTime;

  if (!isDocumentVisible) {return;}
  try {
    const overlay = document.getElementById("offline");
    const offlineStylesheet = document.getElementById("offlineCss");
    const response = await fetch(window.location.href, { method: "HEAD" });
    if (response.ok) {
      if (isIframed) {parentDoc.documentElement.classList.remove("isDown");}
      if (overlay) {overlay.remove();}
      if (offlineStylesheet) {offlineStylesheet.remove();}
    }
  } catch (error) {
    if (debugging) {console.error(error);}
    if (isIframed) {parentDoc.documentElement.classList.add("isDown");}
    setTimeout(isDown, 3000);
    await refreshTorrents();
  }
}

/**
 * @function preloadImage
 * @description Preloads an image into the browser HTTP cache by creating an Image object
 * and periodically refreshing it to prevent cache expiration.
 * @param {string} src - The image source URL to preload and cache.
 * @returns {void}
 */
function preloadImage(src) {
  const load = () => {new Image().src = src;};
  load();
  setInterval(load, 10 * 60 * 1000);
}

/**
 * @function isDown
 * @description Creates and displays an offline overlay with a loading spinner and chimp image.
 * Injects inline CSS styles for the overlay and adds the "isDown" class to the parent
 * document in iframe mode. Only creates the overlay if it doesn't already exist.
 * @returns {void}
 */
function isDown() {
  let chimpSrc;
  if (isStandalone) {chimpSrc = "/i2psnark/.res/themes/snark/midnight/images/chimp.webp";}
  else {chimpSrc = "/themes/snark/midnight/images/chimp.webp";}
  const offlineStyles = `:root{--chimp:url(${chimpSrc});--spinner:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Cg%3E%3Cpath d='M78.75 16.18V1.56a64.1 64.1 0 0 1 47.7 47.7H111.8a49.98 49.98 0 0 0-33.07-33.08zM16.43 49.25H1.8a64.1 64.1 0 0 1 47.7-47.7V16.2a49.98 49.98 0 0 0-33.07 33.07zm33.07 62.32v14.62A64.1 64.1 0 0 1 1.8 78.5h14.63a49.98 49.98 0 0 0 33.07 33.07zm62.32-33.07h14.62a64.1 64.1 0 0 1-47.7 47.7v-14.63a49.98 49.98 0 0 0 33.08-33.07z' fill='%23dd5500bb'/%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 64 64' to='-90 64 64' dur='1200ms' repeatCount='indefinite'/%3E%3C/g%3E%3C/svg%3E")}*{user-select:none}body,html,#circle,#offline{margin:0;padding:0;height:100vh;min-height:100%;position:relative;overflow:hidden}#circle,#offline{position:absolute;top:0;left:0;bottom:0;right:0}#offline{z-index:99999999;background:#000d;backdrop-filter:blur(2px)}#circle::before{content:"";width:230px;height:230px;border-radius:50%;border:28px solid #303;box-shadow:0 0 0 8px #000,0 0 0 8px #000 inset;display:block;position:absolute;top:calc(50% - 132px);left:calc(50% - 135px);background:radial-gradient(circle at center,rgba(0,0,0,0),70%,#313 75%),var(--spinner) no-repeat center center/240px,var(--chimp) no-repeat calc(50% + 5px) calc(50% + 10px)/250px,#000;transform:scale(.8);will-change:transform}`;
  const offlineCss = document.createElement("style");
  offlineCss.id = "offlineCss";
  offlineCss.textContent = offlineStyles;
  const offline = document.createElement("div");
  const spinner = document.createElement("div");
  offline.id = "offline";
  spinner.id = "circle";
  offline.appendChild(spinner);
  if (!document.getElementById("offline")) {
    document.head.appendChild(offlineCss);
    document.body.appendChild(offline);
  }
  if (isIframed) {parentDoc.documentElement.classList.add("isDown");}
}

document.addEventListener("visibilitychange", () => {
  isDocumentVisible = !document.hidden;
  if (isDocumentVisible) {initSnarkRefresh();}
  else {stopSnarkRefresh();}
});

document.addEventListener("DOMContentLoaded", () => {
  convertEncodedSpaces();
  document.body.removeAttribute("style");
});

export { doRefresh, fetchTunnelCounts, getURL, initSnarkRefresh, markStartingRows, refreshScreenLog, refreshTorrents, setActiveSearch, snarkRefreshIntervalId, isDocumentVisible };