Completionism: Auto-sync button

Adds a bottom-right Auto Sync button. Clicks "Refresh account data" then syncs each character sequentially. Works in background tabs.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Completionism: Auto-sync button
// @namespace    tm-completionism-sync
// @version      2.0.0
// @description  Adds a bottom-right Auto Sync button. Clicks "Refresh account data" then syncs each character sequentially. Works in background tabs.
// @match        https://completionism.com/*
// @match        https://www.completionism.com/*
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(() => {
  'use strict';

  const CFG = {
    betweenClicksMs: 500,
    stepTimeoutMs: 120000,
    bootstrapTimeoutMs: 30000,
    debug: true,
  };

  const log = (...args) => CFG.debug && console.log('[TM AutoSync]', ...args);

  // Используем worker-based таймер чтобы не throttle'ился в фоновой вкладке
  const workerBlob = new Blob([`
    self.onmessage = function(e) {
      const id = e.data.id;
      setTimeout(() => self.postMessage({ id }), e.data.ms);
    };
  `], { type: 'application/javascript' });
  const timerWorker = new Worker(URL.createObjectURL(workerBlob));

  let sleepId = 0;
  const sleepCallbacks = new Map();
  timerWorker.onmessage = (e) => {
    const cb = sleepCallbacks.get(e.data.id);
    if (cb) {
      sleepCallbacks.delete(e.data.id);
      cb();
    }
  };

  function sleep(ms) {
    return new Promise((resolve) => {
      const id = ++sleepId;
      sleepCallbacks.set(id, resolve);
      timerWorker.postMessage({ id, ms });
    });
  }

  function isLoading(btn) {
    if (!btn) return false;
    if (btn.getAttribute('data-loading') === 'true') return true;
    if (btn.disabled) return true;
    const svg = btn.querySelector('svg');
    if (svg && svg.getAttribute('data-animate') === 'true') return true;
    return false;
  }

  async function waitFor(predicate, { timeoutMs = 30000, intervalMs = 200 } = {}) {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      try {
        if (await predicate()) return true;
      } catch (_) {}
      await sleep(intervalMs);
    }
    return false;
  }

  function click(btn) {
    btn.scrollIntoView({ block: 'center', inline: 'center' });
    btn.click();
  }

  function findRefreshAccountButton() {
    return Array.from(document.querySelectorAll('button'))
      .find(b => (b.textContent || '').trim().startsWith('Refresh account data')) || null;
  }

  // Находим все строки персонажей и в каждой — кнопку синхронизации (первую кнопку с data-loading, которая НЕ содержит крестик "X")
  function findCharacterRows() {
    // Каждый персонаж в div'е с ссылкой на worldofwarcraft.com
    const links = document.querySelectorAll('a[href*="worldofwarcraft.com"]');
    const rows = [];

    links.forEach(link => {
      const row = link.closest('div[class]'); // ближайший контейнер строки
      if (!row) return;

      // Ищем все кнопки с data-loading в этой строке
      const buttons = Array.from(row.querySelectorAll('button[data-loading]'));

      // Кнопка синхронизации — первая, кнопка удаления — последняя (с иконкой X)
      // Более надёжно: кнопка X содержит path с "m289.94" или текст крестика
      const syncBtn = buttons.find(btn => {
        const paths = Array.from(btn.querySelectorAll('svg path'));
        // Кнопка удаления имеет path начинающийся с "m289.94 256 95-95"
        const isRemove = paths.some(p => (p.getAttribute('d') || '').startsWith('m289.94'));
        return !isRemove;
      });

      if (syncBtn) {
        // Извлекаем имя
        const nameSpan = row.querySelector('span');
        const name = nameSpan ? nameSpan.textContent.trim() : 'Unknown';
        const realmSpans = Array.from(row.querySelectorAll('span'));
        const realm = realmSpans.length > 1 ? realmSpans[1].textContent.trim() : '';

        rows.push({ btn: syncBtn, name, realm });
      }
    });

    return rows;
  }

  async function waitStepDone(btn, label) {
    // Сначала дождёмся что loading НАЧАЛСЯ (или уже true)
    await sleep(150);
    const ok = await waitFor(() => !isLoading(btn), { timeoutMs: CFG.stepTimeoutMs, intervalMs: 300 });
    if (!ok) throw new Error(`Timeout waiting for "${label}" to finish.`);
  }

  let running = false;
  let abortRequested = false;

  async function runAutoSync() {
    if (running) {
      abortRequested = true;
      log('Abort requested.');
      toast('Stopping after current step…');
      return;
    }

    running = true;
    abortRequested = false;
    setButtonState(true);

    try {
      // Ждём появления UI
      const ready = await waitFor(() => {
        return !!findRefreshAccountButton() || findCharacterRows().length > 0;
      }, { timeoutMs: CFG.bootstrapTimeoutMs });

      if (!ready) throw new Error('Не вижу модалку Characters. Открой её и нажми Auto Sync.');

      // Шаг 1: Refresh account data
      const refreshBtn = findRefreshAccountButton();
      if (refreshBtn) {
        log('Click: Refresh account data');
        updateStatus('Refreshing account data…');
        click(refreshBtn);
        await waitStepDone(refreshBtn, 'Refresh account data');
        await sleep(CFG.betweenClicksMs);
      }

      if (abortRequested) throw new Error('Aborted by user.');

      // Шаг 2: Синхронизация каждого персонажа
      let rows = findCharacterRows();
      if (!rows.length) throw new Error('Не нашёл кнопки синхронизации персонажей.');

      log(`Found ${rows.length} characters.`);

      for (let i = 0; i < rows.length; i++) {
        if (abortRequested) throw new Error('Aborted by user.');

        // Пере-ищем каждый раз — DOM может обновиться
        rows = findCharacterRows();
        const row = rows[i];
        if (!row) continue;

        const label = `${row.name}${row.realm ? ' (' + row.realm + ')' : ''}`;
        log(`[${i + 1}/${rows.length}] Syncing: ${label}`);
        updateStatus(`Syncing ${i + 1}/${rows.length}: ${row.name}`);

        click(row.btn);
        await waitStepDone(row.btn, label);
        await sleep(CFG.betweenClicksMs);
      }

      log('Done ✅');
      toast('Auto Sync: DONE ✅');
    } catch (err) {
      console.error('[TM AutoSync] Error:', err);
      toast(`Auto Sync: ${err.message || err}`);
    } finally {
      running = false;
      abortRequested = false;
      setButtonState(false);
    }
  }

  // ---- UI ----
  const BTN_ID = 'tm-completionism-autosync-btn';
  const TOAST_ID = 'tm-completionism-autosync-toast';

  function injectStyles() {
    GM_addStyle(`
      #${BTN_ID}{
        position:fixed; right:18px; bottom:18px; z-index:999999;
        padding:10px 14px; border-radius:12px;
        border:1px solid rgba(255,255,255,.18);
        background:rgba(20,20,28,.88); color:#fff;
        font:600 14px/1 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
        cursor:pointer;
        box-shadow:0 10px 30px rgba(0,0,0,.35);
        backdrop-filter:blur(10px);
        transition: background .15s;
      }
      #${BTN_ID}:hover{ background:rgba(40,40,55,.95); }
      #${BTN_ID}[data-running="true"]{ border-color:rgba(255,120,80,.4); cursor:pointer; }
      #${BTN_ID} .sub{ display:block; margin-top:4px;
        font:500 11px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
        opacity:.75;
      }
      #${TOAST_ID}{
        position:fixed; right:18px; bottom:72px; z-index:999999;
        max-width:360px; white-space:pre-line; padding:10px 12px;
        border-radius:12px; border:1px solid rgba(255,255,255,.14);
        background:rgba(0,0,0,.75); color:#fff;
        font:500 12px/1.3 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
        box-shadow:0 10px 30px rgba(0,0,0,.35); backdrop-filter:blur(10px);
        opacity:0; transform:translateY(6px);
        transition:opacity .18s ease,transform .18s ease;
        pointer-events:none;
      }
      #${TOAST_ID}[data-show="true"]{ opacity:1; transform:translateY(0); }
    `);
  }

  function ensureButton() {
    if (document.getElementById(BTN_ID)) return;
    const btn = document.createElement('button');
    btn.id = BTN_ID;
    btn.type = 'button';
    btn.innerHTML = `Auto Sync<span class="sub">Open Characters modal first</span>`;
    btn.addEventListener('click', runAutoSync);
    document.body.appendChild(btn);

    const toastEl = document.createElement('div');
    toastEl.id = TOAST_ID;
    document.body.appendChild(toastEl);
    log('UI injected.');
  }

  function setButtonState(isRunning) {
    const btn = document.getElementById(BTN_ID);
    if (!btn) return;
    btn.setAttribute('data-running', String(isRunning));
    btn.innerHTML = isRunning
      ? `⏹ Stop<span class="sub">Click to abort</span>`
      : `Auto Sync<span class="sub">Open Characters modal first</span>`;
  }

  function updateStatus(msg) {
    const btn = document.getElementById(BTN_ID);
    if (!btn || !running) return;
    btn.innerHTML = `⏹ Stop<span class="sub">${msg}</span>`;
  }

  let toastTimer = null;
  function toast(msg) {
    const el = document.getElementById(TOAST_ID);
    if (!el) return;
    el.textContent = msg;
    el.setAttribute('data-show', 'true');
    clearTimeout(toastTimer);
    toastTimer = setTimeout(() => el.setAttribute('data-show', 'false'), 5000);
  }

  injectStyles();
  ensureButton();
  const mo = new MutationObserver(() => ensureButton());
  mo.observe(document.documentElement, { childList: true, subtree: true });
})();