AO3: Script Sync

Sync AO3 userscript settings across devices via Google Sheets. View, export, import, and delete data stored by any userscript.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         AO3: Script Sync
// @namespace    https://github.com/Wolfbatcat/ao3-script-sync
// @version      1.0.3
// @description  Sync AO3 userscript settings across devices via Google Sheets. View, export, import, and delete data stored by any userscript.
// @author       BlackBatCat
// @license      MIT
// @match        *://*.archiveofourown.org/*
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// ==/UserScript==

(function () {
  "use strict";

  // ============================================================================
  // UTILITY FUNCTIONS
  // ============================================================================

  // DOM helper functions
  const q = (selector, node = document) => node.querySelector(selector);
  const qa = (selector, node = document) => node.querySelectorAll(selector);
  const ins = (n, l, html) => n.insertAdjacentHTML(l, html);

  /**
   * Escape HTML to prevent XSS
   */
  function escapeHTML(str) {
    if (!str) return "";
    return str
      .replace(/&/g, "&")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#039;");
  }

  // ============================================================================
  // STYLE MANAGER
  // ============================================================================

  class StyleManager {
    static inject() {
      const styles = `
                <style type="text/css">
                    /* Minimal custom styles - inherit AO3's native CSS */
                    #ss-container {
                        margin: 0 auto;
                    }
                    
                    /* Sync widget styles */
                    @keyframes ss-spin {
                        to { transform: rotate(360deg); }
                    }
                    
                    /* Status display with loading states */

                    #ss-connection-status {
                        margin: 5px 0;
                        font-weight: normal;
                    }
                    #ss-connection-status:empty {
                        display: none;
                    }
                    #ss-connection-status.ss-status-loading {
                        color: #0066cc;
                    }
                    #ss-connection-status.ss-status-success {
                        color: green;
                    }
                    #ss-connection-status.ss-status-error {
                        color: red;
                    }
                    
                    /* FicTracker warning styles */
                    .ss-ft-warning-row {
                        opacity: 0.5;
                    }
                    /* Script Sync internal data styles */
                    .ss-internal-row {
                        opacity: 0.5;
                    }
                    .ss-ft-notice-close {
                        float: right;
                        cursor: pointer;
                        font-size: 1.2em;
                        line-height: 1;
                        padding: 0 5px;
                        margin: -5px -5px 0 0;
                    }
                    .ss-ft-notice-close:hover {
                        opacity: 0.7;
                    }
                    
                    /* Hide un-synced rows */
                    .ss-row-hidden {
                        display: none;
                    }

                    /* Add spacing between wrapped button rows */
                    #ss-container input[type="submit"] {
                        margin-bottom: 4px;
                    }

                    /* Keep the AO3 question badge inline and label-sized */
                    #ss-container label .symbol.question {
                        font-size: 0.65em !important;
                        vertical-align: middle;
                    }

                    /* Storage table: fit Select/Sync columns to content, truncate Key */
                    #ss-storagelist th:nth-child(1),
                    #ss-storagelist td:nth-child(1),
                    #ss-storagelist th:nth-child(2),
                    #ss-storagelist td:nth-child(2) {
                        width: 1%;
                        white-space: nowrap;
                    }
                    #ss-storagelist .ss-storage-key {
                        max-width: 200px;
                        overflow: hidden;
                        text-overflow: ellipsis;
                        white-space: nowrap;
                    }

                    /* Mobile: hide Content Preview, allow horizontal scroll */
                    @media (max-width: 600px) {
                        #ss-storagelist th:nth-child(5),
                        #ss-storagelist td:nth-child(5) {
                            display: none;
                        }
                        #ss-storagelist .ss-storage-key {
                            max-width: 110px;
                        }
                    }
                    
                    /* Toggle switch styling */
                    .ss-sync-toggle {
                        position: relative;
                        display: inline-block;
                        width: 2em;
                        height: 1em;
                        vertical-align: middle;
                    }
                    .ss-sync-toggle input {
                        opacity: 0;
                        width: 0;
                        height: 0;
                    }
                    .ss-toggle-slider {
                        position: absolute;
                        cursor: pointer;
                        top: 0;
                        left: 0;
                        right: 0;
                        bottom: 0;
                        background-color: #ccc;
                        transition: 0.3s;
                        border-radius: 1em;
                    }
                    .ss-toggle-slider:before {
                        position: absolute;
                        content: "";
                        height: 0.8em;
                        width: 0.8em;
                        left: 0.1em;
                        bottom: 0.1em;
                        background-color: white;
                        transition: 0.3s;
                        border-radius: 50%;
                    }
                    .ss-sync-toggle input:checked + .ss-toggle-slider {
                        background-color: #2e7d32;
                    }
                    .ss-sync-toggle input:checked + .ss-toggle-slider:before {
                        transform: translateX(1em);
                    }
                </style>
            `;
      ins(q("head"), "beforeend", styles);
    }
  }

  // ============================================================================
  // STORAGE MANAGER
  // ============================================================================

  class StorageManager {
    constructor(prefix = "SS_") {
      this.prefix = prefix;
      this.settingsKey = "settings";
    }

    setItem(key, value) {
      localStorage.setItem(this.prefix + key, JSON.stringify(value));
    }

    getItem(key, defaultValue = null) {
      const item = localStorage.getItem(this.prefix + key);
      if (item === null) return defaultValue;
      try {
        return JSON.parse(item);
      } catch (e) {
        console.error(
          "[AO3: Script Sync] Error parsing stored value for key:",
          key,
          e,
        );
        return defaultValue;
      }
    }

    removeItem(key) {
      localStorage.removeItem(this.prefix + key);
    }

    // Get settings object - all settings stored in single SS_settings key
    getSettings() {
      const settings = this.getItem(this.settingsKey, null);
      if (settings) {
        // Merge with defaults in case new settings were added
        return {
          sheetUrl: "",
          syncEnabled: false,
          syncInterval: 60,
          syncWidgetEnabled: false,
          syncWidgetOpacity: 0.8,
          selectedKeys: [],
          syncInitialized: false,
          lastSync: 0,
          hideUnsynced: false,
          ftWarningDismissed: false,
          ...settings,
        };
      }
      // Return defaults
      return {
        sheetUrl: "",
        syncEnabled: false,
        syncInterval: 60,
        syncWidgetEnabled: false,
        syncWidgetOpacity: 0.8,
        selectedKeys: [],
        syncInitialized: false,
        lastSync: 0,
        hideUnsynced: false,
        ftWarningDismissed: false,
      };
    }

    // Save individual setting
    saveSetting(key, value) {
      const settings = this.getSettings();
      settings[key] = value;
      this.setItem(this.settingsKey, settings);
    }

    // Reset all sync settings
    resetSettings() {
      this.removeItem(this.settingsKey);
      this.removeItem("pendingChanges");
      this.removeItem("writeTimestamps");
      this.removeItem("lastSyncSnapshot");
    }
  }

  // ============================================================================
  // REMOTE SYNC MANAGER
  // ============================================================================

  class RemoteSyncManager {
    constructor(storageManager) {
      this.storage = storageManager;
      this.isOnline = navigator.onLine;
      this.isSyncing = false;
      this.syncTimer = null;
      this.syncTimeout = null;
      this.countdownTimer = null;
      this.widget = null;
      this.syncBadge = null;
      this.timeUntilNextSync = 0;
      this.settings = this.storage.getSettings();
    }

    init() {
      // Initialize pending changes if not exists
      if (!this.storage.getItem("pendingChanges")) {
        this.storage.setItem("pendingChanges", { operations: [] });
      }

      // Register event listeners
      window.addEventListener("online", () => this.handleOnline());
      window.addEventListener("offline", () => this.handleOffline());
      document.addEventListener("visibilitychange", () =>
        this.handleVisibilityChange(),
      );

      // Detect cross-tab/cross-script localStorage changes in real time
      // This catches userscripts (e.g. ao3_advanced_blocker) writing to localStorage
      // on other tabs without going through addPendingChange
      window.addEventListener("storage", (e) => {
        if (!this.settings.syncInitialized) return;
        if (!e.key || e.key.startsWith("SS_")) return;
        if (!(this.settings.selectedKeys || []).includes(e.key)) return;
        if (e.newValue === null) return; // deletion, not an update
        this.addPendingChange(e.key, e.newValue);
      });

      // Queue any local changes made since the last sync
      // (catches changes from previous page loads, e.g. config saved by ao3_advanced_blocker)
      if (this.settings.syncInitialized) {
        const snapshot = this.storage.getItem("lastSyncSnapshot", {});
        const writeTimestamps = this.storage.getItem("writeTimestamps", {});
        let timestampsCleared = false;
        let startupChangesCount = 0;
        (this.settings.selectedKeys || []).forEach((key) => {
          if (key.startsWith("SS_")) return;
          const current = localStorage.getItem(key);
          if (current === null) return;
          // Detect and repair corrupted values written by old client when new backend was first deployed
          if (current === "[object Object]") {
            console.warn(
              "[AO3: Script Sync] Detected corrupted localStorage value for key:",
              key,
              "— will restore from server on next sync",
            );
            localStorage.removeItem(key);
            delete writeTimestamps[key];
            timestampsCleared = true;
            return;
          }
          if (current !== snapshot[key]) {
            this.addPendingChange(key, current, writeTimestamps[key]);
            startupChangesCount++;
          }
        });
        if (startupChangesCount > 0) {
          console.log(
            `[AO3: Script Sync] Queued ${startupChangesCount} local change(s) from previous page load`,
          );
        }
        if (timestampsCleared) {
          this.storage.setItem("writeTimestamps", writeTimestamps);
        }
      }

      // Start sync timer if enabled
      if (this.settings.syncEnabled) {
        this.startSyncTimer();
      }

      // Render widget if enabled
      if (this.settings.syncWidgetEnabled) {
        this.renderWidget();
      }
    }

    renderWidget() {
      // Widget creation is handled by updateWidget
      this.updateWidget("normal");
    }

    removeWidget() {
      if (this.widget) {
        this.widget.remove();
        this.widget = null;
        this.syncBadge = null;
      }
    }

    updateWidget(state = "normal") {
      if (!this.settings.syncWidgetEnabled || !this.settings.syncInitialized)
        return;

      // Create widget if it doesn't exist
      if (!this.widget) {
        const mobile = window.innerWidth <= 768;

        document.body.insertAdjacentHTML(
          "beforeend",
          `
                    <div id="ss-sync-widget" style="position:fixed;bottom:15px;left:10px;z-index:10000;display:flex;align-items:center;opacity:${this.settings.syncWidgetOpacity};gap:${mobile ? "2px" : "4px"};padding:${mobile ? "2px 3px" : "3px 5px"};background:#fff;border:1px solid #ddd;border-radius:${mobile ? "10px" : "16px"};cursor:pointer;font:${mobile ? "11px" : "12px"} -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#666;box-shadow:0 2px 8px rgba(0,0,0,0.1);transition:all 0.2s;user-select:none">
                        <svg width="${mobile ? "12" : "14"}" height="${mobile ? "12" : "14"}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="transition:transform 0.3s">
                            <path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
                        </svg>
                        <span style="font-weight:500;">Sync</span>
                        <span id="ss-sync-badge" style="display:none;background:#ff9800;color:white;border-radius:6px;padding:1px ${mobile ? "3px" : "5px"};font-size:${mobile ? "9px" : "10px"};font-weight:bold;margin-left:2px">0</span>
                    </div>
                `,
        );

        this.widget = document.getElementById("ss-sync-widget");
        this.syncBadge = document.getElementById("ss-sync-badge");

        // Add spin animation if not exists
        if (!document.getElementById("ss-spin")) {
          document.head.insertAdjacentHTML(
            "beforeend",
            '<style id="ss-spin">@keyframes ss-spin{to{transform:rotate(360deg)}}</style>',
          );
        }

        // Click handler
        this.widget.onclick = () =>
          this.isOnline && !this.isSyncing && this.performSync();

        // Hover effects
        this.widget.onmouseenter = () =>
          !this.isSyncing &&
          Object.assign(this.widget.style, {
            opacity: "1",
            background: "#f8f9fa",
            borderColor: "#0066cc",
            transform: "translateY(-1px)",
          });
        this.widget.onmouseleave = () => {
          this.widget.style.opacity = this.settings.syncWidgetOpacity;
          this.updateWidget(this.isSyncing ? "syncing" : "normal");
        };
      }

      // Update badge based on pending count
      const pendingCount = this.getPendingChanges().operations.length;
      if (pendingCount > 0) {
        this.syncBadge.style.display = "inline-block";
        this.syncBadge.textContent = pendingCount;
      } else {
        this.syncBadge.style.display = "none";
      }

      // Update widget based on state
      const states = {
        normal: [
          "#fff",
          "#ddd",
          "#666",
          "none",
          "pointer",
          !this.settings.syncEnabled
            ? "Sync now"
            : this.timeUntilNextSync <= 0
              ? "Sync now"
              : this.timeUntilNextSync > 60
                ? `${Math.floor(this.timeUntilNextSync / 60)}m ${this.timeUntilNextSync % 60}s`
                : `${this.timeUntilNextSync}s`,
        ],
        syncing: [
          "#e3f2fd",
          "#2196f3",
          "#1976d2",
          "ss-spin 1s linear infinite",
          "default",
          "Syncing...",
        ],
        success: [
          "#e8f5e8",
          "#4caf50",
          "#2e7d32",
          "none",
          "pointer",
          "Synced!",
        ],
        error: ["#ffebee", "#f44336", "#c62828", "none", "pointer", "Failed"],
        offline: ["#f5f5f5", "#ccc", "#999", "none", "default", "Offline"],
      };

      const [bg, border, color, animation, cursor, text] =
        states[state] || states.normal;
      const [icon, textEl, badge] = this.widget.children;

      Object.assign(this.widget.style, {
        background: bg,
        borderColor: border,
        cursor,
      });
      Object.assign(icon.style, {
        animation,
        color,
      });
      textEl.textContent = text;
      textEl.style.color = color;

      // Auto-revert success to normal
      if (state === "success") {
        setTimeout(() => this.updateWidget("normal"), 2000);
      }
    }

    updateCountdown() {
      // Decrement timeUntilNextSync and update widget
      if (this.timeUntilNextSync > 0) {
        this.timeUntilNextSync--;
      }
      this.updateWidget("normal");
    }

    startSyncTimer() {
      this.stopSyncTimer();

      const now = Date.now();
      const lastSync = this.settings.lastSync;
      const syncInterval = this.settings.syncInterval * 1000;
      const timeSinceLastSync = now - lastSync;

      if (timeSinceLastSync >= syncInterval) {
        // Sync immediately, then start interval
        this.timeUntilNextSync = 0;
        this.performSync();
        this.syncTimer = setInterval(() => this.performSync(), syncInterval);
      } else {
        // Wait for remaining time, then start interval
        const timeUntilNextSync = syncInterval - timeSinceLastSync;
        this.timeUntilNextSync = Math.ceil(timeUntilNextSync / 1000);
        this.syncTimeout = setTimeout(() => {
          this.syncTimeout = null;
          this.performSync();
          this.syncTimer = setInterval(() => this.performSync(), syncInterval);
        }, timeUntilNextSync);
      }

      // Start countdown update timer (every second)
      if (this.settings.syncWidgetEnabled) {
        this.countdownTimer = setInterval(() => this.updateCountdown(), 1000);
      }
    }

    stopSyncTimer() {
      if (this.syncTimeout) {
        clearTimeout(this.syncTimeout);
        this.syncTimeout = null;
      }
      if (this.syncTimer) {
        clearInterval(this.syncTimer);
        this.syncTimer = null;
      }
      if (this.countdownTimer) {
        clearInterval(this.countdownTimer);
        this.countdownTimer = null;
      }
    }

    handleVisibilityChange() {
      if (document.visibilityState === "visible" && this.settings.syncEnabled) {
        this.startSyncTimer();
      } else {
        this.stopSyncTimer();
      }
    }

    handleOnline() {
      this.isOnline = true;
      if (this.settings.syncEnabled) {
        this.performSync();
      }
    }

    handleOffline() {
      this.isOnline = false;
      this.updateWidget("offline");
    }

    addPendingChange(key, value, timestamp) {
      const ts = timestamp || Date.now();

      // Record local write timestamp for conflict resolution
      const writeTimestamps = this.storage.getItem("writeTimestamps", {});
      writeTimestamps[key] = ts;
      this.storage.setItem("writeTimestamps", writeTimestamps);

      const pendingChanges = this.getPendingChanges();
      const existingIndex = pendingChanges.operations.findIndex(
        (op) => op.key === key,
      );
      const operation = { key, value, timestamp: ts };
      if (existingIndex >= 0) {
        pendingChanges.operations[existingIndex] = operation;
      } else {
        pendingChanges.operations.push(operation);
      }
      this.storage.setItem("pendingChanges", pendingChanges);

      if (this.widget) {
        this.updateWidget("normal");
      }
    }

    getPendingChanges() {
      return this.storage.getItem("pendingChanges", { operations: [] });
    }

    clearPendingChanges() {
      this.storage.setItem("pendingChanges", { operations: [] });
    }

    async performSync() {
      if (!this.isOnline) {
        console.log("[AO3: Script Sync] Offline, skipping sync");
        return;
      }

      if (this.isSyncing) {
        console.log("[AO3: Script Sync] Already syncing, skipping");
        return;
      }

      if (!this.settings.sheetUrl || !this.settings.syncInitialized) {
        console.log("[AO3: Script Sync] Not initialized, skipping sync");
        return;
      }

      this.isSyncing = true;
      this.updateWidget("syncing");

      try {
        // Flush any pending enabled-keys update that previously failed
        const pendingEnabledKeys = this.storage.getItem(
          "pendingEnabledKeysUpdate",
        );
        if (pendingEnabledKeys !== null) {
          try {
            await this.sendSyncRequest({
              action: "update_enabled_keys",
              enabledKeys: pendingEnabledKeys,
            });
            this.storage.removeItem("pendingEnabledKeysUpdate");
          } catch (keysError) {
            console.error(
              "[AO3: Script Sync] Failed to flush pending enabled keys, will retry next sync:",
              keysError,
            );
          }
        }

        // Detect local changes that weren't explicitly queued
        const snapshot = this.storage.getItem("lastSyncSnapshot", {});
        const pendingChanges = this.getPendingChanges();
        const alreadyQueued = new Set(
          pendingChanges.operations.map((op) => op.key),
        );
        const writeTimestamps = this.storage.getItem("writeTimestamps", {});
        let writeTimestampsChanged = false;
        let unqueuedCount = 0;
        const enabledKeys = this.settings.selectedKeys || [];
        enabledKeys.forEach((key) => {
          if (key.startsWith("SS_")) return;
          if (alreadyQueued.has(key)) return;
          const current = localStorage.getItem(key);
          if (current !== null && current !== snapshot[key]) {
            const timestamp = Date.now();
            writeTimestamps[key] = timestamp;
            writeTimestampsChanged = true;
            pendingChanges.operations.push({ key, value: current, timestamp });
            unqueuedCount++;
          }
        });
        if (unqueuedCount > 0) {
          console.log(
            `[AO3: Script Sync] Queued ${unqueuedCount} untracked local change(s)`,
          );
        }
        if (writeTimestampsChanged) {
          this.storage.setItem("writeTimestamps", writeTimestamps);
        }

        // Send sync request
        const response = await this.sendSyncRequest({
          action: "sync",
          queue: pendingChanges,
        });

        if (response.data && response.data.success) {
          // Update local storage with server data
          this.updateLocalStorage(response.data.storage_data);

          // Save snapshot of all enabled key values after sync
          const newSnapshot = {};
          (this.settings.selectedKeys || []).forEach((key) => {
            const val = localStorage.getItem(key);
            if (val !== null) newSnapshot[key] = val;
          });
          this.storage.setItem("lastSyncSnapshot", newSnapshot);

          // Reconcile enabled keys: server is source of truth
          if (Array.isArray(response.data.enabled_keys)) {
            const serverKeys = response.data.enabled_keys;
            const localKeys = this.settings.selectedKeys || [];
            const isDifferent =
              serverKeys.length !== localKeys.length ||
              serverKeys.some((k) => !localKeys.includes(k)) ||
              localKeys.some((k) => !serverKeys.includes(k));
            if (isDifferent) {
              this.settings.selectedKeys = serverKeys;
              this.storage.saveSetting("selectedKeys", serverKeys);
            }
          }

          // Clear pending changes
          this.clearPendingChanges();

          // Update last sync timestamp
          this.settings.lastSync = Date.now();
          this.storage.saveSetting("lastSync", this.settings.lastSync);

          // Reset countdown
          this.timeUntilNextSync = this.settings.syncInterval;

          this.updateWidget("success");
          console.log("[AO3: Script Sync] Sync successful");
        } else {
          throw new Error("Sync response indicates failure");
        }
      } catch (error) {
        console.error("[AO3: Script Sync] Sync error:", error);
        this.updateWidget("error");
        return false;
      } finally {
        this.isSyncing = false;
      }
    }

    sendSyncRequest(data) {
      return new Promise((resolve, reject) => {
        console.log(
          "[AO3: Script Sync] Sending request:",
          data.action,
          "to",
          this.settings.sheetUrl,
        );

        GM_xmlhttpRequest({
          method: "POST",
          url: this.settings.sheetUrl,
          headers: { "Content-Type": "application/json" },
          anonymous: true,
          data: JSON.stringify(data),
          timeout: 30000,
          onload: (response) => {
            try {
              const jsonResponse = JSON.parse(response.responseText);
              if (jsonResponse.status === "success") {
                resolve(jsonResponse);
              } else {
                reject(
                  new Error(jsonResponse.error?.message || "Unknown error"),
                );
              }
            } catch (e) {
              console.error("[AO3: Script Sync] Failed to parse response:", e);
              reject(
                new Error(
                  "Invalid JSON response: " +
                    response.responseText.substring(0, 100),
                ),
              );
            }
          },
          onerror: (error) => {
            console.error("[AO3: Script Sync] Request error:", error);
            reject(new Error("Network error: " + (error.error || error)));
          },
          ontimeout: () => {
            console.error(
              "[AO3: Script Sync] Request timeout after 30 seconds",
            );
            reject(new Error("Request timeout (30s)"));
          },
        });
      });
    }

    updateLocalStorage(serverData) {
      if (!serverData || typeof serverData !== "object") return;

      const writeTimestamps = this.storage.getItem("writeTimestamps", {});
      let timestampsChanged = false;

      for (const key in serverData) {
        if (!serverData.hasOwnProperty(key)) continue;
        // Never overwrite Script Sync internal keys from server data
        if (key.startsWith("SS_")) continue;

        const entry = serverData[key];
        const serverValue =
          typeof entry === "object" && entry !== null ? entry.value : entry;
        const serverTimestamp =
          typeof entry === "object" && entry !== null
            ? entry.timestamp || 0
            : 0;
        const localTimestamp = writeTimestamps[key] || 0;

        if (
          serverValue === null ||
          serverValue === undefined ||
          serverValue === "[object Object]"
        ) {
          console.warn(
            "[AO3: Script Sync] Skipping corrupted server value for key:",
            key,
          );
          continue;
        }

        // Only apply server value if it is newer than the last local write
        if (serverTimestamp >= localTimestamp) {
          localStorage.setItem(key, serverValue);
          writeTimestamps[key] = serverTimestamp;
          timestampsChanged = true;
        }
      }

      if (timestampsChanged) {
        this.storage.setItem("writeTimestamps", writeTimestamps);
      }
    }

    destroy() {
      this.stopSyncTimer();
      this.removeWidget();
    }
  }

  // ============================================================================
  // UI MANAGER
  // ============================================================================

  class UIManager {
    constructor(storageManager, remoteSyncManager) {
      this.storage = storageManager;
      this.syncManager = remoteSyncManager;
      this.menuInjected = false;
      this.countdownInterval = null;
    }

    injectMenu() {
      // Only show on the AO3 homepage
      if (window.location.pathname !== "/") return;

      if (this.menuInjected) return;

      if (q("#ss-open-storage")) return;

      // Create or find userscripts menu
      if (qa("#scriptconfig").length === 0) {
        const searchLi = q('#header nav[aria-label="Site"] li.search');
        if (searchLi) {
          ins(
            searchLi,
            "beforebegin",
            `
                        <li class="dropdown" id="scriptconfig">
                            <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
                            <ul class="menu dropdown-menu"></ul>
                        </li>
                    `,
          );
        }
      }

      // Add menu item
      const dropdown = q("#scriptconfig .dropdown-menu");
      if (dropdown) {
        ins(
          dropdown,
          "beforeend",
          `
                    <li>
                        <a href="#" id="ss-open-storage">Script Sync</a>
                    </li>
                `,
        );

        // Add click handlers
        q("#ss-open-storage").addEventListener("click", (e) => {
          e.preventDefault();
          this.toggleStorageView();
        });
      }

      this.menuInjected = true;
    }

    toggleStorageView() {
      const container = q("#ss-container");

      if (container) {
        this.hideStorageView();
      } else {
        this.showStorageView();
      }
    }

    showStorageView() {
      const settings = this.storage.getSettings();
      const stdContent = q("div.splash");

      if (stdContent) {
        stdContent.style.display = "none";
      }

      // Get all localStorage items
      const storageList = [];
      for (let i = 0; i < localStorage.length; i++) {
        const key = localStorage.key(i);
        if (key === "accepted_tos") continue;
        storageList.push([
          key,
          localStorage.getItem(key),
          this.getScriptSource(key),
        ]);
      }

      // Sort by key name
      storageList.sort((a, b) => a[0].localeCompare(b[0]));

      let html = `<div id="ss-container">`;

      if (storageList.length > 0) {
        html += `
                    <h2>Script Sync 🔄</h2>
                    <p>Toggle sync for keys you want to sync across devices. Use checkboxes to select keys for export or deletion. <a href="https://greatest.deepsurf.us/en/scripts/568443" target="_blank">Read more.</a></p>
                    <p>
                        <input type="submit" value="${settings.hideUnsynced ? "Show All" : "Hide Un-synced"}" id="ss-toggle-unsynced">
                        <input type="submit" value="Select All" id="ss-select-all">
                        <input type="submit" value="Select None" id="ss-select-none">
                        <input type="submit" value="Export" id="ss-export">
                        <input type="submit" value="Import" id="ss-import">
                        <input type="submit" value="Delete" id="ss-delete">
                        <input type="file" accept="application/json" id="ss-import-file" style="display:none">
                    </p>
                    <table id="ss-storagelist">
                        <thead>
                            <tr>
                                <th>Select</th>
                                <th>Sync</th>
                                <th>Key</th>
                                <th>Source</th>
                                <th>Content Preview</th>
                                <th>Length</th>
                            </tr>
                        </thead>
                        <tbody>
                `;

        storageList.forEach(([key, value, source]) => {
          const isSelected = settings.selectedKeys.includes(key);
          const isFicTrackerKey = key.startsWith("FT_");
          const isScriptSyncKey = key.startsWith("SS_");
          const preview =
            value.length > 50 ? value.slice(0, 50) + "..." : value;
          const rowClasses = [];
          if (isFicTrackerKey) rowClasses.push("ss-ft-warning-row");
          if (isScriptSyncKey) rowClasses.push("ss-internal-row");
          if (!isSelected || isScriptSyncKey) {
            rowClasses.push("ss-unsynced-row");
            if (settings.hideUnsynced) rowClasses.push("ss-row-hidden");
          }
          const rowClass =
            rowClasses.length > 0 ? ` class="${rowClasses.join(" ")}"` : "";
          const warningIcon = isFicTrackerKey
            ? "⚠️ "
            : isScriptSyncKey
              ? "🔧 "
              : "";
          const toggleTitle = isFicTrackerKey
            ? ' title="FicTracker data should use FicTracker\'s built-in sync for best results"'
            : isScriptSyncKey
              ? ' title="Script Sync internal data - not syncable"'
              : "";
          const toggleDisabled = isScriptSyncKey ? " disabled" : "";

          html += `
                        <tr${rowClass}>
                            <td><input type="checkbox" class="ss-select-check" data-key="${escapeHTML(key)}"></td>
                            <td>
                                <label class="ss-sync-toggle"${toggleTitle}>
                                    <input type="checkbox" class="ss-sync-check" data-key="${escapeHTML(key)}" ${isSelected ? "checked" : ""}${toggleDisabled}>
                                    <span class="ss-toggle-slider"></span>
                                </label>
                            </td>
                            <td class="ss-storage-key">${warningIcon}${escapeHTML(key)}</td>
                            <td>${source}</td>
                            <td>${escapeHTML(preview)}</td>
                            <td style="text-align: right;">${value.length} char${value.length === 1 ? "" : "s"}</td>
                        </tr>
                    `;
        });

        html += `</tbody></table>`;
      } else {
        html += `<h2>Script Sync</h2><p>No localStorage data found for AO3 userscripts.</p>`;
      }

      // Add settings sections
      html += this.getSettingsSectionsHTML(settings);

      html += `</div>`;

      if (stdContent) {
        ins(stdContent, "beforebegin", html);
      } else {
        ins(q("body"), "beforeend", html);
      }

      this.setupEventListeners();
    }

    getSettingsSectionsHTML(settings) {
      const isInitialized = settings.syncInitialized;
      const urlDisabled = isInitialized ? "disabled" : "";
      const initDisabled =
        !settings.sheetUrl || isInitialized ? "disabled" : "";

      // Format last sync time
      const lastSyncTime = settings.lastSync
        ? new Date(settings.lastSync).toLocaleString()
        : "Never";

      // Calculate next sync countdown
      let nextSyncText = "";
      if (settings.syncEnabled && isInitialized) {
        const now = Date.now();
        const timeSinceLastSync = now - settings.lastSync;
        const syncInterval = settings.syncInterval * 1000;
        const timeUntilNextSync = Math.max(0, syncInterval - timeSinceLastSync);
        const secondsRemaining = Math.ceil(timeUntilNextSync / 1000);
        nextSyncText = `Next sync in ${secondsRemaining}s`;
      }

      let html = `
                <br>`;

      if (!settings.ftWarningDismissed) {
        html += `
                <div class="required notice" id="ss-ft-notice">
                    <span class="ss-ft-notice-close" id="ss-ft-notice-close">&times;</span>
                    <strong>⚠️ Important: FicTracker Users</strong><br>
                    FicTracker has its own optimized cloud sync built-in. For best results with FicTracker data (<code>FT_*</code> keys), 
                    use FicTracker's Google Sheets sync instead of Script Sync. FicTracker's sync includes conflict resolution and 
                    data-aware merging that Script Sync cannot provide.
                </div>`;
      }

      html += `
                <fieldset>
                    <legend>
                        Google Sheet Storage
                        <a href="https://github.com/Wolfbatcat/ao3-script-sync" target="_blank">[Setup Guide]</a>
                    </legend>
                    <ul>
            `;

      if (isInitialized) {
        html += `
                        <li>
                            <label>
                                <input type="checkbox" id="ss-sync-enabled" ${settings.syncEnabled ? "checked" : ""}>
                                Enable automatic sync
                                <span class="symbol question" title="When disabled, you can still sync manually using the Sync Now button or by clicking the sync status widget"><span>?</span></span>
                            </label>
                        </li>
                        <li>
                            <label title="Show a floating sync status indicator with countdown timer and manual sync button">
                                <input type="checkbox" id="ss-widget-enabled" ${settings.syncWidgetEnabled ? "checked" : ""}>
                                Show sync status widget
                            </label>
                        </li>
                `;
      }

      html += `
                        <li>
                            <label for="ss-sheet-url">Google Script URL:</label>
                            <input type="text" id="ss-sheet-url" size="60" value="${escapeHTML(settings.sheetUrl)}" ${urlDisabled}
                                   placeholder="https://script.google.com/macros/s/.../exec">
                        </li>
            `;

      if (isInitialized) {
        if (settings.syncEnabled) {
          html += `
                        <li>
                            <label for="ss-sync-interval">Sync interval:</label>
                            <input type="range" id="ss-sync-interval" min="60" max="3600" step="60" value="${settings.syncInterval}" style="width: 200px; margin-right: 10px;">
                            <strong><span id="ss-interval-value">${settings.syncInterval} seconds</span></strong>
                        </li>
                        <li>
                            <strong><label>Last sync:</label>
                            <span id="ss-last-sync-time">${lastSyncTime}</span>
                            <br>
                            <span id="ss-next-sync-countdown">${nextSyncText}</span></strong>
                        </li>
                `;
        }

        if (settings.syncWidgetEnabled) {
          html += `
                        <li>
                            <label for="ss-widget-opacity">Widget Opacity: </label>
                            <input type="range" id="ss-widget-opacity" min="0.3" max="1" step="0.1" value="${settings.syncWidgetOpacity}" style="width: 200px; margin-right: 10px;">
                            <strong><span id="ss-opacity-value">${settings.syncWidgetOpacity}</span></strong>
                        </li>
                    `;
        }

        html += `
                `;
      }

      html += `
                        <li id="ss-connection-status"></li>
                        <li>
            `;

      if (!isInitialized) {
        html += `
                            <input type="submit" value="Test Connection" id="ss-test-connection" ${urlDisabled}>
                            <input type="submit" value="Initialize" id="ss-initialize" ${initDisabled}>
                `;
      } else {
        html += `
                            <input type="submit" value="Test Connection" id="ss-test-connection">
                            <input type="submit" value="Sync Now" id="ss-sync-now">
                            <input type="submit" value="Change URL" id="ss-change-url">
                            <input type="submit" value="Reset Sync Settings" id="ss-reset">
                `;
      }

      html += `
                        </li>
                    </ul>
                </fieldset>
            `;

      return html;
    }

    setupEventListeners() {
      // Toggle un-synced rows visibility
      const toggleUnsyncedBtn = q("#ss-toggle-unsynced");
      if (toggleUnsyncedBtn) {
        toggleUnsyncedBtn.addEventListener("click", () => {
          const unsyncedRows = qa(".ss-unsynced-row");
          const isHiding =
            !unsyncedRows[0]?.classList.contains("ss-row-hidden");

          unsyncedRows.forEach((row) => {
            if (isHiding) {
              row.classList.add("ss-row-hidden");
            } else {
              row.classList.remove("ss-row-hidden");
            }
          });

          toggleUnsyncedBtn.value = isHiding ? "Show All" : "Hide Unused";

          // Save preference
          this.storage.saveSetting("hideUnsynced", isHiding);
        });
      }

      // FicTracker warning close button
      const ftNoticeClose = q("#ss-ft-notice-close");
      if (ftNoticeClose) {
        ftNoticeClose.addEventListener("click", () => {
          const notice = q("#ss-ft-notice");
          if (notice) {
            notice.remove();
            this.storage.saveSetting("ftWarningDismissed", true);
          }
        });
      }

      // Select all/none buttons
      const selectAll = q("#ss-select-all");
      if (selectAll) {
        selectAll.addEventListener("click", () => {
          qa(".ss-select-check").forEach((cb) => (cb.checked = true));
        });
      }

      const selectNone = q("#ss-select-none");
      if (selectNone) {
        selectNone.addEventListener("click", () => {
          qa(".ss-select-check").forEach((cb) => (cb.checked = false));
        });
      }

      // Sync toggle auto-save
      qa(".ss-sync-check").forEach((toggle) => {
        toggle.addEventListener("change", (e) => this.handleSyncToggle(e));
      });

      // Export/Import
      const exportBtn = q("#ss-export");
      if (exportBtn) {
        exportBtn.addEventListener("click", () => this.exportData());
      }

      const importBtn = q("#ss-import");
      if (importBtn) {
        importBtn.addEventListener("click", () => q("#ss-import-file").click());
      }

      const importFile = q("#ss-import-file");
      if (importFile) {
        importFile.addEventListener("change", (e) => this.importData(e));
      }

      // Delete
      const deleteBtn = q("#ss-delete");
      if (deleteBtn) {
        deleteBtn.addEventListener("click", () => this.deleteData());
      }

      // Connection test
      const testBtn = q("#ss-test-connection");
      if (testBtn) {
        testBtn.addEventListener("click", () => this.testConnection());
      }

      // Initialize
      const initBtn = q("#ss-initialize");
      if (initBtn) {
        initBtn.addEventListener("click", () => this.initializeSync());
      }

      // Reset
      const resetBtn = q("#ss-reset");
      if (resetBtn) {
        resetBtn.addEventListener("click", () => this.resetSync());
      }

      // Sync enabled toggle
      const syncEnabled = q("#ss-sync-enabled");
      if (syncEnabled) {
        syncEnabled.addEventListener("change", (e) => {
          this.storage.saveSetting("syncEnabled", e.target.checked);
          if (e.target.checked) {
            this.syncManager.settings.syncEnabled = true;
            this.syncManager.startSyncTimer();
            this.startCountdownUpdater();
          } else {
            this.syncManager.settings.syncEnabled = false;
            this.syncManager.stopSyncTimer();
            this.stopCountdownUpdater();
          }
          this.hideStorageView();
          this.showStorageView();
        });
      }

      // Widget enabled toggle
      const widgetEnabled = q("#ss-widget-enabled");
      if (widgetEnabled) {
        widgetEnabled.addEventListener("change", (e) => {
          this.storage.saveSetting("syncWidgetEnabled", e.target.checked);
          this.syncManager.settings.syncWidgetEnabled = e.target.checked;
          if (e.target.checked) {
            this.syncManager.renderWidget();
          } else {
            this.syncManager.removeWidget();
          }
          this.hideStorageView();
          this.showStorageView();
        });
      }

      // Widget opacity slider
      const opacitySlider = q("#ss-widget-opacity");
      if (opacitySlider) {
        opacitySlider.addEventListener("input", (e) => {
          q("#ss-opacity-value").textContent = e.target.value;
          if (this.syncManager.widget) {
            this.syncManager.widget.style.opacity = e.target.value;
          }
        });
        opacitySlider.addEventListener("change", (e) => {
          const opacity = parseFloat(e.target.value);
          this.storage.saveSetting("syncWidgetOpacity", opacity);
          this.syncManager.settings.syncWidgetOpacity = opacity;
        });
      }

      // Sync interval slider
      const intervalSlider = q("#ss-sync-interval");
      if (intervalSlider) {
        intervalSlider.addEventListener("input", (e) => {
          q("#ss-interval-value").textContent = e.target.value + " seconds";
        });
        intervalSlider.addEventListener("change", (e) => {
          const interval = parseInt(e.target.value);
          this.storage.saveSetting("syncInterval", interval);
          this.syncManager.settings.syncInterval = interval;
          if (this.syncManager.settings.syncEnabled) {
            this.syncManager.startSyncTimer();
          }
        });
      }

      const settings = this.storage.getSettings();
      if (settings.syncInitialized && settings.syncEnabled) {
        this.startCountdownUpdater();
      }

      // Change URL
      const changeUrlBtn = q("#ss-change-url");
      if (changeUrlBtn) {
        changeUrlBtn.addEventListener("click", () => this.changeUrl());
      }

      // Sync now
      const syncNow = q("#ss-sync-now");
      if (syncNow) {
        syncNow.addEventListener("click", () => this.performManualSync());
      }
    }

    async performManualSync() {
      const syncBtn = q("#ss-sync-now");
      const originalText = syncBtn ? syncBtn.value : "";

      if (syncBtn) {
        syncBtn.value = "Syncing...";
        syncBtn.disabled = true;
      }

      const success = await this.syncManager.performSync();

      if (syncBtn) {
        syncBtn.value = success === false ? "✗ Failed" : "✓ Synced";
        setTimeout(() => {
          syncBtn.value = originalText;
          syncBtn.disabled = false;
        }, 2000);
      }
    }

    handleSyncToggle(event) {
      const settings = this.storage.getSettings();
      const oldSelectedKeys = settings.selectedKeys;
      const selectedKeys = [];
      qa(".ss-sync-check:checked").forEach((cb) => {
        selectedKeys.push(cb.dataset.key);
      });

      this.storage.saveSetting("selectedKeys", selectedKeys);
      this.syncManager.settings.selectedKeys = selectedKeys;

      // Update row classes for un-synced visibility
      const toggledCheckbox = event.target;
      const row = toggledCheckbox.closest("tr");
      if (row) {
        if (toggledCheckbox.checked) {
          row.classList.remove("ss-unsynced-row");
        } else {
          row.classList.add("ss-unsynced-row");
          if (settings.hideUnsynced) {
            row.classList.add("ss-row-hidden");
          }
        }
      }

      // If already initialized, update server
      if (settings.syncInitialized) {
        const newlyEnabledKeys = selectedKeys.filter(
          (key) => !oldSelectedKeys.includes(key),
        );

        // Add data for newly enabled keys to pending changes
        if (newlyEnabledKeys.length > 0) {
          newlyEnabledKeys.forEach((key) => {
            const value = localStorage.getItem(key);
            if (value !== null) {
              this.syncManager.addPendingChange(key, value);
            }
          });
        }

        this.updateServerEnabledKeys(selectedKeys);
      }
    }

    async updateServerEnabledKeys(keys) {
      try {
        const response = await this.syncManager.sendSyncRequest({
          action: "update_enabled_keys",
          enabledKeys: keys,
        });
        // Clear any previously queued retry
        this.storage.removeItem("pendingEnabledKeysUpdate");
        this.showStatus("Sync keys updated on server", "success");
      } catch (error) {
        // Queue for retry on next sync
        this.storage.setItem("pendingEnabledKeysUpdate", keys);
        this.showStatus(
          "Failed to update sync keys on server — click sync to retry",
          "error",
        );
        console.error(
          "[AO3: Script Sync] Failed to update server enabled keys:",
          error,
        );
      }
    }

    exportData() {
      const selectedKeys = [];
      qa(".ss-select-check:checked").forEach((cb) => {
        selectedKeys.push(cb.dataset.key);
      });

      if (selectedKeys.length === 0) {
        alert("Please select at least one key to export.");
        return;
      }

      const exportData = [];
      selectedKeys.forEach((key) => {
        const value = localStorage.getItem(key);
        if (value !== null) {
          exportData.push([key, value]);
        }
      });

      const blob = new Blob([JSON.stringify(exportData)], {
        type: "application/json",
      });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `ao3-script-storage-${new Date().toISOString().replace(/[^\d\w]/g, "")}.json`;
      a.click();
      URL.revokeObjectURL(url);
    }

    importData(event) {
      const file = event.target.files[0];
      if (!file) return;

      if (file.type !== "application/json") {
        alert("Invalid file type. Please select a JSON file.");
        return;
      }

      const reader = new FileReader();
      reader.onload = (e) => {
        try {
          const data = JSON.parse(e.target.result);
          if (
            !Array.isArray(data) ||
            data.length < 1 ||
            !data.every((item) => Array.isArray(item) && item.length === 2)
          ) {
            alert("Invalid file format. Please select a valid export file.");
            return;
          }

          const settings = this.storage.getSettings();
          let syncedKeysCount = 0;

          data.forEach(([key, value]) => {
            localStorage.setItem(key, value);

            // If this key is being synced and sync is initialized, queue it for upload
            if (
              settings.syncInitialized &&
              settings.selectedKeys.includes(key)
            ) {
              this.syncManager.addPendingChange(key, value);
              syncedKeysCount++;
            }
          });

          let message = `Successfully imported ${data.length} item${data.length === 1 ? "" : "s"}!`;

          // If any synced keys were imported, trigger upload
          if (syncedKeysCount > 0) {
            message += `\n\n${syncedKeysCount} synced key${syncedKeysCount === 1 ? "" : "s"} will be uploaded to the server.`;

            // Auto-trigger sync to upload changes
            setTimeout(() => this.syncManager.performSync(), 1000);
          }

          alert(message);
          this.hideStorageView();
          this.showStorageView(); // Refresh
        } catch (error) {
          alert("Error parsing file: " + error.message);
        }
      };
      reader.readAsText(file);
    }

    deleteData() {
      const selectedKeys = [];
      qa(".ss-select-check:checked").forEach((cb) => {
        selectedKeys.push(cb.dataset.key);
      });

      if (selectedKeys.length === 0) {
        alert("Please select at least one key to delete.");
        return;
      }

      const confirmMsg = `Are you sure you want to delete the selected item${selectedKeys.length === 1 ? "" : "s"} from your browser's local storage?\n\nNote: This does not remove data from your Google Sheet. If sync is still enabled for a deleted key, it will be restored on the next sync. To fully remove a key, disable its sync toggle first, then delete it here. To remove data from the sheet as well, delete the row directly in Google Sheets.`;
      if (!confirm(confirmMsg)) {
        return;
      }

      selectedKeys.forEach((key) => {
        localStorage.removeItem(key);
      });

      this.hideStorageView();
      this.showStorageView(); // Refresh
    }

    async testConnection() {
      const urlInput = q("#ss-sheet-url");
      const url = urlInput.value.trim();
      const testBtn = q("#ss-test-connection");

      if (!url) {
        this.showStatus("Please enter a Google Apps Script URL.", "error");
        return;
      }

      if (!url.startsWith("https://script.google.com/macros/s/")) {
        this.showStatus(
          "Invalid URL format. Must start with https://script.google.com/macros/s/",
          "error",
        );
        return;
      }

      // Update button state
      const originalText = testBtn ? testBtn.value : "";
      if (testBtn) {
        testBtn.value = "Testing...";
        testBtn.disabled = true;
      }
      this.showStatus("Testing connection...", "loading");

      try {
        const response = await new Promise((resolve, reject) => {
          GM_xmlhttpRequest({
            method: "GET",
            url: url + "?action=ping",
            anonymous: true,
            timeout: 10000,
            onload: (resp) => {
              try {
                const json = JSON.parse(resp.responseText);
                resolve(json);
              } catch (e) {
                reject(
                  new Error(
                    "Invalid JSON response: " +
                      resp.responseText.substring(0, 100),
                  ),
                );
              }
            },
            onerror: () => reject(new Error("Network error")),
            ontimeout: () => reject(new Error("Connection timeout")),
          });
        });

        if (response.status === "success") {
          this.showStatus(
            "Connection successful! You can now initialize the sync.",
            "success",
          );
          this.storage.saveSetting("sheetUrl", url);
          this.syncManager.settings.sheetUrl = url;
          const initBtn = q("#ss-initialize");
          if (initBtn) {
            initBtn.disabled = false;
          }
          // Show success on button
          if (testBtn) {
            testBtn.value = "✓ Connected";
            setTimeout(() => {
              testBtn.value = originalText;
              testBtn.disabled = false;
            }, 2000);
          }
        } else {
          this.showStatus(
            "Connection test failed: " +
              (response.error?.message || "Unknown error"),
            "error",
          );
          if (testBtn) {
            testBtn.value = "✗ Failed";
            setTimeout(() => {
              testBtn.value = originalText;
              testBtn.disabled = false;
            }, 2000);
          }
        }
      } catch (error) {
        this.showStatus("Connection test failed: " + error.message, "error");
        if (testBtn) {
          testBtn.value = "✗ Failed";
          setTimeout(() => {
            testBtn.value = originalText;
            testBtn.disabled = false;
          }, 2000);
        }
      }
    }

    async initializeSync() {
      const settings = this.storage.getSettings();
      let selectedKeys = settings.selectedKeys;
      const initBtn = q("#ss-initialize");

      // Update button state
      const originalText = initBtn ? initBtn.value : "";
      if (initBtn) {
        initBtn.value = "Initializing...";
        initBtn.disabled = true;
      }
      this.showStatus("Connecting to Google Sheet...", "loading");

      try {
        this.showStatus("Checking for existing configuration...", "loading");
        let serverProbeResponse;
        let serverEnabledKeys = [];

        try {
          serverProbeResponse = await this.syncManager.sendSyncRequest({
            action: "get_storage",
            requestedKeys: [],
          });

          const serverInitialized =
            serverProbeResponse.data?.initialized || false;
          serverEnabledKeys = serverProbeResponse.data?.enabled_keys || [];

          if (serverInitialized && serverEnabledKeys.length > 0) {
            const serverData = serverProbeResponse.data?.storage_data || {};
            this.showStatus(
              `Found existing configuration with ${serverEnabledKeys.length} key(s). Downloading...`,
              "loading",
            );

            const initWriteTimestamps = this.storage.getItem(
              "writeTimestamps",
              {},
            );
            Object.keys(serverData).forEach((key) => {
              const entry = serverData[key];
              const value =
                typeof entry === "object" && entry !== null
                  ? entry.value
                  : entry;
              const ts =
                typeof entry === "object" && entry !== null
                  ? entry.timestamp || 0
                  : 0;
              localStorage.setItem(key, value);
              if (ts) initWriteTimestamps[key] = ts;
            });
            this.storage.setItem("writeTimestamps", initWriteTimestamps);

            selectedKeys = serverEnabledKeys;
            this.storage.saveSetting("selectedKeys", selectedKeys);
            this.syncManager.settings.selectedKeys = selectedKeys;

            this.storage.saveSetting("syncInitialized", true);
            this.storage.saveSetting("lastSync", Date.now());
            this.storage.saveSetting("syncEnabled", true);
            this.syncManager.settings.syncInitialized = true;
            this.syncManager.settings.lastSync = Date.now();
            this.syncManager.settings.syncEnabled = true;

            // Start sync timer
            this.syncManager.init();

            this.showStatus(
              `Initialized! Downloaded ${serverEnabledKeys.length} key(s) from server. Auto-sync enabled.`,
              "success",
            );
            console.log("[AO3: Script Sync] Initialization successful");

            if (initBtn) {
              initBtn.value = "✓ Initialized";
            }

            setTimeout(() => {
              this.hideStorageView();
              this.showStorageView();
            }, 1500);

            return;
          }
        } catch (error) {
          console.log("[AO3: Script Sync] Server probe failed:", error.message);
        }

        if (selectedKeys.length === 0) {
          const errorMsg =
            serverEnabledKeys.length > 0
              ? "Server connection failed but has data. Please try again or check console for errors."
              : "Cannot initialize: No keys selected. Please enable sync for at least one localStorage key before initializing.";
          console.error("[AO3: Script Sync]", errorMsg);
          this.showStatus(errorMsg, "error");
          if (initBtn) {
            initBtn.value = "✗ No keys selected";
            setTimeout(() => {
              initBtn.value = originalText;
              initBtn.disabled = false;
            }, 3000);
          }
          return;
        }

        this.showStatus("Uploading local data...", "loading");
        const localData = {};
        selectedKeys.forEach((key) => {
          const value = localStorage.getItem(key);
          if (value !== null) {
            localData[key] = value;
          }
        });

        const initResponse = await this.syncManager.sendSyncRequest({
          action: "initialize",
          initData: localData,
          selectedKeys: selectedKeys,
        });

        if (initResponse.status === "success") {
          this.storage.saveSetting("syncInitialized", true);
          this.storage.saveSetting("lastSync", Date.now());
          this.storage.saveSetting("syncEnabled", true);
          this.syncManager.settings.syncInitialized = true;
          this.syncManager.settings.lastSync = Date.now();
          this.syncManager.settings.syncEnabled = true;

          this.syncManager.init();

          this.showStatus(
            "Initialized! Your local data is now syncing. Auto-sync enabled.",
            "success",
          );
          console.log("[AO3: Script Sync] Initialization successful");

          if (initBtn) {
            initBtn.value = "✓ Initialized";
          }

          setTimeout(() => {
            this.hideStorageView();
            this.showStorageView();
          }, 1500);
        } else {
          const errorMsg =
            "Initialization failed: " +
            (initResponse.error?.message || "Unknown error");
          console.error("[AO3: Script Sync]", errorMsg);
          this.showStatus(errorMsg, "error");
          if (initBtn) {
            initBtn.value = "✗ Failed";
            setTimeout(() => {
              initBtn.value = originalText;
              initBtn.disabled = false;
            }, 2000);
          }
        }
      } catch (error) {
        const errorMsg = "Initialization failed: " + error.message;
        console.error("[AO3: Script Sync]", errorMsg, error);
        this.showStatus(errorMsg, "error");
        if (initBtn) {
          initBtn.value = "✗ Failed";
          setTimeout(() => {
            initBtn.value = originalText;
            initBtn.disabled = false;
          }, 2000);
        }
      }
    }

    resetSync() {
      if (
        !confirm(
          "This will reset all sync settings. You will need to re-initialize. Continue?",
        )
      ) {
        return;
      }

      this.stopCountdownUpdater();
      this.syncManager.destroy();
      this.storage.resetSettings();
      this.hideStorageView();
      this.showStorageView();
      alert("Sync settings have been reset.");
    }

    changeUrl() {
      if (
        !confirm(
          "This will let you enter a new Google Script URL. Your selected keys and other settings will be kept, but you will need to re-initialize against the new URL. Continue?",
        )
      ) {
        return;
      }

      this.stopCountdownUpdater();
      this.syncManager.stopSyncTimer();
      this.storage.saveSetting("sheetUrl", "");
      this.storage.saveSetting("syncInitialized", false);
      this.storage.saveSetting("syncEnabled", false);
      this.syncManager.settings.sheetUrl = "";
      this.syncManager.settings.syncInitialized = false;
      this.syncManager.settings.syncEnabled = false;

      this.hideStorageView();
      this.showStorageView();
    }

    startCountdownUpdater() {
      this.stopCountdownUpdater();

      this.countdownInterval = setInterval(() => {
        const settings = this.storage.getSettings();
        const lastSyncSpan = q("#ss-last-sync-time");
        const countdownSpan = q("#ss-next-sync-countdown");

        if (lastSyncSpan && settings.lastSync) {
          lastSyncSpan.textContent = new Date(
            settings.lastSync,
          ).toLocaleString();
        }

        if (countdownSpan && settings.syncEnabled) {
          const now = Date.now();
          const timeSinceLastSync = now - settings.lastSync;
          const syncInterval = settings.syncInterval * 1000;
          const timeUntilNextSync = Math.max(
            0,
            syncInterval - timeSinceLastSync,
          );
          const secondsRemaining = Math.ceil(timeUntilNextSync / 1000);
          countdownSpan.textContent = `Next sync in ${secondsRemaining}s`;
        }
      }, 1000);
    }

    stopCountdownUpdater() {
      if (this.countdownInterval) {
        clearInterval(this.countdownInterval);
        this.countdownInterval = null;
      }
    }

    showStatus(message, type) {
      const statusDiv = q("#ss-connection-status");
      if (statusDiv) {
        statusDiv.className = `ss-status-${type}`;

        // Add emoji prefix based on type
        let emoji = "";
        if (type === "loading") {
          emoji = "🔄 ";
        } else if (type === "success") {
          emoji = "✅ ";
        } else if (type === "error") {
          emoji = "❌ ";
        }

        statusDiv.textContent = emoji + message;
      }
    }

    hideStorageView() {
      this.stopCountdownUpdater();

      const container = q("#ss-container");
      if (container) {
        container.remove();
      }

      const stdContent = q("div.splash");
      if (stdContent) {
        stdContent.style.display = "block";
      }
    }

    getScriptSource(key) {
      const scriptMap = {
        ao3jail: "various scripts (rate limit tracker)",
        aia_refdate:
          '<a href="https://greatest.deepsurf.us/en/scripts/475525">AO3: Mark Co- and Solo-Wrangled Fandoms</a>',
        aia_ref:
          '<a href="https://greatest.deepsurf.us/en/scripts/475525">AO3: Mark Co- and Solo-Wrangled Fandoms</a>',
        floatcmt:
          '<a href="https://greatest.deepsurf.us/en/scripts/489335">AO3: Sticky Comment Box</a>',
        glossary:
          '<a href="https://greatest.deepsurf.us/en/scripts/450347">AO3: Glossary Definition Previews</a>',
        agecheck_new:
          '<a href="https://greatest.deepsurf.us/en/scripts/444335">AO3: [Wrangling] Highlight Bins with Overdue Tags</a>',
        agecheck_old:
          '<a href="https://greatest.deepsurf.us/en/scripts/444335">AO3: [Wrangling] Highlight Bins with Overdue Tags</a>',
        "commentFormat-custom":
          '<a href="https://greatest.deepsurf.us/en/scripts/484002">AO3: Comment Formatting and Preview</a>',
        "commentFormat-order":
          '<a href="https://greatest.deepsurf.us/en/scripts/484002">AO3: Comment Formatting and Preview</a>',
        iconify0: "Iconify (icon library)",
        "iconify-count": "Iconify (icon library)",
        "iconify-version": "Iconify (icon library)",
        kbdpages:
          '<a href="https://greatest.deepsurf.us/en/scripts/451524">AO3: [Wrangling] Keyboard Shortcuts</a>',
        kbdshortcuts:
          '<a href="https://greatest.deepsurf.us/en/scripts/451524">AO3: [Wrangling] Keyboard Shortcuts</a>',
        smallertagsearch:
          '<a href="https://greatest.deepsurf.us/en/scripts/443886">AO3: [Wrangling] Smaller Tag Search</a>',
        unread_inbox_count:
          '<a href="https://greatest.deepsurf.us/en/scripts/474892">AO3: Badge for Unread Inbox Messages</a>',
        unread_inbox_date:
          '<a href="https://greatest.deepsurf.us/en/scripts/474892">AO3: Badge for Unread Inbox Messages</a>',
        unread_inbox_conf:
          '<a href="https://greatest.deepsurf.us/en/scripts/474892">AO3: Badge for Unread Inbox Messages</a>',
        "script-replaceYN":
          '<a href="https://greatest.deepsurf.us/en/scripts/477499">AO3: Replace Y/N in works with your name</a>',
        "script-replaceYN-on":
          '<a href="https://greatest.deepsurf.us/en/scripts/477499">AO3: Replace Y/N in works with your name</a>',
        tags_saved_date_map:
          '<a href="https://greatest.deepsurf.us/en/scripts/438063">AO3: [Wrangling] UW Tag Snooze Buttons</a>',
        kudoshistory_kudosed:
          '<a href="https://greatest.deepsurf.us/en/scripts/5835">AO3: Kudosed and seen history</a>',
        kudoshistory_checked:
          '<a href="https://greatest.deepsurf.us/en/scripts/5835">AO3: Kudosed and seen history</a>',
        kudoshistory_seen:
          '<a href="https://greatest.deepsurf.us/en/scripts/5835">AO3: Kudosed and seen history</a>',
        kudoshistory_bookmarked:
          '<a href="https://greatest.deepsurf.us/en/scripts/5835">AO3: Kudosed and seen history</a>',
        kudoshistory_skipped:
          '<a href="https://greatest.deepsurf.us/en/scripts/5835">AO3: Kudosed and seen history</a>',
        ao3tracking_list:
          '<a href="https://greatest.deepsurf.us/en/scripts/8382">AO3: Tracking</a>',
        ao3tracking_lastcheck:
          '<a href="https://greatest.deepsurf.us/en/scripts/8382">AO3: Tracking</a>',
        wrangleActionButtons:
          '<a href="https://greatest.deepsurf.us/en/scripts/501991">AO3: [Wrangling] Action Buttons Everywhere</a>',
        wrangleShortcuts_act:
          '<a href="https://greatest.deepsurf.us/en/scripts/507705">AO3: [Wrangling] Keyboard Shortcuts</a>',
        wrangleShortcuts_tag:
          '<a href="https://greatest.deepsurf.us/en/scripts/507705">AO3: [Wrangling] Keyboard Shortcuts</a>',
        rainbowTables:
          '<a href="https://greatest.deepsurf.us/en/scripts/445805">AO3: [Wrangling] Rainbow Tables</a>',
        wrangleResources:
          '<a href="https://greatest.deepsurf.us/en/scripts/511102">AO3: [Wrangling] Fandom Resources Quicklinks</a>',
        ao3_chapter_shortcuts_config:
          '<a href="https://greatest.deepsurf.us/en/scripts/549571">AO3: Chapter Shortcuts</a>',
        ao3_wizard_config:
          '<a href="https://greatest.deepsurf.us/en/scripts/550537">AO3: Site Wizard</a>',
        ao3_reading_quality_config:
          '<a href="https://greatest.deepsurf.us/en/scripts/549777">AO3: Reading Time & Quality Score</a>',
        ao3_advanced_blocker_config:
          '<a href="https://greatest.deepsurf.us/en/scripts/549942">AO3: Advanced Blocker</a>',
        ao3_auto_pseud_config:
          '<a href="https://greatest.deepsurf.us/en/scripts/556232">AO3: Auto Pseud</a>',
        ao3_menu_helpers:
          '<a href="https://greatest.deepsurf.us/en/scripts/552743">AO3: Menu Helpers Library</a>',
        ao3_quick_hide_config:
          '<a href="https://greatest.deepsurf.us/en/scripts/564383">AO3: Quick Hide</a>',
        ao3_quick_hide_settings:
          '<a href="https://greatest.deepsurf.us/en/scripts/564383">AO3: Quick Hide</a>',
        ao3_skin_switcher_config:
          '<a href="https://greatest.deepsurf.us/en/scripts/551820">AO3: Skin Switcher</a>',
        ao3_no_rekudos_config:
          '<a href="https://greatest.deepsurf.us/en/scripts/551623">AO3: No Re-Kudos</a>',
        FT_finished:
          '<a href="https://greatest.deepsurf.us/en/scripts/513435">AO3 FicTracker</a>',
        FT_favorites:
          '<a href="https://greatest.deepsurf.us/en/scripts/513435">AO3 FicTracker</a>',
        FT_toread:
          '<a href="https://greatest.deepsurf.us/en/scripts/513435">AO3 FicTracker</a>',
        FT_disliked:
          '<a href="https://greatest.deepsurf.us/en/scripts/513435">AO3 FicTracker</a>',
        FT_userNotes:
          '<a href="https://greatest.deepsurf.us/en/scripts/513435">AO3 FicTracker</a>',
        FT_settings:
          '<a href="https://greatest.deepsurf.us/en/scripts/513435">AO3 FicTracker</a>',
        FT_lastSync:
          '<a href="https://greatest.deepsurf.us/en/scripts/513435">AO3 FicTracker</a>',
        FT_pendingChanges:
          '<a href="https://greatest.deepsurf.us/en/scripts/513435">AO3 FicTracker</a>',
        FT_statusesConfig:
          '<a href="https://greatest.deepsurf.us/en/scripts/513435">AO3 FicTracker</a>',
        FT_lastSyncedStatusesConfig:
          '<a href="https://greatest.deepsurf.us/en/scripts/566605">AO3 FicTracker - BlackBatCats Version</a>',
        FT_kudosGiven:
          '<a href="https://greatest.deepsurf.us/en/scripts/566605">AO3 FicTracker - BlackBatCats Version</a>',
        FT_subscribed:
          '<a href="https://greatest.deepsurf.us/en/scripts/566605">AO3 FicTracker - BlackBatCats Version</a>',
        FT_uiConfig:
          '<a href="https://greatest.deepsurf.us/en/scripts/566605">AO3 FicTracker - BlackBatCats Version</a>',
      };

      // Check for Script Sync keys
      if (key.startsWith("SS_")) {
        return '<a href="https://greatest.deepsurf.us/en/scripts/568443">AO3: Script Sync</a>';
      }

      // Check for FicTracker custom list keys
      if (key.startsWith("FT_custom")) {
        return '<a href="https://greatest.deepsurf.us/en/scripts/513435">AO3 FicTracker</a>';
      }

      return scriptMap[key] || "<i>unknown</i>";
    }
  }

  // ============================================================================
  // MAIN INITIALIZATION
  // ============================================================================

  function init() {
    StyleManager.inject();

    const storageManager = new StorageManager();
    const remoteSyncManager = new RemoteSyncManager(storageManager);
    const uiManager = new UIManager(storageManager, remoteSyncManager);

    const settings = storageManager.getSettings();
    if (settings.syncEnabled && settings.syncInitialized) {
      remoteSyncManager.init();
    }

    uiManager.injectMenu();

    window.ScriptSync = {
      storageManager,
      remoteSyncManager,
      uiManager,
    };

    console.log("[AO3: Script Sync] loaded.");
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();