Instrukcja użycia→ Zainstaluj rozszerzenie Tampermonkey w Chrome
→ Otwórz dashboard Tampermonkey → Utwórz nowy skrypt
→ Wklej poniższy kod i zapisz (Ctrl+S)
→ Wejdź na tagassistant.google.com → widget pojawi się automatycznie
→ Zaznacz eventy na liście po lewej → kliknij „Kopiuj zaznaczone”
Skrypt GTM Event Walker
// ==UserScript==
// @name GTM Event Walker
// @namespace https://pawelpiekarski.pl/
// @version 3.6.2
// @description Zaznacz eventy → skrypt przechodzi po nich i kopiuje tagi/zmienne/dataLayer/consent jako jeden markdown. Presety: ecommerce / B2B / kliknięcia przed nimi.
// @author Paweł Piekarski
// @match https://tagassistant.google.com/*
// @icon https://www.gstatic.com/analytics-suite/header/suite/v2/ic_tag_manager.svg
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const WID = 'ta-tb';
const LS_COL = 'taEwCol';
const LS_OPTS = 'taEwOpts3'; // v3.3: wszystko domyślnie zaznaczone (dedup, hideNoise) — nowy klucz, żeby stary zapis nie nadpisał defaultów
const LS_POS = 'taEwPos';
const LS_SETS = 'taEwSets'; // v2.9: edytowalne listy eventów dla presetów
const POLL_MS = 1200;
let collapsed = localStorage.getItem(LS_COL) === '1';
let widget = null;
/* ═══════════════════ STATE ═══════════════════ */
let eventSearch = '';
let eventSearchRegex = null;
let eventSearchError = '';
let instanceGroups = []; // [{title, url, rows:[{num,name,key,el}]}]
const manualOn = new Set(); // eventy zaznaczone ręcznie (tickiem)
const manualOff = new Set(); // eventy ręcznie ODZNACZONE — nadpisują zaznaczenie z presetu
let lastClickedKey = ''; // ostatnio kliknięty wiersz — do zaznaczania zakresów shiftem
let renderedOrder = []; // kolejność wierszy na liście widgetu (do shift-clicka)
let lastInstanceKey = '';
let walking = false; // trwa przechodzenie po eventach
let cancelWalk = false; // żądanie przerwania walkera
let lastOutput = null; // cache ostatnio zebranego outputu — podgląd/edycja bez ponownego przeklikiwania
const globalTags = new Set(); // globalne "Dotyczy tagów" — trafia na początek outputu
const knownTagNames = new Set(); // podpowiedzi: WYŁĄCZNIE tagi zaczytane z Tags Fired / Tags Not Fired
// Opcje kopiowania (checkboxy na dole) — nadpisywane z localStorage
const opts = {
tags: true,
vars: true,
dl: true,
consent: true, // zakładka Consent (sekcja pomijana, gdy brak danych)
skipUndefined: true, // pomiń zmienne undefined
varsDiff: true, // zmienne jako diff vs poprzedni event
dedup: true, // kopiuj tylko pierwszy event z każdej nazwy (duplikaty pomijane)
filterTree: true, // drzewko GTM pokazuje tylko zaznaczone eventy (gdy coś zaznaczono)
hideNoise: true, // ukryj szum (lista skip: Message/History/Consent…) na liście i w drzewku
};
function loadOpts() {
try {
const saved = JSON.parse(localStorage.getItem(LS_OPTS) || '{}');
Object.keys(opts).forEach(k => { if (typeof saved[k] === 'boolean') opts[k] = saved[k]; });
} catch (_) {}
}
function saveOpts() {
try { localStorage.setItem(LS_OPTS, JSON.stringify(opts)); } catch (_) {}
}
function savePos() {
if (!widget) return;
const r = widget.getBoundingClientRect();
try { localStorage.setItem(LS_POS, JSON.stringify({ left: r.left, top: r.top })); } catch (_) {}
}
function restorePos() {
if (!widget) return;
try {
const p = JSON.parse(localStorage.getItem(LS_POS) || 'null');
if (!p) return;
// Nie wypuszczaj widgetu poza viewport (np. po zmianie rozdzielczości)
const left = Math.min(Math.max(0, p.left), window.innerWidth - 60);
const top = Math.min(Math.max(0, p.top), window.innerHeight - 60);
widget.style.left = left + 'px';
widget.style.top = top + 'px';
widget.style.right = 'auto';
widget.style.bottom = 'auto';
} catch (_) {}
}
/* ═══════════════════ THEME ═══════════════════ */
const C = {
bg: '#111827', hover: '#1e293b',
accent: '#3b82f6', accentDk: '#1e3a5f',
green: '#22c55e', red: '#ef4444',
text: '#e5e7eb', dim: '#6b7280', border: '#374151',
orange: '#f97316',
};
const COLORS = ['#3b82f6','#ef4444','#22c55e','#eab308','#a855f7','#f97316','#06b6d4','#ec4899','#14b8a6','#f43f5e','#8b5cf6','#64748b','#84cc16','#0ea5e9','#d946ef'];
const ecMap = {}; let eci = 0;
function ec(t) { if (!ecMap[t]) ecMap[t] = COLORS[eci++ % COLORS.length]; return ecMap[t]; }
function escHtml(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
const sleep = ms => new Promise(r => setTimeout(r, ms));
/* ═══════════════════ KOMPRESJA OUTPUTU ═══════════════════ */
/** Usuwa artefakty łamania linii Tag Assistanta: `..." + \n"...` sklejane w jeden string */
function cleanStr(s) {
return s.replace(/"\s*\+\s*\n?\s*"/g, '');
}
const MAX_STR = 220;
/** Przycina bardzo długie wartości (np. Click Element z pełnym CSS-pathem) */
function truncateStr(s, max = MAX_STR) {
if (typeof s !== 'string' || s.length <= max) return s;
return s.slice(0, max) + `… [skrócono, +${s.length - max} zn.]`;
}
/* ═══════════════════ SEARCH (text/regex) ═══════════════════ */
const REGEX_SIGNAL = /[|\\^$*+?.()\[\]{}]/;
function compileEventSearch(raw) {
eventSearch = raw; eventSearchError = ''; eventSearchRegex = null;
if (raw && REGEX_SIGNAL.test(raw)) {
try { eventSearchRegex = new RegExp(raw, 'i'); }
catch (e) { eventSearchError = e.message.replace(/^Invalid regular expression: /, ''); }
}
}
function matchesSearch(name) {
if (!eventSearch || eventSearchError) return true;
if (eventSearchRegex) return eventSearchRegex.test(name);
return name.toLowerCase().includes(eventSearch.toLowerCase());
}
/* ═══════════════════ PRESETY EVENTÓW (v2.9) ═══════════════════ */
/**
* Cztery szybkie filtry:
* - ecom → eventy e-commerce (GA4)
* - ecomClicks → kliknięcia bezpośrednio POPRZEDZAJĄCE event e-commerce (fallback: purchase_click itp.)
* - b2b → eventy leadowe/B2B
* - b2bClicks → kliknięcia bezpośrednio poprzedzające event B2B
* Listy edytowalne (✎), zapisywane w localStorage.
*/
const DEFAULT_SETS = {
ecom: [
'view_item_list','select_item','view_item','add_to_cart','remove_from_cart',
'view_cart','begin_checkout','add_shipping_info','add_payment_info',
'purchase','refund','add_to_wishlist','view_promotion','select_promotion',
],
b2b: [
'generate_lead','form_start','form_submit','contact','lead','sign_up',
'phone_call','click_phone','click_email','click_call','download',
],
// Tag Assistant pokazuje nazwy wyświetlane ("Click", "Link Click"), nie surowe gtm.click
click: ['gtm.click','gtm.linkClick','click','link click','link_click'],
// Szum pomijany przy szukaniu "kliknięcia przed X" — eventy techniczne między klikiem a eventem docelowym
skip: ['message','history','history change','consent','timer','scroll depth','element visibility','gtm.dom','gtm.load','gtm.js','gtm.init','gtm.init_consent','gtm.historyChange','gtm.timer','gtm.scrollDepth','gtm.elementVisibility'],
};
let SETS = loadSets();
const activePresets = new Set(); // wiele presetów naraz — suma zbiorów (OR)
/**
* Efektywne zaznaczenie = (suma aktywnych presetów ∪ zaznaczone ręcznie) − odznaczone ręcznie.
* Presety ZAZNACZAJĄ eventy (ticki na liście), nie filtrują widoku.
* Liczone na żywo — nowe eventy pasujące do presetu zaznaczają się same.
*/
function effectiveSelectedKeys() {
const out = new Set(manualOn);
activePresets.forEach(p => presetKeySet(p).forEach(k => out.add(k)));
manualOff.forEach(k => out.delete(k));
return out;
}
/** Widoczność wiersza na liście widgetu: wyszukiwarka + opcjonalne ukrycie szumu (zaznaczone zawsze widać) */
function listVisible(r, eff) {
if (opts.hideNoise && inSet(r.name, SETS.skip) && !eff.has(r.key)) return false;
return matchesSearch(r.name);
}
function loadSets() {
try {
const s = JSON.parse(localStorage.getItem(LS_SETS) || 'null');
if (s && Array.isArray(s.ecom) && Array.isArray(s.b2b) && Array.isArray(s.click)) {
// Migracja z v2.9: dołóż brakującą listę skip i nowe aliasy klików
if (!Array.isArray(s.skip)) s.skip = [...DEFAULT_SETS.skip];
if (!s.click.some(x => x.toLowerCase() === 'link click')) s.click.push('link click');
return s;
}
} catch (_) {}
return JSON.parse(JSON.stringify(DEFAULT_SETS));
}
function inSet(name, arr) {
const n = name.toLowerCase().trim();
return arr.some(x => n === x.toLowerCase());
}
const CLICK_LOOKAHEAD = 15; // maks. ile eventów do przodu szukamy eventu docelowego
/**
* Zwraca Set kluczy eventów pasujących do presetu.
* Dla presetów "kliknięcia przed X": klik kwalifikuje się, gdy idąc w dół osi czasu
* (rosnące numery) pierwszy ISTOTNY event po nim należy do zbioru X.
* Po drodze przeskakujemy: kolejne kliki (gtm.click → gtm.linkClick) oraz szum
* z listy skip (Message, History, History Change, Consent…) — Tag Assistant
* niemal zawsze wciska je między klik a właściwy event.
*/
function presetKeySet(preset) {
const keys = new Set();
if (!preset) return keys;
const base = preset.startsWith('ecom') ? SETS.ecom : SETS.b2b;
const wantClicks = preset.endsWith('Clicks');
instanceGroups.forEach(g => {
// Kolejność chronologiczna — sortujemy po numerze eventu
const rows = [...g.rows].sort((a, b) => (+a.num || 0) - (+b.num || 0));
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (!wantClicks) {
if (inSet(r.name, base)) keys.add(r.key);
continue;
}
if (!inSet(r.name, SETS.click)) continue;
let j = i + 1;
const limit = Math.min(rows.length, i + 1 + CLICK_LOOKAHEAD);
while (j < limit && (inSet(rows[j].name, SETS.click) || inSet(rows[j].name, SETS.skip))) j++;
if (j < limit && inSet(rows[j].name, base)) keys.add(r.key);
}
});
return keys;
}
/* ═══════════════════ GTM OUTPUT TABS (Tags/Variables/Data Layer/Consent) ═══════════════════ */
function getActiveGtmTab() {
const sel = document.querySelector('.header__tab--selected.wd-header-tab');
if (!sel) return '';
const text = sel.textContent.trim().toLowerCase();
if (text.startsWith('tag')) return 'tags';
if (text.startsWith('variable')) return 'variables';
if (text.startsWith('data')) return 'datalayer';
if (text.startsWith('consent')) return 'consent';
return text;
}
function findGtmTabElement(tabType) {
const tabs = document.querySelectorAll('.header__tab.wd-header-tab');
for (const tab of tabs) {
const text = tab.textContent.trim().toLowerCase();
if (tabType === 'tags' && text.startsWith('tag')) return tab;
if (tabType === 'variables' && text.startsWith('variable')) return tab;
if (tabType === 'datalayer' && text.startsWith('data')) return tab;
if (tabType === 'consent' && text.startsWith('consent')) return tab;
}
return null;
}
function switchGtmTabAndWait(tabType, checkSelector, timeoutMs = 2000) {
return new Promise((resolve) => {
const currentTab = getActiveGtmTab();
if (currentTab === tabType && document.querySelector(checkSelector)) return resolve(true);
if (currentTab !== tabType) {
const tabEl = findGtmTabElement(tabType);
if (!tabEl) return resolve(false);
tabEl.click();
}
const start = Date.now();
const tick = () => {
if (document.querySelector(checkSelector)) return resolve(true);
if (Date.now() - start > timeoutMs) return resolve(false);
requestAnimationFrame(tick);
};
tick();
});
}
/* ═══════════════════ TAGS — kolekcja ═══════════════════ */
function getTagCards() {
const cards = document.querySelectorAll('.tags-tab__tag');
if (cards.length) return [...cards];
const fallback = document.querySelectorAll('.gtm-debug-card');
return [...fallback].filter(el => el.querySelector('.gtm-debug-card__title'));
}
function nameFromCard(card) {
const title = card.querySelector('.gtm-debug-card__title, .wd-fired-tag-name');
return title ? title.textContent.trim() : '';
}
function typeFromCard(card) {
const subtitle = card.querySelector('.gtm-debug-card__subtitle, .wd-nominated-tag-type');
if (!subtitle) return '';
let text = '';
subtitle.childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
} else if (node.nodeType === Node.ELEMENT_NODE) {
const cls = node.className || '';
if (!cls.includes('wd-summary-label') && !cls.includes('ng-hide') && !cls.includes('wd-event-label')) {
text += node.textContent;
}
}
});
return text.replace(/\s+/g, ' ').trim();
}
function statusFromCard(card) {
const label = card.querySelector('.wd-event-label');
if (label) {
const t = label.textContent.replace(/^[\s-]+/, '').trim();
if (t) return t;
}
const pausedChip = card.querySelector('.tag-details__paused-chip');
if (pausedChip) {
const t = pausedChip.textContent.replace(/^[\s-]+/, '').trim();
if (t) return t;
}
const section = card.closest('.tags-tab__fired-tags, .tags-tab__blocked-tags');
if (section) {
if (section.classList.contains('tags-tab__fired-tags')) return 'Fired';
if (section.classList.contains('tags-tab__blocked-tags')) return 'Not Fired';
}
return '';
}
function sectionOfCard(card) {
const section = card.closest('.tags-tab__fired-tags, .tags-tab__blocked-tags');
if (!section) return '';
if (section.classList.contains('tags-tab__fired-tags')) return 'fired';
if (section.classList.contains('tags-tab__blocked-tags')) return 'notFired';
return '';
}
function expandNotFiredList(timeoutMs = 1500) {
return new Promise((resolve) => {
const toggleBtn = document.querySelector('.wd-tags-not-fired-toggle-btn');
if (!toggleBtn) return resolve({ expanded: true, wasCollapsed: false });
const txt = toggleBtn.textContent.trim().toLowerCase();
const wasCollapsed = txt.includes('show');
if (!wasCollapsed) return resolve({ expanded: true, wasCollapsed: false });
toggleBtn.click();
const start = Date.now();
const tick = () => {
const cards = document.querySelectorAll('.tags-tab__blocked-tags .tags-tab__tag');
if (cards.length > 0) return resolve({ expanded: true, wasCollapsed: true });
if (Date.now() - start > timeoutMs) return resolve({ expanded: false, wasCollapsed: true });
requestAnimationFrame(tick);
};
tick();
});
}
function collapseNotFiredList() {
const toggleBtn = document.querySelector('.wd-tags-not-fired-toggle-btn');
if (!toggleBtn) return;
if (toggleBtn.textContent.trim().toLowerCase().includes('hide')) toggleBtn.click();
}
function collectTagsObject() {
const cards = getTagCards();
const fired = [];
const notFiredByStatus = {};
// "Unknown Tag Type" nic nie wnosi — status i tak niesie informację
const meaningfulType = t => (t && !/^unknown tag type/i.test(t)) ? t : '';
cards.forEach(card => {
const name = nameFromCard(card);
if (!name) return;
knownTagNames.add(name); // nazwy tagów zasilają listę "Dotyczy tagów"
const type = meaningfulType(typeFromCard(card));
const status = statusFromCard(card);
if (sectionOfCard(card) === 'notFired') {
// Kompaktowo: grupujemy po statusie, same nazwy — duża oszczędność znaków
const st = status || 'Not Fired';
(notFiredByStatus[st] = notFiredByStatus[st] || []).push(type ? `${name} [${type}]` : name);
} else {
const entry = { name };
if (type) entry.type = type;
if (status) entry.status = status;
fired.push(entry);
}
});
const result = {};
if (fired.length) result.fired = fired;
if (Object.keys(notFiredByStatus).length) result.notFired = notFiredByStatus;
return result;
}
/* ═══════════════════ VARIABLES — kolekcja ═══════════════════ */
function getVarRows() {
return [...document.querySelectorAll('.gtm-debug-variable-table-row')];
}
function varNameFromRow(row) {
const chip = row.querySelector('.gtm-debug-chip');
return chip ? chip.textContent.trim() : '';
}
function varValueFromRow(row) {
const valEl = row.querySelector('.gtm-debug-variable-table-value');
return valEl ? valEl.textContent.trim() : '';
}
function parseRawValue(rawValue) {
if (rawValue === 'undefined') return undefined;
if (rawValue === 'true') return true;
if (rawValue === 'false') return false;
if (/^-?\d+(\.\d+)?$/.test(rawValue)) return Number(rawValue);
const unquoted = rawValue.replace(/^"(.*)"$/s, '$1');
if (/^\{[\s\S]*\}$/.test(rawValue) || /^\[[\s\S]*\]$/.test(rawValue)) {
try { return JSON.parse(rawValue); } catch (_) { return unquoted; }
}
return unquoted;
}
function collectVarsObject() {
const result = {};
getVarRows().forEach(row => {
const name = varNameFromRow(row);
if (!name) return;
const raw = varValueFromRow(row);
if (opts.skipUndefined && raw === 'undefined') return;
let v = parseRawValue(cleanStr(raw));
if (typeof v === 'string') v = truncateStr(v);
result[name] = v === undefined ? null : v;
});
return result;
}
/**
* Diff zmiennych między eventami: co się zmieniło / doszło / zniknęło.
*/
function diffVars(prev, curr) {
const changed = {};
const removed = [];
Object.keys(curr).forEach(k => {
if (!(k in prev) || JSON.stringify(prev[k]) !== JSON.stringify(curr[k])) {
changed[k] = curr[k];
}
});
Object.keys(prev).forEach(k => {
if (!(k in curr)) removed.push(k);
});
return { changed, removed };
}
/* ═══════════════════ CONSENT — kolekcja ═══════════════════ */
function collectConsentObject() {
// Twardy warunek: działamy TYLKO gdy zakładka Consent jest realnie aktywna.
// Bez tego generyczne selektory łapały tabelę Variables (stąd undefined w outputcie).
if (getActiveGtmTab() !== 'consent') return null;
const scope = document.querySelector('[class*="consent-tab"], [class*="consent"]');
if (!scope) return null;
// Ochrona: jeśli w scope siedzi tabela zmiennych, to zły kontener — nie parsujemy
if (scope.querySelector('.gtm-debug-variable-table-row')) return null;
const result = {};
scope.querySelectorAll('tr, .gtm-debug-table-row').forEach(row => {
const cells = row.querySelectorAll('td, th, .gtm-debug-table-cell');
if (cells.length < 2) return;
const key = cells[0].textContent.replace(/\s+/g, ' ').trim();
if (!key) return;
// Pomiń wiersze nagłówkowe
if (/consent type|typ zgody|^variable$/i.test(key)) return;
const values = [...cells].slice(1)
.map(c => truncateStr(cleanStr(c.textContent.replace(/\s+/g, ' ').trim())))
.filter(Boolean);
if (opts.skipUndefined && values.every(v => v === 'undefined')) return;
if (values.length) result[key] = values.length === 1 ? values[0] : values;
});
if (Object.keys(result).length) return result;
// Fallback: surowy tekst panelu consent (przycięty)
const raw = scope.innerText.replace(/\n{3,}/g, '\n\n').trim();
return raw ? { raw: truncateStr(raw, 800) } : null;
}
/* ═══════════════════ DATA LAYER — kolekcja ═══════════════════ */
function getFullCodeMirrorText() {
const allCM = document.querySelectorAll('.CodeMirror');
for (const cmEl of allCM) {
if (cmEl.CodeMirror) {
const val = cmEl.CodeMirror.getValue();
if (val && val.trim().startsWith('{')) return val;
}
}
const container = document.querySelector('.gtm-debug-data-layer-content-container');
if (container) {
const cmEl = container.querySelector('.CodeMirror');
if (cmEl && cmEl.CodeMirror) return cmEl.CodeMirror.getValue();
}
return null;
}
function parseTagAssistantObject(raw) {
let cleaned = raw.replace(/"(\s*)\+\s*\n?\s*"/g, '');
cleaned = cleaned.replace(/(?<=[{\[,\n]\s*)([a-zA-Z_$][\w$]*)\s*:/g, '"$1":');
cleaned = cleaned.replace(/,(\s*[}\]])/g, '$1');
return JSON.parse(cleaned);
}
function collectDataLayerPushText() {
const raw = getFullCodeMirrorText();
if (!raw) return null;
try {
const parsed = parseTagAssistantObject(raw);
const push = {};
if (parsed.event) push.event = parsed.event;
if (parsed.eventModel && Object.keys(parsed.eventModel).length > 0) {
push.ecommerce = {};
const em = parsed.eventModel;
const ecomFields = [
'transaction_id', 'value', 'shipping', 'currency', 'tax',
'items', 'coupon', 'affiliation', 'payment_type',
'shipping_tier', 'item_list_id', 'item_list_name',
'creative_name', 'creative_slot', 'promotion_id', 'promotion_name'
];
for (const f of ecomFields) {
if (em[f] !== undefined) push.ecommerce[f] = em[f];
}
if (Object.keys(push.ecommerce).length === 0) delete push.ecommerce;
} else if (parsed.ecommerce) {
push.ecommerce = parsed.ecommerce;
}
return `dataLayer.push(${JSON.stringify(push, null, 2)});`;
} catch (e) {
console.warn('[Event Walker] DataLayer parse error:', e.message);
return raw;
}
}
/* ═══════════════════ SKANOWANIE EVENTÓW (grupy = strony) ═══════════════════ */
function scanInstances() {
const groups = [];
document.querySelectorAll('.message-list__group').forEach((group, gi) => {
const groupRow = group.querySelector('.message-list__row--group');
let gTitle = '', gUrl = '';
if (groupRow) {
const span = groupRow.querySelector('.message-list__title span[title]') ||
groupRow.querySelector('span[title]');
if (span) {
gTitle = span.textContent.trim();
const t = span.getAttribute('title') || '';
if (/^https?:\/\//.test(t)) gUrl = t;
else if (t && t !== gTitle) gUrl = t;
} else {
gTitle = groupRow.textContent.replace(/\s+/g, ' ').trim();
}
}
const rows = [];
group.querySelectorAll('.message-list__row--indented').forEach(row => {
const titleSpan = row.querySelector('.message-list__title span[title]');
const name = titleSpan ? titleSpan.getAttribute('title').trim() : '';
if (!name) return;
const numMatch = row.textContent.trim().match(/^(\d+)/);
const num = numMatch ? numMatch[1] : '';
const key = `${gi}|${num}|${name}`;
rows.push({ num, name, key, el: row, gTitle, gUrl });
});
if (rows.length) groups.push({ title: gTitle, url: gUrl, rows });
});
return groups;
}
function allRowsFlat() {
return instanceGroups.flatMap(g => g.rows);
}
function selectedRowsFlat() {
const eff = effectiveSelectedKeys();
return allRowsFlat().filter(r => eff.has(r.key));
}
/**
* Cache outputu jest ważny tylko dla zaznaczenia i opcji, przy których go zebrano.
* Zmiana zaznaczenia lub opcji = unieważnienie (podgląd zbierze od nowa).
*/
function invalidateOutputCache() {
lastOutput = null;
}
/* ═══════════════════ FILTR NA SIDEBARZE GTM (search + preset) ═══════════════════ */
function applySidebarFilter() {
const eff = effectiveSelectedKeys();
const selActive = opts.filterTree && eff.size > 0; // zaznaczenie steruje drzewkiem
const searchActive = eventSearch && !eventSearchError;
if (!selActive && !searchActive && !opts.hideNoise) {
document.querySelectorAll('.message-list__row--indented').forEach(r => { r.style.display = ''; });
return;
}
allRowsFlat().forEach(r => {
if (!r.el || !document.contains(r.el)) return;
let vis;
if (selActive) {
// Tryb "tylko zaznaczone" — drzewko pokazuje dokładnie to, co ma ticki
vis = eff.has(r.key);
} else {
vis = matchesSearch(r.name);
if (vis && opts.hideNoise && inSet(r.name, SETS.skip) && !eff.has(r.key)) vis = false;
}
r.el.style.display = vis ? '' : 'none';
});
}
/* ═══════════════════ CLIPBOARD ═══════════════════ */
async function writeClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (_) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (_) {}
ta.remove();
return ok;
}
}
/* ═══════════════════ WERYFIKACJA ZAŁADOWANIA EVENTU ═══════════════════ */
function getDetailArea() {
// Obszar szczegółów (bez naszego widgetu) do fingerprintu
return document.querySelector('.gtm-debug-pane, .content, main') || null;
}
function detailFingerprint() {
const el = getDetailArea();
if (!el) return '';
const t = el.innerText || '';
return t.length + '|' + t.slice(0, 400);
}
function getSelectedSidebarRow() {
return document.querySelector('.message-list__row--indented[class*="selected"]') ||
document.querySelector('.message-list__row[class*="selected"]');
}
/**
* Klika event i czeka aż detale FAKTYCZNIE się przeładują
* (fingerprint treści się zmieni albo wiersz dostanie klasę selected).
* Fallback: timeout — nie blokujemy walkera w nieskończoność.
*/
async function clickAndWaitForDetail(item, timeoutMs = 1800) {
const wasAlreadySelected = (item.el.className || '').includes('selected');
const before = detailFingerprint();
item.el.scrollIntoView({ block: 'center' });
item.el.click();
if (wasAlreadySelected) {
// Event już był otwarty — treść się nie zmieni, krótki settle wystarczy
await sleep(250);
return;
}
const start = Date.now();
while (Date.now() - start < timeoutMs) {
await sleep(80);
if (detailFingerprint() !== before) break;
const cls = item.el.className || '';
if (cls.includes('selected') && Date.now() - start > 300) break;
}
// Settle — daj Angularowi dokończyć render list wewnątrz zakładek
await sleep(200);
}
/* ═══════════════════ GŁÓWNY WALKER ═══════════════════ */
/**
* Przechodzi po zaznaczonych eventach i buduje markdown.
* Zwraca string albo null (anulowano / błąd / nic nie zebrano).
*/
async function walkAndBuild(progressBtn) {
let selected = selectedRowsFlat();
if (!selected.length) return { error: '✕ nic nie zaznaczono' };
if (!opts.tags && !opts.vars && !opts.dl && !opts.consent) return { error: '✕ wybierz sekcje' };
// Dedup po nazwie: zostaje pierwszy (najwcześniejszy) event z każdej nazwy
let dedupNote = '';
if (opts.dedup) {
const seen = new Set();
const kept = [];
const skipped = [];
selected.forEach(r => {
const n = r.name.toLowerCase().trim();
if (seen.has(n)) { skipped.push(`#${r.num || '?'} ${r.name}`); return; }
seen.add(n);
kept.push(r);
});
selected = kept;
if (skipped.length) {
dedupNote = `Dedup po nazwie — pominięto ${skipped.length} duplikatów: ${skipped.join(', ')}`;
}
if (!selected.length) return { error: '✕ nic po dedupie' };
}
walking = true;
cancelWalk = false;
progressBtn.classList.add('loading');
// Zapamiętaj, który event był otwarty przed startem — przywrócimy go po walkerze
const prevSelectedRow = getSelectedSidebarRow();
const parts = [];
let prevVars = null;
let prevVarsEventLabel = '';
let cancelled = false;
try {
for (let i = 0; i < selected.length; i++) {
if (cancelWalk) { cancelled = true; break; }
const item = selected[i];
progressBtn.textContent = `⏳ ${i + 1}/${selected.length} · kliknij aby przerwać`;
await clickAndWaitForDetail(item);
if (cancelWalk) { cancelled = true; break; }
const sec = [];
sec.push(`## Event #${item.num || '?'}: ${item.name}`);
if (item.gTitle) sec.push(`Tytuł: ${item.gTitle}`);
if (item.gUrl) sec.push(`URL: ${item.gUrl}`);
sec.push('');
if (opts.dl && !cancelWalk) {
const ok = await switchGtmTabAndWait('datalayer', '.CodeMirror');
if (ok) {
await sleep(150);
const dl = collectDataLayerPushText();
sec.push('### Warstwa danych (dataLayer)');
sec.push('```js');
sec.push(dl || '// brak danych');
sec.push('```');
sec.push('');
}
}
if (opts.tags && !cancelWalk) {
const ok = await switchGtmTabAndWait('tags', '.tags-tab__tag, .gtm-debug-card, .tags-tab');
if (ok) {
const { wasCollapsed } = await expandNotFiredList();
await sleep(100);
const tags = collectTagsObject();
if (wasCollapsed) collapseNotFiredList();
sec.push('### Tagi');
sec.push('```json');
sec.push(JSON.stringify(tags, null, 2));
sec.push('```');
sec.push('');
}
}
if (opts.vars && !cancelWalk) {
const ok = await switchGtmTabAndWait('variables', '.gtm-debug-variable-table-row');
if (ok) {
await sleep(100);
const vars = collectVarsObject();
if (opts.varsDiff && prevVars) {
const { changed, removed } = diffVars(prevVars, vars);
sec.push(`### Zmienne (zmiany vs ${prevVarsEventLabel})`);
if (Object.keys(changed).length === 0 && removed.length === 0) {
sec.push('_bez zmian_');
} else {
const diffOut = {};
if (Object.keys(changed).length) diffOut.zmienione = changed;
if (removed.length) diffOut.usuniete = removed;
sec.push('```json');
sec.push(JSON.stringify(diffOut, null, 2));
sec.push('```');
}
} else {
sec.push('### Zmienne' + (opts.varsDiff ? ' (pełna lista — pierwszy event)' : ''));
sec.push('```json');
sec.push(JSON.stringify(vars, null, 2));
sec.push('```');
}
sec.push('');
prevVars = vars;
prevVarsEventLabel = `Event #${item.num || '?'}: ${item.name}`;
}
}
if (opts.consent && !cancelWalk) {
const ok = await switchGtmTabAndWait('consent', '[class*="consent"]');
if (ok && getActiveGtmTab() === 'consent') {
await sleep(150);
const consent = collectConsentObject();
// Brak danych consent = pomijamy sekcję (nie zaśmiecamy outputu)
if (consent && Object.keys(consent).length) {
sec.push('### Consent');
sec.push('```json');
sec.push(JSON.stringify(consent, null, 2));
sec.push('```');
sec.push('');
}
}
}
parts.push(sec.join('\n'));
}
} catch (err) {
console.error('[Event Walker] Walk error:', err);
return { error: '✕ błąd' };
} finally {
walking = false;
progressBtn.classList.remove('loading');
// Przywróć event, który był otwarty przed startem walkera
if (prevSelectedRow && document.contains(prevSelectedRow)) {
try { prevSelectedRow.click(); } catch (_) {}
}
}
if (cancelled && !parts.length) return { error: '✕ przerwano' };
// Nagłówek: notka o dedupie. Linia "Dotyczy tagów" jest dokładana na etapie podglądu.
let header = '';
if (dedupNote) header += dedupNote + '\n\n---\n\n';
const out = header + parts.join('\n---\n\n');
return { text: out, count: parts.length, cancelled };
}
async function runCopyWalk(btn) {
if (walking) { cancelWalk = true; btn.textContent = '⏹ przerywam…'; return; }
const origText = btn.textContent;
const res = await walkAndBuild(btn);
if (res.error) { flashBtn(btn, res.error, C.red, origText); return; }
lastOutput = res.text;
const ok = await writeClipboard(res.text);
const label = res.cancelled
? `✓ skopiowano ${res.count} evt (przerwano)`
: `✓ skopiowano ${res.count} evt`;
flashBtn(btn, ok ? label : '✕ błąd schowka', ok ? C.green : C.red, null);
// Po skopiowaniu od razu pokaż podgląd — widać co poszło do schowka, można doszlifować i skopiować ponownie
openPreviewModal(res.text);
}
async function runPreviewWalk(btn, copyBtn) {
if (walking) return;
// Mamy już zebrany output (po Kopiuj albo poprzednim podglądzie)?
// Pokaż go od razu — bez ponownego przeklikiwania eventów.
if (lastOutput !== null) {
openPreviewModal(lastOutput);
return;
}
const origText = copyBtn.textContent;
const res = await walkAndBuild(copyBtn);
if (res.error) { flashBtn(copyBtn, res.error, C.red, origText); return; }
lastOutput = res.text;
flashBtn(copyBtn, `✓ zebrano ${res.count} evt`, C.green, null);
openPreviewModal(res.text);
}
function flashBtn(btn, text, color, restoreText) {
btn.textContent = text;
btn.style.background = color;
btn.style.borderColor = color;
btn.style.color = '#fff';
setTimeout(() => {
btn.style.background = '';
btn.style.borderColor = '';
btn.style.color = '';
btn.textContent = restoreText !== null && restoreText !== undefined
? restoreText
: `Kopiuj zaznaczone (${effectiveSelectedKeys().size})`;
}, 1800);
}
/* ═══════════════════ MODAL PODGLĄDU / EDYCJI ═══════════════════ */
function fmtCount(text) {
return `${text.length.toLocaleString('pl-PL')} znaków · ~${Math.round(text.length / 4).toLocaleString('pl-PL')} tokenów`;
}
function openPreviewModal(text) {
closePreviewModal();
const modal = document.createElement('div');
modal.id = WID + '-modal';
modal.innerHTML = `
<style>
#${WID}-modal{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:'Google Sans',system-ui,sans-serif}
#${WID}-modal .ta-m-box{width:min(760px,92vw);height:min(640px,86vh);background:${C.bg};border:1px solid ${C.border};border-radius:14px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.6)}
#${WID}-modal .ta-m-hdr{padding:10px 16px;display:flex;align-items:center;gap:10px;border-bottom:1px solid ${C.border};background:${C.hover}}
#${WID}-modal .ta-m-title{flex:1;font-size:13px;font-weight:600;color:#fff}
#${WID}-modal .ta-m-count{font-size:11px;color:${C.dim}}
#${WID}-modal .ta-m-close{background:none;border:none;color:${C.dim};font-size:20px;cursor:pointer;line-height:1;padding:0 4px}
#${WID}-modal .ta-m-close:hover{color:#fff}
#${WID}-modal .ta-m-tags-wrap{padding:7px 16px 8px;border-bottom:1px solid ${C.border};background:${C.bg}}
#${WID}-modal .ta-m-tags-hdr{display:flex;align-items:center;gap:8px;margin-bottom:5px}
#${WID}-modal .ta-m-tags-label{font-size:10px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.4px;white-space:nowrap}
#${WID}-modal .ta-m-tags-cnt{font-size:10px;color:${C.dim};font-weight:700}
#${WID}-modal .ta-m-tags-filter{margin-left:auto;width:170px;background:${C.border};border:1px solid #4b5563;color:${C.text};font-size:10px;padding:3px 8px;border-radius:6px;outline:none;font-family:inherit}
#${WID}-modal .ta-m-tags-filter:focus{border-color:${C.accent}}
#${WID}-modal .ta-m-tags-filter::placeholder{color:#6b7280}
#${WID}-modal .ta-m-tags-toggle{font-size:10px;color:${C.accent};background:none;border:none;cursor:pointer;font-weight:700;white-space:nowrap;padding:2px 4px}
#${WID}-modal .ta-m-tags-toggle:hover{text-decoration:underline}
#${WID}-modal .ta-m-tags{display:flex;flex-wrap:wrap;gap:4px;max-height:56px;overflow-y:auto}
#${WID}-modal .ta-m-tags.expanded{max-height:168px}
#${WID}-modal .ta-m-tags::-webkit-scrollbar{width:5px}#${WID}-modal .ta-m-tags::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
#${WID}-modal .ta-m-tag{font-size:10px;font-weight:600;padding:3px 9px;border-radius:10px;border:1px solid ${C.border};background:${C.bg};color:${C.text};cursor:pointer;transition:all .1s;white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis}
#${WID}-modal .ta-m-tag:hover{border-color:${C.accent}}
#${WID}-modal .ta-m-tag.on{background:${C.accent};border-color:${C.accent};color:#fff}
#${WID}-modal .ta-m-tags-empty{font-size:10px;color:${C.dim};padding:2px 0}
#${WID}-modal textarea{flex:1;background:#0b1220;color:${C.text};border:none;outline:none;resize:none;padding:14px 16px;font-family:'Consolas','Monaco','Courier New',monospace;font-size:11px;line-height:1.5;user-select:text}
#${WID}-modal .ta-m-foot{padding:10px 16px;display:flex;gap:8px;border-top:1px solid ${C.border};background:${C.hover}}
#${WID}-modal .ta-m-btn{font-size:12px;font-weight:700;padding:7px 16px;border-radius:8px;cursor:pointer;border:1px solid ${C.border};background:${C.bg};color:${C.text};transition:all .12s}
#${WID}-modal .ta-m-btn:hover{border-color:${C.accent}}
#${WID}-modal .ta-m-btn--primary{background:${C.accent};border-color:${C.accent};color:#fff}
#${WID}-modal .ta-m-btn--primary:hover{background:#2563eb}
</style>
<div class="ta-m-box">
<div class="ta-m-hdr">
<span class="ta-m-title">Podgląd / edycja — GTM Event Walker</span>
<span class="ta-m-count" id="ta-m-count">${fmtCount(text)}</span>
<button class="ta-m-close" title="Zamknij">✕</button>
</div>
<div class="ta-m-tags-wrap" id="ta-m-tags-wrap" style="display:none">
<div class="ta-m-tags-hdr">
<span class="ta-m-tags-label">Dotyczy tagów</span>
<span class="ta-m-tags-cnt" id="ta-m-tags-cnt"></span>
<input class="ta-m-tags-filter" id="ta-m-tags-filter" placeholder="filtruj tagi..." style="display:none">
<button class="ta-m-tags-toggle" id="ta-m-tags-toggle"></button>
</div>
<div class="ta-m-tags" id="ta-m-tags"></div>
</div>
<textarea spellcheck="false"></textarea>
<div class="ta-m-foot">
<button class="ta-m-btn ta-m-btn--primary" id="ta-m-copy">Kopiuj do schowka</button>
<button class="ta-m-btn" id="ta-m-cancel">Zamknij</button>
</div>
</div>`;
const ta = modal.querySelector('textarea');
ta.value = text;
/* ── Dotyczy tagów: chipy nad treścią, toggle podmienia pierwszą linię outputu ── */
let tagsExpanded = true; // start: rozwinięte — pełna lista z filtrem, "zwiń" chowa do samych wybranych
let tagFilter = '';
const cntEl = () => modal.querySelector('#ta-m-count');
function refreshCount() {
const c = cntEl();
if (c) c.textContent = fmtCount(ta.value);
}
function applyTagsHeader() {
// Zdejmij istniejącą linię nagłówka (idempotentnie), potem dołóż aktualną
let v = ta.value.replace(/^Dotyczy tagów:[^\n]*\n+/, '');
if (globalTags.size) v = `Dotyczy tagów: ${[...globalTags].join(', ')}\n\n` + v;
ta.value = v;
lastOutput = v;
refreshCount();
}
function renderModalTags() {
const wrap = modal.querySelector('#ta-m-tags-wrap');
const box = modal.querySelector('#ta-m-tags');
const cnt = modal.querySelector('#ta-m-tags-cnt');
const tgl = modal.querySelector('#ta-m-tags-toggle');
const filt = modal.querySelector('#ta-m-tags-filter');
const all = [...knownTagNames];
if (!all.length) { wrap.style.display = 'none'; return; }
wrap.style.display = 'block';
cnt.textContent = `${globalTags.size}/${all.length}`;
tgl.textContent = tagsExpanded
? 'zwiń ▴'
: (globalTags.size ? 'pokaż wszystkie ▾' : `wybierz (${all.length}) ▾`);
filt.style.display = tagsExpanded ? '' : 'none';
box.classList.toggle('expanded', tagsExpanded);
// Sort: grupowanie po vendorze (prefiks przed " - "), alfabetycznie; wybrane zawsze na początku
const byVendor = (a, b) => {
const pa = a.split(' - ')[0], pb = b.split(' - ')[0];
return pa.localeCompare(pb, 'pl') || a.localeCompare(b, 'pl');
};
const sorted = all.sort(byVendor);
const sel = sorted.filter(n => globalTags.has(n));
const rest = sorted.filter(n => !globalTags.has(n));
let names = [...sel, ...rest];
if (!tagsExpanded) {
names = sel; // zwinięte: tylko wybrane chipy
} else if (tagFilter) {
const f = tagFilter.toLowerCase();
names = names.filter(n => n.toLowerCase().includes(f));
}
box.innerHTML = '';
if (!names.length) {
box.innerHTML = tagsExpanded
? '<span class="ta-m-tags-empty">brak tagów pasujących do filtra</span>'
: '<span class="ta-m-tags-empty">brak wybranych — kliknij „wybierz", żeby dodać kontekst do outputu</span>';
return;
}
names.forEach(name => {
const chip = document.createElement('button');
chip.className = 'ta-m-tag' + (globalTags.has(name) ? ' on' : '');
chip.textContent = name;
chip.title = name;
chip.addEventListener('click', () => {
globalTags.has(name) ? globalTags.delete(name) : globalTags.add(name);
applyTagsHeader();
renderModalTags(); // re-sort: wybrane wskakują na początek, licznik się aktualizuje
});
box.appendChild(chip);
});
}
modal.querySelector('#ta-m-tags-toggle').addEventListener('click', () => {
tagsExpanded = !tagsExpanded;
renderModalTags();
if (tagsExpanded) modal.querySelector('#ta-m-tags-filter').focus();
});
modal.querySelector('#ta-m-tags-filter').addEventListener('input', e => {
tagFilter = e.target.value;
renderModalTags();
});
renderModalTags();
applyTagsHeader(); // jeśli tagi były już wybrane wcześniej — linia trafia do treści od razu
// Edycja w podglądzie — aktualizuje cache i licznik na żywo,
// więc kolejne otwarcie podglądu i Kopiuj z modala widzą poprawki
ta.addEventListener('input', () => {
lastOutput = ta.value;
refreshCount();
});
modal.addEventListener('click', e => { if (e.target === modal) closePreviewModal(); });
modal.querySelector('.ta-m-close').addEventListener('click', closePreviewModal);
modal.querySelector('#ta-m-cancel').addEventListener('click', closePreviewModal);
modal.querySelector('#ta-m-copy').addEventListener('click', async e => {
const b = e.currentTarget;
const ok = await writeClipboard(ta.value); // kopiuje aktualną (edytowaną) treść
b.textContent = ok ? '✓ skopiowano' : '✕ błąd';
setTimeout(() => { b.textContent = 'Kopiuj do schowka'; }, 1500);
});
document.addEventListener('keydown', escCloseModal);
document.body.appendChild(modal);
}
function escCloseModal(e) {
if (e.key === 'Escape') closePreviewModal();
}
function closePreviewModal() {
const m = document.getElementById(WID + '-modal');
if (m) m.remove();
document.removeEventListener('keydown', escCloseModal);
}
/* ═══════════════════ WIDGET ═══════════════════ */
function buildWidget() {
if (widget) widget.remove();
widget = document.createElement('div');
widget.id = WID;
widget.innerHTML = `
<style>
#${WID}{position:fixed;bottom:16px;right:16px;z-index:2147483646;font-family:'Google Sans',system-ui,sans-serif;width:330px;border-radius:14px;overflow:hidden;box-shadow:0 10px 36px rgba(0,0,0,.55);border:1px solid ${C.border};background:${C.bg};user-select:none}
#${WID} *{box-sizing:border-box}
.ta-hdr{background:linear-gradient(135deg,${C.accentDk},${C.bg});padding:10px 14px;display:flex;align-items:center;gap:8px;cursor:move;border-bottom:1px solid ${C.border}}
.ta-logo{width:22px;height:22px;flex-shrink:0}
.ta-logo svg{display:block}
.ta-title{flex:1;font-size:13px;font-weight:600;color:#fff;letter-spacing:.3px}
.ta-badge{font-size:10px;background:${C.border};color:${C.dim};padding:2px 8px;border-radius:10px}
.ta-hdr-btn{background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;opacity:.6;transition:opacity .15s}
.ta-hdr-btn:hover{opacity:1}
.ta-wrap{overflow:hidden;transition:max-height .25s ease}
.ta-wrap.open{max-height:92vh}.ta-wrap.closed{max-height:0!important}
.ta-panel{padding:10px 14px;max-height:62vh;overflow-y:auto}
.ta-panel::-webkit-scrollbar{width:5px}.ta-panel::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
.ta-label{font-size:10px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
.ta-settings{display:none;padding:10px 14px 12px;max-height:62vh;overflow-y:auto}
.ta-settings::-webkit-scrollbar{width:5px}.ta-settings::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
.ta-set-back{font-size:11px;color:${C.accent};font-weight:600;cursor:pointer;padding:3px 2px}
.ta-set-back:hover{text-decoration:underline}
.ta-settings .ta-set-item{display:flex;align-items:flex-start;gap:9px;padding:7px 5px;border-radius:7px;transition:background .08s}
.ta-settings .ta-set-item:hover{background:${C.hover}}
.ta-settings .ta-set-item .ta-chk{flex-shrink:0;margin-top:2px;width:15px;height:15px}
.ta-set-txt{flex:1;min-width:0}
.ta-set-name{font-size:11px;color:${C.text};font-weight:600;line-height:1.3}
.ta-set-desc{font-size:10px;color:${C.dim};line-height:1.4;margin-top:2px}
.ta-preset-row{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;align-items:center}
.ta-preset{font-size:10px;font-weight:600;padding:4px 9px;border-radius:12px;border:1px solid ${C.border};background:${C.bg};color:${C.text};cursor:pointer;transition:all .12s;font-family:inherit}
.ta-preset:hover{border-color:${C.accent}}
.ta-preset.on{background:${C.accent};border-color:${C.accent};color:#fff}
.ta-preset-cnt{opacity:.7;font-weight:800;margin-left:1px}
.ta-preset--empty{opacity:.45}
.ta-preset--empty:hover{opacity:.8}
.ta-input-wrap{position:relative;margin-bottom:8px}
.ta-input{width:100%;background:${C.border};border:1px solid #4b5563;color:${C.text};padding:7px 10px;border-radius:8px;font-size:12px;outline:none;font-family:inherit}
.ta-input:focus{border-color:${C.accent}}
.ta-input::placeholder{color:#3a3f4a}
.ta-input.ta-input--regex{font-family:'Consolas','Monaco','Courier New',monospace;border-color:#7c3aed}
.ta-input.ta-input--error{border-color:${C.red}!important}
.ta-search-mode{position:absolute;right:6px;top:50%;transform:translateY(-50%);font-size:9px;padding:2px 6px;border-radius:4px;font-weight:700;border:none;background:${C.border};color:${C.dim};pointer-events:none}
.ta-search-mode--clear{color:${C.text};pointer-events:auto;cursor:pointer}
.ta-search-mode--regex{background:#7c3aed;color:#fff;pointer-events:auto;cursor:pointer}
.ta-search-mode--error{background:${C.red};color:#fff;pointer-events:auto;cursor:pointer}
.ta-search-hint{font-size:10px;color:${C.dim};margin:-4px 0 6px 2px;line-height:1.3}
.ta-search-error{font-size:10px;color:${C.red};margin:-4px 0 6px 2px;line-height:1.3}
.ta-ghdr{display:flex;align-items:center;gap:6px;font-size:10px;color:${C.orange};font-weight:600;padding:7px 4px 3px;border-bottom:1px dashed ${C.border};margin-bottom:2px;cursor:pointer;border-radius:4px;transition:background .08s}
.ta-ghdr:hover{background:${C.hover}}
.ta-ghdr .ta-chk{width:13px;height:13px;font-size:8px}
.ta-ghdr.on .ta-chk{background:${C.accent};border-color:${C.accent};color:#fff}
.ta-ghdr-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ta-ghdr-cnt{font-size:9px;color:${C.dim};font-weight:700;flex-shrink:0}
.ta-frow{display:flex;align-items:center;gap:8px;padding:5px 4px;cursor:pointer;transition:background .08s;border-radius:6px}
.ta-frow:hover{background:${C.hover}}
.ta-chk{width:15px;height:15px;border-radius:4px;border:2px solid ${C.border};display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .12s;font-size:9px;color:transparent}
.ta-frow.on .ta-chk{background:${C.accent};border-color:${C.accent};color:#fff}
.ta-dot{width:7px;height:7px;border-radius:2px;flex-shrink:0}
.ta-num{font-size:10px;color:${C.dim};min-width:16px;text-align:right}
.ta-fname{flex:1;font-size:12px;color:${C.text};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ta-optbar{border-top:1px solid ${C.border};padding:8px 12px;background:${C.hover}}
.ta-optrow{display:flex;gap:4px 12px;flex-wrap:wrap}
.ta-opt{display:flex;align-items:center;gap:5px;cursor:pointer;padding:2px 0}
.ta-opt .ta-chk{width:14px;height:14px}
.ta-opt.on .ta-chk{background:${C.accent};border-color:${C.accent};color:#fff}
.ta-opt-label{font-size:10px;color:${C.dim};white-space:nowrap}
.ta-opt:hover .ta-opt-label{color:${C.text}}
.ta-copybar{border-top:1px solid ${C.border};padding:8px 12px;background:${C.hover};display:flex;gap:6px}
.ta-copy-btn{flex:1;font-size:12px;color:#fff;background:${C.accent};border:1px solid ${C.accent};cursor:pointer;font-weight:700;padding:8px 4px;border-radius:8px;transition:all .12s;text-align:center}
.ta-copy-btn:hover{background:#2563eb}
.ta-copy-btn:active{transform:scale(.98)}
.ta-copy-btn.loading{opacity:.85}
.ta-eye-btn{font-size:12px;font-weight:600;color:${C.text};background:${C.bg};border:1px solid ${C.border};cursor:pointer;padding:8px 12px;border-radius:8px;transition:all .12s;text-align:center;white-space:nowrap}
.ta-eye-btn:hover{border-color:${C.accent}}
.ta-empty{font-size:11px;color:${C.dim};padding:6px 0}
</style>
<div class="ta-hdr" id="ta-hdr">
<div class="ta-logo"><svg width="22" height="22" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg"><rect width="192" height="192" rx="24" fill="#0d9488"/><circle cx="82" cy="82" r="36" fill="none" stroke="#fff" stroke-width="12"/><line x1="108" y1="108" x2="148" y2="148" stroke="#fff" stroke-width="12" stroke-linecap="round"/><rect x="60" y="70" width="44" height="6" rx="3" fill="#fff" opacity=".7"/><rect x="60" y="82" width="30" height="6" rx="3" fill="#fff" opacity=".7"/></svg></div>
<span class="ta-title">GTM Event Walker</span>
<span class="ta-badge" id="ta-badge">—</span>
<button class="ta-hdr-btn" id="ta-settings-btn" title="Ustawienia — opcje widoczności i kopiowania">⚙</button>
<button class="ta-hdr-btn" id="ta-col">▾</button>
</div>
<div class="ta-wrap ${collapsed ? 'closed' : 'open'}" id="ta-wrap">
<div class="ta-panel" id="ta-evt-panel">
<div class="ta-label">Szukaj po nazwie eventu <span style="font-weight:400;text-transform:none;letter-spacing:0;color:#9ca3af">· regex, np. purchase|add_to_cart</span></div>
<div class="ta-input-wrap">
<input class="ta-input" id="ta-evt-search" placeholder="wpisz nazwę eventu..." style="padding-right:38px">
<span class="ta-search-mode" id="ta-evt-search-mode">TEXT</span>
</div>
<div id="ta-evt-search-hint" class="ta-search-hint" style="display:none"></div>
<div class="ta-label">Presety — zaznaczają pasujące eventy</div>
<div class="ta-preset-row">
<button class="ta-preset" data-preset="ecom" title="Zaznacza eventy e-commerce (GA4): purchase, add_to_cart, begin_checkout…">🛒 Ecommerce <span class="ta-preset-cnt"></span></button>
<button class="ta-preset" data-preset="ecomClicks" title="Zaznacza kliknięcia (Click / Link Click) poprzedzające event e-commerce — pomija po drodze Message/History/Consent. Do fallbacków typu purchase_click">🖱→🛒 Kliknięcia przed ecom <span class="ta-preset-cnt"></span></button>
<button class="ta-preset" data-preset="b2b" title="Zaznacza eventy leadowe/B2B: generate_lead, form_submit, contact…">📋 B2B <span class="ta-preset-cnt"></span></button>
<button class="ta-preset" data-preset="b2bClicks" title="Zaznacza kliknięcia poprzedzające event B2B (pomija Message/History/Consent po drodze) — do fallbacków klikowych">🖱→📋 Kliknięcia przed B2B <span class="ta-preset-cnt"></span></button>
</div>
<div id="ta-instance-list"></div>
</div>
<!-- ═══ USTAWIENIA (pod zębatką) ═══ -->
<div class="ta-settings" id="ta-settings">
<div class="ta-set-back" id="ta-settings-back">← wróć do listy eventów</div>
<div class="ta-label" style="margin-top:8px">Widoczność</div>
<div class="ta-opt ta-set-item" data-opt="filterTree">
<div class="ta-chk">✓</div>
<div class="ta-set-txt">
<div class="ta-set-name">Drzewko GTM: tylko zaznaczone</div>
<div class="ta-set-desc">Gdy cokolwiek ma ticka (z presetu albo ręcznie), drzewko eventów w Tag Assistant pokazuje wyłącznie zaznaczone pozycje. Wyłącz, żeby zawsze widzieć pełną oś czasu.</div>
</div>
</div>
<div class="ta-opt ta-set-item" data-opt="hideNoise">
<div class="ta-chk">✓</div>
<div class="ta-set-txt">
<div class="ta-set-name">Ukryj szum</div>
<div class="ta-set-desc">Chowa eventy techniczne (Message, History, History Change, Consent…) z listy widgetu i z drzewka. Lista „skip" jest edytowalna niżej. Zaznaczone eventy są zawsze widoczne, nawet jeśli to szum.</div>
</div>
</div>
<div class="ta-label" style="margin-top:12px">Co kopiować z każdego eventu</div>
<div class="ta-opt ta-set-item" data-opt="tags">
<div class="ta-chk">✓</div>
<div class="ta-set-txt">
<div class="ta-set-name">Tagi</div>
<div class="ta-set-desc">Sekcja Tags eventu: tagi odpalone (z typem i statusem, np. „Fired 1 time") oraz nieodpalone, pogrupowane po statusie.</div>
</div>
</div>
<div class="ta-opt ta-set-item" data-opt="vars">
<div class="ta-chk">✓</div>
<div class="ta-set-txt">
<div class="ta-set-name">Zmienne</div>
<div class="ta-set-desc">Wartości wszystkich zmiennych GTM w momencie eventu (zakładka Variables). Długie wartości są przycinane do 220 znaków.</div>
</div>
</div>
<div class="ta-opt ta-set-item" data-opt="dl">
<div class="ta-chk">✓</div>
<div class="ta-set-txt">
<div class="ta-set-name">DL — warstwa danych</div>
<div class="ta-set-desc">Zrekonstruowany dataLayer.push() eventu z polami e-commerce (transaction_id, value, items…) z zakładki Data Layer.</div>
</div>
</div>
<div class="ta-opt ta-set-item" data-opt="consent">
<div class="ta-chk">✓</div>
<div class="ta-set-txt">
<div class="ta-set-name">Consent</div>
<div class="ta-set-desc">Stany zgód (consent mode) w momencie eventu. Sekcja jest pomijana w outputcie, gdy zakładka Consent nie ma danych.</div>
</div>
</div>
<div class="ta-opt ta-set-item" data-opt="skipUndefined">
<div class="ta-chk">✓</div>
<div class="ta-set-txt">
<div class="ta-set-name">Pomiń undefined</div>
<div class="ta-set-desc">Zmienne o wartości undefined nie trafiają do outputu — mniej śmieci i mniejszy koszt tokenów.</div>
</div>
</div>
<div class="ta-opt ta-set-item" data-opt="varsDiff">
<div class="ta-chk">✓</div>
<div class="ta-set-txt">
<div class="ta-set-name">Zmienne jako diff</div>
<div class="ta-set-desc">Pierwszy event ma pełną listę zmiennych, każdy kolejny tylko to, co się zmieniło lub zniknęło względem poprzedniego — dużo mniejszy output przy serii eventów.</div>
</div>
</div>
<div class="ta-opt ta-set-item" data-opt="dedup">
<div class="ta-chk">✓</div>
<div class="ta-set-txt">
<div class="ta-set-name">Dedup po nazwie</div>
<div class="ta-set-desc">Z zaznaczonych eventów kopiowany jest tylko pierwszy z każdej nazwy (np. z 3× view_item zostaje jeden). Pominięte duplikaty są wylistowane w nagłówku outputu, żeby agent wiedział o pełnej sekwencji.</div>
</div>
</div>
</div>
<!-- ═══ PRZYCISKI ═══ -->
<div class="ta-copybar">
<button class="ta-copy-btn" id="ta-copy-walk" title="Przechodzi po zaznaczonych eventach i kopiuje wybrane sekcje jako jeden markdown. Podczas pracy: kliknij, aby przerwać.">Kopiuj zaznaczone (0)</button>
<button class="ta-eye-btn" id="ta-preview-walk" title="Podgląd zebranych danych przed skopiowaniem (z licznikiem znaków/tokenów)">👁 Podgląd</button>
</div>
</div>
`;
document.body.appendChild(widget);
/* ── Przywróć stan checkboxów opcji z localStorage ── */
widget.querySelectorAll('.ta-opt').forEach(el => {
el.classList.toggle('on', !!opts[el.dataset.opt]);
});
/* ── Collapse ── */
const colBtn = document.getElementById('ta-col');
colBtn.textContent = collapsed ? '▸' : '▾';
colBtn.addEventListener('click', () => {
collapsed = !collapsed;
localStorage.setItem(LS_COL, collapsed ? '1' : '0');
updateExpandDirection();
document.getElementById('ta-wrap').classList.toggle('open', !collapsed);
document.getElementById('ta-wrap').classList.toggle('closed', collapsed);
colBtn.textContent = collapsed ? '▸' : '▾';
});
/* ── Ustawienia (zębatka) — przełącza widok panelu ── */
let settingsOpen = false;
const setBtn = document.getElementById('ta-settings-btn');
function toggleSettings(force) {
settingsOpen = typeof force === 'boolean' ? force : !settingsOpen;
document.getElementById('ta-settings').style.display = settingsOpen ? 'block' : 'none';
document.getElementById('ta-evt-panel').style.display = settingsOpen ? 'none' : 'block';
setBtn.style.opacity = settingsOpen ? '1' : '';
setBtn.style.color = settingsOpen ? '#60a5fa' : '';
// Rozwiń widget, jeśli był zwinięty
if (settingsOpen && collapsed) colBtn.click();
}
setBtn.addEventListener('click', () => toggleSettings());
document.getElementById('ta-settings-back').addEventListener('click', () => toggleSettings(false));
/* ── Presety (można łączyć) — ZAZNACZAJĄ pasujące eventy tickiem ── */
widget.querySelectorAll('.ta-preset').forEach(btn => {
btn.addEventListener('click', () => {
const p = btn.dataset.preset;
activePresets.has(p) ? activePresets.delete(p) : activePresets.add(p);
btn.classList.toggle('on', activePresets.has(p));
invalidateOutputCache();
renderInstanceList();
applySidebarFilter();
});
});
/* ── Search ── */
document.getElementById('ta-evt-search').addEventListener('input', e => {
compileEventSearch(e.target.value);
updateSearchIndicator();
renderInstanceList();
applySidebarFilter();
});
document.getElementById('ta-evt-search-mode').addEventListener('click', () => {
if (!eventSearch) return;
document.getElementById('ta-evt-search').value = '';
compileEventSearch('');
updateSearchIndicator();
renderInstanceList();
applySidebarFilter();
document.getElementById('ta-evt-search').focus();
});
/* ── Opcje (z zapisem do localStorage) ── */
widget.querySelectorAll('.ta-opt').forEach(optEl => {
optEl.addEventListener('click', () => {
const key = optEl.dataset.opt;
opts[key] = !opts[key];
optEl.classList.toggle('on', opts[key]);
saveOpts();
if (key === 'filterTree' || key === 'hideNoise') {
// Opcje widoku — nie unieważniają zebranego outputu
renderInstanceList();
applySidebarFilter();
} else {
invalidateOutputCache();
}
});
});
/* ── Kopiuj / Podgląd ── */
const copyBtn = document.getElementById('ta-copy-walk');
copyBtn.addEventListener('click', () => runCopyWalk(copyBtn));
document.getElementById('ta-preview-walk').addEventListener('click', e => {
runPreviewWalk(e.currentTarget, copyBtn);
});
makeDraggable(widget, document.getElementById('ta-hdr'));
restorePos();
}
function updateSearchIndicator() {
const input = document.getElementById('ta-evt-search');
const badge = document.getElementById('ta-evt-search-mode');
const hint = document.getElementById('ta-evt-search-hint');
if (!input || !badge || !hint) return;
input.classList.remove('ta-input--regex', 'ta-input--error');
badge.className = 'ta-search-mode';
hint.style.display = 'none';
if (!eventSearch) { badge.textContent = 'TEXT'; return; }
if (eventSearchError) {
input.classList.add('ta-input--regex', 'ta-input--error');
badge.classList.add('ta-search-mode--error');
badge.textContent = '✕';
hint.style.display = 'block'; hint.className = 'ta-search-error'; hint.textContent = eventSearchError;
} else if (eventSearchRegex) {
input.classList.add('ta-input--regex');
badge.classList.add('ta-search-mode--regex');
badge.textContent = '✕';
hint.style.display = 'block'; hint.className = 'ta-search-hint';
hint.textContent = 'Tryb regex · użyj | jako OR, np. purchase|add_to_cart';
} else {
badge.classList.add('ta-search-mode--clear');
badge.textContent = '✕';
}
}
function renderInstanceList() {
const list = document.getElementById('ta-instance-list');
if (!list) return;
list.innerHTML = '';
let shown = 0;
const eff = effectiveSelectedKeys();
renderedOrder = [];
instanceGroups.forEach(group => {
const visibleRows = group.rows.filter(r => listVisible(r, eff));
if (!visibleRows.length) return;
const selCount = visibleRows.filter(r => eff.has(r.key)).length;
const allSel = selCount === visibleRows.length;
const ghdr = document.createElement('div');
ghdr.className = 'ta-ghdr' + (allSel ? ' on' : '');
ghdr.title = (group.url || group.title || '') + ' · klik: zaznacz/odznacz wszystkie eventy tej strony';
ghdr.innerHTML = `<div class="ta-chk">✓</div><span class="ta-ghdr-name">📄 ${escHtml(group.title || '(strona)')}</span><span class="ta-ghdr-cnt">${selCount}/${visibleRows.length}</span>`;
ghdr.addEventListener('click', () => {
const effNow = effectiveSelectedKeys();
const all = visibleRows.every(r => effNow.has(r.key));
visibleRows.forEach(r => {
if (all) { manualOff.add(r.key); manualOn.delete(r.key); }
else { manualOn.add(r.key); manualOff.delete(r.key); }
});
invalidateOutputCache();
renderInstanceList();
applySidebarFilter();
});
list.appendChild(ghdr);
visibleRows.forEach(r => {
shown++;
renderedOrder.push(r);
const row = document.createElement('div');
row.className = 'ta-frow' + (eff.has(r.key) ? ' on' : '');
row.innerHTML = `<div class="ta-chk">✓</div><span class="ta-num">${escHtml(r.num)}</span><div class="ta-dot" style="background:${ec(r.name)}"></div><span class="ta-fname" title="${escHtml(r.name)} · Shift+klik: zakres · Alt+klik: otwórz event w Tag Assistant">${escHtml(r.name)}</span>`;
row.addEventListener('click', (e) => {
// Alt+klik: otwórz event w Tag Assistant (bez zmiany zaznaczenia)
if (e.altKey) {
if (r.el && document.contains(r.el)) {
r.el.scrollIntoView({ block: 'center' });
r.el.click();
}
return;
}
const turningOn = !effectiveSelectedKeys().has(r.key);
let applyTo = [r];
// Shift+klik: zakres od ostatnio klikniętego wiersza (w kolejności listy)
if (e.shiftKey && lastClickedKey) {
const i1 = renderedOrder.findIndex(x => x.key === lastClickedKey);
const i2 = renderedOrder.findIndex(x => x.key === r.key);
if (i1 !== -1 && i2 !== -1 && i1 !== i2) {
const [a, b] = i1 < i2 ? [i1, i2] : [i2, i1];
applyTo = renderedOrder.slice(a, b + 1);
}
}
applyTo.forEach(x => {
if (turningOn) { manualOn.add(x.key); manualOff.delete(x.key); }
else { manualOff.add(x.key); manualOn.delete(x.key); }
});
lastClickedKey = r.key;
invalidateOutputCache();
if (applyTo.length === 1) {
// Pojedynczy klik: bez pełnego re-renderu (nie skacze scroll)
row.classList.toggle('on', effectiveSelectedKeys().has(r.key));
updateCopyBtn();
} else {
renderInstanceList();
}
applySidebarFilter();
});
list.appendChild(row);
});
});
if (!shown) {
list.innerHTML = '<div class="ta-empty">Brak eventów do wyświetlenia</div>';
}
updateCopyBtn();
updatePresetCounts();
}
/** Liczniki na chipach presetów: ile eventów pasuje do każdego (przy aktualnych danych) */
function updatePresetCounts() {
if (!widget) return;
widget.querySelectorAll('.ta-preset').forEach(btn => {
const p = btn.dataset.preset;
const n = presetKeySet(p).size;
const cnt = btn.querySelector('.ta-preset-cnt');
if (cnt) cnt.textContent = `(${n})`;
btn.classList.toggle('ta-preset--empty', n === 0 && !activePresets.has(p));
});
}
function updateCopyBtn() {
const btn = document.getElementById('ta-copy-walk');
if (!btn || walking) return;
btn.textContent = `Kopiuj zaznaczone (${effectiveSelectedKeys().size})`;
}
/* ═══════════════════ EXPAND DIRECTION ═══════════════════ */
function updateExpandDirection() {
if (!widget) return;
const rect = widget.getBoundingClientRect();
const hdrH = document.getElementById('ta-hdr').offsetHeight;
const hdrMid = rect.top + hdrH / 2;
const viewMid = window.innerHeight / 2;
if (hdrMid > viewMid) {
widget.style.bottom = (window.innerHeight - rect.bottom) + 'px';
widget.style.top = 'auto';
} else {
widget.style.top = rect.top + 'px';
widget.style.bottom = 'auto';
}
}
/* ═══════════════════ DRAG ═══════════════════ */
function makeDraggable(el, handle) {
let sx, sy, sl, st;
handle.addEventListener('mousedown', e => {
if (e.target.closest('button')) return;
e.preventDefault();
sx = e.clientX; sy = e.clientY;
const r = el.getBoundingClientRect();
sl = r.left; st = r.top;
const mv = ev => {
el.style.left = (sl + ev.clientX - sx) + 'px';
el.style.top = (st + ev.clientY - sy) + 'px';
el.style.right = 'auto'; el.style.bottom = 'auto';
};
const up = () => {
document.removeEventListener('mousemove', mv);
document.removeEventListener('mouseup', up);
updateExpandDirection();
savePos();
};
document.addEventListener('mousemove', mv);
document.addEventListener('mouseup', up);
});
}
/* ═══════════════════ POLL ═══════════════════ */
function poll() {
if (walking) return; // nie skanuj podczas walkera — klikanie zmienia DOM
const groups = scanInstances();
const key = groups.map(g => g.title + '::' + g.rows.map(r => r.key).join(',')).join(';');
const total = groups.reduce((a, g) => a + g.rows.length, 0);
const badge = document.getElementById('ta-badge');
if (badge) badge.textContent = total ? `${total} evt` : '—';
// Zbieraj nazwy tagów do listy "Dotyczy tagów" (gdy zakładka Tags jest akurat otwarta)
if (getActiveGtmTab() === 'tags') {
getTagCards().forEach(card => { const n = nameFromCard(card); if (n) knownTagNames.add(n); });
}
if (key !== lastInstanceKey) {
lastInstanceKey = key;
instanceGroups = groups;
// Usuń zaznaczenia eventów, których już nie ma
const validKeys = new Set(allRowsFlat().map(r => r.key));
[...manualOn].forEach(k => { if (!validKeys.has(k)) manualOn.delete(k); });
[...manualOff].forEach(k => { if (!validKeys.has(k)) manualOff.delete(k); });
renderInstanceList();
} else {
// Odśwież referencje do elementów DOM (Angular może przerenderować)
instanceGroups = groups;
}
applySidebarFilter();
}
/* ═══════════════════ OBSERVER ═══════════════════ */
let contentObserver = null;
function watchContent() {
if (contentObserver) contentObserver.disconnect();
const target = document.querySelector('.message-list__groups, main, #content');
if (!target) return;
contentObserver = new MutationObserver(() => {
if (walking) return;
requestAnimationFrame(() => { poll(); });
});
contentObserver.observe(target, { childList: true, subtree: true });
}
/* ═══════════════════ INIT ═══════════════════ */
function init() {
loadOpts();
buildWidget();
updateExpandDirection();
poll();
watchContent();
setInterval(() => { if (!walking) { poll(); watchContent(); } }, POLL_MS);
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(() => { poll(); watchContent(); }, 600);
}
}).observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'complete') setTimeout(init, 2000);
else window.addEventListener('load', () => setTimeout(init, 2000));
})();