Загрузка данных
(async function() {
if (window._isTfsCollecting) {
alert("Сбор уже запущен!");
return;
}
window._isTfsCollecting = true;
const resultsMap = new Map();
window._tfsResultsMap = resultsMap;
// Плавающий оверлей со статусом
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;top:15px;right:15px;z-index:999999;background:#107c41;color:#fff;padding:14px 20px;border-radius:8px;box-shadow:0 6px 18px rgba(0,0,0,0.4);font-family:Segoe UI,sans-serif;font-size:13px;max-width:380px;line-height:1.4;";
overlay.innerHTML = `
<div style="font-weight:600;font-size:14px;margin-bottom:4px;">Сбор заявок TFS</div>
<div id="tfs-status" style="min-height:36px;font-size:12px;opacity:0.95;">Инициализация...</div>
<div style="margin-top:10px;display:flex;gap:8px;">
<button id="tfs-stop-btn" style="flex:1;padding:6px 10px;background:#fff;color:#222;border:none;border-radius:4px;cursor:pointer;font-weight:600;font-size:12px;">Остановить и выгрузить</button>
</div>
`;
document.body.appendChild(overlay);
let stopRequested = false;
document.getElementById("tfs-stop-btn").onclick = () => {
stopRequested = true;
updateStatus("Остановка... Формирую CSV файл.");
};
function updateStatus(msg) {
const el = document.getElementById("tfs-status");
if (el) el.innerHTML = msg;
}
function downloadCSV(data) {
let csvContent = "Номер заявки;Последний комментарий\n";
for (const row of data) {
const num = String(row.number || "").replace(/[\n\r;]/g, " ").trim();
const comment = String(row.comment || "")
.replace(/"/g, '""')
.replace(/[\n\r]+/g, " ");
csvContent += `"${num}";"${comment}"\n`;
}
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `заявки_TFS_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Извлечение ID и выбор приоритетного элемента для клика
function findRowInfo(row) {
const cells = Array.from(row.querySelectorAll('.grid-cell'));
let id = null;
for (const cell of cells) {
const txt = cell.innerText.trim();
if (/^\d{4,8}$/.test(txt)) {
id = txt;
break;
}
}
if (!id) return null;
const idCell = cells.find(c => c.innerText.trim() === id);
const titleLink = row.querySelector('.work-item-title-link');
const clickable = idCell || row || titleLink;
return { id, clickable };
}
// Безопасный клик: отменяем переход по ссылке, сохраняя события
function safeClick(element) {
const targetAnchor = element.tagName === 'A' ? element : element.closest('a');
if (targetAnchor) {
targetAnchor.addEventListener('click', e => e.preventDefault(), { capture: true, once: true });
}
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}
// Определение ID открытой карточки формы в правой панели
function getActiveFormId() {
const selectors = [
'.workitem-header-bar .work-item-form-id',
'.work-item-form-id span',
'.work-item-form-id',
'.workitem-header-bar .info-text',
'.work-item-form-header span',
'.work-item-form-header',
'.work-item-title-textfield',
'.workitem-info-bar'
];
for (const sel of selectors) {
const elements = document.querySelectorAll(sel);
for (const el of elements) {
if (el.closest('.grid-canvas, .slick-viewport')) continue;
const m = el.innerText.match(/\b\d{4,8}\b/);
if (m) return m[0];
}
}
const formLinks = document.querySelectorAll('.work-item-form a[href*="/_workitems/edit/"], .workitem-header-bar a[href*="/_workitems/edit/"]');
for (const link of formLinks) {
const m = link.href.match(/_workitems\/edit\/(\d{4,8})/);
if (m) return m[1];
}
return null;
}
function isDiscussionRendered() {
return !!document.querySelector('.comments-section, .discussion-messages-container, .work-item-discussion-control, .discussion-control, .work-item-comments');
}
function isFormLoading() {
const spinners = document.querySelectorAll('.bowtie-spinner, .status-indicator, .workitem-form-loading, .form-loading');
for (const s of spinners) {
if (s.offsetParent !== null) return true;
}
return false;
}
function getCommentsSignature() {
const comments = document.querySelectorAll('.comments-section .comment-item, .discussion-messages-container .comment-item, .work-item-comments .comment-item');
if (!comments || comments.length === 0) return "EMPTY";
const first = comments[0];
const last = comments[comments.length - 1];
return `${comments.length}_${first.getAttribute('data-comment-id') || ''}_${last.innerText.slice(0, 30)}`;
}
// Извлечение ТОЛЬКО последнего комментария (в TFS новые сверху)
function extractComments() {
const commentItems = document.querySelectorAll('.comments-section .comment-item, .discussion-messages-container .comment-item, .work-item-comments .comment-item');
if (!commentItems || commentItems.length === 0) {
return "Комментариев нет";
}
for (let i = 0; i < commentItems.length; i++) {
const item = commentItems[i];
const contentEl = item.querySelector('.comment-content, .message-content') || item;
const text = contentEl.innerText.trim().replace(/\s+/g, ' ');
if (text) {
return text;
}
}
return "Комментариев нет";
}
// Поиск реального контейнера прокрутки (правый вьюпорт в SlickGrid)
function findScrollContainer() {
// 1. Приоритет: .slick-viewport-right или вьюпорты с реальным скроллом
const viewports = Array.from(document.querySelectorAll('.slick-viewport-right, .slick-viewport, .grid-viewport'));
const scrollable = viewports.find(el => {
const ov = window.getComputedStyle(el).overflowY;
return el.scrollHeight > el.clientHeight + 10 && (ov === 'auto' || ov === 'scroll');
});
if (scrollable) return scrollable;
// 2. Поиск родителя .grid-canvas с реальным overflow
const canvases = document.querySelectorAll('.grid-canvas');
for (const c of canvases) {
let p = c.parentElement;
while (p && p !== document.body) {
if (p.scrollHeight > p.clientHeight + 10) {
const ov = window.getComputedStyle(p).overflowY;
if (ov === 'auto' || ov === 'scroll') return p;
}
p = p.parentElement;
}
}
// 3. Fallback: контейнер с максимальным запасом скролла
let best = null, maxDiff = 0;
document.querySelectorAll('div').forEach(el => {
const diff = el.scrollHeight - el.clientHeight;
if (diff > maxDiff && el.clientHeight > 150) {
const ov = window.getComputedStyle(el).overflowY;
if (ov === 'auto' || ov === 'scroll') {
maxDiff = diff;
best = el;
}
}
});
return best;
}
try {
const scrollContainer = findScrollContainer();
if (!scrollContainer) throw new Error("Не найден скроллируемый контейнер таблицы TFS.");
updateStatus("Перемотка списка наверх...");
scrollContainer.scrollTop = 0;
scrollContainer.dispatchEvent(new Event('scroll', { bubbles: true }));
await new Promise(r => setTimeout(r, 800));
let bottomStallCount = 0;
while (!stopRequested) {
// Собираем строки таблицы в DOM
const domRows = Array.from(document.querySelectorAll('.grid-row'))
.filter(r => r.style.display !== 'none')
.sort((a, b) => a.offsetTop - b.offsetTop);
for (const row of domRows) {
if (stopRequested) break;
const rowInfo = findRowInfo(row);
if (!rowInfo) continue;
const { id, clickable } = rowInfo;
if (resultsMap.has(id)) continue;
const prevSignature = getCommentsSignature();
updateStatus(`[${resultsMap.size + 1}] Открываю #${id}...`);
safeClick(clickable);
// Ожидание смены ID в форме (до 4 сек)
let formOpened = false;
for (let i = 0; i < 20; i++) {
await new Promise(r => setTimeout(r, 200));
if (getActiveFormId() === id) {
formOpened = true;
break;
}
}
// Ожидание загрузки комментариев
updateStatus(`[${resultsMap.size + 1}] Загрузка комментариев #${id}...`);
for (let i = 0; i < 25; i++) {
await new Promise(r => setTimeout(r, 200));
if (isFormLoading()) continue;
const curSignature = getCommentsSignature();
const discussionReady = isDiscussionRendered();
if (curSignature !== prevSignature && curSignature !== "EMPTY") {
break;
}
if (discussionReady && formOpened && i >= 4) {
break;
}
}
await new Promise(r => setTimeout(r, 200));
const comment = extractComments();
resultsMap.set(id, comment);
updateStatus(`Собрано: <b>${resultsMap.size}</b><br>Последняя: #${id} (${comment === "Комментариев нет" ? "без комм." : "комм. получен"})`);
}
// Проверка достижения реального конца списка
const currentTop = scrollContainer.scrollTop;
const maxScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight;
const isBottom = maxScroll > 0 && (maxScroll - currentTop <= 15);
const visibleRowsWithIds = domRows.map(findRowInfo).filter(Boolean);
const allVisibleProcessed = visibleRowsWithIds.length > 0 && visibleRowsWithIds.every(info => resultsMap.has(info.id));
if (isBottom && allVisibleProcessed) {
bottomStallCount++;
if (bottomStallCount >= 3) {
break; // Действительно дошли до низа списка
}
await new Promise(r => setTimeout(r, 800));
} else {
bottomStallCount = 0;
}
// Шаг скролла: половина высоты видимой зоны
const scrollStep = Math.max(160, Math.floor(scrollContainer.clientHeight * 0.5));
const targetTop = Math.min(currentTop + scrollStep, maxScroll > 0 ? maxScroll : currentTop + scrollStep);
updateStatus(`Собрано: <b>${resultsMap.size}</b>. Прокручиваю список дальше...`);
scrollContainer.scrollTop = targetTop;
scrollContainer.dispatchEvent(new Event('scroll', { bubbles: true }));
// Синхронизируем левый вьюпорт, если он отдельный
const leftViewport = document.querySelector('.slick-viewport-left');
if (leftViewport && leftViewport !== scrollContainer) {
leftViewport.scrollTop = targetTop;
leftViewport.dispatchEvent(new Event('scroll', { bubbles: true }));
}
// Пауза, чтобы SlickGrid успел создать в DOM новые строки
await new Promise(r => setTimeout(r, 600));
}
const finalData = Array.from(resultsMap.entries()).map(([number, comment]) => ({ number, comment }));
updateStatus(`<b>Готово!</b> Собрано: ${finalData.length}. Скачиваю CSV...`);
downloadCSV(finalData);
setTimeout(() => overlay.remove(), 7000);
} catch (err) {
alert("Ошибка сбора TFS: " + err.message);
if (overlay) overlay.remove();
} finally {
window._isTfsCollecting = false;
}
})();