Загрузка данных
(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));
})();