Загрузка данных
(async function () {
'use strict';
// ============ КОНФИГ ============
const DELAY_BETWEEN_ROWS = 1500; // пауза между заявками (мс)
const PANEL_LOAD_TIMEOUT = 20000; // максимум ждём загрузки панели
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;
}
// Перебираем: сам spacer, его родитель, и ближайшие предки
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;
}
}
// 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;
}
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. ЧТЕНИЕ ПОЛЕЙ ПАНЕЛИ ============
/** Поиск поля по aria-label (надёжнее, чем autogen #witc_XXX) */
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;
// fallback — заголовок из caption (там "Заявка на изменение 5122794")
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 '';
}
function getComments(panel) {
const items = panel.querySelectorAll('.comment-item');
const out = [];
items.forEach(ci => {
out.push({
author: ci.querySelector('.user-display-name')?.textContent.trim() || '',
ts: ci.querySelector('.comment-timestamp')?.textContent.trim() || '',
body: (ci.querySelector('.comment-content')?.innerText || '').trim()
});
});
return out;
}
// ============ 4. ОЖИДАНИЕ ЗАГРУЗКИ ПАНЕЛИ ============
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' };
// caption с нужным id
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: "${caption.textContent.slice(0,40)}"` };
if (expectedId && m[1] !== String(expectedId)) {
return { ok: false, diag: `caption id ${m[1]} != ${expectedId}` };
}
// состояние отрисовано (input ИЛИ display)
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' };
}
// блок обсуждения присутствует (даже если пустой — важно, что он есть в DOM)
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})`);
}
// ============ 5. ОТКРЫТИЕ ЗАЯВКИ ============
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);
// pointer + mouse события — на случай кастомных обработчиков
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);
}
// ============ 6. ГЛАВНЫЙ ЦИКЛ ============
async function main() {
const scroller = getScroller();
log('Скролл-контейнер:', scroller);
const sc = scroller || document.scrollingElement;
// 1) наверх
await scrollToTop(sc);
// 2) собрать список заявок
const items = await collectAllRows(sc);
if (!items.length) { warn('Заявок не найдено'); 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 = getPanel();
const data = {
id: it.id,
title: getTitle(panel),
state: getState(panel),
assignedTo: getAssignedTo(panel),
area: getArea(panel),
iteration: getIteration(panel),
changedBy: getChangedBy(panel),
comments: getComments(panel)
};
report.push(data);
log(` ✓ ${data.state || '?'} | ${data.assignedTo || '?'} | комм.: ${data.comments.length}`);
} catch (e) {
warn(` ✗ Ошибка ${it.id}: ${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 ? 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`;
document.body.appendChild(a);
a.click();
a.remove();
// CSV-выгрузка (удобно для Excel)
const csvEsc = (s) => `"${String(s ?? '').replace(/"/g, '""').replace(/\r?\n/g, ' ')}"`;
const csv = [
['id', 'title', 'state', 'assignedTo', 'area', 'iteration', 'changedBy', 'comments', 'error'].map(csvEsc).join(';'),
...report.map(r => [
r.id, r.title, r.state, r.assignedTo, r.area, r.iteration, r.changedBy,
(r.comments || []).map(c => `[${c.author} / ${c.ts}] ${c.body}`).join(' | '),
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));
})();