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


(async function () {
  'use strict';

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

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

  function log(...args) {
    console.log('%c[TFS-Report]', 'color:#0a0;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(); resolve(el); }
      });
      obs.observe(root, { childList: true, subtree: true });
      setTimeout(() => { obs.disconnect(); reject(new Error('timeout: ' + selector)); }, timeout);
    });
  }

  /** Дождаться, пока условие выполнится (с наблюдателем) */
  function waitForCondition(checkFn, { timeout = 10000, label = '' } = {}) {
    return new Promise((resolve, reject) => {
      let done = false;
      const tryResolve = () => {
        if (done) return;
        try {
          const v = checkFn();
          if (v) { done = true; obs.disconnect(); clearTimeout(timer); resolve(v); }
        } catch (_) { /* ignore */ }
      };
      const obs = new MutationObserver(tryResolve);
      obs.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true });
      const timer = setTimeout(() => { if (!done) { obs.disconnect(); reject(new Error('timeout: ' + label)); } }, timeout);
      tryResolve();
    });
  }

  // ============ 1. СКРОЛЛ-КОНТЕЙНЕР ============
  function getScroller() {
    // В присланной разметке: <div class="grid-content-spacer" role="presentation">
    // Обычно скролл-контейнер — это родитель .grid-content-spacer,
    // но иногда сам spacer имеет overflow. Проверим оба.
    const spacer = document.querySelector('.grid-content-spacer');
    if (!spacer) return null;

    const candidates = [spacer.parentElement, spacer, ...spacer.parentElement?.children ? [spacer.parentElement] : []];
    for (const el of candidates) {
      if (!el) continue;
      const st = getComputedStyle(el);
      const oy = st.overflowY;
      if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight + 5) {
        return el;
      }
    }
    // fallback — ближайший предок с переполнением
    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;
  }

  async function scrollToTop(scroller) {
    if (!scroller) return;
    scroller.scrollTop = 0;
    // некоторые гриды реагируют только на wheel/scroll события
    scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
    await sleep(SCROLL_WAIT);
  }

  // ============ 2. СБОР СПИСКА ЗАЯВОК (id -> title) ============
  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 idMatch = a.getAttribute('href')?.match(/\/edit\/(\d+)/);
      if (!idMatch) return;
      result.push({
        id: idMatch[1],
        title: a.textContent.trim(),
        href: a.getAttribute('href'),
        rowEl: row
      });
    });
    return result;
  }

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

    await scrollToTop(scroller);

    // защита от бесконечного цикла
    const maxIterations = 500;
    let iter = 0;

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

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

      if (atBottom) {
        // прокрутим ещё раз — вдруг догрузится
        idle++;
        if (idle >= 2) break; // две прокрутки без нового контента — всё
      } else {
        idle = 0;
      }

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

      // если добавилось — сбросим idle
      if (map.size > before) idle = 0;
    }

    // Ещё раз собираем всё, что видно в конце
    readVisibleRows().forEach(r => { if (!map.has(r.id)) map.set(r.id, r); });

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

  // ============ 3. ПАРСИНГ ПРАВОЙ ПАНЕЛИ ============
  function getRightPanel() {
    return document.querySelector('.rightPane .work-item-form');
  }

  function getFieldValueById(panel, inputIdPrefix) {
    // inputIdPrefix например: 'witc_221' (заголовок), 'witc_235' (подсистема)
    const inp = panel.querySelector(`input[id^="${inputIdPrefix}_"]`);
    if (inp) return inp.value || inp.getAttribute('title') || '';
    // некоторые поля — read-only input
    return '';
  }

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

  function getState(panel) {
    const inp = panel.querySelector('#witc_223_txt');
    if (inp && inp.value) return inp.value.trim();
    const span = panel.querySelector('.workitem-state-value');
    return span ? span.textContent.trim() : '';
  }

  function getTitleFromPanel(panel) {
    const inp = panel.querySelector('#witc_221_txt');
    return inp ? inp.value.trim() : '';
  }

  function getCreatedBy(panel) {
    const el = panel.querySelector('.work-item-form-changedby .freshness-indicator');
    return el ? el.textContent.trim() : '';
  }

  function getComments(panel) {
    const comments = [];
    panel.querySelectorAll('.comment-item').forEach(ci => {
      const author = ci.querySelector('.user-display-name')?.textContent.trim() || '';
      const ts = ci.querySelector('.comment-timestamp')?.textContent.trim() || '';
      const body = ci.querySelector('.comment-content')?.innerText.trim() || '';
      comments.push({ author, ts, body });
    });
    return comments;
  }

  function getIteration(panel) {
    return panel.querySelector('#witc_229_txt')?.value || '';
  }

  function getArea(panel) {
    return panel.querySelector('#witc_225_txt')?.value || '';
  }

  // ============ 4. ОЖИДАНИЕ ЗАГРУЗКИ ПАНЕЛИ ============
  async function waitForPanelLoaded(expectedId) {
    const panelSelector = '.rightPane .work-item-form';
    const start = performance.now();

    // ждём появления панели
    await waitFor(panelSelector, { timeout: PANEL_LOAD_TIMEOUT });

    // ждём, пока появится caption с номером и он совпадёт с ожидаемым
    await waitForCondition(() => {
      const panel = document.querySelector(panelSelector);
      if (!panel) return false;
      const caption = panel.querySelector('.workitem-info-bar .caption');
      if (!caption) return false;
      const m = caption.textContent.match(/(\d{5,})/);
      if (!m) return false;
      if (expectedId && m[1] !== String(expectedId)) return false;

      // поля заголовка заполнены
      const titleInp = panel.querySelector('#witc_221_txt');
      if (!titleInp) return false;
      if (!titleInp.value || !titleInp.value.trim()) return false;

      // состояние
      const stateInp = panel.querySelector('#witc_223_txt');
      if (!stateInp || !stateInp.value) return false;

      // вкладки «Подробности» / «Обсуждение» отрисованы
      if (!panel.querySelector('#vss_1349')) return false;

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

    // ждём, пока прогрузятся комментарии (если они вообще есть)
    await sleep(400);

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

  // ============ 5. КЛИК ПО ЗАЯВКЕ ============
  async function openWorkItem(item, scroller) {
    // Убедимся, что строка в DOM — иначе скроллим
    let row = document.getElementById('row_vss_4_0')?.querySelector?.(`a[href*="/edit/${item.id}"]`);
    if (!row) {
      // найдём по href среди всех
      row = document.querySelector(`a.work-item-title-link[href*="/edit/${item.id}"]`);
    }

    if (!row) {
      // скроллим к строке
      log(`Строка ${item.id} не найдена, скроллю...`);
      // Простой подход: прокрутить контейнер до тех пор, пока не появится ссылка
      const maxScroll = scroller ? scroller.scrollHeight : 0;
      let found = false;
      if (scroller) {
        scroller.scrollTop = 0;
        await sleep(300);
        for (let pos = 0; pos < maxScroll; pos += SCROLL_STEP) {
          scroller.scrollTop = pos;
          scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
          await sleep(250);
          row = document.querySelector(`a.work-item-title-link[href*="/edit/${item.id}"]`);
          if (row) { found = true; break; }
        }
      }
      if (!found) throw new Error(`Не удалось найти строку ${item.id}`);
    }

    row.scrollIntoView({ block: 'center' });
    await sleep(200);
    row.click();
    await waitForPanelLoaded(item.id);
  }

  // ============ 6. ГЛАВНЫЙ ЦИКЛ ============
  async function main() {
    const scroller = getScroller();
    if (!scroller) {
      console.warn('[TFS-Report] Скролл-контейнер не найден. Работаем с document.scrollingElement.');
    } else {
      log('Скролл-контейнер найден:', scroller);
    }
    const sc = scroller || document.scrollingElement;

    // 1) вверх
    await scrollToTop(sc);

    // 2) собрать все строки
    const items = await collectAllRows(sc);
    if (!items.length) { log('Заявок не найдено'); return; }

    // 3) парсим каждую
    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 = getRightPanel();

        const data = {
          id: it.id,
          title: getTitleFromPanel(panel) || it.title,
          state: getState(panel),
          assignedTo: getAssignedTo(panel),
          area: getArea(panel),
          iteration: getIteration(panel),
          changedBy: getCreatedBy(panel),
          comments: getComments(panel)
        };

        report.push(data);
        log(`   ✓ ${data.state} | ${data.assignedTo} | комм.: ${data.comments.length}`);
      } catch (e) {
        log(`   ✗ Ошибка: ${e.message}`);
        report.push({ id: it.id, title: it.title, error: e.message });
      }

      await sleep(DELAY_BETWEEN_ROWS);
    }

    // 4) Выгрузка
    console.table(report.map(r => ({
      id: r.id, title: r.title, state: r.state,
      assignedTo: r.assignedTo, comments: r.comments?.length ?? '—', 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`;
    a.click();

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

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