Загрузка данных


(async function () {
  'use strict';

  // ============ КОНФИГ ============
  const DELAY_BETWEEN_ROWS   = 1500;   // пауза между заявками (мс)
  const PANEL_LOAD_TIMEOUT   = 20000;  // максимум ждём загрузки панели
  const COMMENTS_TIMEOUT     = 10000;  // максимум ждём догрузки комментариев
  const SCROLL_STEP          = 300;    // шаг скролла при сборе списка
  const SCROLL_WAIT          = 600;    // ожидание после скролла
  const MAX_IDLE_SCROLLS     = 3;      // сколько шагов без новых строк — стоп
  const MAX_SCROLL_ITER      = 500;    // защита от бесконечного цикла

  // ============ УТИЛИТЫ ============
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  function log(...args) {
    console.log('%c[TFS-Report]', 'color:#0a0;font-weight:bold', ...args);
  }
  function warn(...args) {
    console.warn('%c[TFS-Report]', 'color:#c80;font-weight:bold', ...args);
  }

  /** Дождаться появления элемента */
  function waitFor(selector, { root = document, timeout = 10000 } = {}) {
    return new Promise((resolve, reject) => {
      const found = root.querySelector(selector);
      if (found) return resolve(found);
      const obs = new MutationObserver(() => {
        const el = root.querySelector(selector);
        if (el) { obs.disconnect(); clearTimeout(timer); resolve(el); }
      });
      obs.observe(root, { childList: true, subtree: true });
      const timer = setTimeout(() => {
        obs.disconnect();
        reject(new Error('timeout waiting for: ' + selector));
      }, timeout);
    });
  }

  /** Универсальное ожидание условия — MutationObserver + polling fallback */
  function waitForCondition(checkFn, { timeout = 10000, label = '', pollMs = 300 } = {}) {
    return new Promise((resolve, reject) => {
      let done = false;
      let lastDiag = '';

      const check = () => {
        try {
          const v = checkFn();
          if (v === true || (v && v.ok)) return { ok: true, value: v };
          if (v && typeof v === 'object' && v.diag) lastDiag = v.diag;
        } catch (e) {
          lastDiag = 'exception: ' + e.message;
        }
        return { ok: false };
      };

      const tryResolve = () => {
        if (done) return;
        const r = check();
        if (r.ok) { done = true; cleanup(); resolve(r.value); }
      };

      const obs = new MutationObserver(tryResolve);
      obs.observe(document.body, {
        childList: true, subtree: true, characterData: true, attributes: true
      });

      const poll = setInterval(tryResolve, pollMs);

      function cleanup() {
        obs.disconnect();
        clearInterval(poll);
        clearTimeout(timer);
      }

      const timer = setTimeout(() => {
        if (done) return;
        done = true;
        cleanup();
        reject(new Error(`timeout: ${label}${lastDiag ? ` (lastDiag="${lastDiag}")` : ''}`));
      }, timeout);

      tryResolve();
    });
  }

  // ============ 1. СКРОЛЛ-КОНТЕЙНЕР ============
  function getScroller() {
    const spacer = document.querySelector('.grid-content-spacer');
    if (!spacer) {
      warn('grid-content-spacer не найден');
      return document.scrollingElement;
    }

    const candidates = [spacer, spacer.parentElement, ...ancestors(spacer, 8)];

    for (const el of candidates) {
      if (!el || el === document.body || el === document.documentElement) continue;
      const st = getComputedStyle(el);
      const oy = st.overflowY;
      if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight + 5) {
        return el;
      }
    }

    let el = spacer.parentElement;
    while (el && el !== document.body) {
      if (el.scrollHeight > el.clientHeight + 5) return el;
      el = el.parentElement;
    }
    return spacer.parentElement || document.scrollingElement;
  }

  function ancestors(el, depth = 8) {
    const arr = [];
    let p = el;
    for (let i = 0; i < depth && p; i++) { p = p.parentElement; if (p) arr.push(p); }
    return arr;
  }

  async function scrollToTop(scroller) {
    if (!scroller) return;
    scroller.scrollTop = 0;
    scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
    await sleep(SCROLL_WAIT);
  }

  // ============ 2. СБОР СПИСКА ЗАЯВОК ============
  function readVisibleRows() {
    const rows = document.querySelectorAll('.grid-row');
    const result = [];
    rows.forEach(row => {
      const a = row.querySelector('a.work-item-title-link');
      if (!a) return;
      const m = (a.getAttribute('href') || '').match(/\/edit\/(\d+)/);
      if (!m) return;
      result.push({
        id: m[1],
        title: a.textContent.trim(),
        href: a.getAttribute('href')
      });
    });
    return result;
  }

  async function collectAllRows(scroller) {
    log('Сбор списка заявок...');
    const map = new Map();
    let idle = 0;
    let iter = 0;

    await scrollToTop(scroller);

    while (iter++ < MAX_SCROLL_ITER) {
      const before = map.size;
      readVisibleRows().forEach(r => { if (!map.has(r.id)) map.set(r.id, r); });
      if (map.size > before) idle = 0;

      const atBottom = !scroller ||
        scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 10;

      if (atBottom) {
        idle++;
        if (idle >= MAX_IDLE_SCROLLS) break;
        scroller.scrollTop = scroller.scrollTop + 5;
        scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
        await sleep(SCROLL_WAIT);
        continue;
      }

      scroller.scrollTop = Math.min(
        scroller.scrollTop + SCROLL_STEP,
        scroller.scrollHeight
      );
      scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
      await sleep(SCROLL_WAIT);
    }

    readVisibleRows().forEach(r => { if (!map.has(r.id)) map.set(r.id, r); });

    log(`Найдено заявок: ${map.size}`);
    return Array.from(map.values());
  }

  // ============ 3. ЧТЕНИЕ ПОЛЕЙ ПАНЕЛИ ============
  function findInput(panel, ariaLabel) {
    return panel.querySelector(`input[aria-label="${ariaLabel}"]`);
  }

  function readInputOrDisplay(panel, ariaLabel, displaySelector) {
    const inp = findInput(panel, ariaLabel);
    const v = inp?.value?.trim();
    if (v) return v;
    return panel.querySelector(displaySelector)?.textContent?.trim() || '';
  }

  function getPanel() {
    return document.querySelector('.rightPane');
  }

  function getPanelWorkItemId(panel) {
    const caption = panel.querySelector('.workitem-info-bar .caption');
    if (!caption) return null;
    const m = caption.textContent.match(/(\d{4,})/);
    return m ? m[1] : null;
  }

  function getTitle(panel) {
    const v = findInput(panel, 'Поле заголовка')?.value?.trim()
           || panel.querySelector('#witc_221_txt')?.value?.trim();
    if (v) return v;
    const caption = panel.querySelector('.workitem-info-bar .caption')?.textContent?.trim() || '';
    return caption;
  }

  function getState(panel) {
    return readInputOrDisplay(panel, 'Поле состояния', '.workitem-state-value');
  }

  function getAssignedTo(panel) {
    return panel.querySelector('.work-item-form-assignedTo .identity-picker-resolved-name')
                ?.textContent?.trim() || '';
  }

  function getArea(panel) {
    const inp = findInput(panel, 'Путь области') || panel.querySelector('#witc_225_txt');
    return inp?.value?.trim() || '';
  }

  function getIteration(panel) {
    const inp = findInput(panel, 'Путь итерации') || panel.querySelector('#witc_229_txt');
    return inp?.value?.trim() || '';
  }

  function getChangedBy(panel) {
    const indicators = panel.querySelectorAll('.freshness-indicator');
    for (const el of indicators) {
      const t = el.textContent.trim();
      if (t && el.offsetParent !== null) return t;
    }
    return '';
  }

  // ============ 4. КОММЕНТАРИИ ============
  function getExpectedCommentCount(panel) {
    const adorn = panel.querySelector('.discussion-adornment-control');
    if (adorn) {
      const m = (adorn.getAttribute('aria-label') || adorn.textContent || '').match(/(\d+)/);
      if (m) return parseInt(m[1], 10);
    }
    const badge = panel.querySelector('.discussion-adornment-container .text');
    if (badge) {
      const m = badge.textContent.match(/(\d+)/);
      if (m) return parseInt(m[1], 10);
    }
    return null;
  }

  async function ensureDiscussionExpanded(panel) {
    const header = Array.from(panel.querySelectorAll('.tfs-collapsible-header'))
      .find(h => /Обсуждени/i.test(h.textContent));
    if (!header) return;
    const content = header.parentElement?.querySelector('.tfs-collapsible-content');
    if (content && content.style.display === 'none') {
      (header.querySelector('.headerlabel') || header).click();
      await sleep(400);
    }
  }

  function parseRelativeTime(s) {
    if (!s) return Infinity;
    const t = s.toLowerCase();
    const m = t.match(/(\d+)\s*(сек|мин|ч|дн|час|день|недел)/);
    if (!m) return Infinity;
    const n = parseInt(m[1], 10);
    const unit = m[2];
    if (/сек/.test(unit)) return n;
    if (/мин/.test(unit)) return n * 60;
    if (/ч|час/.test(unit)) return n * 3600;
    if (/дн|день/.test(unit)) return n * 86400;
    if (/недел/.test(unit)) return n * 604800;
    return Infinity;
  }

  function extractCommentData(ci) {
    const author = ci.querySelector('.user-display-name')?.textContent.trim() || '';
    const tsRaw  = ci.querySelector('.comment-timestamp')?.textContent.trim() || '';
    const body   = (ci.querySelector('.comment-content')?.innerText || '').trim();
    const idAttr = ci.querySelector('[data-comment-id]')?.getAttribute('data-comment-id');
    const id     = idAttr ? parseInt(idAttr, 10) : null;
    return { id, author, tsRaw, ts: parseRelativeTime(tsRaw), body };
  }

  function pickLatest(comments) {
    if (!comments.length) return null;
    const withId = comments.filter(c => c.id != null);
    if (withId.length) return withId.reduce((a, b) => (b.id > a.id ? b : a));
    return comments.reduce((a, b) => (b.ts < a.ts ? b : a));
  }

  async function loadAllComments(panel, { timeout = COMMENTS_TIMEOUT } = {}) {
    await ensureDiscussionExpanded(panel);
    const expected = getExpectedCommentCount(panel);
    if (expected === 0) return [];

    const messagesRoot =
      panel.querySelector('.discussion-messages') ||
      panel.querySelector('.discussion-messages-container') ||
      panel.querySelector('.discussion-control');
    if (!messagesRoot) return [];

    let scroller = messagesRoot;
    while (scroller && scroller !== panel) {
      const st = getComputedStyle(scroller);
      if ((st.overflowY === 'auto' || st.overflowY === 'scroll') &&
          scroller.scrollHeight > scroller.clientHeight + 5) break;
      scroller = scroller.parentElement;
    }
    if (scroller === panel) scroller = messagesRoot;

    const started = performance.now();
    let lastCount = -1, stableTicks = 0;
    const maxTicks = Math.ceil(timeout / 300);

    for (let tick = 0; tick < maxTicks; tick++) {
      const count = panel.querySelectorAll('.comment-item').length;

      if (expected != null && count >= expected) break;
      if (expected == null) {
        if (count === lastCount) stableTicks++;
        else stableTicks = 0;
        if (stableTicks >= 3 && count > 0) break;
        if (stableTicks >= 5 && count === 0) break;
      }

      if (scroller) {
        scroller.scrollTop = scroller.scrollHeight;
        scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
      }
      window.scrollBy(0, 1); window.scrollBy(0, -1);

      lastCount = count;
      await sleep(300);
      if (performance.now() - started > timeout) break;
    }

    return Array.from(panel.querySelectorAll('.comment-item')).map(extractCommentData);
  }

  async function waitForComments(panel, opts = {}) {
    const all = await loadAllComments(panel, opts);
    return { all, latest: pickLatest(all) };
  }

  // ============ 5. ОЖИДАНИЕ ЗАГРУЗКИ ПАНЕЛИ ============
  async function waitForPanelLoaded(expectedId, { timeout = PANEL_LOAD_TIMEOUT } = {}) {
    const panelSelector = '.rightPane';
    await waitFor(panelSelector, { timeout });

    const started = performance.now();

    await waitForCondition(() => {
      const panel = document.querySelector(panelSelector);
      if (!panel) return { ok: false, diag: 'no panel' };

      const caption = panel.querySelector('.workitem-info-bar .caption');
      if (!caption) return { ok: false, diag: 'no caption' };
      const m = caption.textContent.match(/(\d{4,})/);
      if (!m) return { ok: false, diag: `caption without id` };
      if (expectedId && m[1] !== String(expectedId)) {
        return { ok: false, diag: `caption id ${m[1]} != ${expectedId}` };
      }

      const stateInput = findInput(panel, 'Поле состояния') || panel.querySelector('#witc_223_txt');
      const stateText =
        (stateInput?.value || '').trim() ||
        (panel.querySelector('.workitem-state-value')?.textContent || '').trim();
      if (!stateText) return { ok: false, diag: 'state empty' };

      const tabs = panel.querySelectorAll('.work-item-form-tab');
      if (tabs.length < 3) return { ok: false, diag: `tabs=${tabs.length}` };

      if (!panel.querySelector('.work-item-form-tab.selected-tab')) {
        return { ok: false, diag: 'no active tab' };
      }

      const discussion = panel.querySelector('.discussion-control, .discussion-messages-container');
      if (!discussion) return { ok: false, diag: 'no discussion container' };

      return { ok: true };
    }, { timeout, label: 'panel fields ready' });

    log(`Панель загружена за ${Math.round(performance.now() - started)} мс (id=${expectedId})`);
  }

  // ============ 6. ОТКРЫТИЕ ЗАЯВКИ ============
  async function findRowLink(itemId, scroller) {
    const sel = `a.work-item-title-link[href*="/edit/${itemId}"]`;
    let link = document.querySelector(sel);
    if (link) return link;

    if (scroller) {
      scroller.scrollTop = 0;
      scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
      await sleep(300);

      const total = scroller.scrollHeight;
      for (let pos = 0; pos <= total; pos += SCROLL_STEP) {
        scroller.scrollTop = pos;
        scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
        await sleep(250);
        link = document.querySelector(sel);
        if (link) return link;
      }
    }
    return null;
  }

  async function openWorkItem(item, scroller) {
    const link = await findRowLink(item.id, scroller);
    if (!link) throw new Error(`Не найдена строка для заявки ${item.id}`);

    link.scrollIntoView({ block: 'center' });
    await sleep(200);

    const opts = { bubbles: true, cancelable: true, view: window, button: 0 };
    link.dispatchEvent(new MouseEvent('mousedown', opts));
    link.dispatchEvent(new MouseEvent('mouseup', opts));
    link.dispatchEvent(new MouseEvent('click', opts));

    await waitForPanelLoaded(item.id);
  }

  // ============ 7. ГЛАВНЫЙ ЦИКЛ ============
  async function main() {
    const scroller = getScroller();
    log('Скролл-контейнер:', scroller);
    const sc = scroller || document.scrollingElement;

    await scrollToTop(sc);

    const items = await collectAllRows(sc);
    if (!items.length) { warn('Заявок не найдено'); return; }

    const report = [];
    for (let i = 0; i < items.length; i++) {
      const it = items[i];
      log(`(${i + 1}/${items.length}) Открываю ${it.id} — ${it.title}`);
      try {
        await openWorkItem(it, sc);
        const panel = getPanel();

        const { all: comments, latest } = await waitForComments(panel);

        const data = {
          id: it.id,
          title: getTitle(panel),
          state: getState(panel),
          assignedTo: getAssignedTo(panel),
          area: getArea(panel),
          iteration: getIteration(panel),
          changedBy: getChangedBy(panel),
          commentsCount: comments.length,
          latestComment: latest
            ? `${latest.author} [${latest.tsRaw}]: ${latest.body}`
            : '',
          comments
        };

        report.push(data);
        log(`   ✓ ${data.state || '?'} | ${data.assignedTo || '?'} | комм.: ${data.commentsCount}, свежий: ${latest ? `[${latest.author}] ${latest.body.slice(0, 60)}` : '—'}`);
      } catch (e) {
        warn(`   ✗ Ошибка ${it.id}: ${e.message}`);
        report.push({ id: it.id, title: it.title, error: e.message });
      }

      await sleep(DELAY_BETWEEN_ROWS);
    }

    // ============ ВЫГРУЗКА ============
    console.table(report.map(r => ({
      id: r.id,
      title: r.title,
      state: r.state,
      assignedTo: r.assignedTo,
      'комм. всего': r.commentsCount ?? '—',
      'последний': r.latestComment ? r.latestComment.slice(0, 100) : '—',
      error: r.error || ''
    })));
    window.__tfsReport = report;

    // JSON
    const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = `tfs_report_${Date.now()}.json`;
    document.body.appendChild(a);
    a.click();
    a.remove();

    // CSV
    const csvEsc = (s) => `"${String(s ?? '').replace(/"/g, '""').replace(/\r?\n/g, ' ')}"`;
    const csv = [
      ['id', 'title', 'state', 'assignedTo', 'area', 'iteration', 'changedBy',
       'commentsCount', 'latestComment', 'error'].map(csvEsc).join(';'),
      ...report.map(r => [
        r.id, r.title, r.state, r.assignedTo, r.area, r.iteration, r.changedBy,
        r.commentsCount ?? '',
        r.latestComment ?? '',
        r.error || ''
      ].map(csvEsc).join(';'))
    ].join('\r\n');

    const blobCsv = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
    const aCsv = document.createElement('a');
    aCsv.href = URL.createObjectURL(blobCsv);
    aCsv.download = `tfs_report_${Date.now()}.csv`;
    document.body.appendChild(aCsv);
    aCsv.click();
    aCsv.remove();

    log('Готово. Отчёт: window.__tfsReport');
  }

  main().catch(e => console.error('[TFS-Report] FATAL:', e));
})();