Instrukcja użycia→ Ściągnij wtyczkę Tampermonkey do przeglądarki
→ W opcjach wtyczki włącz opcję „Allow User Scripts” (dzięki temu będzie można korzystać z własnych skryptów)
→ Wklej w Tampermonkey poniższy skrypt GTM Toolbox
→ Wejdź do Google Tag Manager i korzystaj z filtrowania i wyszukiwania w listach tagów, reguł i zmiennych
Skrypt GTM Toolbox
// ==UserScript==
// @name GTM Filter
// @namespace https://pawelpiekarski.pl/
// @version 7.77
// @description Filtrowanie po typie elementu, typie reguły uruchamiającej i wyszukiwanie po nazwie (z regex) lub po ID (np. 1|2, 100-120) w listach GTM, opcjonalne #ID, ID vendorów (G-, AW-, GT-, Meta…) i opcja uruchamiania przy nazwach + biblioteka standardowych parametrów tagów (GA4, Google tag, Ads, Consent, Meta)
// @author Paweł Piekarski
// @match https://tagmanager.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';
/* ╔══════════════════════════════════════════════════════════════════════════════╗
║ GTM Filter — MAPA PLIKU (szukaj po nagłówku „MODUŁ n:”) ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ 1. STAN I USTAWIENIA — klucze localStorage, flagi opcji, kolory ║
║ 2. ID I WYSZUKIWANIE — regex / ID w szukajce, ID elementu z wiersza ║
║ 3. API GTM — pobieranie list (tags/triggers/variables/…), ║
║ auto-odświeżanie (podsłuch XHR/fetch), zapisy ║
║ 4. ADNOTACJE PRZY NAZWIE — #ID, ID vendorów (+walidacja), opcja ║
║ uruchamiania, event+parametry, consent, ESV ║
║ 5. ZMIENNE: KOLUMNA TAGI — użycia zmiennych (tagi/reguły/klienci/transf.) ║
║ 6. AKCJE NA LIŚCIE — zmiana nazwy / duplikat (PUT/POST do API) ║
║ 7. UKŁAD ADNOTACJI — sloty o równej szerokości, kolejność, kolumna ║
║ 8. GTM DOM — wykrywanie tabel, kolumn, reguł z wiersza ║
║ 9. FILTRY — typ, reguła, nazwa/ID, nieużywane, pauza, ║
║ duplikaty (+grupowanie), bez reguły, foldery ║
║ 10. PARAMETRY (zakładka) — biblioteka parametrów tagów + synonimy ║
║ 11. WIDGET — HTML/CSS panelu, obsługa klików, harmonijki ║
║ 12. POZYCJA / DRAG — przeciąganie, pilnowanie ekranu, zapis pozycji ║
║ 13. PĘTLA / OBSERVER — poll co 1,2 s, MutationObserver tabeli ║
║ 14. DEBUG — gtmTbDebug() w konsoli ║
║ 15. INIT ║
╚══════════════════════════════════════════════════════════════════════════════╝ */
/* ═══════════════════ MODUŁ 1: STAN I USTAWIENIA ═══════════════════ */
const WID = 'gtm-tb';
const LS_COL = 'gtmTbCol';
const LS_POS = 'gtmTbPos'; // zapamiętane położenie okna (po przeciągnięciu)
const LS_H = 'gtmTbH'; // zapamiętana wysokość okna (uchwyt w stopce)
const LS_TIDY = 'gtmTbTidy'; // '0' = wyłączone „Porządkuj” (zapauzowane na szaro i na dół); domyślnie wł.
const LS_TRIG = 'gtmTbTrigTypes'; // + ':' + klucz kontenera
const LS_TAB = 'gtmTbTab'; // ostatnia zakładka: filter | params
const LS_IDS = 'gtmTbShowIds'; // '1' = pokazuj #ID przy nazwach na liście (domyślnie wył.)
const LS_VIDS = 'gtmTbShowVendorIds'; // '1' = ID vendorów (G-…, AW-…) przy nazwach
const LS_FIRE = 'gtmTbShowFiring'; // '1' = opcja uruchamiania tagu (raz na zdarzenie / stronę)
const LS_EVT = 'gtmTbShowEvents'; // '1' = nazwa eventu + parametry tagu
const LS_CONSENT = 'gtmTbShowConsent'; // '1' = ustawienia zgody przy tagu
const LS_OPT_ACC = 'gtmTbOptAcc'; // '1' = harmonijka „Opcje wyświetlania” rozwinięta
const LS_TRIG_ACC = 'gtmTbTrigAcc'; // '1' = harmonijka „Szukaj po nazwie reguły” rozwinięta
const LS_VAR_USES = 'gtmTbVarUses'; // '0' = wyłączona kolumna „Tagi” na liście zmiennych (domyślnie wł.)
const POLL_MS = 1200;
let collapsed = localStorage.getItem(LS_COL) === '1';
let activeTab = localStorage.getItem(LS_TAB) === 'params' ? 'params' : 'filter';
let currentSection = null;
let widget = null;
let lastScanKey = '';
let typeMap = {};
let trigTypeMap = {}; // { typReguły: liczbaTagów } — tylko sekcja tagów
let activeTypeFilters = new Set();
let activeTrigTypeFilters = new Set(); // wybrane TYPY reguł
let trigNameSearch = ''; // szukajka po NAZWIE reguły (filtruje tagi)
let trigNameRegex = null;
let trigNameError = '';
let trigNameIds = null; // predykaty ID reguł (szukajka reguł na Tagach)
let nameSearch = '';
let nameSearchRegex = null;
let nameSearchError = '';
let nameSearchIds = null; // predykaty ID, gdy wpis w szukajce to same ID
let showIds = false;
try { showIds = localStorage.getItem(LS_IDS) === '1'; } catch (e) {}
let showVendorIds = false, showFiring = false, trigAccOpen = false, showEvents = false, showConsent = false, optAccOpen = false;
const annFirst = true; // etykiety zawsze przed nazwą
const openParams = new Set(); // tagi z rozwiniętą listą parametrów
try {
showVendorIds = localStorage.getItem(LS_VIDS) === '1';
showFiring = localStorage.getItem(LS_FIRE) === '1';
showEvents = localStorage.getItem(LS_EVT) === '1';
showConsent = localStorage.getItem(LS_CONSENT) === '1';
optAccOpen = localStorage.getItem(LS_OPT_ACC) === '1';
trigAccOpen = localStorage.getItem(LS_TRIG_ACC) !== '0'; // domyślnie rozwinięta
} catch (e) {}
let showVarUses = true;
try { showVarUses = localStorage.getItem(LS_VAR_USES) !== '0'; } catch (e) {}
// mapa nazwa reguły → typ reguły (budowana przy wizycie na stronie Reguł,
// trzymana w localStorage per kontener)
let trigTypeStore = {};
let lastContainerKey = '';
// cache układu kolumn per tabela
const colCache = new WeakMap();
const C = {
bg: '#111827', hover: '#1e293b',
accent: '#3b82f6', accentDk: '#1e3a5f',
green: '#22c55e', red: '#ef4444', yellow: '#eab308',
text: '#e5e7eb', dim: '#6b7280', border: '#374151',
};
const COLORS = ['#3b82f6','#ef4444','#22c55e','#eab308','#a855f7','#f97316','#06b6d4','#ec4899','#14b8a6','#f43f5e','#8b5cf6','#64748b','#84cc16','#0ea5e9','#d946ef'];
const tcMap = {}; let ci = 0;
function tc(t) { if (!tcMap[t]) tcMap[t] = COLORS[ci++ % COLORS.length]; return tcMap[t]; }
function escHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function escAttr(s) { return escHtml(s).replace(/"/g, '"'); }
/* ═══════════════════ MODUŁ 2: ID I WYSZUKIWANIE ═══════════════════ */
/* ── regex w szukajce ── */
const REGEX_SIGNAL = /[|\\^$*+?.()\[\]{}]/;
/* ── ID elementu: z linku, atrybutów, danych AngularJS ── */
// ID elementu siedzi tylko w linku: …/workspaces/64/tags/112
// (luźny wzorzec — łapie też względne linki typu "triggers/5" i atrybuty ng-href / routerlink)
const ENT_RE = /(?:^|[/#])(tags|triggers|variables)\/(\d+)(?=[/?&#]|$)/;
const ID_ATTRS = ['href', 'ng-href', 'data-href', 'routerlink', 'ng-reflect-router-link'];
// Wpis traktowany jako ID, gdy KAŻDY token to: 112 · #112 · 100-120 · >50 · <=50 · URL elementu.
// Separatory: | , ; spacja. Zwraca tablicę predykatów albo null (= zwykłe szukanie po nazwie).
function parseIdQuery(raw) {
const toks = String(raw).trim().split(/[\s,;|]+/).filter(Boolean);
if (!toks.length) return null;
const preds = [];
for (const t of toks) {
let m;
if ((m = ENT_RE.exec(t))) { const n = +m[2]; preds.push(id => id === n); }
else if ((m = /^#?(\d+)$/.exec(t))) { const n = +m[1]; preds.push(id => id === n); }
else if ((m = /^#?(\d+)-#?(\d+)$/.exec(t))) {
const lo = Math.min(+m[1], +m[2]), hi = Math.max(+m[1], +m[2]);
preds.push(id => id >= lo && id <= hi);
}
else if ((m = /^([<>]=?)#?(\d+)$/.exec(t))) {
const op = m[1], n = +m[2];
preds.push(id => op === '>' ? id > n : op === '>=' ? id >= n : op === '<' ? id < n : id <= n);
}
else return null;
}
return preds;
}
// ID wiersza: link w kolumnie Nazwa, a jeśli go nie ma — dowolny element wiersza
// wskazujący na element TEJ sekcji (na Tagach nie weźmie ID z chipa reguły).
function entFromEl(el) {
for (const at of ID_ATTRS) {
const v = el.getAttribute && el.getAttribute(at);
if (!v) continue;
const m = ENT_RE.exec(v);
if (m) return { type: m[1], id: Number(m[2]) };
}
return null;
}
const ID_SEL = ID_ATTRS.map(a => '[' + a + ']').join(',');
/* ── ID z danych AngularJS ──
Na Regułach / Zmiennych GTM nie daje linku z ID — nazwa to <a href="" ng-click="ctrl.openTriggerSheet($event, trigger.key)">.
ID siedzi w obiekcie trigger.key. Scope() bywa wyłączony (debugInfo off), ale kontrolery dyrektyw
(np. gtm-table-row-checkbox data-item="trigger") zawsze są w angular.element(el).data() → szukamy tam
pola tagId / triggerId / variableId (BFS, płytko, żeby nie wpaść w listę wszystkich elementów tabeli). */
const NG_ID_KEYS = { tagId: 'tags', triggerId: 'triggers', variableId: 'variables' };
const ngIdCache = new WeakMap();
function findNgId(root, wantType) {
const queue = [[root, 0]];
const seen = new Set();
while (queue.length) {
const [o, d] = queue.shift();
if (!o || typeof o !== 'object' || seen.has(o)) continue;
if (o.nodeType || o === window) continue;
seen.add(o);
let keys;
try { keys = Object.keys(o); } catch (e) { continue; }
for (const k of keys) {
const type = NG_ID_KEYS[k];
if (!type || (wantType && type !== wantType)) continue;
const v = o[k];
if ((typeof v === 'string' && /^\d+$/.test(v)) || (typeof v === 'number' && v >= 0)) return { type, id: Number(v) };
}
if (d >= 4) continue;
if (Array.isArray(o) && o.length > 1) continue; // listy (np. wszystkie wiersze tabeli) pomijamy
for (const k of keys) {
if (k.charAt(0) === '$' || /^(tableCtrl|parentCtrl|ctrl)$/.test(k)) continue;
let v; try { v = o[k]; } catch (e) { continue; }
if (v && typeof v === 'object') queue.push([v, d + 1]);
}
}
return null;
}
function ngIdFromRow(row) {
if (ngIdCache.has(row)) return ngIdCache.get(row);
const ng = window.angular;
if (!ng || !ng.element) return null;
const want = currentSection || null;
const els = [row, ...row.querySelectorAll('*')];
for (const el of els) {
let data;
try { data = ng.element(el).data(); } catch (e) { continue; }
if (!data) continue;
for (const k of Object.keys(data)) {
const r = findNgId(data[k], want);
if (r) { ngIdCache.set(row, r); return r; }
}
}
return null; // nie cache'ujemy porażki — Angular mógł jeszcze nie podpiąć danych
}
// Cały obiekt elementu (tag / reguła / zmienna) z danych Angulara — ten, który ma .key z ID.
const ngItemCache = new WeakMap();
// Szukamy pola tagId/triggerId/variableId w danych Angulara i bierzemy obiekt elementu:
// jeśli ID leży w czymś w rodzaju .key (bez name/data/type) → elementem jest rodzic, inaczej sam obiekt.
function ngItemFromRow(row) {
if (ngItemCache.has(row)) return ngItemCache.get(row);
const ng = window.angular;
if (!ng || !ng.element) return null;
const want = currentSection || null;
const hasId = o => Object.keys(NG_ID_KEYS).some(f => o[f] != null && (!want || NG_ID_KEYS[f] === want));
const looksLikeItem = o => ['name', 'data', 'type', 'parameter', 'tagFiringOption'].some(k => k in o);
for (const el of [row, ...row.querySelectorAll('*')]) {
let data;
try { data = ng.element(el).data(); } catch (e) { continue; }
if (!data) continue;
for (const dk of Object.keys(data)) {
const queue = [{ o: data[dk], d: 0, parent: null }], seen = new Set();
while (queue.length) {
const cur = queue.shift();
const o = cur.o;
if (!o || typeof o !== 'object' || o.nodeType || o === window || seen.has(o)) continue;
seen.add(o);
let hit = false;
try { hit = hasId(o); } catch (e) {}
if (hit) {
const item = (!looksLikeItem(o) && cur.parent && cur.d > 1) ? cur.parent : o;
ngItemCache.set(row, item);
return item;
}
if (cur.d >= 4 || (Array.isArray(o) && o.length > 1)) continue;
let keys; try { keys = Object.keys(o); } catch (e) { continue; }
for (const k of keys) {
if (k.charAt(0) === '$' || /^(tableCtrl|parentCtrl|ctrl)$/.test(k)) continue;
let v; try { v = o[k]; } catch (e) { continue; }
if (v && typeof v === 'object') queue.push({ o: v, d: cur.d + 1, parent: o });
}
}
}
}
// Nowy Angular (lista Tagów): dane wiersza w __ngContext__ (LView), np. kontekst *ngFor → $implicit = tag
const it2 = ivyItemFromRow(row, hasId, looksLikeItem);
if (it2) { ngItemCache.set(row, it2); return it2; }
return null;
}
/* ═══════════════════ MODUŁ 3: API GTM ═══════════════════ */
/* ── Dane z API GTM (to samo, z czego korzysta panel): /api/accounts/A/containers/C/workspaces/W/tags ──
Lista Tagów to nowy Angular — w DOM nie ma parametrów tagu. Pobieramy listę raz (cache 30 s,
reset przy zmianie adresu) i mapujemy ID → obiekt (tagId / variableId / triggerId). */
const API_ID_FIELD = { tags: 'tagId', variables: 'variableId', triggers: 'triggerId', clients: 'clientId', transformations: 'transformationId', folders: 'folderId' };
// kontener serwerowy (SST) ma w menu Klientów i Transformacje — tylko wtedy pobieramy te listy
function isServerContainer() {
return !!document.querySelector('a[href*="/workspaces/"][href$="/clients"], a[href*="/workspaces/"][href$="/transformations"], .wd-open-client-list-button');
}
let apiStore = { at: 0, map: null, loading: false, status: '', sample: '' }; // ostatnio użyty (do debugu)
function wsApiBase() {
const m = location.hash.match(/accounts\/(\d+)\/containers\/(\d+)\/workspaces\/(\d+)/);
return m ? `/api/accounts/${m[1]}/containers/${m[2]}/workspaces/${m[3]}` : null;
}
function parseApiText(txt) {
return JSON.parse(String(txt).replace(/^\)\]\}'[^\n]*\n?/, '').trim());
}
// w API panelu zmienne to „makra” — ID bywa w key.macroId zamiast key.variableId
const API_ID_ALT = { variableId: ['variableId', 'macroId'], tagId: ['tagId'], triggerId: ['triggerId', 'ruleId'], clientId: ['clientId'], transformationId: ['transformationId'], folderId: ['folderId'] };
function collectApiItems(json, field) {
const fields = API_ID_ALT[field] || [field];
const pick = o => { for (const f of fields) if (o[f] != null && /^\d+$/.test(String(o[f]))) return Number(o[f]); return null; };
const map = new Map();
const stack = [json], seen = new Set();
let budget = 200000;
while (stack.length && budget-- > 0) {
const o = stack.pop();
if (!o || typeof o !== 'object' || seen.has(o)) continue;
seen.add(o);
// format API panelu: { key: { tagId: 197, … }, data: { … }, properties: { … } }
if (!Array.isArray(o) && o.key && typeof o.key === 'object' && pick(o.key) !== null) {
const id = pick(o.key);
if (!map.has(id)) map.set(id, o);
continue; // nie schodzimy niżej — to już element
}
if (!Array.isArray(o) && pick(o) !== null && ('name' in o || 'data' in o || 'parameter' in o)) {
const id = pick(o);
if (!map.has(id)) map.set(id, o);
continue;
}
for (const k in o) { const v = o[k]; if (v && typeof v === 'object') stack.push(v); }
}
return map;
}
const apiStores = {}; // klucz: baza workspace + sekcja
function apiMapFor(section) {
const base = wsApiBase();
const field = API_ID_FIELD[section];
if (!base || !field) return null;
const key = base + '/' + section;
const st = apiStores[key] || (apiStores[key] = { map: null, at: 0, loading: false, status: '', sample: '' });
apiStore = st;
const fresh = st.map && Date.now() - st.at < 30000;
if (!fresh && !st.loading) {
st.loading = true;
const url = base + '/' + section + '?hl=en';
origFetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json, text/plain, */*' } })
.then(r => r.text().then(t => ({ ok: r.ok, status: r.status, t })))
.then(({ ok, status, t }) => {
st.sample = t.slice(0, 1500);
if (!ok) throw new Error('HTTP ' + status);
st.map = collectApiItems(parseApiText(t), field);
st.at = Date.now();
st.status = 'ok · ' + st.map.size + ' elem.';
bumpData();
if (needVarParams()) hydrateStore(key, section);
})
.catch(e => { st.status = 'błąd: ' + (e && e.message); st.at = Date.now(); st.map = st.map || new Map(); })
.finally(() => { st.loading = false; renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter(); });
}
return st.map;
}
/* ── Zmienne: lista z API ma puste vendorTemplate.param → dociągamy pełne dane pojedynczo (…/variables/ID),
z cache po entityVersion, żeby odświeżenie listy nie wymuszało ponownych pobrań ── */
const fullCache = new Map(); // 'variables:ID' → { ver, data, properties }
const hydrating = new Set();
// parametry zmiennych są potrzebne tylko do: wartości przy zmiennych, ID vendorów (stałe), duplikatów zmiennych
function needVarParams() {
return (currentSection === 'variables' && (showEvents || onlyDup || showVendorIds)) || (currentSection === 'tags' && showVendorIds);
}
function ensureVarParams() { const base = wsApiBase(); if (base && needVarParams()) hydrateStore(base + '/variables', 'variables'); }
// cache pełnych zmiennych przeżywa przeładowanie strony (sessionStorage, per kontener)
function fullCacheKey() { return 'gtmTbFull:' + (wsApiBase() || ''); }
function loadFullCache() {
try {
const raw = sessionStorage.getItem(fullCacheKey());
if (!raw) return;
const obj = JSON.parse(raw);
Object.keys(obj).forEach(k => fullCache.set(k, obj[k]));
} catch (e) {}
}
function saveFullCache() {
try {
const obj = {}; fullCache.forEach((v, k) => { obj[k] = v; });
const raw = JSON.stringify(obj);
if (raw.length < 2500000) sessionStorage.setItem(fullCacheKey(), raw);
} catch (e) {}
}
loadFullCache();
window.addEventListener('hashchange', loadFullCache);
function itemVer(it) { const sm = it && it.statMetadata; return sm ? String(sm.entityVersion || sm.modifiedTime || '') : ''; }
function hasParams(vt) { return !!vt && Array.isArray(vt.param) && vt.param.length > 0; }
function itemLacksParams(it) {
if (!it || it.isBuiltIn) return false;
const vtD = it.data && it.data.vendorTemplate, vtP = it.properties && it.properties.vendorTemplate;
if (!vtD && !vtP) return false;
return !hasParams(vtD) && !hasParams(vtP);
}
// „data” bez parametrów (są tylko w „properties”) → zapis z taką data skasowałby konfigurację
function dataLacksParams(it) {
if (!it || it.isBuiltIn) return false;
const vtD = it.data && it.data.vendorTemplate;
return !!vtD && !hasParams(vtD) && hasParams(it.properties && it.properties.vendorTemplate);
}
function mergeFull(it, full) {
if (!full || !full.data || !it) return;
it.data = full.data;
if (full.properties) it.properties = full.properties; else delete it.properties;
}
async function fetchFullItem(sec, id) {
const base = wsApiBase();
if (!base) return null;
const r = await origFetch(base + '/' + sec + '/' + id + '?hl=en', { credentials: 'same-origin', headers: { 'Accept': 'application/json, text/plain, */*' } });
if (!r.ok) throw new Error('HTTP ' + r.status);
const m = collectApiItems(parseApiText(await r.text()), API_ID_FIELD[sec]);
return m.get(id) || m.values().next().value || null;
}
// pełne dane elementu do zapisu (lista zmiennych nie ma parametrów — PUT z taką „data” wyczyściłby konfigurację)
async function ensureFullItem(sec, id) {
const tm = apiMapFor(sec);
const it = tm && tm.get(id);
if (!it || !itemLacksParams(it)) return it;
const full = await fetchFullItem(sec, id);
if (full && full.data) { mergeFull(it, full); fullCache.set(sec + ':' + id, { ver: itemVer(full), data: full.data, properties: full.properties || null }); }
return it;
}
function hydrateStore(key, sec) {
const st = apiStores[key];
if (!st || !st.map || sec !== 'variables' || hydrating.has(key)) return;
const todo = [];
st.map.forEach((it, id) => {
if (!itemLacksParams(it)) return;
const c = fullCache.get(sec + ':' + id);
if (c && c.ver === itemVer(it)) { mergeFull(it, c); return; }
todo.push(id);
});
if (!todo.length) return;
hydrating.add(key);
st.status = 'ok · ' + st.map.size + ' elem. · dociągam ' + todo.length + '…';
let i = 0, done = 0;
const map = st.map;
const next = () => {
if (i >= todo.length) return Promise.resolve();
const id = todo[i++];
return fetchFullItem(sec, id).then(full => {
const it = map.get(id);
if (full && full.data && it) { mergeFull(it, full); fullCache.set(sec + ':' + id, { ver: itemVer(full), data: full.data, properties: full.properties || null }); }
}).catch(() => {}).then(() => { done++; return next(); });
};
Promise.all([next(), next(), next(), next(), next(), next()]).then(() => {
hydrating.delete(key);
if (st.map === map) { st.map = new Map(map); st.status = 'ok · ' + map.size + ' elem.'; } // nowa mapa → przeliczą się cache (ID vendorów, duplikaty)
saveFullCache(); bumpData();
try { renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter(); } catch (e) {}
});
}
function apiItemName(it) {
if (!it) return '';
return (it.data && it.data.name) || it.name || (it.properties && it.properties.displayName) || '';
}
// {{Nazwa zmiennej}} → ID vendora (np. stała z G-…), z API zmiennych
let varResolveCache = { src: null, map: null };
function varResolveMap() {
const vm = apiMapFor('variables');
if (!vm) return null;
ensureVarParams();
if (varResolveCache.src === vm) return varResolveCache.map;
const out = new Map();
vm.forEach(it => {
const n = apiItemName(it);
if (!n) return;
const ids = vendorIdsFromItem(it).filter(x => x.v !== 'ID' && x.v !== 'BAD');
let raw = null;
walkStrings(it.data || it, (str, key) => { if (raw === null && key === 'value' && /^[\w.-]{4,40}$/.test(str.trim())) raw = str.trim(); });
out.set('{{' + n + '}}', { ids, raw });
});
varResolveCache = { src: vm, map: out };
return out;
}
window.addEventListener('hashchange', () => { Object.values(apiStores).forEach(st => { st.at = 0; }); });
/* ── Auto-odświeżanie: podsłuch zapytań panelu GTM do /api ──
• GTM pobiera listę (…/workspaces/W/tags?…) → bierzemy tę samą odpowiedź (bez dodatkowego requestu),
• GTM pobiera pojedynczy element (…/tags/43?…, np. po zapisie) → podmieniamy go w mapie,
• zapis / usunięcie (POST/PUT/PATCH/DELETE) → unieważniamy cache i pobieramy dane ponownie. */
const origFetch = window.fetch.bind(window);
let invT = null;
function invalidateApi(delay) {
Object.values(apiStores).forEach(st => { st.at = 0; });
clearTimeout(invT);
invT = setTimeout(() => { try { renderIdBadges(); } catch (e) {} }, delay == null ? 400 : delay);
}
function apiPath(url) {
try { return new URL(url, location.href).pathname; } catch (e) { return ''; }
}
function ingestApiResponse(method, url, text) {
if (!xsrfSeen.value && text) {
const i = text.search(/xsrf|csrf/i);
if (i >= 0) { const mm = TOKEN_RE.exec(text.slice(Math.max(0, i - 300), i + 600)); if (mm) rememberXsrf(mm[1], 'odpowiedź ' + apiPath(url)); }
}
const path = apiPath(url);
if (!/^\/api\/accounts\//.test(path)) return;
if (method && method.toUpperCase() !== 'GET') { invalidateApi(500); return; }
let m = path.match(/^(\/api\/accounts\/\d+\/containers\/\d+\/workspaces\/\d+)\/(tags|variables|triggers)$/);
if (m) {
try {
const map = collectApiItems(parseApiText(text), API_ID_FIELD[m[2]]);
const key = m[1] + '/' + m[2];
const st = apiStores[key] || (apiStores[key] = { map: null, at: 0, loading: false, status: '', sample: '' });
st.map = map; st.at = Date.now(); st.status = 'ok · ' + map.size + ' elem. (z panelu GTM)';
bumpData();
if (needVarParams()) hydrateStore(key, m[2]);
clearTimeout(invT);
invT = setTimeout(() => { try { renderIdBadges(); } catch (e) {} }, 150);
} catch (e) {}
return;
}
m = path.match(/^(\/api\/accounts\/\d+\/containers\/\d+\/workspaces\/\d+)\/(tags|variables|triggers)\/(\d+)$/);
if (m) {
const st = apiStores[m[1] + '/' + m[2]];
if (!st || !st.map) return;
try {
const one = collectApiItems(parseApiText(text), API_ID_FIELD[m[2]]);
if (!one.size) return;
const next = new Map(st.map); // nowa mapa = przeliczą się też cache zmiennych / użyć
one.forEach((v, k) => { next.set(k, v); if (v && v.data && !itemLacksParams(v)) fullCache.set(m[2] + ':' + k, { ver: itemVer(v), data: v.data, properties: v.properties || null }); });
st.map = next; bumpData();
clearTimeout(invT);
invT = setTimeout(() => { try { renderIdBadges(); } catch (e) {} }, 150);
} catch (e) {}
}
}
(function hookNetwork() {
const XO = XMLHttpRequest.prototype.open, XS = XMLHttpRequest.prototype.send;
// rejestr zapisów (PUT/POST/DELETE) do API — do podejrzenia formatu w gtmTbDebug()
window.gtmTbWrites = [];
const noteWrite = (method, url, headers, body, kind) => {
if (!method || method.toUpperCase() === 'GET' || !/\/api\/accounts\//.test(url)) return;
let b = '';
try { b = typeof body === 'string' ? body : body instanceof URLSearchParams ? body.toString() : body instanceof FormData ? '[FormData: ' + [...body.keys()].join(',') + ']' : body ? String(body) : ''; } catch (e) {}
const rec = { time: new Date().toISOString(), via: kind, method: method.toUpperCase(), url: String(url), headers: headers || {}, body: b.slice(0, 6000), bodyLen: b.length };
window.gtmTbWrites.push(rec);
if (window.gtmTbWrites.length > 20) window.gtmTbWrites.shift();
};
const XSH = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function (k, v) {
(this.__tbHdr = this.__tbHdr || {})[k] = v;
return XSH.apply(this, arguments);
};
XMLHttpRequest.prototype.open = function (method, url) {
this.__tbReq = { method: method, url: String(url) };
return XO.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
const r = this.__tbReq;
if (r) noteWrite(r.method, r.url, this.__tbHdr, body, 'xhr');
if (r && /\/api\/accounts\//.test(r.url)) {
this.addEventListener('loadend', () => {
if (this.status < 200 || this.status >= 300) return;
let t = '';
try { t = (this.responseType === '' || this.responseType === 'text') ? this.responseText : (typeof this.response === 'string' ? this.response : JSON.stringify(this.response)); } catch (e) {}
ingestApiResponse(r.method, r.url, t);
});
}
return XS.apply(this, arguments);
};
window.fetch = function (input, init) {
const url = typeof input === 'string' ? input : (input && input.url) || '';
const method = (init && init.method) || (input && input.method) || 'GET';
try {
let h = {};
const hs = (init && init.headers) || (input && input.headers);
if (hs && typeof hs.forEach === 'function') hs.forEach((v, k) => { h[k] = v; }); else if (hs) h = Object.assign({}, hs);
noteWrite(method, url, h, init && init.body, 'fetch');
} catch (e) {}
const p = origFetch(input, init);
if (/\/api\/accounts\//.test(url)) {
p.then(res => { if (res.ok) res.clone().text().then(t => ingestApiResponse(method, url, t)).catch(() => {}); }).catch(() => {});
}
return p;
};
})();
function ivyItemFromRow(row, hasId, looksLikeItem) {
// Angular debug API (window.ng) — kontekst wiersza *ngFor → $implicit = element listy
const ngApi = window.ng;
if (ngApi && typeof ngApi.getContext === 'function') {
try {
const c = ngApi.getContext(row);
if (c && c.$implicit && typeof c.$implicit === 'object') return c.$implicit;
} catch (e) {}
}
const els = [row, ...row.querySelectorAll('*')];
for (const el of els) {
const ctx = el.__ngContext__;
if (!ctx || typeof ctx !== 'object') continue;
const queue = [{ o: ctx, d: 0, parent: null }], seen = new Set();
let budget = 3000;
while (queue.length && budget-- > 0) {
const cur = queue.shift();
const o = cur.o;
if (!o || typeof o !== 'object' || o.nodeType || o === window || seen.has(o)) continue;
seen.add(o);
let hit = false;
try { hit = !Array.isArray(o) && hasId(o); } catch (e) {}
if (hit) return (!looksLikeItem(o) && cur.parent && !Array.isArray(cur.parent)) ? cur.parent : o;
if (cur.d >= 5) continue;
let keys; try { keys = Object.keys(o); } catch (e) { continue; }
if (Array.isArray(o) && o.length > 200) continue;
for (const k of keys) {
if (k.charAt(0) === '$' && k !== '$implicit') continue;
let v; try { v = o[k]; } catch (e) { continue; }
if (v && typeof v === 'object') queue.push({ o: v, d: cur.d + 1, parent: o });
}
}
}
return null;
}
// Przejście po wszystkich polach obiektu (z limitem), cb(wartośćString, nazwaPola)
function walkStrings(root, cb, skipRe) {
const stack = [[root, '', 0]], seen = new Set();
let budget = 4000;
while (stack.length && budget-- > 0) {
const [o, pk, d] = stack.pop();
if (o == null) continue;
if (typeof o === 'string') { cb(o, pk); continue; }
if (typeof o === 'number' || typeof o === 'boolean') { cb(String(o), pk); continue; }
if (typeof o !== 'object' || o.nodeType || seen.has(o) || d > 10) continue;
seen.add(o);
// para {key: 'tagId', value: 'G-…'} (format parametrów GTM)
if (typeof o.key === 'string' && (typeof o.value === 'string' || typeof o.value === 'number')) cb(String(o.value), o.key);
// format API panelu: { key: 'measurementIdOverride', value: { type: 1, string: 'G-…' } }
if (typeof o.key === 'string' && o.value && typeof o.value === 'object' && typeof o.value.string === 'string') cb(o.value.string, o.key);
// odwołanie do zmiennej w „data”: { key: 'pixelId', value: { type: 4, macroReference: 'Constant - FB Pixel ID' } }
if (typeof o.key === 'string' && o.value && typeof o.value === 'object' && typeof o.value.macroReference === 'string') cb('{{' + o.value.macroReference + '}}', o.key);
let keys; try { keys = Object.keys(o); } catch (e) { continue; }
for (const k of keys) {
if (k.charAt(0) === '$' || (skipRe && skipRe.test(k))) continue;
let v; try { v = o[k]; } catch (e) { continue; }
stack.push([v, Array.isArray(o) ? pk : k, d + 1]);
}
}
}
// ID vendorów: wzorce w dowolnym tekście (też w Custom HTML) + pola typowo trzymające ID
const VENDOR_RES = [
{ v: 'GA4', re: /\bG-[A-Z0-9]{6,12}\b/g },
{ v: 'Ads', re: /\bAW-\d{6,12}\b/g },
{ v: 'Google tag', re: /\bGT-[A-Z0-9]{6,12}\b/g },
{ v: 'GTM', re: /\bGTM-[A-Z0-9]{4,10}\b/g },
{ v: 'UA', re: /\bUA-\d{4,10}-\d{1,4}\b/g },
{ v: 'Floodlight', re: /\bDC-\d{6,10}\b/g },
{ v: 'Meta', re: /fbq\(\s*['"]init['"]\s*,\s*['"](\d{10,20})['"]/g, grp: 1 },
{ v: 'TikTok', re: /ttq\.load\(\s*['"]([A-Z0-9]{15,25})['"]/g, grp: 1 },
{ v: 'Clarity', re: /clarity['"]?\s*,\s*['"]script['"]\s*,\s*['"]([a-z0-9]{8,12})['"]/g, grp: 1 },
{ v: 'Hotjar', re: /hjid\s*:\s*(\d{5,10})/g, grp: 1 },
{ v: 'LinkedIn', re: /_linkedin_partner_id\s*=\s*['"](\d{4,10})['"]/g, grp: 1 },
{ v: 'Bing', re: /\bti\s*:\s*['"](\d{6,10})['"]/g, grp: 1 },
];
const VENDOR_KEY_RE = /^(tagId|measurementId|measurementIdOverride|conversionId|trackingId|pixelId|pixel_id|pixelIds|partnerId|uetTagId|advertiserId)$/i;
const VENDOR_COL = { 'GA4': '#e37400', 'Ads': '#1a73e8', 'Google tag': '#188038', 'GTM': '#4285f4', 'UA': '#9aa0a6', 'Floodlight': '#a142f4', 'Meta': '#0866ff', 'TikTok': '#202124', 'Clarity': '#0078d4', 'Hotjar': '#fd3a5c', 'LinkedIn': '#0a66c2', 'Bing': '#008373', 'ID': '#5f6368' };
// pola elementu, których nie przeszukujemy (klucz z ID konta/kontenera, nazwa, notatki, metadane)
const VENDOR_SKIP = /^(key|name|notes|statMetadata|fingerprint|parentFolderId|path|tagManagerUrl|workspaceId|accountId|containerId|tagId|variableId|triggerId|firingTriggerId|blockingTriggerId|setupTag|teardownTag)$/;
// Poprawność ID: format per vendor (czerwona etykieta, gdy coś nie gra)
const ID_FORMAT = {
'GA4': [/^G-[A-Z0-9]{10}$/, 'GA4: „G-” + 10 znaków (wielkie litery / cyfry)'],
'Ads': [/^AW-\d{9,11}$/, 'Google Ads: „AW-” + 9–11 cyfr'],
'Google tag': [/^GT-[A-Z0-9]{6,10}$/, 'Google tag: „GT-” + 6–10 znaków'],
'GTM': [/^GTM-[A-Z0-9]{6,8}$/, 'GTM: „GTM-” + 6–8 znaków'],
'Meta': [/^\d{15,16}$/, 'piksel Meta: 15–16 cyfr'],
'UA': [null, 'Universal Analytics (UA-) nie zbiera danych od lipca 2024 — tag do usunięcia'],
};
function idProblem(v, id) {
const f = ID_FORMAT[v];
if (!f) return '';
if (!f[0]) return f[1];
return f[0].test(id) ? '' : 'Niepoprawny format — oczekiwane ' + f[1];
}
// czego oczekujemy w polu o danej nazwie (do opisu błędu)
function expectedForKey(k) {
if (/^measurementId(Override)?$/i.test(k)) return ID_FORMAT['GA4'][1];
if (/^conversionId$/i.test(k)) return 'Google Ads: same cyfry (9–11) albo „AW-…”';
if (/pixel/i.test(k)) return ID_FORMAT['Meta'][1];
if (/^tagId$/i.test(k)) return '„G-…”, „AW-…” albo „GT-…”';
return 'ID vendora';
}
function vendorIdsFromItem(item) {
const out = new Map(); // id → vendor
const keyOf = new Map(); // {{zmienna}} → nazwa pola (pixelId, conversionId…)
const bad = new Map(); // błędna wartość w polu z ID → opis
const spaced = new Set(); // ID ze spacjami — pokazujemy tylko czerwoną wersję
const root = {};
Object.keys(item).forEach(k => { if (!VENDOR_SKIP.test(k)) root[k] = item[k]; });
walkStrings(root, (str, key) => {
// macroReference = nazwa zmiennej bez {{ }} (już podana wyżej jako {{…}}) — pomijamy
if (key && /^(name|displayName|notes|eventName|macroReference)$/.test(key)) return;
// pole z ID wpisanym wprost: puste / spacje / nic nie pasuje do formatu → błąd
if (key && VENDOR_KEY_RE.test(key) && typeof str === 'string' && str.indexOf('{{') < 0) {
const t = str.trim();
if (!t) { bad.set('(puste ' + key + ')', 'Puste pole „' + key + '” — oczekiwane ' + expectedForKey(key)); return; }
if (t !== str) { bad.set('„' + str + '”', 'Spacja na początku / końcu ID w polu „' + key + '”'); spaced.add(t); }
else {
const okWhole = VENDOR_RES.some(({ re, grp }) => { if (grp) return false; re.lastIndex = 0; const m = re.exec(t); return m && m[0] === t; });
const okDigits = /^\d{6,20}$/.test(t) && /^(tagId|conversionId|pixelId|pixel_id|pixelIds|partnerId|uetTagId|advertiserId)$/i.test(key);
if (!okWhole && !okDigits && !/^(true|false)$/i.test(t)) bad.set(t, 'Wartość „' + t + '” w polu „' + key + '” nie wygląda na ID — oczekiwane ' + expectedForKey(key));
}
}
if (!str) return;
// {{Zmienna}} w tekście: wzorców nie szukamy w nazwie zmiennej (np. {{GA4 ID G-XXX}}),
// tylko dodajemy samą zmienną, jeśli siedzi w polu z ID albo jej nazwa wygląda na ID vendora
const refs = str.match(/\{\{[^{}]+\}\}/g) || [];
refs.forEach(ref => {
const looksVendor = VENDOR_RES.some(({ re }) => { re.lastIndex = 0; return re.test(ref); });
if ((key && VENDOR_KEY_RE.test(key)) || looksVendor) {
if (!out.has(ref)) { out.set(ref, 'ID'); keyOf.set(ref, key || ''); }
}
});
const plain = refs.length ? str.replace(/\{\{[^{}]+\}\}/g, ' ') : str;
if (plain.length < 4000 || /fbq|ttq|gtag|clarity|hjid|linkedin|uetq/.test(plain)) {
VENDOR_RES.forEach(({ v, re, grp }) => {
re.lastIndex = 0;
let m;
while ((m = re.exec(plain))) {
const id = grp ? m[grp] : m[0];
if (!out.has(id)) out.set(id, v);
}
});
}
// pole z ID: Meta pixel (same cyfry), Ads conversionId (bez AW-) albo {{Zmienna}}
if (key && VENDOR_KEY_RE.test(key)) {
const t = str.trim();
if (/^\d{6,20}$/.test(t)) {
const id = /^conversionId$/i.test(key) ? 'AW-' + t : t;
if (!out.has(id)) out.set(id, /^conversionId$/i.test(key) ? 'Ads' : /pixel/i.test(key) ? 'Meta' : 'ID');
if (!keyOf.has(id)) keyOf.set(id, key);
} else if (out.has(t) && !keyOf.has(t)) keyOf.set(t, key); // np. tagId = G-… wpisane wprost
}
}, /^valueCache$/);
spaced.forEach(t => out.delete(t));
const res = [...out.entries()].map(([id, v]) => keyOf.has(id) ? { id, v, k: keyOf.get(id) } : { id, v });
bad.forEach((msg, id) => { if (!out.has(id)) res.push({ id, v: 'BAD', msg }); });
return res;
}
// Opcja uruchamiania tagu: raz na zdarzenie / raz na stronę / bez limitu
// API panelu zwraca liczbę: 1 = raz na zdarzenie (domyślne), 2 = raz na stronę, 3 = bez limitu
const FIRING_NUM = { '1': 'Raz na zdarzenie', '2': 'Raz na stronę', '3': 'Bez limitu' };
function firingFromItem(item) {
let raw = null;
walkStrings(item, (str, key) => { if (raw === null && key && /firing_?option/i.test(key)) raw = str; });
// pole pominięte w API = wartość domyślna GTM („Once per event”)
if (raw === null) return (item && item.data && item.key) ? { label: 'Raz na zdarzenie', raw: 'brak pola = domyślne' } : null;
if (FIRING_NUM[String(raw)]) return { label: FIRING_NUM[String(raw)], raw };
const r = String(raw).toLowerCase();
if (/event/.test(r)) return { label: 'Raz na zdarzenie', raw };
if (/load|page/.test(r)) return { label: 'Raz na stronę', raw };
if (/unlimit|none/.test(r)) return { label: 'Bez limitu', raw };
return { label: 'opcja: ' + raw, raw };
}
function idLinkFromRow(row) {
const { nameIdx } = colsForRow(row);
const cells = row.querySelectorAll('td');
const nameCell = cells.length > nameIdx ? cells[nameIdx] : null;
if (nameCell) {
for (const el of nameCell.querySelectorAll(ID_SEL)) {
const e = entFromEl(el);
if (e) return { a: el, type: e.type, id: e.id };
}
}
for (const el of row.querySelectorAll(ID_SEL)) {
const e = entFromEl(el);
if (e && (!currentSection || e.type === currentSection)) return { a: el, type: e.type, id: e.id };
}
const e = entFromEl(row);
if (e && (!currentSection || e.type === currentSection)) return { a: nameCell && nameCell.querySelector('a') || row, type: e.type, id: e.id };
const n = ngIdFromRow(row);
if (n) return { a: nameCell && nameCell.querySelector('a') || row, type: n.type, id: n.id };
return null;
}
function idFromRow(row) {
const l = idLinkFromRow(row);
return l ? l.id : null;
}
/* ═══════════════════ MODUŁ 4: ADNOTACJE PRZY NAZWIE ═══════════════════ */
/* ── #ID przy nazwach (checkbox „Pokaż #ID na liście”) ── */
const ID_BADGE = 'tb-id-badge';
let idCssEl = null;
function ensureIdCss() {
if (idCssEl && idCssEl.isConnected) return;
idCssEl = document.createElement('style');
idCssEl.id = 'gtm-tb-id-css';
idCssEl.textContent = `.${ID_BADGE}{display:inline-block;margin-left:6px;padding:0 5px;border:1px solid #dadce0;border-radius:4px;font:11px/16px Consolas,Monaco,monospace;color:#5f6368;background:#f1f3f4;cursor:copy;vertical-align:middle;white-space:nowrap;user-select:none;text-decoration:none}
.${ID_BADGE}:hover{border-color:#1a73e8;color:#1a73e8}
.tb-meta{display:inline;margin-left:2px}
.tb-vid{display:inline-block;margin-left:5px;padding:1px 6px;border-radius:4px;font:600 11px/15px Consolas,Monaco,monospace;color:#fff;cursor:copy;vertical-align:middle;white-space:nowrap;user-select:none;border:2px solid transparent;letter-spacing:.2px}
.tb-vid:hover{filter:brightness(1.12)}
.tb-vid .tb-via{font-weight:400;opacity:.8;margin-left:4px;font-size:10px}
.tb-vid.var{background:#fff!important;color:#5f6368;border:1px dashed #9aa0a6;font-weight:600}
.tb-vid.var.warn::before{color:#d93025}
.tb-vid.val{margin-left:3px}
.tb-vid.val::before{content:'= ';opacity:.85}
.tb-vid.bad{background:#d93025!important;color:#fff!important;border-color:#d93025!important}
.tb-vid.bad::before{content:'✖ '!important;color:#fff!important}
.tb-vid.var.bad{background:#fce8e6!important;color:#c5221f!important;border:1px dashed #d93025!important}
.tb-vid.var.bad::before{color:#c5221f!important}
.tb-vid.warn{border-color:#fff;box-shadow:0 0 0 2px #d93025}
.tb-vid.warn::before{content:'⚠ ';color:#fff}
.tb-fire:hover{filter:brightness(1.15);box-shadow:0 0 0 2px rgba(0,0,0,.08)}
.tb-fire{display:inline-block;margin-left:5px;padding:1px 8px;border-radius:10px;font:600 10px/15px 'Google Sans',Roboto,sans-serif;color:#fff;vertical-align:middle;white-space:nowrap;user-select:none;letter-spacing:.2px}
.tb-fire.f-event{background:#80868b}
.tb-fire.f-page{background:#202124}
.tb-fire.f-unl{background:#d93025}
.tb-fire.f-other{background:#5f6368}
.tb-vid.tb-ev{background:#7c3aed;color:#fff}
.tb-vid.tb-ev::before{content:'⚡ ';opacity:.9}
.tb-vid.tb-ev.dupe{box-shadow:0 0 0 2px #c4b5fd;background:#7c3aed}
.tb-vid.tb-ev.dupe.hot{box-shadow:0 0 0 2px #d93025;background:#d93025}
.tb-vid.tb-ev.pz{opacity:.55}
.tb-vid.tb-vv{background:#e8f0fe!important;color:#174ea6;border:1px solid #aecbfa;font-weight:500;max-width:240px;overflow:hidden;text-overflow:ellipsis}
.tb-vid.tb-vv::before{content:'= ';opacity:.7}
.tb-vid.tb-vv.ref{color:#1a73e8;border-style:dashed}
/* konflikt: ≥2 aktywne tagi z tym samym eventem — czerwona ramka wokół całej grupy */
tr.tb-grp-hot > td{background:#fdecea!important;border-top:none!important;border-bottom:none!important}
tr.tb-grp-hot.tb-grp-start > td{border-top:3px solid #d93025!important}
tr.tb-grp-hot.tb-grp-end > td{border-bottom:3px solid #d93025!important}
tr.tb-grp-hot > td:first-child{box-shadow:inset 3px 0 0 #d93025}
tr.tb-grp-hot > td:last-child{box-shadow:inset -3px 0 0 #d93025}
.tb-cons{display:inline-block;margin-left:5px;padding:1px 7px;border-radius:10px;font:600 10px/15px 'Google Sans',Roboto,sans-serif;vertical-align:middle;white-space:nowrap;user-select:none}
.tb-cons.c-req{color:#0d652d;background:#e6f4ea}
.tb-cons.c-none{color:#5f6368;background:#f1f3f4}
.tb-cons.c-unset{color:#b06000;background:#fef7e0}
.tb-noesv{margin-left:4px;font-size:10px}
/* zapauzowane: wyraźnie szary wiersz — szara nazwa, etykiety przygaszone (bez zmiany ich kolorów, żeby napisy nie znikały) */
tr.tb-paused > td{background:#e8eaed!important;color:#80868b!important}
tr.tb-paused > td > a,tr.tb-paused > td .fill-cell{color:#80868b!important}
tr.tb-paused > td:first-child{box-shadow:inset 4px 0 0 #9aa0a6}
tr.tb-paused .tb-ann,tr.tb-paused .tb-meta{opacity:.45}
tr.tb-paused .tb-meta *{text-decoration:none!important}
tr.tb-paused .tb-act{opacity:0}
tr.tb-paused:hover .tb-act{opacity:1}
tr.tb-paused.tb-grp-hot > td{background:#fdecea!important}
.tb-act{display:inline-flex;gap:3px;margin-left:8px;vertical-align:middle;opacity:0;transition:opacity .12s;white-space:nowrap}
tr:hover .tb-act{opacity:1}
.tb-act-btn{display:inline-block;border:1px solid #dadce0;background:#fff;border-radius:4px;font:12px/18px system-ui,sans-serif;cursor:pointer;padding:0 5px;color:#5f6368;text-decoration:none}
.tb-act-btn:hover{border-color:#1a73e8;color:#1a73e8;background:#e8f0fe}
tr.tb-newrow > td{background:#e6f4ea!important}
tr.tb-grp-hot > td:first-child::before{content:'⚠';color:#d93025;font-size:13px;font-weight:700;margin-right:4px;vertical-align:middle}
.tb-pc{display:inline-block;margin-left:5px;padding:0 6px;border-radius:4px;font:500 11px/16px 'Google Sans',Roboto,sans-serif;color:#5b21b6;background:#fff;border:1px solid #c4b5fd;cursor:pointer;vertical-align:middle;white-space:nowrap;user-select:none}
.tb-pc:hover,.tb-pc.open{background:#ede9fe}
.tb-plist{margin:4px 0 4px 0;padding:6px 10px;background:#f8f7ff;border-left:3px solid #7c3aed;border-radius:4px;font:11px/17px Consolas,Monaco,monospace;color:#3c4043;white-space:normal;word-break:break-word;cursor:text;user-select:text}
.tb-plist .k{color:#6d28d9;font-weight:600}
.tb-plist .eq{color:#9aa0a6}
.tb-plist .v.var{color:#1a73e8}
.tb-plist .v.code{display:block;white-space:pre-wrap;max-height:260px;overflow:auto;background:#fff;border:1px solid #e8e5ff;border-radius:4px;padding:4px 6px;margin-top:2px;font-size:10.5px;line-height:15px}
.tb-vid.tb-vv.code{background:#f1f3f4!important;color:#3c4043;border-color:#dadce0;font-weight:400}
.tb-vid.tb-vv.code::before{content:''}
.tb-plist .v.edit{cursor:text;border-bottom:1px dotted #c4b5fd}
.tb-inline{font:inherit;font-size:11px;padding:0 4px;border:1px solid #7c3aed;border-radius:3px;background:#fff;color:#202124;outline:none;vertical-align:middle;min-width:90px}
.tb-menu{position:fixed;z-index:2147483647;min-width:200px;max-width:420px;background:#fff;border:1px solid #dadce0;border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,.2);padding:4px 0;font:12px/18px 'Google Sans',Roboto,sans-serif;color:#202124}
.tb-menu-h{padding:4px 12px 6px;font-size:10px;color:#5f6368;border-bottom:1px solid #f1f3f4;margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tb-menu-i{display:flex;justify-content:space-between;gap:12px;padding:5px 12px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Consolas,Monaco,monospace;font-size:11px}
.tb-menu-i:hover{background:#e8f0fe}
.tb-menu-i.act{font-family:'Google Sans',Roboto,sans-serif;color:#1a73e8;border-top:1px solid #f1f3f4}
.tb-menu-s{color:#80868b;font-size:10px}
.tb-menu-e{padding:4px 12px;font-size:11px;color:#5f6368}
.tb-menu-in{padding:6px 10px 6px;border-top:1px solid #f1f3f4;margin-top:3px}
.tb-menu-in input{width:100%;box-sizing:border-box;font:11px Consolas,Monaco,monospace;padding:4px 6px;border:1px solid #dadce0;border-radius:4px;outline:none;color:#202124;background:#fff}
.tb-menu-in input:focus{border-color:#1a73e8}
.tb-plist .v.edit:hover{background:#ede9fe}
.tb-slot{display:inline-block;vertical-align:middle;white-space:nowrap;box-sizing:border-box;margin-right:5px}
.tb-slot:empty{height:1px}
.tb-slot > *{box-sizing:border-box}
.tb-slot > :first-child{margin-left:0!important}
.tb-s-ev > .tb-vid,.tb-s-pc > .tb-pc,.tb-s-fire > .tb-fire{text-align:left}
.tb-id-badge{box-sizing:border-box;text-align:center}
table.tb-wide{table-layout:auto!important}
table.tb-wide .tb-namecell{white-space:nowrap!important;width:auto!important;max-width:none!important}
table.tb-wide .tb-namecell > a{white-space:nowrap!important;display:inline-block!important}
table.tb-wide .tb-namecell .tb-plist{white-space:normal;max-width:720px}
.tb-annfirst > .tb-meta{margin-right:1px}
.tb-annfirst > .tb-id-badge{margin-left:0;margin-right:5px}
.tb-annfirst > .tb-meta > :first-child{margin-left:0}
.tb-plist .tb-pg{font:600 9px/14px 'Google Sans',Roboto,sans-serif;text-transform:uppercase;letter-spacing:.4px;color:#80868b;margin-top:3px}
th.tb-uses-col,td.tb-uses-col{white-space:nowrap;padding-right:16px}
.tb-uses-n{font-variant-numeric:tabular-nums;cursor:help}
.tb-uses-n.zero{color:#d93025;font-weight:600}
.tb-uses-o{margin-left:6px;font-size:11px;color:#80868b;cursor:help}`;
document.head.appendChild(idCssEl);
}
function stopAll(el) {
['mousedown', 'mouseup'].forEach(t => el.addEventListener(t, ev => ev.stopPropagation()));
}
function nameAnchor(row) {
const { nameIdx } = colsForRow(row);
const cell = row.querySelectorAll('td')[nameIdx];
if (!cell) return null;
return cell.querySelector('a') || cell;
}
/* ── Nazwa eventu + parametry tagu (z API panelu: data.vendorTemplate.param) ── */
function valText(v) {
if (v == null) return '';
if (typeof v !== 'object') return String(v);
if (typeof v.macroReference === 'string') return '{{' + v.macroReference + '}}';
// szablon z wstawkami: "SCROLL_{{Scroll Depth Threshold}}" = lista tokenów (tekst / zmienna)
if (Array.isArray(v.templateToken) && v.templateToken.length) return v.templateToken.map(valText).join('');
if (typeof v.string === 'string') return v.string;
if (typeof v.boolean === 'boolean') return String(v.boolean);
if (v.integer != null) return String(v.integer);
if (Array.isArray(v.listItem) && v.listItem.length) return '[' + v.listItem.map(valText).join(', ') + ']';
if (Array.isArray(v.mapKey) && v.mapKey.length) return '{' + v.mapKey.map((k, i) => valText(k) + ': ' + valText((v.mapValue || [])[i])).join(', ') + '}';
if (typeof v.value !== 'undefined') return valText(v.value);
return '';
}
function mapOf(v) {
const o = {};
if (!v) return o;
if (Array.isArray(v.mapKey)) v.mapKey.forEach((k, i) => { o[valText(k)] = valText((v.mapValue || [])[i]); });
else if (Array.isArray(v.map)) v.map.forEach(e => { if (e && e.key) o[e.key] = valText(e.value != null ? e.value : e); });
return o;
}
function tplParams(item) {
const o = {};
// „data” ma pełną strukturę; „properties” (wersja do wyświetlania) uzupełnia pola, których w data nie da się odczytać
const vtD = item.data && item.data.vendorTemplate, vtP = item.properties && item.properties.vendorTemplate;
if (vtD && Array.isArray(vtD.param)) vtD.param.forEach(p => { if (p && typeof p.key === 'string') o[p.key] = p.value; });
if (vtP && Array.isArray(vtP.param)) vtP.param.forEach(p => {
if (!p || typeof p.key !== 'string') return;
if (!(p.key in o) || !valText(o[p.key]).trim()) o[p.key] = p.value;
});
if (!vtD && !vtP && Array.isArray(item.parameter)) item.parameter.forEach(p => { if (p && typeof p.key === 'string') o[p.key] = p; });
return o;
}
// tabele parametrów: [pole szablonu, kolumna nazwy, kolumna wartości, grupa]
const PARAM_TABLES = [
['eventSettingsTable', 'parameter', 'parameterValue', ''],
['eventParameters', 'name', 'value', ''],
['configSettingsTable', 'parameter', 'parameterValue', 'config'],
['fieldsToSet', 'fieldName', 'value', 'config'],
['userProperties', 'name', 'value', 'user'],
['userPropertiesTable', 'name', 'value', 'user'],
['objectPropertyList', 'name', 'value', ''],
['customParams', 'key', 'value', ''],
];
const PARAM_SKIP = /^(eventName|standardEventName|customEventName|measurementIdOverride|measurementId|tagId|pixelId|conversionId|conversionLabel|html|supportDocumentWrite|sendEcommerceData|enableSendToServerContainer|enableConversionLinker|disablePushState|consent|dpoLDU|advancedMatching|useGA4Ecommerce|enhancedEcommerce|disableAutoConfig|optInMetaCAPI)$/;
function eventInfoFromItem(item) {
if (!item) return null;
const o = tplParams(item);
const t = k => (o[k] != null ? valText(o[k]).trim() : '');
let ev = '', evKey = '';
const en = t('eventName');
if (en && !/^(standard|custom)$/i.test(en)) { ev = en; evKey = 'eventName'; }
if (t('standardEventName') && (!en || /^standard$/i.test(en))) { ev = t('standardEventName'); evKey = 'standardEventName'; }
if (t('customEventName') && (!ev || /^custom$/i.test(en))) { ev = t('customEventName'); evKey = 'customEventName'; }
if (!ev && o.html) {
const h = t('html');
const m = /gtag\(\s*['"]event['"]\s*,\s*['"]([^'"]+)/.exec(h) || /fbq\(\s*['"]track(?:Custom)?['"]\s*,\s*['"]([^'"]+)/.exec(h) ||
/['"]?event['"]?\s*:\s*['"]([^'"]+)['"]/.exec(h);
if (m) ev = m[1];
}
if (!ev && t('conversionLabel')) ev = 'label: ' + t('conversionLabel');
const params = [];
if (t('eventSettingsVariable')) params.push({ k: 'Event Settings', v: t('eventSettingsVariable'), g: 'var', src: { key: 'eventSettingsVariable' } });
PARAM_TABLES.forEach(([field, kn, vn, g]) => {
const v = o[field];
if (!v || !Array.isArray(v.listItem)) return;
v.listItem.forEach((it, ri) => {
const m = mapOf(it);
const name = m[kn] || m.name || m.parameter || m.key;
if (name) params.push({ k: name, v: m[vn] != null ? m[vn] : (m.value || ''), g, src: { field, row: ri, col: vn } });
});
});
// pozostałe tagi (bez tabel): zwykłe parametry szablonu
if (!params.length) Object.keys(o).forEach(k => {
if (PARAM_SKIP.test(k)) return;
if (PARAM_TABLES.some(pt => pt[0] === k)) return;
const sv = valText(o[k]);
if (!sv || sv === 'false' || sv === '[]' || sv === '{}') return;
params.push({ k, v: sv.length > 140 ? sv.slice(0, 137) + '…' : sv, g: 'opt', src: sv.length > 140 ? null : { key: k } });
});
if (o.html) params.push({ k: 'html', v: t('html').length + ' znaków kodu', g: 'opt' });
if (!ev && !params.length) return null;
return { ev, evKey, params };
}
/* ── Reguły: nazwa eventu (Custom Event) + warunki + parametry (timer, scroll, widoczność…) ── */
// typ warunku: w API może być tekstem (equals/contains…) albo liczbą (kolejność jak w publicznym API GTM)
const COND_NUM = { 1: 'equals', 2: 'contains', 3: 'startsWith', 4: 'endsWith', 5: 'matchRegex', 6: 'greater', 7: 'greaterOrEquals', 8: 'less', 9: 'lessOrEquals', 10: 'cssSelector', 11: 'urlMatches', 12: 'matchRegexIgnoreCase' };
const COND_PL = { equals: 'równa się', contains: 'zawiera', startsWith: 'zaczyna się od', endsWith: 'kończy się na', matchRegex: 'pasuje do regex', matchRegexIgnoreCase: 'pasuje do regex (i)', greater: '>', greaterOrEquals: '≥', less: '<', lessOrEquals: '≤', cssSelector: 'pasuje do selektora CSS', urlMatches: 'pasuje do URL' };
const COND_NEG = { equals: 'nie równa się', contains: 'nie zawiera', startsWith: 'nie zaczyna się od', endsWith: 'nie kończy się na', matchRegex: 'nie pasuje do regex', matchRegexIgnoreCase: 'nie pasuje do regex (i)', greater: '≤', greaterOrEquals: '<', less: '≥', lessOrEquals: '>', cssSelector: 'nie pasuje do selektora CSS', urlMatches: 'nie pasuje do URL' };
function condParts(c) {
// warunek = { type, parameter:[{key:'arg0',value},{key:'arg1',value},{key:'negate',value}] } (różne warianty nazw)
const ps = Array.isArray(c.parameter) ? c.parameter : (Array.isArray(c.param) ? c.param : []);
const m = {};
ps.forEach(p => { if (p && typeof p.key === 'string') m[p.key] = valText(p.value != null ? p.value : p); });
// wewnętrzne API GTM: { type, negate, arg:[wartość0, wartość1], operator?:'CONTAINS' }
if (Array.isArray(c.arg)) { if (m.arg0 == null) m.arg0 = valText(c.arg[0]); if (m.arg1 == null) m.arg1 = valText(c.arg[1]); }
if (typeof c.operator === 'string') { const op = c.operator.toLowerCase().replace(/_([a-z])/g, (x, l) => l.toUpperCase()); if (COND_PL[op]) return { a: m.arg0 || '', op: (c.negate ? COND_NEG[op] : COND_PL[op]), b: m.arg1 || '', key: op, neg: !!c.negate }; }
let t = c.type != null ? c.type : (c.conditionType != null ? c.conditionType : c.operator);
if (typeof t === 'number' || /^\d+$/.test(String(t))) t = COND_NUM[Number(t)] || ('typ ' + t);
t = String(t || '');
const tk = Object.keys(COND_PL).find(k => k.toLowerCase() === t.toLowerCase().replace(/_/g, '')) || t;
const neg = /^(true|1)$/i.test(m.negate || '') || c.negate === true;
return { a: m.arg0 || '', op: (neg ? COND_NEG[tk] : COND_PL[tk]) || (neg ? 'NIE ' : '') + tk, b: m.arg1 || '', key: tk, neg };
}
function triggerInfoFromItem(item) {
if (!item) return null;
// Reguły: pokazujemy tylko nazwę eventu (warunki filtra lista GTM pokazuje sama).
// Nazwa eventu = warunek na {{_event}} — szukamy go w całej strukturze (customEventFilter / filter / inne pola).
let ev = '';
const conds = [];
const walk = (o, depth) => {
if (!o || typeof o !== 'object' || depth > 7) return;
if (Array.isArray(o)) { o.forEach(x => walk(x, depth + 1)); return; }
const ps = Array.isArray(o.parameter) ? o.parameter : (Array.isArray(o.param) ? o.param : null);
if ((ps && ps.some(x => x && /^arg[01]$/.test(String(x.key)))) || (Array.isArray(o.arg) && o.arg.length >= 2 && o.arg.every(x => x && typeof x === 'object'))) { conds.push(condParts(o)); return; }
Object.keys(o).forEach(k => { if (k !== 'key' && k !== 'properties') walk(o[k], depth + 1); });
};
walk(item.data || item, 0);
if (!conds.length && item.properties) walk(item.properties, 0);
const evc = conds.find(c => /_event/.test(c.a)) || conds.find(c => /_event/.test(c.b));
if (evc) {
const name = /_event/.test(evc.a) ? evc.b : evc.a;
// typ 1 = równa się; inny typ albo znaki specjalne → regex
const rx = /regex/i.test(evc.key) || (evc.key !== 'equals' && !/^typ 1$/.test(evc.key) && /[|^$*+?()\[\]\\]/.test(name));
ev = name + (rx ? ' (regex)' : '');
}
if (!ev) { // zapasowo: parametr reguły z nazwą eventu
const o = tplParams(item);
const d = item.data || item;
const cand = ['customEventName', 'eventName', 'event'].map(k => (o[k] != null ? valText(o[k]) : (d[k] != null && typeof d[k] !== 'object' ? String(d[k]) : ''))).find(v => v && v.trim() && !/^gtm\./.test(v));
if (cand) ev = cand.trim();
}
if (!ev) return null;
return { ev, evKey: '', params: [], kind: 'trigger' };
}
// zmiana nazwy eventu reguły (Custom Event): podmieniamy drugi argument warunku {{_event}} i zapisujemy regułę
function findEventCond(root) {
let found = null;
const walk = (o, depth) => {
if (found || !o || typeof o !== 'object' || depth > 7) return;
if (Array.isArray(o)) { o.forEach(x => walk(x, depth + 1)); return; }
if (Array.isArray(o.arg) && o.arg.length >= 2 && /_event/.test(valText(o.arg[0]))) { found = { cond: o, kind: 'arg' }; return; }
const ps = Array.isArray(o.parameter) ? o.parameter : null;
if (ps) { const a0 = ps.find(x => x && x.key === 'arg0'), a1 = ps.find(x => x && x.key === 'arg1'); if (a0 && a1 && /_event/.test(valText(a0.value != null ? a0.value : a0))) { found = { cond: o, kind: 'param', a1 }; return; } }
Object.keys(o).forEach(k => { if (k !== 'key') walk(o[k], depth + 1); });
};
walk(root, 0);
return found;
}
async function editTriggerEvent(id, newName) {
const tm = apiMapFor('triggers');
const it = tm && tm.get(id);
if (!it || !it.data) { alert('Nie mam danych tej reguły — kliknij ⟳ Odśwież i spróbuj ponownie.'); return; }
const draft = clone(it);
const f = findEventCond(draft.data);
if (!f) { alert('Nie znalazłem w tej regule warunku na {{_event}} — zmień nazwę w edytorze GTM.'); return; }
const nv = Object.assign({ type: 1, listItem: [], mapKey: [], mapValue: [], templateToken: [], escaping: [] }, { string: String(newName).trim() });
if (f.kind === 'arg') { const old = f.cond.arg[1] || {}; f.cond.arg[1] = Object.assign({}, old, nv, { macroReference: undefined }); delete f.cond.arg[1].macroReference; }
else f.a1.value = nv;
const base = wsApiBase();
try {
await apiWrite('PUT', base + '/triggers/' + id + '?hl=en', { key: clone(draft.key), data: draft.data });
it.data = draft.data;
Object.values(apiStores).forEach(st => { st.at = 0; });
renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter();
} catch (e) { alert('Zmiana nazwy eventu nie powiodła się.\n' + (e && e.message)); }
}
const PARAM_SKIP_TRIG = /^(name|notes|type|fingerprint|parentFolderId|containerDraftId|triggerId|accountId|containerId|workspaceId|path|tagManagerUrl|customEventFilter|autoEventFilter|filter)$/;
/* ── Zmienne: główna wartość (stała, klucz DLV, nazwa cookie…) + pozostałe parametry ── */
// publicId szablonu → pole z „główną” wartością
const VAR_MAIN = { c: 'value', v: 'name', k: 'name', j: 'name', u: 'component', aev: 'varType', d: 'elementId', smm: 'input', remm: 'input', e: null, jsm: null, f: 'queryKey', r: 'name', cid: null, ctv: 'name', gas: 'trackingId', gtes: null, gtcs: null, uv: 'name', vis: 'selector', dbg: null, ev: null, cn: 'name', awec: 'mode', gclid: null, gtlp: 'name', lcl: 'name', cvt: null };
function variableInfoFromItem(item) {
if (!item) return null;
const vt = (item.data && item.data.vendorTemplate) || (item.properties && item.properties.vendorTemplate) || {};
const pub = String((vt.key && vt.key.publicId) || (item.data && item.data.type) || '');
const o = tplParams(item);
const t = k => (o[k] != null ? valText(o[k]).trim() : '');
const mainKey = VAR_MAIN[pub] !== undefined ? VAR_MAIN[pub] : (o.value != null ? 'value' : (o.name != null ? 'name' : null));
let ev = mainKey ? t(mainKey) : '';
let evKey = mainKey && ev ? mainKey : '';
const params = [];
// tabele (lookup/regex table, GA4 settings, Google tag config) → wiersze
const TABLES = [['map', 'key', 'value', ''], ['eventSettingsTable', 'parameter', 'parameterValue', ''], ['configSettingsTable', 'parameter', 'parameterValue', 'config'], ['fieldsToSet', 'fieldName', 'value', 'config'], ['userProperties', 'name', 'value', 'user'], ['userPropertiesTable', 'name', 'value', 'user'], ['eventParameters', 'name', 'value', ''], ['customParams', 'key', 'value', '']];
const tableKeys = new Set();
TABLES.forEach(([field, kn, vn, g]) => {
const v = o[field];
if (!v || !Array.isArray(v.listItem)) return;
tableKeys.add(field);
v.listItem.forEach((it, ri) => {
const m = mapOf(it);
const name = m[kn] || m.name || m.parameter || m.key || m.fieldName;
if (name != null) params.push({ k: name, v: m[vn] != null ? m[vn] : (m.value || ''), g: g || (field === 'map' ? 'map' : ''), src: { field, row: ri, col: vn } });
});
});
Object.keys(o).forEach(k => {
if (k === mainKey || tableKeys.has(k) || /^(name|notes|javascript)$/.test(k)) return;
const sv = valText(o[k]).trim();
if (!sv || sv === 'false' || sv === '[]' || sv === '{}') return;
const long = sv.length > 140;
params.push({ k, v: long ? sv.slice(0, 137) + '…' : sv, g: 'opt', src: long ? null : { key: k } });
});
if (pub === 'jsm' && o.javascript) {
// Custom JS: bez etykiety wartości (kod to nie „wartość”) — kod pod nazwą po kliknięciu „kod ▾”
const code = t('javascript');
ev = ''; evKey = '';
params.push({ k: 'javascript', v: code.length > 1500 ? code.slice(0, 1497) + '…' : code, g: 'code', src: null });
}
// formatowanie wartości (Format Value): tylko gdy coś ustawiono
const fv = (item.data && item.data.formatValue) || (item.properties && item.properties.formatValue);
if (fv && typeof fv === 'object') Object.keys(fv).forEach(k => { const sv = valText(fv[k]); if (sv && sv !== 'false') params.push({ k: 'format: ' + k, v: sv, g: 'opt', src: null }); });
if (!ev && !params.length) return null;
return { ev, evKey, params, kind: 'variable', pub, full: pub === 'jsm' ? t('javascript') : '' };
}
function rowInfoFromItem(item) {
if (currentSection === 'triggers') return triggerInfoFromItem(item);
if (currentSection === 'variables') return variableInfoFromItem(item);
return eventInfoFromItem(item);
}
/* ── Edycja wartości parametru z poziomu listy — zapis przez API jak zmiana nazwy ── */
// wartości danego pola szablonu użyte w innych tagach (do menu wyboru)
// pola, które trzymają to samo ID w różnych szablonach (Google tag: tagId, GA4 Event: measurementIdOverride…)
const KEY_FAMILY = [
['tagId', 'measurementIdOverride', 'measurementId', 'trackingId'],
['conversionId'],
['pixelId', 'pixel_id', 'pixelIds'],
];
function keyFamily(key) { return KEY_FAMILY.find(f => f.includes(key)) || [key]; }
function valuesUsedFor(key, exceptId) {
const tm = apiMapFor('tags');
const cnt = new Map();
if (!tm) return [];
const fam = keyFamily(key);
tm.forEach((it, id) => {
if (id === exceptId) return;
const o = tplParams(it);
fam.forEach(k => {
if (!(k in o)) return;
const v = valText(o[k]).trim();
if (!v || /^\{\{[^}]+\}\}$/.test(v) === false && !/^[A-Z]{1,4}-[A-Z0-9-]{4,}$|^\d{6,20}$/i.test(v)) return; // tylko ID albo {{zmienna}}
cnt.set(v, (cnt.get(v) || 0) + 1);
});
});
return [...cnt.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
let openMenu = null;
function closeMenu() { if (openMenu) { openMenu.remove(); openMenu = null; } }
document.addEventListener('mousedown', e => { if (openMenu && !openMenu.contains(e.target)) closeMenu(); }, true);
document.addEventListener('keydown', e => { if (e.key === 'Escape' && openMenu) closeMenu(); }, true);
// menu pod etykietą: items = [{label, sub, onPick, cls}]
function showMenu(anchor, items, title, opts) {
closeMenu();
ensureIdCss();
opts = opts || {};
const m = document.createElement('div');
m.className = 'tb-menu';
if (title) { const h = document.createElement('div'); h.className = 'tb-menu-h'; h.textContent = title; m.appendChild(h); }
if (!items.length && opts.empty) { const e = document.createElement('div'); e.className = 'tb-menu-e'; e.textContent = opts.empty; m.appendChild(e); }
items.forEach(it => {
const d = document.createElement('div');
d.className = 'tb-menu-i' + (it.cls ? ' ' + it.cls : '');
const l = document.createElement('span'); l.textContent = it.label; d.appendChild(l);
if (it.sub) { const sb = document.createElement('span'); sb.className = 'tb-menu-s'; sb.textContent = it.sub; d.appendChild(sb); }
d.addEventListener('mousedown', ev => ev.stopPropagation());
d.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); closeMenu(); it.onPick(); });
m.appendChild(d);
});
if (opts.onInput) {
const w = document.createElement('div'); w.className = 'tb-menu-in';
const inp = document.createElement('input'); inp.type = 'text'; inp.value = opts.input || ''; inp.placeholder = 'wpisz wartość albo {{Zmienna}} · Enter = zapis';
['keydown', 'keyup', 'keypress'].forEach(t => inp.addEventListener(t, e => e.stopPropagation()));
inp.addEventListener('mousedown', e => e.stopPropagation());
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); const v = inp.value; if (v.trim() && v !== (opts.input || '')) { closeMenu(); opts.onInput(v); } }
else if (e.key === 'Escape') { e.preventDefault(); closeMenu(); }
});
w.appendChild(inp); m.appendChild(w);
setTimeout(() => { inp.focus(); inp.select(); }, 0);
}
document.body.appendChild(m);
const r = anchor.getBoundingClientRect();
m.style.left = Math.min(r.left, window.innerWidth - m.offsetWidth - 8) + 'px';
m.style.top = (r.bottom + 4 + (window.innerHeight - r.bottom < m.offsetHeight + 12 ? -(m.offsetHeight + r.height + 8) : 0)) + 'px';
openMenu = m;
}
// pole tekstowe w miejscu etykiety: Enter = zapis, Esc = anuluj
function inlineEdit(chip, cur, onSave) {
if (chip.querySelector('input')) return;
const old = chip.innerHTML;
chip.innerHTML = '';
const inp = document.createElement('input');
inp.type = 'text'; inp.value = cur; inp.className = 'tb-inline';
inp.style.width = Math.max(90, Math.min(360, cur.length * 7.5 + 24)) + 'px';
['keydown', 'keyup', 'keypress'].forEach(t => inp.addEventListener(t, e => e.stopPropagation()));
['mousedown', 'mouseup', 'click', 'dblclick'].forEach(t => inp.addEventListener(t, e => e.stopPropagation()));
let done = false;
const finish = (save) => {
if (done) return; done = true;
const v = inp.value;
chip.innerHTML = old;
if (save && v !== cur && v.trim()) onSave(v);
};
inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); finish(true); } else if (e.key === 'Escape') { e.preventDefault(); finish(false); } });
inp.addEventListener('blur', () => setTimeout(() => finish(false), 0));
chip.appendChild(inp);
inp.focus(); inp.select();
}
function valueFromText(txt, old) {
const t = String(txt);
const base = { type: 1, listItem: [], mapKey: [], mapValue: [], templateToken: [], escaping: [] };
const m = /^\{\{([^{}]+)\}\}$/.exec(t.trim());
if (m) return Object.assign({}, base, { type: 4, macroReference: m[1].trim() });
if (/\{\{[^{}]+\}\}/.test(t)) return null; // mieszane: tekst + zmienna — nie znamy formatu zapisu, edytuj w GTM
if (/^(true|false)$/.test(t) && old && old.type === 8) return Object.assign({}, base, { type: 8, boolean: t === 'true' });
return Object.assign({}, base, { type: 1, string: t });
}
function findParamRef(item, key) {
const vt = item.data && item.data.vendorTemplate;
if (!vt || !Array.isArray(vt.param)) return null;
const pr = vt.param.find(p => p && p.key === key);
return pr || null;
}
async function editParam(id, src, label, preset, sec) {
if (!src) return;
sec = sec || currentSection || 'tags';
if (!/^(tags|variables)$/.test(sec)) return;
const tm = apiMapFor(sec);
let it = tm && tm.get(id);
if (!it || !it.data) { alert('Nie mam danych tego elementu — kliknij ⟳ Odśwież i spróbuj ponownie.'); return; }
try { it = (await ensureFullItem(sec, id)) || it; } catch (e) { alert('Nie udało się pobrać pełnych danych elementu — nie zapisuję.\n' + (e && e.message)); return; }
if (dataLacksParams(it)) { alert('API GTM zwraca ten element bez parametrów w „data” — zapis z listy mógłby skasować konfigurację. Zmień wartość w edytorze GTM.'); return; }
const draft = clone(it);
let target = null, cur = '';
if (src.key) {
let pr = findParamRef(draft, src.key);
if (!pr) { const vt = draft.data.vendorTemplate; if (!vt) return; vt.param = vt.param || []; pr = { key: src.key, value: null }; vt.param.push(pr); }
target = pr; cur = pr.value ? valText(pr.value) : '';
} else if (src.field) {
const pr = findParamRef(draft, src.field);
const rowV = pr && pr.value && Array.isArray(pr.value.listItem) ? pr.value.listItem[src.row] : null;
if (!rowV || !Array.isArray(rowV.mapKey)) { alert('Tej wartości nie da się zmienić z listy — otwórz element w GTM.'); return; }
const ci = rowV.mapKey.findIndex(k => valText(k) === src.col);
if (ci < 0) { alert('Tej wartości nie da się zmienić z listy — otwórz element w GTM.'); return; }
target = { holder: rowV.mapValue, idx: ci }; cur = valText(rowV.mapValue[ci]);
} else return;
const nv = (preset != null) ? preset : prompt('Nowa wartość: ' + label + '\n(zmienna: {{Nazwa zmiennej}})', cur);
if (nv == null || nv === cur) return;
const old = target.holder ? target.holder[target.idx] : target.value;
const val = valueFromText(nv, old);
if (!val) { alert('Wartość mieszana (tekst + {{zmienna}}) — tę zmień w edytorze GTM.'); return; }
if (target.holder) target.holder[target.idx] = val; else target.value = val;
if (draft.data.vendorTemplate && draft.data.vendorTemplate.valueCache) draft.data.vendorTemplate.valueCache = [];
const base = wsApiBase();
try {
await apiWrite('PUT', base + '/' + sec + '/' + id + '?hl=en', { key: clone(draft.key), data: draft.data });
it.data = draft.data;
if (it.properties) delete it.properties.vendorTemplate; // nieaktualne — tplParams weźmie z data
Object.values(apiStores).forEach(st => { st.at = 0; });
renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter();
} catch (e) { alert('Zmiana nie powiodła się.\n' + (e && e.message)); }
}
// rozwinięta lista parametrów pod nazwą tagu (poza linkiem, żeby klik nie otwierał tagu)
function renderParamList(row, evi, isOpen) {
const { nameIdx } = colsForRow(row);
const cell = row.querySelectorAll('td')[nameIdx];
if (!cell) return;
let pl = cell.querySelector('.tb-plist');
if (!isOpen) { if (pl) pl.remove(); return; }
const sig = JSON.stringify(evi.params.map(p => [p.k, p.g, p.v.length, p.v.slice(0, 64)]));
if (pl && pl.dataset.sig === sig) return;
if (!pl) {
pl = document.createElement('div');
pl.className = 'tb-plist';
stopAll(pl);
pl.addEventListener('click', ev => ev.stopPropagation());
cell.appendChild(pl);
}
pl.dataset.sig = sig;
pl.innerHTML = '';
const groupLbl = { '': '', config: 'konfiguracja', user: 'właściwości użytkownika', var: '', opt: '', cond: 'warunki', auto: 'filtr zdarzenia', map: 'tabela', code: 'kod' };
let lastG = null;
evi.params.forEach(p => {
if (p.g !== lastG && groupLbl[p.g]) {
const h = document.createElement('div');
h.className = 'tb-pg';
h.textContent = groupLbl[p.g];
pl.appendChild(h);
}
lastG = p.g;
const line = document.createElement('div');
const k = document.createElement('span'); k.className = 'k'; k.textContent = p.k;
const eq = document.createElement('span'); eq.className = 'eq'; eq.textContent = (p.g === 'cond' || p.g === 'auto') ? ' ' : ' = ';
const v = document.createElement('span'); v.className = 'v' + (/\{\{/.test(p.v) ? ' var' : '') + (p.src ? ' edit' : '') + (p.g === 'code' ? ' code' : ''); v.textContent = p.v;
if (p.src) {
v.title = 'klik: wpisz nową wartość (Enter = zapis przez API GTM)';
v.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); const id = idFromRow(row); if (id !== null) inlineEdit(v, p.v, nv => editParam(id, p.src, p.k, nv)); });
}
line.append(k, eq, v);
pl.appendChild(line);
});
}
// Consent Settings tagu: manualConsentStatus 1 = „no additional consent required”, 2 = wymaga zgód z listy, 0/brak = nie ustawiono
function consentFromItem(item) {
const cs = (item.data && item.data.consentSettings) || (item.properties && item.properties.consentSettings);
if (!cs) return { st: 0, list: [] };
const st = Number(cs.manualConsentStatus || 0);
const list = (cs.manualConsentSettings && Array.isArray(cs.manualConsentSettings.listItem)) ? cs.manualConsentSettings.listItem.map(valText).filter(Boolean) : [];
return { st, list };
}
// GA4 Event bez Event Settings Variable — gdy większość tagów GA4 Event w kontenerze ją ma
let esvCache = { src: null, ratio: 0 };
function esvRatio() {
const tm = apiMapFor('tags');
if (!tm) return 0;
if (esvCache.src === tm) return esvCache.ratio;
let all = 0, withEsv = 0;
tm.forEach(it => {
const vt = it.data && it.data.vendorTemplate;
if (!vt || !vt.key || vt.key.publicId !== 'gaawe') return;
all++;
const o = tplParams(it);
if (o.eventSettingsVariable && valText(o.eventSettingsVariable).trim()) withEsv++;
});
esvCache = { src: tm, ratio: all ? withEsv / all : 0 };
return esvCache.ratio;
}
function missingEsv(item) {
const vt = item.data && item.data.vendorTemplate;
if (!vt || !vt.key || vt.key.publicId !== 'gaawe') return false;
const o = tplParams(item);
if (o.eventSettingsVariable && valText(o.eventSettingsVariable).trim()) return false;
return esvRatio() >= 0.5;
}
// ID vendorów + opcja uruchamiania (tylko gdy któraś opcja włączona)
let metaStats = null;
function updateMetaNote() {
const n = document.getElementById('tb-meta-note');
if (!n) return;
if (!metaStats || !metaStats.rows) { n.style.display = 'none'; return; }
let html = '';
if (!metaStats.items) html = escHtml('Nie widzę danych elementów listy (0/' + metaStats.rows + '). Wpisz w konsoli gtmTbDebug() i wklej wynik.');
else if (!metaStats.found) html = escHtml('Dane elementów są (' + metaStats.items + '/' + metaStats.rows + '), ale bez ID vendorów / opcji. Wpisz w konsoli gtmTbDebug() i wklej wynik.');
else if ((metaStats.errors && metaStats.errors.length) || (metaStats.conflicts && metaStats.conflicts.length)) {
const errs = metaStats.errors || [];
if (errs.length) html += '<span style="color:#f87171">✖ <b>' + errs.length + ' ' + (errs.length === 1 ? 'problem' : ([2, 3, 4].includes(errs.length % 10) && ![12, 13, 14].includes(errs.length % 100) ? 'problemy' : 'problemów')) + ' z ID</b> (czerwone etykiety):</span><br>' +
errs.slice(0, 4).map(e => '<span style="color:#fca5a5">· ' + escHtml(e.tag) + ': ' + escHtml(e.msg) + '</span>').join('<br>') +
(errs.length > 4 ? '<br><span style="color:#fca5a5">· … i ' + (errs.length - 4) + ' więcej</span>' : '') +
((metaStats.conflicts || []).length ? '<br>' : '');
html += (metaStats.conflicts || []).map(c => '⚠ <b>' + escHtml(c.v) + '</b>: ' + c.entries.length + ' różne ID — ' +
c.entries.map(([id, k]) => '<code style="font-family:Consolas,monospace;color:#fde68a">' + escHtml(id) + '</code> ×' + k).join(', ')).join('<br>');
}
n.innerHTML = html;
n.style.display = html ? '' : 'none';
}
function renderRowMeta() {
const wantV = showVendorIds && (currentSection === 'tags' || currentSection === 'variables');
const wantF = showFiring && currentSection === 'tags';
const wantE = showEvents && (currentSection === 'tags' || currentSection === 'triggers' || currentSection === 'variables');
const wantC = showConsent && currentSection === 'tags';
const dupAware = currentSection !== 'triggers'; // duplikaty liczymy dla tagów i zmiennych
if (!wantE) document.querySelectorAll('.tb-plist').forEach(b => b.remove());
if (!wantV && !wantF && !wantE && !wantC) {
document.querySelectorAll('.tb-meta').forEach(b => b.remove());
metaStats = null;
updateMetaNote();
return;
}
ensureIdCss();
const apiMap = apiMapFor(currentSection);
if (!apiMap && apiStore.loading) return; // czekamy na odpowiedź API — potem render jeszcze raz
if (apiMap) ensureVarParams();
metaStats = { rows: 0, items: 0, found: 0, conflicts: [], errors: [] };
const vres = wantV && currentSection === 'tags' ? varResolveMap() : null;
// 1) dane wszystkich wierszy
const list = getDataRows().map(row => {
const id = idFromRow(row);
const item = (apiMap && id !== null && apiMap.get(id)) || ngItemFromRow(row);
metaStats.rows++;
if (item) metaStats.items++;
let vids = wantV && item ? vendorIdsFromItem(item) : [];
// {{zmienna}} → rzeczywiste ID (stała z G-… itp.)
// zmienna, której wartości nie znamy, ale nazwa zawiera ID (np. {{GA4 ID G-XXX}}) → kolor/porównanie wg nazwy
const fromName = x => {
for (const { v, re, grp } of VENDOR_RES) {
re.lastIndex = 0;
const m = re.exec(x.id);
if (m) return { id: x.id, v, rid: grp ? m[grp] : m[0], byName: true, k: x.k };
}
return x;
};
vids = vids.map(x => {
if (x.v !== 'ID' || !/^\{\{/.test(x.id)) return x;
if (!vres) return fromName(x);
if (!vres.has(x.id)) return (x.k && vres.size > 0) ? Object.assign(fromName(x), { err: 'Zmienna ' + x.id + ' nie istnieje w tym kontenerze' }) : fromName(x);
const r = vres.get(x.id);
// etykietą zostaje zmienna; rozwinięte ID (rid) pokazujemy obok, służy też do porównań
if (r.ids.length === 1) {
const o = { id: x.id, v: r.ids[0].v, rid: r.ids[0].id, k: x.k };
// nazwa zmiennej sugeruje inne ID niż jej wartość → błąd
const nm = fromName(x);
if (nm.byName && nm.rid !== o.rid) o.err = 'Nazwa zmiennej wskazuje ' + nm.rid + ', a jej wartość to ' + o.rid;
return o;
}
if (x.k && !r.ids.length && r.raw && !/^\d{6,20}$/.test(r.raw) && !/\{\{/.test(r.raw))
return Object.assign(fromName(x), { err: 'Zmienna ma wartość „' + r.raw + '”, która nie wygląda na ID — oczekiwane ' + expectedForKey(x.k) });
// stała z samymi cyframi: piksel Meta / konwersja Ads — rodzaj z nazwy pola w tagu
if (r.raw && /^\d{6,20}$/.test(r.raw) && x.k) {
if (/pixel/i.test(x.k)) return { id: x.id, v: 'Meta', rid: r.raw, k: x.k };
if (/^conversionId$/i.test(x.k)) return { id: x.id, v: 'Ads', rid: 'AW-' + r.raw, k: x.k };
}
return fromName(x);
});
// bez duplikatów; ID wpisane wprost, które jest wartością/nazwą użytej zmiennej → zostaje tylko zmienna
const seenIds = new Set();
vids = vids.filter(x => !seenIds.has(x.id) && seenIds.add(x.id));
const varChips = vids.filter(x => /^\{\{/.test(x.id));
if (varChips.length) vids = vids.filter(x => /^\{\{/.test(x.id) ||
!varChips.some(vc => vc.rid === x.id || vc.id.indexOf(x.id) >= 0));
// format ID (także rozwiniętego ze zmiennej)
vids.forEach(x => {
if (x.v === 'BAD') { x.err = x.msg; return; }
if (x.err) return;
const target = /^\{\{/.test(x.id) ? (x.byName ? null : x.rid) : x.id;
if (target) { const pr = idProblem(x.v, target); if (pr) x.err = pr; }
});
vids.forEach(x => { if (x.err) metaStats.errors.push({ tag: apiItemName(item) || nameFromRow(row), id: x.id, msg: x.err }); });
const fire = wantF && item ? firingFromItem(item) : null;
const evi = wantE && item ? rowInfoFromItem(item) : null;
const cons = wantC && item ? consentFromItem(item) : null;
const noEsv = wantE && item && currentSection === 'tags' ? missingEsv(item) : false;
if (vids.length || fire || evi || cons) metaStats.found++;
return { row, vids, fire, evi, cons, noEsv, tid: id, item };
});
// 2) ostrzeżenia: w ramach vendora ID inne niż najczęstsze na liście
const byVendor = {};
list.forEach(({ vids }) => vids.forEach(x => {
if (x.v === 'ID' || x.v === 'BAD') return;
const m = byVendor[x.v] || (byVendor[x.v] = new Map());
const rid = x.rid || x.id;
m.set(rid, (m.get(rid) || 0) + 1);
}));
const odd = new Set(); // 'vendor|id'
Object.entries(byVendor).forEach(([v, m]) => {
if (m.size < 2) return;
const max = Math.max(...m.values());
const entries = [...m.entries()].sort((a, b) => b[1] - a[1]);
entries.forEach(([id, n]) => { if (n < max) odd.add(v + '|' + id); });
metaStats.conflicts.push({ v, entries });
});
// 3) render
list.forEach(({ row, vids, fire, evi, cons, noEsv, tid, item }) => {
vids.forEach(x => { x.warn = odd.has(x.v + '|' + (x.rid || x.id)); });
const isOpen = !!(evi && evi.params.length && openParams.has(tid));
renderParamList(row, evi, isOpen);
const dsig = evi && dupAware ? rowDupInfo(row) : null;
// sygnatura bez pełnych wartości (kod CJS bywa długi) — nazwa/długość/początek wystarczą
const eviSig = evi ? [evi.ev, evi.evKey, evi.kind, evi.params.map(p => p.k + ':' + p.g + ':' + p.v.length + ':' + p.v.slice(0, 48))] : null;
const sig = JSON.stringify([vids, fire && fire.label, fire && fire.raw, eviSig, isOpen, wantE, wantV, wantF, wantC, cons, noEsv, dsig && dsig.dup ? dsig.names.length : 0]);
let box = row.querySelector('.tb-meta');
if (box && box.dataset.sig === sig) return;
// pusty wiersz też dostaje (puste) sloty — żeby nazwy były wyrównane w pionie
if (!box) {
box = document.createElement('span');
box.className = 'tb-meta tb-ann';
const host = nameAnchor(row);
if (!host) return;
host.appendChild(box);
}
box.dataset.sig = sig;
box.innerHTML = '';
// sloty o równej szerokości w całej tabeli (wyrównuje layoutAnnotations): event · param. · opcja · ID vendorów
const mkSlot = cls => { const sl = document.createElement('span'); sl.className = 'tb-slot ' + cls; box.appendChild(sl); return sl; };
const slEv = wantE ? mkSlot('tb-s-ev') : null;
const slPc = wantE ? mkSlot('tb-s-pc') : null;
const slFire = wantF ? mkSlot('tb-s-fire') : null;
const slVid = wantV ? mkSlot('tb-s-vid') : null;
const slCons = wantC ? mkSlot('tb-s-cons') : null;
let target = box;
const mkChip = (text, cls, bg, fg, title, copyVal) => {
const c = document.createElement('span');
c.className = 'tb-vid' + cls;
if (bg) c.style.background = bg;
if (fg) { c.style.color = fg; c.style.borderColor = fg; }
const label = document.createElement('span');
label.textContent = text;
c.appendChild(label);
const src = editSrc, lbl = editLabel, mode = editMode, fn = editFn, cur0 = editCur; // zamrożone dla tej etykiety
const canEdit = !!(src && tid !== null);
c.title = title + (canEdit
? (mode === 'inline' ? '\nklik: wpisz nową wartość (Enter = zapis przez API GTM) · Ctrl+klik: kopiuj' : '\nklik: wybierz wartość z innych tagów albo wpisz · Ctrl+klik: kopiuj')
: '\nklik: kopiuj' + (copyVal !== text ? ' ' + copyVal : ''));
stopAll(c);
const doCopy = () => { copyText(copyVal); label.textContent = '✓ ' + text; setTimeout(() => { label.textContent = text; }, 800); };
c.addEventListener('click', ev => {
ev.preventDefault(); ev.stopPropagation();
if (!canEdit || ev.ctrlKey || ev.metaKey) { doCopy(); return; }
const curVal = cur0 != null ? cur0 : curValueOf(src);
if (mode === 'inline') { inlineEdit(c, curVal, v => fn ? fn(v) : editParam(tid, src, lbl || text, v)); return; }
const used = valuesUsedFor(src.key, tid).filter(([v]) => v !== curVal);
const items = used.slice(0, 12).map(([v, n]) => ({ label: v, sub: 'w ' + n + ' ' + (n === 1 ? 'tagu' : 'tagach'), onPick: () => editParam(tid, src, lbl || text, v) }));
// zmienne-stałe z ID vendora, których żaden tag jeszcze nie używa w tym polu
if (VENDOR_KEY_RE.test(src.key)) {
const vres = varResolveMap();
if (vres) vres.forEach((r, vname) => {
if (vname === curVal || !r.ids.length || used.some(([v]) => v === vname)) return;
items.push({ label: vname, sub: r.ids[0].id, onPick: () => editParam(tid, src, lbl || text, vname) });
});
}
showMenu(c, items, (lbl || src.key) + ' · teraz: ' + (curVal || '—'), { input: curVal, onInput: v => editParam(tid, src, lbl || text, v), empty: 'Brak innych wartości na liście — wpisz nową:' });
});
target.appendChild(c);
return c;
};
const curValueOf = src => { const o = item ? tplParams(item) : {}; return src && src.key && o[src.key] != null ? valText(o[src.key]).trim() : ''; };
let editSrc = null, editLabel = '', editMode = 'menu', editFn = null, editCur = null;
if (evi) {
target = slEv || box;
if (evi.ev && evi.kind === 'variable') {
// zmienna: główna wartość (stała / klucz DLV / nazwa cookie…) — klik = edycja
editSrc = evi.evKey ? { key: evi.evKey } : null; editLabel = evi.evKey || 'wartość'; editMode = 'inline';
const isVarRef = /\{\{/.test(evi.ev);
const e = mkChip(evi.ev.length > 60 ? evi.ev.slice(0, 57) + '…' : evi.ev, ' tb-vv' + (isVarRef ? ' ref' : '') + (evi.pub === 'jsm' ? ' code' : ''), null, null,
evi.pub === 'jsm' ? 'Custom JavaScript:\n' + (evi.full.length > 1200 ? evi.full.slice(0, 1197) + '…' : evi.full) : (evi.evKey ? evi.evKey + ': ' : '') + evi.ev, evi.pub === 'jsm' ? evi.full : evi.ev);
e.classList.add('tb-vv');
} else if (evi.ev && evi.kind === 'trigger') {
const evName = evi.ev.replace(/ \(regex\)$/, '');
editSrc = { ev: true }; editLabel = 'nazwa eventu'; editMode = 'inline'; editCur = evName; editFn = v => editTriggerEvent(tid, v);
const e = mkChip(evi.ev, ' ev', null, null, 'Nazwa eventu (Custom Event): ' + evi.ev, evName);
e.classList.add('tb-ev');
editFn = null; editCur = null;
} else if (evi.ev) {
const di = rowDupInfo(row);
const dupTxt = di && di.dup ? '\n' + (di.hot ? '⚠ KONFLIKT: ≥2 aktywne tagi wysyłają ten sam event' : 'ℹ duplikat, ale pozostałe tagi są zapauzowane') + '\n ' + di.names.join('\n ') : '';
const pz = di && di.dup && isRowPaused(row) === true;
editSrc = evi.evKey ? { key: evi.evKey } : null; editLabel = 'nazwa eventu'; editMode = 'inline';
const e = mkChip(evi.ev + (di && di.dup ? ' ×' + di.names.length : '') + (pz ? ' ⏸' : ''), ' ev' + (di && di.dup ? (di.hot ? ' dupe hot' : ' dupe') : '') + (pz ? ' pz' : ''), null, null, 'Nazwa eventu: ' + evi.ev + (pz ? '\n⏸ tag zapauzowany' : '') + dupTxt, evi.ev);
e.classList.add('tb-ev');
}
if (evi.params.length) {
const pc = document.createElement('span');
pc.className = 'tb-pc' + (isOpen ? ' open' : '');
const nC = evi.params.filter(p => p.g === 'cond' || p.g === 'auto').length, nP = evi.params.length - nC;
const onlyCode = evi.params.every(p => p.g === 'code');
pc.textContent = (onlyCode ? 'kod' : evi.kind === 'trigger' ? (nC ? nC + ' war.' : '') + (nC && nP ? ' + ' : '') + (nP ? nP + ' param.' : '') : evi.params.length + ' param.') + ' ' + (isOpen ? '▴' : '▾');
pc.title = evi.params.map(p => p.g === 'code' ? (p.v.length > 700 ? p.v.slice(0, 697) + '…' : p.v) : p.k + ' = ' + p.v).join('\n') + '\n\nkliknij, aby ' + (isOpen ? 'zwinąć' : 'rozwinąć ' + (evi.params.every(p => p.g === 'code') ? 'kod' : 'listę') + ' pod nazwą');
stopAll(pc);
pc.addEventListener('click', ev => {
ev.preventDefault(); ev.stopPropagation();
if (openParams.has(tid)) openParams.delete(tid); else openParams.add(tid);
renderIdBadges();
});
// zmienne: wartość i pigułka „kod / N param.” w jednej kolumnie (szybki podgląd, co siedzi w środku)
((evi.kind === 'variable' ? slEv : slPc) || box).appendChild(pc);
}
}
editSrc = null; editLabel = ''; editMode = 'menu';
target = slVid || box;
vids.forEach(x => {
editSrc = x.k ? { key: x.k } : null; editLabel = x.k || 'ID'; editMode = 'menu';
const isVar = /^\{\{/.test(x.id);
const col = VENDOR_COL[x.v] && x.v !== 'ID' ? VENDOR_COL[x.v] : null;
const cnt = byVendor[x.v] && byVendor[x.v].get(x.rid || x.id);
const warnTxt = x.warn ? '\n⚠ inne ID niż większość tagów (' + cnt + '× na liście)' : '';
const errTxt = x.err ? '\n✖ ' + x.err : '';
if (x.v === 'BAD') {
mkChip(x.id, ' bad', null, null, '✖ ' + x.msg, x.id);
return;
}
if (isVar) {
const varErr = x.err && (!x.rid || x.byName || /Nazwa zmiennej|nie istnieje|nie wygląda/.test(x.err));
mkChip(x.id, ' var' + (varErr ? ' bad' : '') + (x.warn && x.byName ? ' warn' : ''), null, varErr ? null : col,
'Zmienna ' + x.id + (x.rid ? '\n→ ' + x.v + ' · ' + x.rid + (x.byName ? ' (z nazwy zmiennej — wartość nieznana)' : '') : '\n(wartość nieznana — to nie jest stała z ID)') + warnTxt + errTxt,
x.rid || x.id);
// obok: rzeczywista wartość zmiennej (gdy ją znamy)
if (x.rid && !x.byName) {
const valErr = x.err && !varErr;
mkChip(x.rid, ' val' + (valErr ? ' bad' : '') + (x.warn ? ' warn' : ''), valErr ? null : (col || '#5f6368'), null,
x.v + ' · ' + x.rid + '\nwartość zmiennej ' + x.id + warnTxt + (valErr ? errTxt : ''), x.rid);
}
return;
}
mkChip(x.id, (x.err ? ' bad' : '') + (x.warn ? ' warn' : ''), x.err ? null : (col || '#5f6368'), null,
x.v + ' · ' + x.id + warnTxt + errTxt, x.id);
});
if (fire) {
const f = document.createElement('span');
const cls = /zdarzenie/.test(fire.label) ? 'f-event' : /stronę/.test(fire.label) ? 'f-page' : /limitu/.test(fire.label) ? 'f-unl' : 'f-other';
f.className = 'tb-fire ' + cls;
f.textContent = fire.label;
const cur = /zdarzenie/.test(fire.label) ? 1 : /stronę/.test(fire.label) ? 2 : /limitu/.test(fire.label) ? 3 : 0;
const nxt = cur === 1 ? 2 : 1;
f.title = 'Opcja uruchamiania tagu (' + fire.raw + ')\nklik: ' + (nxt === 2 ? 'zmień na „Raz na stronę”' : 'zmień na „Raz na zdarzenie”') + '\nShift+klik: „Bez limitu”' + (tid === null ? '' : '\n(zapis przez API GTM)');
f.style.cursor = tid !== null ? 'pointer' : '';
stopAll(f);
f.addEventListener('click', ev => {
ev.preventDefault(); ev.stopPropagation();
if (tid === null) return;
setFiringOption(tid, ev.shiftKey ? 3 : nxt, f);
});
(slFire || box).appendChild(f);
}
if (noEsv && slEv) {
const e = document.createElement('span');
e.className = 'tb-vid bad tb-noesv';
e.textContent = 'bez ESV';
e.title = 'Ten tag GA4 Event nie ma Event Settings Variable, a większość tagów GA4 Event w kontenerze ją ma';
stopAll(e); e.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); });
slEv.appendChild(e);
}
if (cons) {
const c = document.createElement('span');
const cls = cons.st === 2 ? 'c-req' : cons.st === 1 ? 'c-none' : 'c-unset';
c.className = 'tb-cons ' + cls;
c.textContent = cons.st === 2 ? '🛡 ' + (cons.list.length ? cons.list.map(x => x.replace(/_storage$/, '')).join(', ') : 'wymaga zgód') : cons.st === 1 ? '🛡 bez dodatkowych' : '🛡 nie ustawiono';
c.title = 'Consent Settings: ' + (cons.st === 2 ? 'wymaga dodatkowych zgód: ' + cons.list.join(', ') : cons.st === 1 ? 'No additional consent required' : 'Not set (nie sprawdzono ustawień zgody)');
stopAll(c); c.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); });
(slCons || box).appendChild(c);
}
});
updateMetaNote();
}
/* ═══════════════════ MODUŁ 5: ZMIENNE — KOLUMNA „TAGI” ═══════════════════ */
/* ── Zmienne: kolumna „Tagi” = w ilu tagach użyto {{zmiennej}} (jak kolumna Tags na liście reguł) ──
Liczymy z API (tagi + reguły + zmienne) po nazwie, więc działa też dla zmiennych wbudowanych. */
let usesCache = { key: null, map: null };
function refsOf(item) {
const out = new Set();
walkStrings(item, (str, key) => {
if (!str || str.indexOf('{{') < 0 || (key && /^(name|displayName|notes)$/.test(key))) return;
const re = /\{\{([^{}]+)\}\}/g;
let m;
while ((m = re.exec(str))) out.add(m[1].trim());
});
return out;
}
function varUsesMap() {
const tm = apiMapFor('tags'), rm = apiMapFor('triggers'), vm = apiMapFor('variables');
const sst = isServerContainer();
const cm = sst ? apiMapFor('clients') : new Map(), xm = sst ? apiMapFor('transformations') : new Map();
if (!tm || !rm || !vm || !cm || !xm) return null;
const key = [tm, rm, vm, cm, xm];
if (usesCache.key && usesCache.key.every((k, i) => k === key[i])) return usesCache.map;
const map = new Map(); // nazwa → { tags:[], triggers:[], variables:[], clients:[], transformations:[] }
const add = (kind, src) => src.forEach(it => {
const n = apiItemName(it);
refsOf(it).forEach(ref => {
if (kind === 'variables' && ref === n) return;
const u = map.get(ref) || { tags: [], triggers: [], variables: [], clients: [], transformations: [] };
u[kind].push(n);
map.set(ref, u);
});
});
add('tags', tm); add('triggers', rm); add('variables', vm); add('clients', cm); add('transformations', xm);
usesCache = { key, map };
return map;
}
function renderVarUses() {
const on = showVarUses && currentSection === 'variables';
if (!on) {
document.querySelectorAll('.tb-uses-col').forEach(el => el.remove());
return;
}
const uses = varUsesMap();
if (!uses) return; // API jeszcze się ładuje — render po odpowiedzi
ensureIdCss();
findDataTables().forEach(table => {
const { typeIdx } = resolveColumns(table);
const headRow = headerRowOf(table);
const cg = table.querySelector('colgroup');
if (cg && !cg.querySelector('.tb-uses-col')) {
const col = document.createElement('col');
col.className = 'tb-uses-col';
col.setAttribute('width', '90px');
const cols = cg.querySelectorAll(':scope > col');
cg.insertBefore(col, cols[typeIdx + 1] || null);
}
if (headRow && !headRow.querySelector('.tb-uses-col')) {
const th = document.createElement('th');
th.className = 'tb-uses-col';
th.textContent = 'Tagi';
th.title = 'W ilu tagach użyto zmiennej (GTM Filter) — szczegóły po najechaniu na liczbę';
const hc = [...headRow.children].filter(e => e.tagName === 'TH' || e.tagName === 'TD');
headRow.insertBefore(th, hc[typeIdx + 1] || null);
}
table.querySelectorAll('tbody tr').forEach(row => {
if (row === headRow || !row.querySelector(':scope > td')) return;
const name = nameFromRow(row);
if (!name) return;
const u = uses.get(name) || { tags: [], triggers: [], variables: [], clients: [], transformations: [] };
const n = u.tags.length;
const others = u.triggers.length + u.variables.length + u.clients.length + u.transformations.length;
const sig = n + '|' + others;
let td = row.querySelector('td.tb-uses-col');
if (td && td.dataset.sig === sig) return;
if (!td) {
td = document.createElement('td');
td.className = 'tb-uses-col';
const cells = row.querySelectorAll(':scope > td:not(.tb-uses-col)');
const ref = cells[typeIdx + 1] || null;
row.insertBefore(td, ref);
}
td.dataset.sig = sig;
td.innerHTML = '';
const num = document.createElement('span');
num.className = 'tb-uses-n' + (n === 0 && others === 0 ? ' zero' : '');
num.textContent = String(n);
td.appendChild(num);
if (others) {
const o = document.createElement('span');
o.className = 'tb-uses-o';
o.textContent = '+' + others;
td.appendChild(o);
}
const list = (lbl, arr) => arr.length ? lbl + ' (' + arr.length + '):\n ' + arr.slice(0, 25).join('\n ') + (arr.length > 25 ? '\n …' : '') : '';
td.title = [list('Tagi', u.tags), list('Reguły', u.triggers), list('Zmienne', u.variables), list('Klienci', u.clients), list('Transformacje', u.transformations)].filter(Boolean).join('\n') ||
'Nieużywana — żaden tag, reguła, zmienna' + (isServerContainer() ? ', klient ani transformacja' : '') + ' jej nie wywołuje';
});
});
}
// pomiar czasu (gtmTbDebug() → „czas”): ile kosztują poszczególne etapy
const perf = {};
function timed(name, fn) { const t0 = performance.now(); try { return fn(); } finally { const d = performance.now() - t0; const p = perf[name] || (perf[name] = { n: 0, ms: 0, max: 0 }); p.n++; p.ms += d; if (d > p.max) p.max = d; } }
window.gtmTbPerf = perf;
function renderIdBadges() {
timed('renderIdBadgesCore', renderIdBadgesCore);
timed('renderRowActions', renderRowActions);
timed('renderPausedRows', renderPausedRows);
timed('layoutAnnotations', () => layoutAnnotations());
}
window.addEventListener('resize', () => { try { layoutAnnotations(true); } catch (e) {} });
// Zapauzowane tagi: cały wiersz przygaszony (ikona ⏸ z GTM jest za mało widoczna)
function renderPausedRows() {
if (!tidyActive()) { document.querySelectorAll('tr.tb-paused').forEach(r => r.classList.remove('tb-paused')); return; }
ensureIdCss();
getDataRows().forEach(row => row.classList.toggle('tb-paused', isRowPaused(row) === true));
}
/* ═══════════════════ MODUŁ 6: AKCJE NA LIŚCIE ═══════════════════ */
/* ── zmiana nazwy / duplikat (przez to samo API, co panel GTM) ── */
// Token XSRF: GTM nie trzyma go w czytelnym ciasteczku — szukamy w kilku miejscach
const TOKEN_RE = /\b([A-Za-z0-9_-]{25,}:\d{12,14})\b/;
let xsrfSeen = { value: null, from: '' };
function rememberXsrf(v, from) { if (v && TOKEN_RE.test(v)) { xsrfSeen = { value: TOKEN_RE.exec(v)[1], from }; } }
function xsrfToken() {
// 1) nagłówek z zapisu wykonanego przez GTM w tej sesji (najpewniejsze)
const w = (window.gtmTbWrites || []).slice().reverse().find(x => x.headers && (x.headers['X-XSRF-TOKEN'] || x.headers['x-xsrf-token']));
if (w) return w.headers['X-XSRF-TOKEN'] || w.headers['x-xsrf-token'];
// 2) ciasteczko — w GTM nazywa się GTM-XSRF-TOKEN
const m = document.cookie.match(/(?:^|;\s*)([A-Za-z0-9_-]*XSRF[-_]?TOKEN[A-Za-z0-9_-]*)=([^;]+)/i);
if (m) { let v = m[2]; try { v = decodeURIComponent(v); } catch (e) {} rememberXsrf(v, 'ciasteczko ' + m[1]); return v; }
// 3) coś, co zauważyliśmy w odpowiedziach API / w stronie
if (xsrfSeen.value) return xsrfSeen.value;
// 4) skan: <meta>, inline <script>, globalne zmienne
const meta = document.querySelector('meta[name*="xsrf" i], meta[name*="csrf" i]');
if (meta && meta.content) { rememberXsrf(meta.content, 'meta'); if (xsrfSeen.value) return xsrfSeen.value; }
for (const sc of document.querySelectorAll('script:not([src])')) {
const t = sc.textContent || '';
const i = t.search(/xsrf|csrf/i);
if (i >= 0) { const mm = TOKEN_RE.exec(t.slice(Math.max(0, i - 200), i + 400)); if (mm) { rememberXsrf(mm[1], 'inline script'); return mm[1]; } }
}
try {
for (const k of Object.keys(window)) {
if (!/xsrf|csrf|token|config|__/i.test(k)) continue;
let v; try { v = window[k]; } catch (e) { continue; }
if (typeof v === 'string' && TOKEN_RE.test(v)) { rememberXsrf(v, 'window.' + k); return xsrfSeen.value; }
if (v && typeof v === 'object') {
let js = ''; try { js = JSON.stringify(v).slice(0, 20000); } catch (e) {}
const i = js.search(/xsrf|csrf/i);
if (i >= 0) { const mm = TOKEN_RE.exec(js.slice(Math.max(0, i - 200), i + 400)); if (mm) { rememberXsrf(mm[1], 'window.' + k); return mm[1]; } }
}
}
} catch (e) {}
return null;
}
async function apiWrite(method, path, body) {
const tok = xsrfToken();
if (!tok) throw new Error('Brak tokenu zabezpieczającego (XSRF). Zapisz raz cokolwiek w GTM albo odśwież stronę i spróbuj ponownie.');
const r = await window.fetch(path, {
method, credentials: 'same-origin',
headers: { 'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/json;charset=utf-8', 'X-XSRF-TOKEN': tok },
body: JSON.stringify(body),
});
const t = await r.text();
try { const w = (window.gtmTbWrites || []).slice(-1)[0]; if (w && w.url === path) { w.status = r.status; w.resp = t.slice(0, 1500); } } catch (e) {}
if (!r.ok) throw new Error('GTM odpowiedział HTTP ' + r.status + (t ? ': ' + t.replace(/^\)\]\}'[^\n]*\n?/, '').slice(0, 300) : ''));
return t;
}
// po zapisie: pobierz element z API i sprawdź, czy pole ma oczekiwaną wartość (GTM potrafi odpowiedzieć 200 i nic nie zmienić)
async function verifyField(sec, id, getter, expected) {
try {
const t = await (await window.fetch(wsApiBase() + '/' + sec + '/' + id + '?hl=en', { credentials: 'same-origin' })).text();
const m = collectApiItems(parseApiText(t), API_ID_FIELD[sec]);
const it = m.get(id) || m.values().next().value;
if (!it) return null;
return { ok: getter(it) === expected, got: getter(it), item: it };
} catch (e) { return null; }
}
function clone(o) { return JSON.parse(JSON.stringify(o)); }
function reloadList() {
// GTM trzyma listę w pamięci — wymuszamy ponowne wczytanie przez krótkie przejście na Przegląd i z powrotem
const h = location.hash;
const base = h.replace(/\/(tags|triggers|variables)(\/.*)?$/, '');
Object.values(apiStores).forEach(st => { st.at = 0; });
location.hash = base;
setTimeout(() => { location.hash = h; }, 250);
}
const SEC_PL = { tags: 'tagu', triggers: 'reguły', variables: 'zmiennej' };
async function renameTag(id) {
const sec = currentSection;
const tm = apiMapFor(sec);
let it = tm && tm.get(id);
if (!it || !it.data) { alert('Nie mam danych tego elementu — kliknij ⟳ Odśwież i spróbuj ponownie.'); return; }
const cur = it.data.name || '';
const name = prompt('Nowa nazwa ' + (SEC_PL[sec] || 'elementu') + ':', cur);
if (name == null || !name.trim() || name.trim() === cur) return;
try { it = (await ensureFullItem(sec, id)) || it; } catch (e) { alert('Nie udało się pobrać pełnych danych elementu — nie zapisuję.\n' + (e && e.message)); return; }
if (dataLacksParams(it)) { alert('API GTM zwraca tę zmienną bez parametrów w „data” — zapis z listy mógłby skasować jej konfigurację. Zmień nazwę w edytorze GTM.\n(Wpisz w konsoli gtmTbDebug() i prześlij sekcję „pojedyncza zmienna” — poprawię format.)'); return; }
const base = wsApiBase();
const body = { key: clone(it.key), data: Object.assign(clone(it.data), { name: name.trim() }) };
try {
await apiWrite('PUT', base + '/' + sec + '/' + id + '?hl=en', body);
// lista GTM trzyma stare dane w pamięci — podmieniamy nazwę w wierszu od razu, dane w cache też
it.data.name = name.trim();
if (it.properties) it.properties.displayName = name.trim();
getDataRows().forEach(r => {
if (idFromRow(r) !== id) return;
const a = nameAnchor(r);
if (!a) return;
const tn = [...a.childNodes].find(n => n.nodeType === 3 && n.textContent.trim());
if (tn) tn.textContent = ' ' + name.trim() + ' ';
});
Object.values(apiStores).forEach(st => { st.at = 0; });
renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter();
} catch (e) { alert('Zmiana nazwy nie powiodła się.\n' + (e && e.message)); }
}
// Klik w etykietę opcji uruchamiania: 1 = raz na zdarzenie, 2 = raz na stronę, 3 = bez limitu
async function setFiringOption(id, opt, chip) {
const tm = apiMapFor('tags');
const it = tm && tm.get(id);
if (!it || !it.data) { alert('Nie mam danych tego tagu — kliknij ⟳ Odśwież i spróbuj ponownie.'); return; }
if (Number(it.data.tagFiringOption || 1) === opt) return;
const base = wsApiBase();
const body = { key: clone(it.key), data: Object.assign(clone(it.data), { tagFiringOption: opt }) };
if (chip) { chip.style.opacity = '.5'; chip.textContent = '…'; }
try {
const resp = await apiWrite('PUT', base + '/tags/' + id + '?hl=en', body);
const chk = await verifyField('tags', id, x => Number((x.data || {}).tagFiringOption || 1), opt);
if (chk && !chk.ok) {
renderIdBadges();
alert('GTM przyjął zapis (HTTP 200), ale opcja uruchamiania się nie zmieniła (jest: ' + chk.got + ', miało być: ' + opt + ').\nWpisz w konsoli gtmTbDebug() i prześlij sekcję „zapisy do API” — poprawię format.');
return;
}
it.data.tagFiringOption = opt;
if (it.properties) it.properties.tagFiringOption = opt;
Object.values(apiStores).forEach(st => { st.at = 0; });
renderIdBadges(); updateFooter();
} catch (e) {
if (chip) { chip.style.opacity = ''; }
renderIdBadges();
alert('Zmiana opcji uruchamiania nie powiodła się.\n' + (e && e.message));
}
}
async function duplicateTag(id) {
const sec = currentSection;
const field = API_ID_FIELD[sec];
const tm = apiMapFor(sec);
let it = tm && tm.get(id);
if (!it || !it.data) { alert('Nie mam danych tego elementu — kliknij ⟳ Odśwież i spróbuj ponownie.'); return; }
const cur = it.data.name || '';
const name = prompt('Nazwa kopii ' + (SEC_PL[sec] || 'elementu') + ':', 'Copy of ' + cur);
if (name == null || !name.trim()) return;
try { it = (await ensureFullItem(sec, id)) || it; } catch (e) { alert('Nie udało się pobrać pełnych danych elementu — nie kopiuję.\n' + (e && e.message)); return; }
if (dataLacksParams(it)) { alert('API GTM zwraca tę zmienną bez parametrów w „data” — kopia z listy byłaby pusta. Skopiuj w edytorze GTM.\n(Wpisz w konsoli gtmTbDebug() i prześlij sekcję „pojedyncza zmienna” — poprawię format.)'); return; }
const base = wsApiBase();
const body = { key: null, data: Object.assign(clone(it.data), { name: name.trim() }) };
try {
const resp = await apiWrite('POST', base + '/' + sec + '?hl=en', body);
// nowy element z odpowiedzi (albo z ponownie pobranej listy) → dokładamy wiersz pod oryginałem, bez przeładowania
let created = null;
try { const m = collectApiItems(parseApiText(resp), field); created = m.values().next().value || null; } catch (e) {}
if (!created) {
try {
const t = await (await window.fetch(base + '/' + sec + '?hl=en', { credentials: 'same-origin' })).text();
const m = collectApiItems(parseApiText(t), field);
m.forEach(it => { if (!created && apiItemName(it) === name.trim()) created = it; });
} catch (e) {}
}
if (!created) { location.reload(); return; }
const newId = Number((API_ID_ALT[field] || [field]).map(f => created.key[f]).find(v => v != null));
const tm2 = apiMapFor(sec);
if (tm2) { const nm = new Map(tm2); nm.set(newId, created); const st = apiStores[base + '/' + sec]; if (st) { st.map = nm; st.at = Date.now(); } }
const src = getDataRows().find(r => idFromRow(r) === id);
if (!src) { location.reload(); return; }
invalidateRows(); bumpUi();
const nr = src.cloneNode(true);
nr.classList.add('tb-newrow');
nr.classList.remove('tb-grp', 'tb-grp-start', 'tb-grp-end', 'tb-grp-hot', 'tb-grp-odd');
nr.removeAttribute('data-tb-hidden'); nr.removeAttribute('data-tb-oi');
nr.querySelectorAll('.tb-ann, .tb-act, .tb-plist, .tb-meta, .' + ID_BADGE).forEach(x => x.remove());
const a = nameAnchor(nr);
if (a && a.tagName === 'A') {
const href = a.getAttribute('href') || '';
if (/\/(tags|triggers|variables)\/\d+/.test(href)) a.setAttribute('href', href.replace(/\/(tags|triggers|variables)\/\d+/, '/' + sec + '/' + newId));
else { a.removeAttribute('data-ng-click'); a.removeAttribute('ng-click'); a.setAttribute('href', location.hash.replace(/\/(tags|triggers|variables)(\/.*)?$/, '') + '/' + sec + '/' + newId); }
a.textContent = ' ' + name.trim() + ' ';
}
nr.querySelectorAll('[aria-checked]').forEach(x => x.setAttribute('aria-checked', 'false'));
nr.title = 'Nowy element (dodany przez GTM Filter) — pełne dane po odświeżeniu listy przez GTM';
src.insertAdjacentElement('afterend', nr);
renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter();
} catch (e) { alert('Duplikowanie nie powiodło się.\n' + (e && e.message)); }
}
function renderRowActions() {
const on = currentSection === 'tags' || currentSection === 'triggers' || currentSection === 'variables';
if (!on) { document.querySelectorAll('.tb-act').forEach(a => a.remove()); return; }
if (currentSection === 'variables') { /* wbudowane zmienne nie mają ID → bez akcji */ }
ensureIdCss();
getDataRows().forEach(row => {
const { nameIdx } = colsForRow(row);
const cell = row.querySelectorAll('td')[nameIdx];
if (!cell || cell.querySelector('.tb-act')) return;
if (cell.querySelector('.tb-plist')) { /* lista parametrów pod nazwą nie przeszkadza */ }
const id = idFromRow(row);
if (id === null) return;
const box = document.createElement('span');
box.className = 'tb-act';
stopAll(box);
box.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); });
const mk = (txt, title, fn) => {
const b = document.createElement('span');
b.className = 'tb-act-btn'; b.textContent = txt; b.title = title; b.setAttribute('role', 'button');
stopAll(b);
b.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); fn(id); });
box.appendChild(b);
};
mk('✎', 'Zmień nazwę (zapis przez API GTM, trafia do zmian workspace)', renameTag);
mk('⧉', 'Duplikuj (jak ⋮ → Copy w edytorze)', duplicateTag);
// w środku linku nazwy — zostaje w tej samej linii, tuż za nazwą
const host = cell.querySelector('a') || cell;
host.appendChild(box);
});
}
/* ═══════════════════ MODUŁ 7: UKŁAD ADNOTACJI ═══════════════════ */
/* ── sloty o równej szerokości, kolejność, poszerzenie kolumny Nazwa ── */
let layoutSig = '';
function layoutAnnotations(force) {
// pomiar szerokości (reflow) tylko gdy coś się zmieniło: zestaw etykiet, liczba wierszy, szerokość okna
const tables = findDataTables();
let sig = currentSection + '|' + window.innerWidth + '|' + annFirst;
tables.forEach(t => { sig += '|' + t.querySelectorAll('tbody tr').length + ':' + t.querySelectorAll('.' + ID_BADGE).length + ':' + t.querySelectorAll('.tb-uses-col').length; t.querySelectorAll('.tb-meta').forEach(m => { sig += m.dataset.sig; }); });
if (!force && sig === layoutSig) return;
layoutSig = sig;
tables.forEach(table => {
const { nameIdx } = resolveColumns(table);
const has = !!table.querySelector('.tb-ann');
// kolejność etykiet względem nazwy (przesuwamy tylko, gdy trzeba — inaczej observer kręciłby się w kółko)
table.querySelectorAll('tbody tr').forEach(row => {
const host = row.querySelector('.tb-ann') && row.querySelector('.tb-ann').parentElement;
if (!host) return;
const anns = [...host.querySelectorAll(':scope > .' + ID_BADGE), ...host.querySelectorAll(':scope > .tb-meta')];
if (!anns.length) return;
const kids = [...host.childNodes].filter(n => !(n.nodeType === 3 && !n.textContent.trim()) && n.nodeType !== 8);
const slice = annFirst ? kids.slice(0, anns.length) : kids.slice(-anns.length);
const ok = slice.length === anns.length && slice.every((n, i) => n === anns[i]);
if (!ok) {
if (annFirst) host.prepend(...anns); else host.append(...anns);
}
host.classList.toggle('tb-annfirst', annFirst);
});
// równe szerokości: #ID, event, param., opcja, ID vendorów (slot i — dla pojedynczych etykiet — sama etykieta)
if (has) [
['.' + ID_BADGE, false],
['.tb-s-ev', true],
['.tb-s-pc', true],
['.tb-s-fire', true],
['.tb-s-vid', false],
['.tb-s-cons', true],
].forEach(([sel, chipToo]) => {
const els = [...table.querySelectorAll(sel)];
if (!els.length) return;
// samą etykietę rozciągamy tylko, gdy jest jedyna w slocie (zmienne: wartość + pigułka „kod” dzielą jeden slot)
const stretch = el => chipToo && el.children.length === 1 && !!el.firstElementChild;
els.forEach(el => { el.style.minWidth = ''; [...el.children].forEach(c => { c.style.minWidth = ''; }); });
let max = 0;
els.forEach(el => { if (el.offsetParent !== null) max = Math.max(max, el.offsetWidth); });
if (!max) return;
els.forEach(el => {
el.style.minWidth = max + 'px';
if (stretch(el)) el.firstElementChild.style.minWidth = max + 'px';
});
});
// poszerzenie kolumny Nazwa (nowrap) — tylko gdy są etykiety
table.classList.toggle('tb-wide', has);
const cg = table.querySelector('colgroup');
if (cg) {
const cols = [...cg.querySelectorAll(':scope > col')].filter(c => !c.classList.contains('tb-uses-col'));
const col = cols[nameIdx];
if (col) {
if (has && !col.hasAttribute('data-tb-w')) { col.setAttribute('data-tb-w', col.getAttribute('width') || ''); col.removeAttribute('width'); }
if (!has && col.hasAttribute('data-tb-w')) { const w = col.getAttribute('data-tb-w'); if (w) col.setAttribute('width', w); col.removeAttribute('data-tb-w'); }
}
}
table.querySelectorAll('tr').forEach(row => {
const cells = [...row.children].filter(e => (e.tagName === 'TD' || e.tagName === 'TH') && !e.classList.contains('tb-uses-col'));
const c = cells[nameIdx];
if (c) c.classList.toggle('tb-namecell', has);
});
});
}
function renderIdBadgesCore() {
renderVarUses();
renderRowMeta();
if (!showIds) {
document.querySelectorAll('.' + ID_BADGE).forEach(b => b.remove());
return;
}
if (!currentSection) return;
ensureIdCss();
getDataRows().forEach(row => {
const l = idLinkFromRow(row);
if (!l) return;
let b = row.querySelector('.' + ID_BADGE);
if (b) {
if (b.dataset.id !== String(l.id)) { b.dataset.id = l.id; b.textContent = '#' + l.id; }
return;
}
b = document.createElement('span');
b.className = ID_BADGE + ' tb-ann';
b.dataset.id = l.id;
b.textContent = '#' + l.id;
b.title = 'ID ' + l.id + ' — kliknij, aby skopiować';
b.addEventListener('mousedown', ev => ev.stopPropagation());
b.addEventListener('mouseup', ev => ev.stopPropagation());
b.addEventListener('click', ev => {
ev.preventDefault();
ev.stopPropagation(); // nie otwieraj elementu
copyText(b.dataset.id);
b.textContent = '✓ ' + b.dataset.id;
setTimeout(() => { b.textContent = '#' + b.dataset.id; }, 800);
});
if (l.a === row) {
const { nameIdx } = colsForRow(row);
const cell = row.querySelectorAll('td')[nameIdx];
if (cell) cell.appendChild(b);
} else {
// w środku linku — zostaje w tej samej linii także przy a.fill-cell; przed chipami vendorów
const meta = l.a.querySelector('.tb-meta');
if (meta) l.a.insertBefore(b, meta); else l.a.appendChild(b);
}
});
}
function compileSearch(raw) {
nameSearch = raw;
nameSearchError = '';
nameSearchRegex = null;
nameSearchIds = null;
if (!raw) return;
// same ID (np. 1|2, 100-120, >50) → filtr po ID, nie po nazwie
const ids = parseIdQuery(raw);
if (ids) { nameSearchIds = ids; return; }
if (REGEX_SIGNAL.test(raw)) {
try {
nameSearchRegex = new RegExp(raw, 'i');
} catch (e) {
nameSearchError = e.message.replace(/^Invalid regular expression: /, '');
nameSearchRegex = null;
}
}
}
function nameMatchesSearch(name) {
if (!nameSearch) return true;
if (nameSearchRegex) return nameSearchRegex.test(name);
return name.toLowerCase().includes(nameSearch.toLowerCase());
}
function compileTrigSearch(raw) {
trigNameSearch = raw;
trigNameError = '';
trigNameRegex = null;
trigNameIds = null;
if (!raw) return;
const ids = parseIdQuery(raw);
if (ids) { trigNameIds = ids; return; }
if (REGEX_SIGNAL.test(raw)) {
try {
trigNameRegex = new RegExp(raw, 'i');
} catch (e) {
trigNameError = e.message.replace(/^Invalid regular expression: /, '');
trigNameRegex = null;
}
}
}
function trigNameMatches(name) {
if (!trigNameSearch) return true;
if (trigNameRegex) return trigNameRegex.test(name);
return name.toLowerCase().includes(trigNameSearch.toLowerCase());
}
/* ═══════════════════ MAPA TYPÓW REGUŁ ═══════════════════ */
function containerKey() {
const m = location.href.match(/accounts\/(\d+)\/containers\/(\d+)/);
return m ? m[1] + ':' + m[2] : 'default';
}
function loadTrigTypeStore() {
try {
trigTypeStore = JSON.parse(localStorage.getItem(LS_TRIG + ':' + containerKey()) || '{}');
} catch (e) { trigTypeStore = {}; }
lastContainerKey = containerKey();
}
function saveTrigTypeStore() {
try {
localStorage.setItem(LS_TRIG + ':' + containerKey(), JSON.stringify(trigTypeStore));
} catch (e) { /* quota — trudno */ }
}
// Reguły wbudowane nie pojawiają się na liście Reguł — mapujemy ręcznie
const BUILTIN_TRIG_TYPES = {
'All Pages': 'Page View',
'Wszystkie strony': 'Page View',
'Initialization - All Pages': 'Initialization',
'Inicjowanie – wszystkie strony': 'Initialization',
'Inicjowanie - wszystkie strony': 'Initialization',
'Consent Initialization - All Pages': 'Consent Initialization',
'Inicjowanie zgody – wszystkie strony': 'Consent Initialization',
'Inicjowanie zgody - wszystkie strony': 'Consent Initialization',
};
const UNKNOWN_TYPE = '❓ Nieznany typ';
// Typ reguły odczytywany WPROST z ikony chipa w tabeli tagów:
// <i class="gtm-trigger-custom-event-icon-small ..."> → 'custom-event'
const ICON_TYPE_MAP = {
'pageview': 'Page View',
'dom-ready': 'DOM Ready',
'window-loaded': 'Window Loaded',
'custom-event': 'Custom Event',
'click': 'Click',
'link-click': 'Just Links',
'element-visibility': 'Element Visibility',
'form-submit': 'Form Submission',
'form-submission': 'Form Submission',
'history-change': 'History Change',
'js-error': 'JavaScript Error',
'scroll-depth': 'Scroll Depth',
'timer': 'Timer',
'youtube-video': 'YouTube Video',
'trigger-group': 'Trigger Group',
'init': 'Initialization',
'consent-init': 'Consent Initialization',
};
function iconTypeFromChip(chipEl) {
const icon = chipEl.querySelector('i[class*="gtm-trigger-"]');
if (!icon) return null;
const m = icon.className.match(/gtm-trigger-([a-z-]+?)-icon/);
if (!m) return null;
const slug = m[1];
if (ICON_TYPE_MAP[slug]) return ICON_TYPE_MAP[slug];
// nieznany slug → ładnie sformatuj (np. 'ampl-timer' → 'Ampl Timer')
return slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
function typeForTrigger(name) {
return trigTypeStore[name] || BUILTIN_TRIG_TYPES[name] || UNKNOWN_TYPE;
}
// Na stronie Reguł: zbierz pary nazwa→typ i zapisz
function harvestTriggerTypes() {
if (currentSection !== 'triggers') return;
let changed = false;
getDataRows().forEach(row => {
const n = nameFromRow(row), t = typeFromRow(row);
if (n && t && trigTypeStore[n] !== t) { trigTypeStore[n] = t; changed = true; }
});
if (changed) saveTrigTypeStore();
}
/* ═══════════════════ MODUŁ 8: GTM DOM (odporne wykrywanie) ═══════════════════ */
function detectSection() {
const u = location.href;
if (/\/tags\b/.test(u)) return 'tags';
if (/\/triggers\b/.test(u)) return 'triggers';
if (/\/variables\b/.test(u)) return 'variables';
return null;
}
function sectionLabel(s) { return { tags: 'Tagi', triggers: 'Reguły', variables: 'Zmienne' }[s] || '—'; }
// Znajdź WSZYSTKIE tabele z danymi (strona zmiennych ma dwie:
// zmienne wbudowane + zdefiniowane przez użytkownika)
function findDataTables() {
const legacy = [...document.querySelectorAll('table.gtm-table--hide-empty')]
.filter(t => t.offsetParent !== null && t.querySelector('tbody tr'));
if (legacy.length) return legacy;
const out = [];
document.querySelectorAll('table').forEach(tab => {
if (tab.offsetParent === null) return;
if (tab.closest('#' + WID)) return;
const rows = tab.querySelectorAll('tbody tr');
if (!rows.length) return;
const hasLink = [...rows].some(r => r.querySelector('a'));
const headTxt = headerCellsOf(tab)
.map(th => th.textContent.trim().toLowerCase());
const hasNameHeader = headTxt.some(t => /^(nazwa|name)\b/.test(t));
if (hasLink || hasNameHeader) out.push(tab);
});
return out;
}
// Wiersz nagłówka: <thead><tr> albo (stary GTM, np. lista zmiennych) pierwszy <tr> z <th> w <tbody>
function headerRowOf(table) {
const t = table.querySelector('thead tr');
if (t) return t;
return [...table.querySelectorAll('tr')].find(r => r.querySelector(':scope > th')) || null;
}
function headerCellsOf(table) {
const r = headerRowOf(table);
return r ? [...r.children].filter(e => e.tagName === 'TH' || e.tagName === 'TD') : [];
}
// Ustal indeksy kolumn: Nazwa, Typ, Reguły uruchamiające (cache per tabela)
function resolveColumns(table) {
const cached = colCache.get(table);
if (cached) return cached;
let nameIdx = -1, typeIdx = -1, trigIdx = -1;
const headCells = headerCellsOf(table).filter(c => !c.classList.contains('tb-uses-col'));
[...headCells].forEach((th, i) => {
const txt = th.textContent.trim().toLowerCase();
if (nameIdx < 0 && /^(nazwa|name)\b/.test(txt)) nameIdx = i;
if (typeIdx < 0 && /^(typ|type)\b/.test(txt)) typeIdx = i;
if (trigIdx < 0 && /(regu|trigger)/.test(txt)) trigIdx = i;
});
if (nameIdx < 0) {
const firstRow = [...table.querySelectorAll('tbody tr')].find(r => r.querySelector(':scope > td'));
if (firstRow) {
const cells = firstRow.querySelectorAll('td');
[...cells].forEach((c, i) => {
if (nameIdx < 0 && c.querySelector('a')) nameIdx = i;
});
}
}
if (nameIdx < 0) nameIdx = 1;
if (typeIdx < 0) typeIdx = nameIdx + 1;
if (trigIdx < 0) trigIdx = typeIdx + 1; // w tagach: Nazwa | Typ | Reguły
const cols = { nameIdx, typeIdx, trigIdx };
colCache.set(table, cols);
return cols;
}
let rowsMemo = { t: 0, rows: null, sec: null };
function invalidateRows() { rowsMemo.t = 0; }
function getDataRows() {
const now = Date.now();
if (rowsMemo.rows && rowsMemo.sec === currentSection && now - rowsMemo.t < 120) return rowsMemo.rows;
const out = getDataRowsRaw();
rowsMemo = { t: now, rows: out, sec: currentSection };
return out;
}
function getDataRowsRaw() {
const tables = findDataTables();
const out = [];
tables.forEach(table => {
const { nameIdx } = resolveColumns(table);
[...table.querySelectorAll('tbody tr')].forEach(r => {
const cells = r.querySelectorAll('td');
if (cells.length > nameIdx && cells[nameIdx].textContent.trim().length > 0) out.push(r);
});
});
return out;
}
function colsForRow(row) {
const table = row.closest('table');
return table ? resolveColumns(table) : { nameIdx: 1, typeIdx: 2, trigIdx: 3 };
}
function nameFromRow(row) {
const { nameIdx } = colsForRow(row);
const cells = row.querySelectorAll('td');
if (cells.length > nameIdx) {
const a = cells[nameIdx].querySelector('a');
const el = a || cells[nameIdx];
let txt = el.textContent;
el.querySelectorAll('.tb-ann, .tb-act').forEach(b => { txt = txt.replace(b.textContent, ''); });
return txt.trim();
}
return '';
}
function typeFromRow(row) {
const { typeIdx } = colsForRow(row);
const cells = row.querySelectorAll('td');
if (cells.length > typeIdx) return cells[typeIdx].textContent.trim();
return null;
}
// Reguły uruchamiające z wiersza tagu: [{ name, type }]
// Typ czytamy z ikony chipa (gtm-trigger-*-icon), z fallbackiem na mapę
// nazwa→typ z localStorage i mapę reguł wbudowanych.
function triggersFromRow(row) {
const { trigIdx } = colsForRow(row);
const cells = row.querySelectorAll('td');
if (cells.length <= trigIdx) return [];
const cell = cells[trigIdx];
// 1) chipy reguł (nowy i stary UI)
const chips = [...cell.querySelectorAll('gtm-trigger-chip a, a.small-trigger-chip')];
if (chips.length) {
const seen = new Set(), out = [];
chips.forEach(a => {
const name = a.textContent.trim();
if (!name || seen.has(name)) return;
seen.add(name);
const type = iconTypeFromChip(a) || typeForTrigger(name);
const e = entFromEl(a) || entFromEl(a.closest('gtm-trigger-chip') || a);
out.push({ name, type, id: e && e.type === 'triggers' ? e.id : null });
});
return out;
}
// 2) fallback: dowolne linki / dzieci / cały tekst — typ z mapy nazw
const linkIds = {};
cell.querySelectorAll('a').forEach(a => { const e = entFromEl(a); if (e && e.type === 'triggers') linkIds[a.textContent.trim()] = e.id; });
let items = [...cell.querySelectorAll('a')].map(a => a.textContent.trim());
if (!items.length) items = [...cell.children].map(el => el.textContent.trim());
if (!items.length) {
const whole = cell.textContent.trim();
items = whole ? [whole] : [];
}
return [...new Set(items.filter(t => t && t.length > 1))]
.map(name => ({ name, type: typeForTrigger(name), id: name in linkIds ? linkIds[name] : null }));
}
// TYPY reguł uruchamiających dla wiersza tagu
function triggerTypesFromRow(row) {
return [...new Set(triggersFromRow(row).map(t => t.type))];
}
function scanTypes() {
const map = {};
getDataRows().forEach(row => { const t = typeFromRow(row); if (t) map[t] = (map[t] || 0) + 1; });
return map;
}
function scanTriggerTypes() {
const map = {};
if (currentSection !== 'tags') return map;
getDataRows().forEach(row => {
triggerTypesFromRow(row).forEach(t => { map[t] = (map[t] || 0) + 1; });
});
return map;
}
/* ═══════════════════ PAGINACJA: wymuś "ALL" ═══════════════════ */
// GTM domyślnie pokazuje 50 wierszy na stronę — filtr działałby tylko na
// widocznej stronie. Przy aktywnym filtrze przełączamy "Show rows" na ALL.
function ensureShowAll() {
document.querySelectorAll('gtm-pagination select, .gtm-pagination select').forEach(sel => {
if (sel.offsetParent === null) return;
const allOpt = [...sel.options].find(o =>
/all/i.test(o.value) || /all/i.test(o.label || '') || /all/i.test(o.textContent || ''));
if (!allOpt || sel.value === allOpt.value) return;
sel.value = allOpt.value;
sel.dispatchEvent(new Event('change', { bubbles: true }));
});
}
/* ═══════════════════ MODUŁ 9: FILTRY ═══════════════════ */
let filterStyleEl = null;
function getFilterStyleEl() {
if (!filterStyleEl || !filterStyleEl.isConnected) {
filterStyleEl = document.createElement('style');
filterStyleEl.id = 'gtm-tb-filter-css';
document.head.appendChild(filterStyleEl);
}
return filterStyleEl;
}
/* ── Reguły: ile tagów ich używa (uruchamiające + wyjątki) — z API tagów, po ID ── */
let trigUsesCache = { src: null, map: null };
function trigUsesMap() {
const tm = apiMapFor('tags');
if (!tm) return null;
if (trigUsesCache.src === tm) return trigUsesCache.map;
const map = new Map(); // triggerId → liczba tagów
tm.forEach(it => {
const d = it.data || it;
['positiveTriggerId', 'negativeTriggerId', 'firingTriggerId', 'blockingTriggerId'].forEach(k => {
const arr = d[k] || (it.properties && it.properties[k]);
if (Array.isArray(arr)) arr.forEach(id => { const n = Number(id); if (!isNaN(n)) map.set(n, (map.get(n) || 0) + 1); });
});
});
trigUsesCache = { src: tm, map };
return map;
}
// Filtr „tylko nieużywane”: zmienne bez żadnego użycia (tagi, reguły, zmienne, klienci, transformacje), reguły bez tagów
let onlyUnused = false;
function isRowUnused(row) {
if (currentSection === 'variables') {
const uses = varUsesMap();
if (!uses) return null; // dane jeszcze się ładują
const u = uses.get(nameFromRow(row));
return !u || !(u.tags.length + u.triggers.length + u.variables.length + u.clients.length + u.transformations.length);
}
if (currentSection === 'triggers') {
const tu = trigUsesMap();
if (!tu) return null;
const id = idFromRow(row);
if (id === null) return null;
return !tu.get(id);
}
return null;
}
// Filtr „tylko niezapauzowane” (Tagi): data.paused z API, z fallbackiem na ikonę pauzy w wierszu
let pausedMode = 0; // 0 = wszystkie, 1 = tylko niezapauzowane, 2 = tylko zapauzowane
// Pauza: najpierw to, co pokazuje lista GTM (klasa wiersza / ikona ⏸ — zawsze aktualne), potem dane z API
function rowPausedDom(row) {
if (row.classList.contains('gtm-table-row--paused')) return true;
return !!row.querySelector('.pause-circle-filled-icon, [class*="paused" i], [title*="has been paused" i], [title*="wstrzyman" i]');
}
function isRowPaused(row) {
if (rowPausedDom(row)) return true;
const tm = apiMapFor('tags');
const id = idFromRow(row);
if (tm && id !== null && tm.has(id)) {
const it = tm.get(id);
const d = it.data || it;
if (typeof d.paused === 'boolean') return d.paused;
if (it.properties && typeof it.properties.paused === 'boolean') return it.properties.paused;
return false;
}
return tm ? false : null; // null = dane jeszcze się ładują
}
/* ── Duplikaty eventów (Tagi): ten sam typ tagu + ta sama nazwa eventu w >1 tagu ── */
let onlyDup = false;
let dupCache = { src: null, map: null };
const dupKeyCache = new WeakMap(); // item.data → klucz (kod CJS bywa długi — nie licz za każdym razem)
function dupKeyOf(item) {
const d = item && item.data;
if (d && typeof d === 'object') {
const c = dupKeyCache.get(d);
if (c && c.sec === currentSection) return c.key;
const key = dupKeyOfRaw(item);
dupKeyCache.set(d, { sec: currentSection, key });
return key;
}
return dupKeyOfRaw(item);
}
function dupKeyOfRaw(item) {
if (currentSection === 'variables') {
// zmienna: ten sam typ + ta sama konfiguracja (stała: wartość; DLV: ścieżka; inne: wszystkie parametry)
const vt = (item.data && item.data.vendorTemplate) || {};
const pub = (vt.key && vt.key.publicId) || '';
if (!pub || item.isBuiltIn || itemLacksParams(item)) return null; // bez parametrów (jeszcze nie dociągnięte) = nie porównujemy
const o = tplParams(item);
// uwaga: „name” w parametrach to np. klucz dataLayer / nazwa cookie — to jest część konfiguracji
const keys = Object.keys(o).filter(k => !/^(notes)$/.test(k)).sort();
const sig = keys.map(k => k + '=' + valText(o[k]).trim()).join('\u0001');
if (!sig) return null;
return String(pub) + '|' + sig;
}
const evi = eventInfoFromItem(item);
if (!evi || !evi.ev || /^label: /.test(evi.ev)) return null;
const vt = (item.data && item.data.vendorTemplate) || (item.properties && item.properties.vendorTemplate) || {};
const pub = (vt.key && vt.key.publicId) || (item.data && item.data.type) || '';
return String(pub) + '|' + evi.ev.trim().toLowerCase();
}
function dupSection() { return currentSection === 'variables' ? 'variables' : 'tags'; }
function dupEventMap() {
const tm = apiMapFor(dupSection());
if (!tm) return null;
if (dupSection() === 'variables') { ensureVarParams(); if (hydrating.has((wsApiBase() || '') + '/variables')) return null; } // trwa dociąganie parametrów
if (dupCache.src === tm) return dupCache.map;
const map = new Map(); // klucz → [nazwy tagów]
tm.forEach(it => {
const d = it.data || {};
const k = dupKeyOf(it);
// zapauzowane też liczymy (często to właśnie para „nowy / stary”); w etykiecie oznaczamy ⏸
if (k) {
const a = map.get(k) || [];
a.push(apiItemName(it) + (d.paused === true ? ' ⏸' : ''));
a.active = (a.active || 0) + (d.paused === true ? 0 : 1); // ile AKTYWNYCH tagów wysyła ten event
map.set(k, a);
}
});
dupCache = { src: tm, map };
return map;
}
function rowDupKey(row) {
const tm = apiMapFor(dupSection());
const id = idFromRow(row);
const it = tm && id !== null ? tm.get(id) : null;
if (!it) return null;
return dupKeyOf(it);
}
// Grupowanie duplikatów: wiersze z tym samym eventem jeden pod drugim (oryginalna kolejność zapamiętana w data-tb-oi)
let grouped = false;
let tidy = true;
try { tidy = localStorage.getItem(LS_TIDY) !== '0'; } catch (e) {}
function tidyActive() { return tidy && currentSection === 'tags'; }
function groupDuplicates(enable) {
ensureIdCss(); // style grup są w tym samym arkuszu co etykiety — bez włączonych etykiet nie byłby wstrzyknięty
findDataTables().forEach(table => {
const tbody = table.querySelector('tbody') || table;
const rows = [...tbody.querySelectorAll(':scope > tr')].filter(r => r.querySelector(':scope > td'));
if (!rows.length) return;
rows.forEach((r, i) => { if (!r.hasAttribute('data-tb-oi')) r.setAttribute('data-tb-oi', String(i)); });
let order;
if (enable) {
const dm = dupEventMap();
if (!dm) return;
const groups = new Map(); // klucz → [rows]
const rest = [];
rows.forEach(r => {
const k = r.hasAttribute('data-tb-hidden') ? null : rowDupKey(r);
const arr = k && dm.get(k);
if (arr && arr.length > 1) { const g = groups.get(k) || []; g.push(r); groups.set(k, g); }
else rest.push(r);
});
order = [];
let gi = 0;
groups.forEach((g, k) => {
const hot = (dm.get(k).active || 0) >= 2;
g.forEach((r, i) => {
r.classList.toggle('tb-grp-start', i === 0);
r.classList.toggle('tb-grp-end', i === g.length - 1);
r.classList.toggle('tb-grp-odd', gi % 2 === 1);
r.classList.toggle('tb-grp-hot', hot);
r.classList.add('tb-grp');
});
order.push(...g);
gi++;
});
rest.forEach(r => r.classList.remove('tb-grp', 'tb-grp-start', 'tb-grp-end', 'tb-grp-odd', 'tb-grp-hot'));
order.push(...rest);
} else {
order = rows.slice().sort((a, b) => Number(a.getAttribute('data-tb-oi')) - Number(b.getAttribute('data-tb-oi')));
rows.forEach(r => r.classList.remove('tb-grp', 'tb-grp-start', 'tb-grp-end', 'tb-grp-odd', 'tb-grp-hot'));
}
// „Porządkuj”: zapauzowane na sam dół (stabilnie — kolejność w obrębie grup bez zmian)
if (tidyActive()) {
const act = [], pz = [];
order.forEach(r => { (isRowPaused(r) === true ? pz : act).push(r); });
order = act.concat(pz);
}
// przestaw tylko, gdy kolejność faktycznie się różni (inaczej observer kręciłby się w kółko)
const same = order.every((r, i) => r === rows[i]);
if (!same) order.forEach(r => tbody.appendChild(r));
});
grouped = enable || tidyActive();
}
function syncOrder() {
const dupOn = onlyDup && (currentSection === 'tags' || currentSection === 'variables');
if (dupOn || tidyActive() || grouped) groupDuplicates(dupOn);
}
function rowDupInfo(row) {
const tm = apiMapFor(dupSection()), dm = dupEventMap();
if (!tm || !dm) return null;
const id = idFromRow(row);
const it = id !== null ? tm.get(id) : null;
if (!it) return { dup: false };
const k = dupKeyOf(it);
const arr = k ? dm.get(k) : null;
return arr && arr.length > 1 ? { dup: true, names: arr, hot: (arr.active || 0) >= 2 } : { dup: false };
}
// Tagi bez reguły uruchamiającej (z API: positiveTriggerId puste)
let onlyNoTrig = false;
function isRowNoTrigger(row) {
const tm = apiMapFor('tags');
if (!tm) return null;
const id = idFromRow(row);
const it = id !== null ? tm.get(id) : null;
if (!it) return null;
const d = it.data || {};
const pos = d.positiveTriggerId || d.firingTriggerId || (it.properties && it.properties.positiveTriggerId) || [];
return !pos.length;
}
// Foldery: nazwa folderu elementu (parentFolderId → /folders)
let activeFolderFilters = new Set();
function folderNameOf(item) {
const fm = apiMapFor('folders');
const d = item.data || item;
const fid = d.parentFolderId != null ? d.parentFolderId : (item.properties && item.properties.parentFolderId);
if (fid == null || fid === '' || Number(fid) === 0) return 'Unfiled items';
const f = fm && fm.get(Number(fid));
return f ? (apiItemName(f) || ('folder #' + fid)) : ('folder #' + fid);
}
function rowFolder(row) {
const sec = currentSection;
if (!API_ID_FIELD[sec]) return null;
const m = apiMapFor(sec);
if (!m) return null;
const id = idFromRow(row);
const it = id !== null ? m.get(id) : null;
return it ? folderNameOf(it) : null;
}
function hasActiveFilters() {
return activeTypeFilters.size > 0 || activeTrigTypeFilters.size > 0 || nameSearch || trigNameSearch || onlyUnused || pausedMode || onlyDup || onlyNoTrig || activeFolderFilters.size > 0;
}
function applyAllFilters() {
if (hasActiveFilters()) ensureShowAll();
const rows = getDataRows();
if (!hasActiveFilters()) {
rows.forEach(r => r.removeAttribute('data-tb-hidden'));
getFilterStyleEl().textContent = '';
syncOrder();
return;
}
rows.forEach(row => {
let show = true;
if (activeTypeFilters.size > 0) {
const t = typeFromRow(row);
if (!t || !activeTypeFilters.has(t)) show = false;
}
if (show && activeTrigTypeFilters.size > 0) {
const types = triggerTypesFromRow(row);
if (!types.some(t => activeTrigTypeFilters.has(t))) show = false;
}
if (show && trigNameSearch && !trigNameError) {
const trigs = triggersFromRow(row);
if (trigNameIds) {
if (!trigs.some(t => t.id !== null && trigNameIds.some(p => p(t.id)))) show = false;
} else if (!trigs.some(t => trigNameMatches(t.name))) show = false;
}
if (show && onlyUnused) {
const un = isRowUnused(row);
if (un === false) show = false;
}
if (show && onlyNoTrig && currentSection === 'tags') {
if (isRowNoTrigger(row) === false) show = false;
}
if (show && activeFolderFilters.size > 0) {
const f = rowFolder(row);
if (f !== null && !activeFolderFilters.has(f)) show = false;
}
if (show && onlyDup && (currentSection === 'tags' || currentSection === 'variables')) {
const di = rowDupInfo(row);
if (di && !di.dup) show = false;
}
if (show && pausedMode && currentSection === 'tags') {
const pz = isRowPaused(row);
if (pausedMode === 1 && pz === true) show = false;
if (pausedMode === 2 && pz === false) show = false;
}
if (show && nameSearch) {
if (nameSearchError) { /* nie filtruj zepsutym regexem */ }
else if (nameSearchIds) {
const id = idFromRow(row);
if (id === null || !nameSearchIds.some(p => p(id))) show = false;
}
else if (!nameMatchesSearch(nameFromRow(row))) show = false;
}
if (show) row.removeAttribute('data-tb-hidden');
else row.setAttribute('data-tb-hidden', '1');
});
getFilterStyleEl().textContent = 'tr[data-tb-hidden="1"] { display: none !important; }';
syncOrder();
}
function clearAllFilters() {
activeTypeFilters.clear();
activeTrigTypeFilters.clear();
trigNameSearch = '';
trigNameRegex = null;
trigNameError = '';
trigNameIds = null;
nameSearch = '';
nameSearchRegex = null;
nameSearchError = '';
nameSearchIds = null;
onlyUnused = false;
pausedMode = 0;
onlyDup = false;
onlyNoTrig = false;
activeFolderFilters.clear();
folderListKey = '';
const nt = document.getElementById('tb-notrig-toggle');
if (nt) nt.classList.remove('on');
if (grouped) groupDuplicates(false);
const dt = document.getElementById('tb-dup-toggle');
if (dt) dt.classList.remove('on');
const ut = document.getElementById('tb-unused-toggle');
if (ut) ut.classList.remove('on');
renderPausedToggle();
getDataRows().forEach(r => r.removeAttribute('data-tb-hidden'));
getFilterStyleEl().textContent = '';
}
/* ═══════════════════ MODUŁ 10: PARAMETRY (zakładka) ═══════════════════ */
/* ── synonimy (wyszukiwarka łapie różne warianty nazw) ── */
// Grupy pojęć przypinane do sekcji — wpisanie DOWOLNEGO wariantu
// ("Google Analytics", "GA4", "ga 4", "analityka") pokazuje te same sekcje.
const SYN = {
GA: ['google analytics', 'google analytics 4', 'googleanalytics', 'ga', 'ga4', 'ga 4', 'g4', 'analytics', 'analityka', 'analitics', 'gtag', 'g-'],
GTAG: ['google tag', 'tag google', 'gtag', 'gtag.js', 'gt', 'config', 'konfiguracja', 'configuration', 'configuration settings', 'ustawienia konfiguracji', 'ustawienia tagu'],
EVENTS: ['event', 'eventy', 'zdarzenie', 'zdarzenia', 'event parameters', 'parametry eventu', 'parametry zdarzenia', 'shared event settings', 'event settings', 'lead', 'leady', 'generate_lead', 'login', 'logowanie', 'rejestracja', 'sign up', 'search', 'wyszukiwanie'],
ECOM: ['ecommerce', 'e-commerce', 'e commerce', 'ecom', 'sklep', 'shop', 'zakup', 'purchase', 'koszyk', 'cart', 'checkout', 'transakcja', 'zamowienie', 'order', 'sprzedaz', 'promocja', 'promotion', 'woocommerce', 'shopify'],
ITEMS: ['items', 'item', 'produkt', 'produkty', 'product', 'sku', 'kategoria', 'category', 'marka', 'brand', 'wariant'],
EM: ['enhanced measurement', 'pomiar zaawansowany', 'ulepszony pomiar', 'automatyczne', 'auto events', 'click', 'klik', 'klikniecia', 'outbound', 'linki wychodzace', 'download', 'pobieranie', 'plik', 'pliki', 'scroll', 'przewijanie', 'video', 'wideo', 'youtube', 'form', 'formularz', 'formularze'],
CONSENT: ['consent', 'consent mode', 'consent mode v2', 'cm', 'cmv2', 'gcm', 'zgody', 'zgoda', 'zgod', 'cmp', 'rodo', 'gdpr', 'dma', 'baner cookies', 'cookie banner', 'usercentrics', 'cookiebot', 'onetrust', 'cookieyes', 'prywatnosc', 'privacy'],
EC: ['enhanced conversions', 'ec', 'ulepszone konwersje', 'rozszerzone konwersje', 'user data', 'user_data', 'user provided data', 'upd', 'dane uzytkownika', 'first party data', 'hash', 'sha256', 'email', 'telefon', 'phone'],
ADS_SHORT: ['google ads', 'ads', 'adwords'],
ADS: ['google ads', 'googleads', 'ads', 'adwords', 'aw', 'aw-', 'gads', 'ppc', 'sem', 'konwersja', 'konwersje', 'conversion', 'conversions', 'remarketing', 'dynamic remarketing', 'cart data', 'cwcd', 'merchant center', 'gmc', 'new customer', 'nowy klient', 'pmax', 'performance max', 'shopping', 'doubleclick'],
ROUTING: ['cross domain', 'cross-domain', 'crossdomain', 'miedzy domenami', 'linker', '_gl', 'domeny', 'domains', 'routing', 'send_to', 'groups', 'callback', 'redirect', 'platnosci', 'bramka platnosci'],
SGTM: ['sgtm', 'server side', 'server-side', 'serverside', 'ssgtm', 'serwer', 'server', 'stape', 'cloud run', 'override', 'nadpisanie', 'measurement protocol', 'mp'],
META: ['meta', 'meta pixel', 'facebook', 'facebook pixel', 'fb', 'fbq', 'pixel', 'piksel', 'instagram', 'ig', 'capi', 'conversions api'],
AM: ['advanced matching', 'am', 'zaawansowane dopasowanie', 'dopasowanie', 'emq', 'email', 'telefon'],
};
function syn() { return [].concat(...[...arguments].map(k => SYN[k] || [])); }
// Dodatkowe słowa kluczowe per parametr (szukanie po znaczeniu, nie tylko po nazwie)
const PRM_KEYWORDS = {
server_container_url: 'sgtm server side serwer stape endpoint first party',
transport_url: 'sgtm server side serwer stape endpoint',
send_page_view: 'pageview page view odslona odslony',
ignore_referrer: 'referral referrer unwanted referrals niechciane odeslania samoodeslania self referral bramka platnosci',
allow_google_signals: 'signals sygnaly remarketing',
allow_ad_personalization_signals: 'personalizacja reklam remarketing',
user_id: 'uid userid identyfikator logowanie crm',
client_id: 'cid clientid identyfikator',
debug_mode: 'debugview debug testy',
page_location: 'url adres strony',
page_referrer: 'referrer odeslanie',
page_title: 'tytul strony',
event_id: 'dedup deduplikacja capi eventid',
eventID: 'dedup deduplikacja capi event_id',
transaction_id: 'order id zamowienie numer dedup deduplikacja',
new_customer: 'nowy klient nca acquisition pozyskiwanie',
aw_merchant_id: 'cart data merchant gmc', aw_feed_country: 'cart data gmc feed', aw_feed_language: 'cart data gmc feed',
wait_for_update: 'cmp opoznienie timeout',
url_passthrough: 'gclid consent', ads_data_redaction: 'gclid consent redakcja',
ip_override: 'ip geo geolokalizacja',
em: 'email mail', ph: 'telefon phone', fn: 'imie first name', ln: 'nazwisko last name', ct: 'miasto city',
st: 'region wojewodztwo state', zp: 'kod pocztowy zip', ge: 'plec gender', db: 'data urodzenia birth',
email: 'mail', phone_number: 'telefon', 'address.first_name': 'imie', 'address.last_name': 'nazwisko',
'address.street': 'ulica adres', 'address.city': 'miasto', 'address.region': 'wojewodztwo',
'address.postal_code': 'kod pocztowy zip', 'address.country': 'kraj', country: 'kraj',
sha256_email_address: 'mail', sha256_phone_number: 'telefon',
'address.sha256_first_name': 'imie', 'address.sha256_last_name': 'nazwisko',
external_id: 'crm user id', value: 'wartosc przychod revenue', currency: 'waluta pln',
linker: 'cross domain _gl', domains: 'cross domain _gl', accept_incoming: 'cross domain _gl',
decorate_forms: 'cross domain _gl formularze', url_position: 'cross domain _gl',
};
const PRM_PREFIX_KEYWORDS = [
[/^cookie_/, 'cookies ciasteczka ciastka _ga'],
[/^client_id$/, 'cookie _ga'],
[/^campaign/, 'utm kampania kampanie zrodlo ruchu source medium'],
[/^(sha256_|address\.sha256_)/, 'hash hashowany'],
];
/* ═══════════════════ PARAMETRY TAGÓW GTM ═══════════════════ */
// Nazwy parametrów do wpisania w polach tagów/zmiennych GTM (Configuration
// settings, Event parameters, User-Provided Data, szablony CMP/Meta…).
// p = nazwa (kopiowana kliknięciem), v = przykładowa wartość, s = zakres, d = opis.
// Źródła: developers.google.com (GA4 config reference, gtag parameter reference,
// cross-domain, consent mode, sGTM send-data), Google Ads Help (enhanced conversions,
// new customer, cart data), Simo Ahava (settings variables, sGTM field overrides).
const TAG_PARAMS = [
{ group: 'Google tag — Configuration settings', where: 'Google tag → Configuration settings / zmienna „Google Tag: Configuration Settings” · gtag(\'config\')',
aliases: syn('GTAG','GA'), items: [
{ p: 'server_container_url', v: 'https://sgtm.domena.pl', s: 'config', d: 'Wysyłka hitów do sGTM — sam origin, bez końcowego /' },
{ p: 'send_page_view', v: 'false', s: 'config', d: 'Domyślnie true — false wyłącza automatyczny page_view przy starcie Google tagu' },
{ p: 'cookie_domain', v: 'auto', s: 'config', d: 'Domena cookie _ga: auto = eTLD+1, none = pełny host bieżącej strony' },
{ p: 'cookie_expires', v: '63072000', s: 'config', d: 'Ważność cookie w sekundach (domyślnie 2 lata; 0 = cookie sesyjne)' },
{ p: 'cookie_flags', v: 'SameSite=None;Secure', s: 'config', d: 'Dodatkowe flagi cookie, rozdzielone średnikiem (np. GA w iframe)' },
{ p: 'cookie_path', v: '/', s: 'config', d: 'Ścieżka, pod którą zapisywane jest cookie' },
{ p: 'cookie_prefix', v: 'pk', s: 'config', d: 'Prefiks nazw cookies — np. dwie niezależne konfiguracje na jednej domenie' },
{ p: 'cookie_update', v: 'false', s: 'config', d: 'false = wygaśnięcie liczone od 1. wizyty (bez odświeżania przy każdej odsłonie)' },
{ p: 'ignore_referrer', v: 'true', s: 'config', d: 'Referrer nie staje się źródłem ruchu (np. powrót z bramki płatności). Też jako event param' },
{ p: 'allow_google_signals', v: 'false', s: 'config', d: 'Wyłącza Google Signals (cechy reklamowe z identyfikatorów Google)' },
{ p: 'allow_ad_personalization_signals', v: 'false', s: 'config', d: 'Wyłącza personalizację reklam dla danych z tego tagu' },
{ p: 'user_id', v: '{{DLV - user_id}}', s: 'config', d: 'Stały ID zalogowanego użytkownika — nie PII, max 256 znaków' },
{ p: 'client_id', v: '{{Cookie - _ga}}', s: 'config', d: 'Nadpisanie Client ID — zwykle tylko w nietypowych integracjach' },
{ p: 'page_location', v: '{{Page URL}}', s: 'config', d: 'Nadpisanie URL (max 1000 zn.) — np. usunięcie PII/tokenów z query. Też jako event param' },
{ p: 'page_referrer', v: '{{Referrer}}', s: 'config', d: 'Nadpisanie referrera (max 420 zn.) — przydatne w SPA. Też jako event param' },
{ p: 'page_title', v: '{{Page Title}}', s: 'config', d: 'Nadpisanie tytułu strony (max 300 zn.). Też jako event param' },
{ p: 'content_group', v: '/blog/', s: 'config', d: 'Grupa treści — zasila wymiar „Grupa treści” w raportach GA4' },
{ p: 'language', v: 'pl-pl', s: 'config', d: 'Nadpisanie języka (domyślnie navigator.language)' },
{ p: 'screen_resolution', v: '1920x1080', s: 'config', d: 'Nadpisanie rozdzielczości ekranu (format SZERxWYS)' },
{ p: 'campaign_source', v: 'newsletter', s: 'config', d: 'Nadpisuje utm_source z URL' },
{ p: 'campaign_medium', v: 'email', s: 'config', d: 'Nadpisuje utm_medium z URL' },
{ p: 'campaign_name', v: 'jesien_2026', s: 'config', d: 'Nadpisuje utm_campaign z URL' },
{ p: 'campaign_term', v: 'wiertarka', s: 'config', d: 'Nadpisuje utm_term z URL' },
{ p: 'campaign_content', v: 'baner_top', s: 'config', d: 'Nadpisuje utm_content z URL' },
{ p: 'campaign_id', v: 'abc.123', s: 'config', d: 'Nadpisuje utm_id z URL' },
{ p: 'user_data', v: '{{User-Provided Data}}', s: 'config', d: 'Dane do enhanced conversions / user-provided data — zmienna typu User-Provided Data' },
{ p: 'user_properties', v: '{favorite_store: \'Bydgoszcz\'}', s: 'config', d: 'Obiekt właściwości użytkownika — w GTM lepiej wpisać je w sekcji „User Properties” tagu' },
{ p: 'debug_mode', v: 'true', s: 'config', d: 'Hity w DebugView. Działa KAŻDA wartość — żeby wyłączyć, usuń parametr (nie publikuj na prod!)' },
{ p: 'update', v: 'true', s: 'config', d: 'Tylko gtag.js: scala nowe ustawienia z configiem bez wysyłania page_view (SPA)' },
{ p: 'transport_url', v: 'https://sgtm.domena.pl', s: 'config', d: 'Legacy — starsza nazwa endpointu sGTM; dziś server_container_url' },
]},
{ group: 'GA4 — parametry eventów (ogólne)', where: 'GA4 Event → Event parameters / zmienna „Google Tag: Event Settings” / Google tag → Shared event settings',
aliases: syn('GA','EVENTS'), items: [
{ p: 'event_id', v: '{{DLV - event_id}}', s: 'event', d: 'Własny ID eventu — deduplikacja web ↔ server (sGTM, Meta CAPI). Parametr niestandardowy' },
{ p: 'engagement_time_msec', v: '100', s: 'event', d: 'Czas zaangażowania w ms — potrzebny m.in. w Measurement Protocol / sGTM' },
{ p: 'currency', v: 'PLN', s: 'event', d: 'Waluta ISO 4217 — wymagana, gdy wysyłasz value' },
{ p: 'value', v: '{{DLV - value}}', s: 'event', d: 'Wartość eventu (liczba, nie string) — np. generate_lead, purchase' },
{ p: 'lead_source', v: 'formularz_kontakt', s: 'event', d: 'Źródło leada — rekomendowany dla eventów lead gen (generate_lead itd.)' },
{ p: 'method', v: 'email', s: 'event', d: 'Metoda — dla login / sign_up / share' },
{ p: 'search_term', v: '{{DLV - search_term}}', s: 'event', d: 'Fraza wyszukiwania — search / view_search_results' },
{ p: 'content_type', v: 'product', s: 'event', d: 'Typ treści — select_content / share' },
{ p: 'content_id', v: 'C_12345', s: 'event', d: 'ID wybranej treści — select_content' },
]},
{ group: 'GA4 — e-commerce (poziom eventu)', where: 'GA4 Event → Event parameters · albo „More settings → Ecommerce → Send Ecommerce data (Data Layer)”',
aliases: syn('GA','ECOM'), items: [
{ p: 'transaction_id', v: '{{DLV - ecommerce.transaction_id}}', s: 'event', d: 'Unikalny ID zamówienia — GA4 deduplikuje purchase po tym polu' },
{ p: 'value', v: '{{DLV - ecommerce.value}}', s: 'event', d: 'Wartość zamówienia/eventu (bez shipping i tax, jeśli tak raportujesz)' },
{ p: 'currency', v: 'PLN', s: 'event', d: 'Waluta — obowiązkowa przy value' },
{ p: 'tax', v: '59.09', s: 'event', d: 'Podatek transakcji' },
{ p: 'shipping', v: '15.99', s: 'event', d: 'Koszt dostawy' },
{ p: 'coupon', v: 'LATO10', s: 'event', d: 'Kupon na poziomie zamówienia' },
{ p: 'payment_type', v: 'BLIK', s: 'event', d: 'Metoda płatności — add_payment_info' },
{ p: 'shipping_tier', v: 'Kurier DPD', s: 'event', d: 'Metoda dostawy — add_shipping_info' },
{ p: 'item_list_id', v: 'bestsellery', s: 'event', d: 'ID listy — view_item_list / select_item' },
{ p: 'item_list_name', v: 'Bestsellery', s: 'event', d: 'Nazwa listy — view_item_list / select_item' },
{ p: 'promotion_id', v: 'P_LATO', s: 'event', d: 'ID promocji — view_promotion / select_promotion' },
{ p: 'promotion_name', v: 'Lato -10%', s: 'event', d: 'Nazwa promocji' },
{ p: 'creative_name', v: 'baner_lato', s: 'event', d: 'Nazwa kreacji promocji' },
{ p: 'creative_slot', v: 'hero_1', s: 'event', d: 'Slot kreacji promocji' },
{ p: 'items', v: '{{DLV - ecommerce.items}}', s: 'event', d: 'Tablica produktów — pola w grupie „items[]” poniżej' },
]},
{ group: 'GA4 — parametry items[]', where: 'wewnątrz tablicy ecommerce.items (dataLayer / gtag) — nie jako osobne parametry tagu',
aliases: syn('GA','ECOM','ITEMS'), items: [
{ p: 'item_id', v: 'SKU-750W', s: 'item', d: 'ID produktu (item_id lub item_name wymagane)' },
{ p: 'item_name', v: 'Wiertarka udarowa 750W', s: 'item', d: 'Nazwa produktu' },
{ p: 'price', v: '299.99', s: 'item', d: 'Cena jednostkowa (liczba)' },
{ p: 'quantity', v: '1', s: 'item', d: 'Ilość' },
{ p: 'item_brand', v: 'Domitech', s: 'item', d: 'Marka' },
{ p: 'item_category', v: 'Elektronarzędzia', s: 'item', d: 'Kategoria (poziom 1)' },
{ p: 'item_category2', v: 'Wiertarki', s: 'item', d: 'Kategoria poziom 2' },
{ p: 'item_category3', v: 'Udarowe', s: 'item', d: 'Kategoria poziom 3' },
{ p: 'item_category4', v: '', s: 'item', d: 'Kategoria poziom 4' },
{ p: 'item_category5', v: '', s: 'item', d: 'Kategoria poziom 5' },
{ p: 'item_variant', v: 'czarny', s: 'item', d: 'Wariant (kolor, rozmiar…)' },
{ p: 'discount', v: '30.00', s: 'item', d: 'Rabat jednostkowy na produkcie' },
{ p: 'coupon', v: 'LATO10', s: 'item', d: 'Kupon na poziomie produktu' },
{ p: 'index', v: '0', s: 'item', d: 'Pozycja produktu na liście' },
{ p: 'item_list_id', v: 'bestsellery', s: 'item', d: 'Lista, z której pochodzi produkt' },
{ p: 'item_list_name', v: 'Bestsellery', s: 'item', d: 'Nazwa listy produktu' },
{ p: 'affiliation', v: 'Sklep online', s: 'item', d: 'Sklep/dostawca (np. marketplace, sklep stacjonarny)' },
{ p: 'location_id', v: 'ChIJ…', s: 'item', d: 'Lokalizacja (np. Google Place ID sklepu)' },
{ p: 'promotion_id', v: 'P_LATO', s: 'item', d: 'Promocja przypisana do produktu' },
{ p: 'promotion_name', v: 'Lato -10%', s: 'item', d: 'Nazwa promocji na produkcie' },
{ p: 'creative_name', v: 'baner_lato', s: 'item', d: 'Kreacja promocji na produkcie' },
{ p: 'creative_slot', v: 'hero_1', s: 'item', d: 'Slot kreacji na produkcie' },
]},
{ group: 'GA4 — Enhanced Measurement (auto)', where: 'nazwy zbierane automatycznie przez GA4 — warto je reużyć w custom eventach (spójne wymiary)',
aliases: syn('GA','EM'), items: [
{ p: 'link_url', v: '{{Click URL}}', s: 'event', d: 'URL klikniętego linku (click, file_download)' },
{ p: 'link_domain', v: 'allegro.pl', s: 'event', d: 'Domena linku' },
{ p: 'link_text', v: '{{Click Text}}', s: 'event', d: 'Tekst linku' },
{ p: 'link_id', v: '{{Click ID}}', s: 'event', d: 'Atrybut id linku' },
{ p: 'link_classes', v: '{{Click Classes}}', s: 'event', d: 'Klasy CSS linku' },
{ p: 'outbound', v: 'true', s: 'event', d: 'Czy link wychodzący' },
{ p: 'file_name', v: '/cennik.pdf', s: 'event', d: 'Ścieżka/nazwa pobranego pliku' },
{ p: 'file_extension', v: 'pdf', s: 'event', d: 'Rozszerzenie pliku' },
{ p: 'percent_scrolled', v: '90', s: 'event', d: 'Głębokość scrolla (%)' },
{ p: 'video_title', v: '{{Video Title}}', s: 'event', d: 'Tytuł wideo' },
{ p: 'video_url', v: '{{Video URL}}', s: 'event', d: 'URL wideo' },
{ p: 'video_provider', v: 'youtube', s: 'event', d: 'Dostawca wideo' },
{ p: 'video_percent', v: '{{Video Percent}}', s: 'event', d: 'Procent obejrzenia' },
{ p: 'video_current_time', v: '{{Video Current Time}}', s: 'event', d: 'Bieżący czas wideo (s)' },
{ p: 'video_duration', v: '{{Video Duration}}', s: 'event', d: 'Długość wideo (s)' },
{ p: 'visible', v: '{{Video Visible}}', s: 'event', d: 'Czy wideo było widoczne' },
{ p: 'form_id', v: '{{Form ID}}', s: 'event', d: 'Atrybut id formularza (form_start / form_submit)' },
{ p: 'form_name', v: 'kontakt', s: 'event', d: 'Atrybut name formularza' },
{ p: 'form_destination', v: '{{Form URL}}', s: 'event', d: 'URL, na który wysyłany jest formularz' },
{ p: 'form_submit_text', v: 'Wyślij', s: 'event', d: 'Tekst przycisku wysyłki' },
]},
{ group: 'Consent Mode v2', where: 'szablon CMP / Consent Initialization · gtag(\'consent\', \'default\'|\'update\') · gtag(\'set\')',
aliases: syn('CONSENT'), items: [
{ p: 'ad_storage', v: 'denied', s: 'consent', d: 'Zapis cookies reklamowych (_gcl_*, IDE…)' },
{ p: 'analytics_storage', v: 'denied', s: 'consent', d: 'Zapis cookies analitycznych (_ga, _ga_*)' },
{ p: 'ad_user_data', v: 'denied', s: 'consent', d: 'v2: zgoda na wysyłkę danych usera do Google w celach reklamowych' },
{ p: 'ad_personalization', v: 'denied', s: 'consent', d: 'v2: zgoda na personalizację reklam / remarketing' },
{ p: 'functionality_storage', v: 'denied', s: 'consent', d: 'Storage funkcjonalny (np. język)' },
{ p: 'personalization_storage', v: 'denied', s: 'consent', d: 'Storage personalizacji (np. rekomendacje)' },
{ p: 'security_storage', v: 'granted', s: 'consent', d: 'Storage bezpieczeństwa (uwierzytelnianie, anty-fraud)' },
{ p: 'wait_for_update', v: '500', s: 'consent', d: 'Ile ms tagi czekają na consent update z CMP (tylko w default)' },
{ p: 'region', v: "['PL','DE']", s: 'consent', d: 'Default tylko dla regionów (ISO 3166-2) — np. inne ustawienia dla EOG' },
{ p: 'url_passthrough', v: 'true', s: 'set', d: 'Przy braku zgody przenosi gclid/dclid w URL między stronami' },
{ p: 'ads_data_redaction', v: 'true', s: 'set', d: 'Przy ad_storage=denied usuwa identyfikatory kliknięć z hitów reklamowych' },
]},
{ group: 'Enhanced conversions / user_data', where: 'zmienna „User-Provided Data” (Manual → Code/pola) → user_data · gtag(\'set\', \'user_data\', {…})',
aliases: syn('EC','ADS_SHORT'), items: [
{ p: 'email', v: '{{DLV - email}}', s: 'user_data', d: 'E-mail (niehashowany — Google sam normalizuje i hashuje)' },
{ p: 'phone_number', v: '+48600100200', s: 'user_data', d: 'Telefon w formacie E.164 (z +48)' },
{ p: 'address.first_name', v: 'Jan', s: 'user_data', d: 'Imię (w obiekcie address)' },
{ p: 'address.last_name', v: 'Kowalski', s: 'user_data', d: 'Nazwisko' },
{ p: 'address.street', v: 'Gdańska 1', s: 'user_data', d: 'Ulica i numer' },
{ p: 'address.city', v: 'Bydgoszcz', s: 'user_data', d: 'Miasto' },
{ p: 'address.region', v: 'kujawsko-pomorskie', s: 'user_data', d: 'Województwo / region' },
{ p: 'address.postal_code', v: '85-001', s: 'user_data', d: 'Kod pocztowy' },
{ p: 'address.country', v: 'PL', s: 'user_data', d: 'Kod kraju ISO' },
{ p: 'sha256_email_address', v: '{{JS - email sha256}}', s: 'user_data', d: 'E-mail już zahashowany (hex SHA-256, po trim + lowercase)' },
{ p: 'sha256_phone_number', v: '{{JS - phone sha256}}', s: 'user_data', d: 'Telefon zahashowany (E.164 przed hashowaniem)' },
{ p: 'address.sha256_first_name', v: '{{JS - fname sha256}}', s: 'user_data', d: 'Imię zahashowane' },
{ p: 'address.sha256_last_name', v: '{{JS - lname sha256}}', s: 'user_data', d: 'Nazwisko zahashowane' },
{ p: 'allow_enhanced_conversions', v: 'true', s: 'config', d: 'gtag.js: włącza enhanced conversions w configu tagu' },
]},
{ group: 'Google Ads — konwersje i cart data', where: 'Google Ads Conversion Tracking (pola tagu, „Provide product-level sales data”) · gtag(\'event\', \'conversion\'|\'purchase\')',
aliases: syn('ADS'), items: [
{ p: 'send_to', v: 'AW-123456789/AbCdEfGh', s: 'ads', d: 'ID konwersji + etykieta; bez etykiety leci tylko remarketing' },
{ p: 'value', v: '{{DLV - ecommerce.value}}', s: 'ads', d: 'Wartość konwersji' },
{ p: 'currency', v: 'PLN', s: 'ads', d: 'Waluta konwersji' },
{ p: 'transaction_id', v: '{{DLV - ecommerce.transaction_id}}', s: 'ads', d: 'ID zamówienia — deduplikacja konwersji w Ads' },
{ p: 'new_customer', v: 'true', s: 'ads', d: 'Pozyskiwanie nowych klientów — true/false liczone dynamicznie' },
{ p: 'aw_merchant_id', v: '123456789', s: 'ads', d: 'Cart data: ID konta Merchant Center' },
{ p: 'aw_feed_country', v: 'PL', s: 'ads', d: 'Cart data: kraj / etykieta feedu w GMC' },
{ p: 'aw_feed_language', v: 'pl', s: 'ads', d: 'Cart data: język feedu' },
{ p: 'discount', v: '10.00', s: 'ads', d: 'Cart data: rabat na poziomie ZAMÓWIENIA (w GA4 discount jest per item)' },
{ p: 'id', v: 'SKU-750W', s: 'ads', d: 'Cart data / remarketing: w items[] ID produktu zgodne z feedem GMC' },
]},
{ group: 'gtag — routing i cross-domain (linker)', where: 'gtag.js config/event/set — w GTM cross-domain: Google tag → Configure tag settings → Configure your domains',
aliases: syn('ROUTING','GTAG'), items: [
{ p: 'send_to', v: 'G-XXXXXXX', s: 'gtag', d: 'Docelowe ID / grupa, do której idzie event (nadpisuje routing domyślny)' },
{ p: 'groups', v: 'default,vendors', s: 'gtag', d: 'Przypisanie targetu w configu do grupy (routing przez send_to)' },
{ p: 'event_callback', v: 'function(){ form.submit(); }', s: 'gtag', d: 'Funkcja wywołana po przetworzeniu eventu (np. submit/redirect)' },
{ p: 'event_timeout', v: '2000', s: 'gtag', d: 'Timeout (ms) dla event_callback — odpali nawet bez odpowiedzi' },
{ p: 'linker', v: "{domains:['sklep.pl']}", s: 'gtag', d: 'Obiekt linkera — ustaw PRZED komendą config' },
{ p: 'domains', v: "['sklep.pl','platnosci.pl']", s: 'gtag', d: 'linker: domeny, do których linki dostają _gl' },
{ p: 'accept_incoming', v: 'true', s: 'gtag', d: 'linker: przyjmuj _gl na domenie docelowej (domyślnie true, gdy są domains)' },
{ p: 'decorate_forms', v: 'true', s: 'gtag', d: 'linker: dekoruj też formularze (brak odpowiednika w GTM)' },
{ p: 'url_position', v: 'fragment', s: 'gtag', d: 'linker: _gl w query (domyślnie) albo po #' },
]},
{ group: 'Server-side GTM — nadpisania w tagu GA4', where: 'sGTM → tag Google Analytics: GA4 → Event Parameters → Parameters to Add / Edit',
aliases: syn('SGTM','GA'), items: [
{ p: 'ip_override', v: '{{X-Forwarded-For}}', s: 'sgtm', d: 'Nadpisanie IP użytkownika (geo) w hicie wysyłanym do GA4' },
{ p: 'client_id', v: '{{ED - client_id}}', s: 'sgtm', d: 'Nadpisanie Client ID' },
{ p: 'user_id', v: '{{ED - user_id}}', s: 'sgtm', d: 'Nadpisanie / dołożenie User ID po stronie serwera' },
{ p: 'ga_session_id', v: '1727700000', s: 'sgtm', d: 'ID sesji GA4' },
{ p: 'ga_session_number', v: '4', s: 'sgtm', d: 'Numer sesji użytkownika' },
{ p: 'engagement_time_msec', v: '1000', s: 'sgtm', d: 'Czas zaangażowania w ms' },
{ p: 'page_referrer', v: 'https://google.com', s: 'sgtm', d: 'Nadpisanie referrera' },
]},
{ group: 'Meta Pixel — parametry eventów', where: 'szablon Meta/Facebook Pixel → Object properties · fbq(\'track\', \'Purchase\', {…}, {eventID})',
aliases: syn('META'), items: [
{ p: 'value', v: '{{DLV - ecommerce.value}}', s: 'meta', d: 'Wartość — wymagana dla Purchase' },
{ p: 'currency', v: 'PLN', s: 'meta', d: 'Waluta — wymagana dla Purchase' },
{ p: 'content_ids', v: "['SKU-750W']", s: 'meta', d: 'Tablica ID produktów — zgodna z katalogiem (DPA)' },
{ p: 'content_type', v: 'product', s: 'meta', d: 'product albo product_group (gdy ID to grupa wariantów)' },
{ p: 'contents', v: "[{id:'SKU-750W',quantity:1}]", s: 'meta', d: 'Tablica obiektów id + quantity (+ item_price)' },
{ p: 'content_name', v: 'Wiertarka udarowa 750W', s: 'meta', d: 'Nazwa produktu / strony' },
{ p: 'content_category', v: 'Elektronarzędzia', s: 'meta', d: 'Kategoria' },
{ p: 'num_items', v: '1', s: 'meta', d: 'Liczba sztuk — InitiateCheckout' },
{ p: 'search_string', v: 'wiertarka', s: 'meta', d: 'Fraza — Search' },
{ p: 'predicted_ltv', v: '1200', s: 'meta', d: 'Przewidywana wartość klienta — Subscribe/StartTrial' },
{ p: 'status', v: 'true', s: 'meta', d: 'Status — CompleteRegistration' },
{ p: 'eventID', v: '{{DLV - event_id}}', s: 'meta', d: 'Deduplikacja z CAPI — ten sam ID w pixelu i w evencie serwerowym' },
]},
{ group: 'Meta Pixel — Advanced Matching', where: 'szablon Meta Pixel → Advanced Matching · fbq(\'init\', ID, {em, ph, …})',
aliases: syn('META','AM'), items: [
{ p: 'em', v: '{{DLV - email}}', s: 'meta', d: 'E-mail (lowercase; pixel hashuje sam)' },
{ p: 'ph', v: '48600100200', s: 'meta', d: 'Telefon — same cyfry z kierunkowym, bez + i spacji' },
{ p: 'fn', v: 'jan', s: 'meta', d: 'Imię (lowercase)' },
{ p: 'ln', v: 'kowalski', s: 'meta', d: 'Nazwisko (lowercase)' },
{ p: 'ct', v: 'bydgoszcz', s: 'meta', d: 'Miasto (lowercase, bez spacji)' },
{ p: 'st', v: 'kp', s: 'meta', d: 'Region/stan — kod' },
{ p: 'zp', v: '85001', s: 'meta', d: 'Kod pocztowy' },
{ p: 'country', v: 'pl', s: 'meta', d: 'Kraj — 2-literowy kod ISO, lowercase' },
{ p: 'external_id', v: '{{DLV - user_id}}', s: 'meta', d: 'Twój ID użytkownika (CRM/konto)' },
{ p: 'ge', v: 'm', s: 'meta', d: 'Płeć: f / m' },
{ p: 'db', v: '19900131', s: 'meta', d: 'Data urodzenia YYYYMMDD' },
]},
];
let prmQuery = '';
function copyText(txt) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(txt).catch(() => fallbackCopy(txt));
}
return Promise.resolve(fallbackCopy(txt));
}
function fallbackCopy(txt) {
const ta = document.createElement('textarea');
ta.value = txt;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (e) {}
ta.remove();
}
/* ── wyszukiwarka: słowa + synonimy + odmiana, bez polskich znaków ── */
function normTxt(str) {
return String(str).toLowerCase().replace(/ł/g, 'l').normalize('NFD').replace(/[̀-ͯ]/g, '');
}
function wordsOf(str) { return normTxt(str).split(/[^a-z0-9]+/).filter(Boolean); }
function phraseWords(str) {
// "google analytics" → google, analytics, googleanalytics (wariant bez spacji)
const w = wordsOf(str);
return w.length > 1 ? w.concat(w.join('')) : w;
}
function uniq(a) { return [...new Set(a)]; }
// indeks budowany raz
/* ── chipy kategorii pod wyszukiwarką ── */
const PRM_CHIPS = {
plat: { label: 'Platforma', items: [
{ id: 'ga4', label: 'GA4' }, { id: 'gtag', label: 'Google tag' }, { id: 'ads', label: 'Google Ads' },
{ id: 'consent', label: 'Consent' }, { id: 'sgtm', label: 'sGTM' }, { id: 'meta', label: 'Meta' },
]},
topic: { label: 'Temat', items: [
{ id: 'cfg', label: 'Konfiguracja' }, { id: 'evt', label: 'Eventy' }, { id: 'ecom', label: 'E-commerce' },
{ id: 'ud', label: 'Dane usera' }, { id: 'ck', label: 'Cookies' }, { id: 'utm', label: 'Kampanie/UTM' },
{ id: 'xd', label: 'Cross-domain' },
]},
};
const LS_PRM_CHIPS = 'gtmTbPrmChips';
let prmSel = { plat: new Set(), topic: new Set() };
try {
const saved = JSON.parse(localStorage.getItem(LS_PRM_CHIPS) || '{}');
['plat', 'topic'].forEach(r => { if (Array.isArray(saved[r])) prmSel[r] = new Set(saved[r]); });
} catch (e) {}
function savePrmSel() {
try { localStorage.setItem(LS_PRM_CHIPS, JSON.stringify({ plat: [...prmSel.plat], topic: [...prmSel.topic] })); } catch (e) {}
}
// przypisanie parametru do platform i tematów
function prmTags(g, it) {
const G = g.group, p = it.p;
const plat = new Set(
/^Google tag/.test(G) ? ['gtag', 'ga4'] :
/^GA4/.test(G) ? ['ga4'] :
/^Consent/.test(G) ? ['consent'] :
/^Enhanced conversions/.test(G) ? ['ads', 'ga4'] :
/^Google Ads/.test(G) ? ['ads'] :
/^gtag/.test(G) ? ['gtag'] :
/^Server-side/.test(G) ? ['sgtm', 'ga4'] :
/^Meta/.test(G) ? ['meta'] : []);
if (/^(server_container_url|transport_url)$/.test(p)) plat.add('sgtm');
const t = new Set();
if (it.s === 'config' || it.s === 'set') t.add('cfg');
if (it.s === 'event' || /^(GA4 — parametry eventów|GA4 — e-commerce|GA4 — Enhanced|Google Ads|Meta Pixel — parametry)/.test(G)) t.add('evt');
if (/^(GA4 — e-commerce|GA4 — parametry items)/.test(G)) t.add('ecom');
if (/^GA4 — parametry eventów/.test(G) && /^(currency|value)$/.test(p)) t.add('ecom');
if (/^Google Ads/.test(G) && p !== 'send_to') t.add('ecom');
if (/^Meta Pixel — parametry/.test(G) && /^(value|currency|content_ids|content_type|contents|content_name|content_category|num_items)$/.test(p)) t.add('ecom');
if (/^(Enhanced conversions|Meta Pixel — Advanced)/.test(G) || /^(user_id|client_id|user_data|user_properties|external_id|ip_override|ga_session_id|ga_session_number)$/.test(p)) t.add('ud');
if (/^cookie_/.test(p) || /^(client_id|ad_storage|analytics_storage|functionality_storage|personalization_storage|security_storage)$/.test(p)) t.add('ck');
if (/^campaign/.test(p) || /^(ignore_referrer|page_referrer|url_passthrough)$/.test(p)) t.add('utm');
if ((/^gtag/.test(G) && /^(linker|domains|accept_incoming|decorate_forms|url_position)$/.test(p)) || /^(cookie_domain|cookie_flags)$/.test(p)) t.add('xd');
return { plat, topic: t };
}
const PRM_INDEX = TAG_PARAMS.map(g => {
let gw = phraseWords(g.group).concat(wordsOf(g.where));
(g.aliases || []).forEach(a => { gw = gw.concat(phraseWords(a)); });
return {
g,
words: uniq(gw),
items: g.items.map(it => {
let kw = PRM_KEYWORDS[it.p] || '';
PRM_PREFIX_KEYWORDS.forEach(([re, k]) => { if (re.test(it.p)) kw += ' ' + k; });
return {
it,
words: uniq(phraseWords(it.p).concat(wordsOf(it.s), phraseWords(kw))),
vWords: uniq(wordsOf(it.v || '')),
dWords: uniq(wordsOf(it.d)),
tags: prmTags(g, it),
};
}),
};
});
function tokenHit(tok, words) {
return words.some(w => {
if (tok.length <= 2) return w === tok; // ga, fb, ec, am — tylko całe słowo
if (w.startsWith(tok)) return true; // prefiks: "anal" → analytics
if (tok.length >= 5 && w.startsWith(tok.slice(0, -1))) return true; // odmiana: zgoda → zgody, produkty → produkt
return tok.length >= 4 && w.includes(tok); // w środku słowa: "storage"
});
}
// jedno zapytanie (bez |): każde słowo musi trafić w sekcję LUB w parametr;
// wariant sklejony ("ga 4" → ga4, "google analytics" → googleanalytics) też się liczy
function makeMatcher(q, useDesc) {
const toks = wordsOf(q);
if (!toks.length) return () => true;
const compact = toks.join('');
return (gw, iw) => {
const all = useDesc ? gw.concat(iw.words, iw.vWords, iw.dWords) : gw.concat(iw.words, iw.vWords);
if (toks.every(t => tokenHit(t, all))) return true;
return toks.length > 1 && tokenHit(compact, all);
};
}
function searchParams(useDesc) {
const raw = prmQuery;
let test;
if (/[\\^$*+?()[\]{}]/.test(raw)) {
// pełny regex (np. ^item_, _id$) — na surowych nazwach/opisach
let re = null;
try { re = new RegExp(raw, 'i'); } catch (e) {}
if (!re) return [];
test = (e, ix) => [e.g.group, e.g.where, ...(e.g.aliases || []), ix.it.p, ix.it.s, ix.it.v || '', useDesc ? ix.it.d : ''].some(x => re.test(x));
} else {
// "ga4|meta" → OR kilku zapytań, każde z synonimami
const alts = raw.split('|').map(x => x.trim()).filter(Boolean);
const ms = alts.map(a => makeMatcher(a, useDesc));
test = (e, ix) => !ms.length || ms.some(m => m(e.words, ix));
}
const out = [];
PRM_INDEX.forEach(e => e.items.forEach(ix => { if (test(e, ix)) out.push({ e, ix }); }));
return out;
}
// chipy: OR w obrębie rzędu, AND między rzędami (skipRow — do liczników)
function passChips(hit, skipRow) {
return ['plat', 'topic'].every(r =>
r === skipRow || !prmSel[r].size || [...prmSel[r]].some(id => hit.ix.tags[r].has(id)));
}
let prmUsedDesc = false;
let prmLastBase = [];
function countParams() {
prmUsedDesc = false;
let base = searchParams(false);
if (!base.length && prmQuery) { base = searchParams(true); prmUsedDesc = base.length > 0; } // 2. próba: po opisach
prmLastBase = base;
const out = [];
base.filter(h => passChips(h)).forEach(h => {
const last = out[out.length - 1];
if (last && last.g === h.e.g) last.items.push(h.ix.it);
else out.push({ g: h.e.g, items: [h.ix.it] });
});
return out;
}
// rozwijane listy z checkboxami (Platforma ▾ / Temat ▾)
let prmOpenDd = null; // 'plat' | 'topic' | null
function renderPrmChips() {
const box = document.getElementById('tb-prm-chips');
if (!box) return;
box.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'tb-dds';
['plat', 'topic'].forEach(r => {
const def = PRM_CHIPS[r];
const sel = def.items.filter(c => prmSel[r].has(c.id)).map(c => c.label);
const dd = document.createElement('div');
dd.className = 'tb-dd tb-dd-' + r + (prmOpenDd === r ? ' open' : '');
dd.innerHTML =
`<button class="tb-dd-btn${sel.length ? ' active' : ''}" title="${escAttr(sel.join(', ') || 'wszystkie')}">` +
`<span class="tb-dd-lab">${escHtml(def.label)}</span>` +
`<span class="tb-dd-val">${sel.length ? escHtml(sel.length > 1 ? sel[0] + ' +' + (sel.length - 1) : sel[0]) : 'wszystkie'}</span>` +
`<span class="tb-dd-arr">${prmOpenDd === r ? '▴' : '▾'}</span>` +
`</button>` +
`<div class="tb-dd-menu"></div>`;
dd.querySelector('.tb-dd-btn').addEventListener('click', () => {
prmOpenDd = prmOpenDd === r ? null : r;
renderPrmChips();
});
const menu = dd.querySelector('.tb-dd-menu');
def.items.forEach(c => {
const cnt = prmLastBase.filter(h => passChips(h, r) && h.ix.tags[r].has(c.id)).length;
const on = prmSel[r].has(c.id);
const row = document.createElement('div');
row.className = 'tb-frow' + (on ? ' on' : '') + (!cnt && !on ? ' zero' : '');
row.innerHTML = `<div class="tb-chk">✓</div><span class="tb-fname">${escHtml(c.label)}</span><span class="tb-fcnt">${cnt}</span>`;
row.addEventListener('click', () => {
if (on) prmSel[r].delete(c.id); else prmSel[r].add(c.id);
savePrmSel();
renderParams(); // lista zostaje rozwinięta (prmOpenDd)
});
menu.appendChild(row);
});
if (prmSel[r].size) {
const clr = document.createElement('div');
clr.className = 'tb-dd-foot';
clr.textContent = '✕ odznacz wszystko';
clr.addEventListener('click', () => { prmSel[r].clear(); savePrmSel(); renderParams(); });
menu.appendChild(clr);
}
wrap.appendChild(dd);
});
if (prmSel.plat.size || prmSel.topic.size) {
const x = document.createElement('button');
x.className = 'tb-dd-x';
x.textContent = '✕';
x.title = 'Wyczyść kategorie';
x.addEventListener('click', () => {
prmSel.plat.clear(); prmSel.topic.clear(); prmOpenDd = null;
savePrmSel();
renderParams();
});
wrap.appendChild(x);
}
box.appendChild(wrap);
}
// klik poza listą / Esc → zwiń
document.addEventListener('mousedown', e => {
if (prmOpenDd && !(e.target.closest && e.target.closest('.tb-dd'))) { prmOpenDd = null; renderPrmChips(); }
}, true);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && prmOpenDd) { prmOpenDd = null; renderPrmChips(); }
});
function renderParams() {
const list = document.getElementById('tb-prm-list');
if (!list) return;
list.innerHTML = '';
const groups = countParams();
let n = 0;
groups.forEach(({ g, items }) => {
const lab = document.createElement('div');
lab.className = 'tb-label tb-prm-group';
lab.textContent = g.group;
list.appendChild(lab);
const wh = document.createElement('div');
wh.className = 'tb-prm-where';
wh.textContent = '↳ ' + g.where;
list.appendChild(wh);
items.forEach(it => {
n++;
const row = document.createElement('div');
row.className = 'tb-prm';
row.innerHTML =
`<div class="tb-prm-hd">` +
`<span class="tb-prm-name">${escHtml(it.p)}</span>` +
`<span class="tb-prm-sc sc-${escAttr(it.s)}">${escHtml(it.s)}</span>` +
`<span class="tb-copied" style="display:none">✓</span>` +
(it.v ? `<button class="tb-prm-val" title="Kopiuj przykładową wartość: ${escAttr(it.v)}">= ${escHtml(it.v)}</button>` : '') +
`</div>` +
`<div class="tb-prm-desc">${escHtml(it.d)}</div>`;
const flash = txt => {
const c = row.querySelector('.tb-copied');
c.textContent = txt;
c.style.display = '';
setTimeout(() => { c.style.display = 'none'; }, 900);
};
row.addEventListener('click', () => { copyText(it.p); flash('✓ nazwa'); });
const vb = row.querySelector('.tb-prm-val');
if (vb) vb.addEventListener('click', ev => {
ev.stopPropagation();
copyText(it.v);
flash('✓ wartość');
});
list.appendChild(row);
});
});
if (!n) list.innerHTML = '<div class="tb-prm-empty">Nic nie pasuje' + ((prmSel.plat.size || prmSel.topic.size) ? ' — sprawdź zaznaczone kategorie.' : '.') + '</div>';
renderPrmChips();
updateFooter();
}
function paramsCount() {
return countParams().reduce((a, x) => a + x.items.length, 0);
}
function switchTab(tab) {
activeTab = tab;
try { localStorage.setItem(LS_TAB, tab); } catch (e) {}
document.querySelectorAll('#' + WID + ' .tb-tab').forEach(b => b.classList.toggle('on', b.dataset.tab === tab));
document.getElementById('tb-panel-filter').style.display = tab === 'filter' ? '' : 'none';
document.getElementById('tb-panel-params').style.display = tab === 'params' ? '' : 'none';
document.getElementById('tb-clear').style.display = tab === 'filter' ? '' : 'none';
if (tab === 'params') renderParams();
updateFooter();
}
/* ═══════════════════ MODUŁ 11: 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:2147483647;font-family:'Google Sans',system-ui,sans-serif;width:340px;max-height:min(calc(100vh - 24px),var(--tb-h,640px));display:flex;flex-direction:column;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}
.tb-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}}
.tb-logo{width:22px;height:22px;flex-shrink:0}
.tb-logo svg{display:block}
.tb-title{flex:1;font-size:13px;font-weight:600;color:#fff;letter-spacing:.3px}
.tb-badge{font-size:10px;background:${C.border};color:${C.dim};padding:2px 8px;border-radius:10px}
.tb-hdr-btn{background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;opacity:.6;transition:opacity .15s}
.tb-hdr-btn:hover{opacity:1}
.tb-hdr{flex:none}
.tb-wrap{overflow:hidden;transition:max-height .25s ease;flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
.tb-wrap.open{max-height:none}.tb-wrap.closed{max-height:0!important;flex:0 0 0}
/* zminimalizowany do paska: zostaje sam nagłówek, znika stopka i uchwyt wysokości */
#${WID}.tb-min{height:auto!important;max-height:none!important}
#${WID}.tb-min .tb-wrap,#${WID}.tb-min .tb-foot,#${WID}.tb-min .tb-resize{display:none!important}
#${WID}.tb-min .tb-hdr{border-bottom:none}
.tb-tabs{flex:none}
.tb-panel{padding:10px 14px;flex:1 1 auto;min-height:0;overflow-y:auto}
.tb-foot{flex:none}
.tb-panel::-webkit-scrollbar{width:5px}.tb-panel::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
.tb-label{font-size:10px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
.tb-input-wrap{position:relative;margin-bottom:8px}
.tb-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}
.tb-input:focus{border-color:${C.accent}}
.tb-input::placeholder{color:#6b7280}
.tb-input.tb-input--regex{font-family:'Consolas','Monaco','Courier New',monospace;border-color:#7c3aed}
.tb-input.tb-input--regex:focus{border-color:#a78bfa}
.tb-input.tb-input--error{border-color:${C.red}!important}
.tb-search-mode{position:absolute;right:6px;top:50%;transform:translateY(-50%);font-size:9px;padding:2px 6px;border-radius:4px;font-weight:700;letter-spacing:.3px;border:none;background:none;pointer-events:none}
.tb-search-mode--text{background:${C.border};color:${C.dim}}
.tb-search-mode--clear{background:${C.border};color:${C.text};pointer-events:auto;cursor:pointer}
.tb-search-mode--clear:hover{background:#4b5563}
.tb-search-mode--regex{background:#7c3aed;color:#fff;pointer-events:auto;cursor:pointer}
.tb-search-mode--regex:hover{background:#6d28d9}
.tb-search-mode--id{background:#0e7490;color:#fff;pointer-events:auto;cursor:pointer}
.tb-search-mode--id:hover{background:#155e75}
.tb-input.tb-input--id{font-family:'Consolas','Monaco','Courier New',monospace;border-color:#0e7490}
.tb-idtoggle{display:flex;align-items:center;gap:9px;padding:3px 4px;cursor:pointer;border-radius:6px;margin:0 0 1px}
.tb-idtoggle:hover{background:${C.hover}}
.tb-idtoggle.on .tb-chk{background:${C.accent};border-color:${C.accent};color:#fff}
.tb-idtoggle.tb-filt.on .tb-chk{background:${C.red};border-color:${C.red}}
#tb-paused-toggle .tb-chk{font-size:9px}
#tb-paused-toggle.on .tb-chk{background:${C.yellow};border-color:${C.yellow};color:#111}
.tb-idtoggle span{font-size:11px;color:${C.text}}
.tb-idtoggle + .tb-idtoggle{margin-top:0}
.tb-acc-hd{display:flex;align-items:center;gap:6px;margin:10px 0 6px;padding:4px 4px;border-radius:6px;cursor:pointer}
.tb-acc-hd:hover{background:${C.hover}}
.tb-acc-arr{display:inline-block;font-size:10px;color:${C.dim};width:10px;text-align:center;transition:transform .15s}
.tb-acc-hd.open .tb-acc-arr{transform:rotate(90deg)}
.tb-acc-on{font-size:9px;font-weight:700;color:#fff;background:${C.accent};padding:1px 6px;border-radius:7px;text-transform:uppercase;letter-spacing:.3px}
.tb-search-mode--error{background:${C.red};color:#fff;pointer-events:auto;cursor:pointer}
.tb-search-mode--error:hover{background:#dc2626}
.tb-input-sm{width:100%;background:${C.border};border:1px solid #4b5563;color:${C.text};padding:5px 8px;border-radius:6px;font-size:11px;outline:none;font-family:inherit;margin-bottom:6px}
.tb-input-sm:focus{border-color:${C.accent}}
.tb-input-sm::placeholder{color:#6b7280}
.tb-search-error{font-size:10px;color:${C.red};margin:-4px 0 6px 2px;line-height:1.3}
.tb-search-hint{font-size:10px;color:${C.dim};margin:-4px 0 6px 2px;line-height:1.3}
.tb-frow{display:flex;align-items:center;gap:9px;padding:4px 4px;cursor:pointer;transition:background .08s;border-radius:6px}
.tb-frow:hover{background:${C.hover}}
.tb-chk{width:16px;height:16px;border-radius:4px;border:2px solid ${C.border};display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .12s;font-size:10px;color:transparent}
.tb-frow.on .tb-chk{background:${C.accent};border-color:${C.accent};color:#fff}
.tb-dot{width:7px;height:7px;border-radius:2px;flex-shrink:0}
.tb-fname{flex:1;font-size:12px;color:${C.text};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tb-fcnt{font-size:10px;color:${C.dim};background:${C.border};padding:1px 7px;border-radius:7px;min-width:20px;text-align:center}
.tb-note{font-size:10px;color:${C.yellow};margin:4px 0 0 2px;line-height:1.4}
.tb-foot{border-top:1px solid ${C.border};padding:7px 14px 9px;display:flex;justify-content:space-between;align-items:center;position:relative}
/* dolna krawędź okna = uchwyt zmiany wysokości */
.tb-resize{position:absolute;left:0;right:0;bottom:0;height:7px;cursor:ns-resize;z-index:2;border-radius:0 0 14px 14px}
.tb-resize::before{content:'';position:absolute;left:50%;bottom:2px;transform:translateX(-50%);width:36px;height:3px;border-radius:2px;background:${C.border};transition:background .12s}
.tb-resize:hover::before{background:${C.accent}}
.tb-foot-txt{font-size:10px;color:${C.dim}}
.tb-foot-btn{font-size:10px;color:${C.accent};background:none;border:none;cursor:pointer;font-weight:600;padding:2px 4px;border-radius:4px}
.tb-foot-btn:hover{background:rgba(59,130,246,.12)}
.tb-foot-btn.on{background:${C.accent};color:#fff}
.tb-tabs{display:flex;border-bottom:1px solid ${C.border}}
.tb-tab{flex:1;background:none;border:none;color:${C.dim};font-size:11px;font-weight:600;padding:8px 4px;cursor:pointer;font-family:inherit;border-bottom:2px solid transparent;transition:all .15s}
.tb-tab:hover{color:${C.text};background:${C.hover}}
.tb-tab.on{color:${C.accent};border-bottom-color:${C.accent}}
.tb-prm-group{margin-top:10px}
.tb-prm-group:first-child{margin-top:0}
.tb-prm-where{font-size:9px;color:#94a3b8;margin:-2px 0 4px 2px;line-height:1.35}
.tb-prm{padding:5px 7px;border-radius:7px;cursor:pointer;transition:background .1s;margin-bottom:1px}
.tb-prm:hover{background:${C.hover}}
.tb-prm-hd{display:flex;align-items:center;gap:6px;min-width:0}
.tb-prm-name{font-family:'Consolas','Monaco',monospace;font-size:11px;font-weight:700;color:#bfdbfe;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.tb-prm-desc{font-size:10px;color:${C.dim};line-height:1.35}
.tb-copied{color:${C.green};font-size:10px;font-weight:700;flex-shrink:0}
.tb-prm-sc{font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;padding:1px 5px;border-radius:5px;flex-shrink:0;background:rgba(107,114,128,.2);color:${C.dim}}
.tb-prm-sc.sc-config{background:rgba(59,130,246,.15);color:#93c5fd}
.tb-prm-sc.sc-event{background:rgba(34,197,94,.13);color:${C.green}}
.tb-prm-sc.sc-item{background:rgba(234,179,8,.13);color:${C.yellow}}
.tb-prm-sc.sc-consent{background:rgba(192,132,252,.15);color:#c084fc}
.tb-prm-sc.sc-set{background:rgba(192,132,252,.1);color:#d8b4fe}
.tb-prm-sc.sc-user_data{background:rgba(244,114,182,.13);color:#f472b6}
.tb-prm-sc.sc-ads{background:rgba(251,146,60,.13);color:#fb923c}
.tb-prm-sc.sc-gtag{background:rgba(45,212,191,.13);color:#2dd4bf}
.tb-prm-sc.sc-sgtm{background:rgba(248,113,113,.13);color:#f87171}
.tb-prm-sc.sc-meta{background:rgba(96,165,250,.13);color:#60a5fa}
.tb-prm-val{margin-left:auto;background:${C.border};border:none;color:${C.dim};font-family:'Consolas','Monaco',monospace;font-size:9px;padding:1px 6px;border-radius:5px;cursor:pointer;max-width:130px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:1;transition:.1s}
.tb-prm-val:hover{color:${C.text};background:#4b5563}
.tb-dds{display:flex;gap:6px;align-items:stretch;margin-bottom:4px}
.tb-dd{position:relative;flex:1;min-width:0}
.tb-dd-btn{width:100%;display:flex;align-items:center;gap:5px;background:${C.border};border:1px solid #4b5563;color:${C.text};padding:5px 8px;border-radius:7px;font-size:11px;cursor:pointer;font-family:inherit;text-align:left;line-height:1.3}
.tb-dd-btn:hover,.tb-dd.open .tb-dd-btn{border-color:${C.accent}}
.tb-dd-btn.active{border-color:${C.accent};background:rgba(59,130,246,.18)}
.tb-dd-lab{color:${C.dim};font-size:10px;font-weight:600;flex-shrink:0}
.tb-dd-val{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
.tb-dd-btn:not(.active) .tb-dd-val{color:#9ca3af;font-weight:400}
.tb-dd-arr{color:${C.dim};font-size:9px;flex-shrink:0}
.tb-dd-menu{display:none;position:absolute;top:calc(100% + 4px);left:0;min-width:175px;background:${C.bg};border:1px solid ${C.border};border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px;z-index:5}
.tb-dd-topic .tb-dd-menu{left:auto;right:0}
.tb-dd.open .tb-dd-menu{display:block}
.tb-dd-menu .tb-frow{padding:5px 6px}
.tb-frow.zero{opacity:.4}
.tb-dd-foot{font-size:10px;color:${C.dim};padding:6px 6px 3px;margin-top:2px;border-top:1px solid ${C.border};cursor:pointer}
.tb-dd-foot:hover{color:${C.red}}
.tb-dd-x{flex-shrink:0;background:${C.border};border:1px solid #4b5563;color:${C.dim};border-radius:7px;padding:0 8px;cursor:pointer;font-size:11px;font-family:inherit}
.tb-dd-x:hover{color:${C.red};border-color:${C.red}}
.tb-prm-empty{font-size:12px;color:${C.dim};text-align:center;padding:14px 8px}
</style>
<div class="tb-hdr" id="tb-hdr">
<div class="tb-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="#4285F4"/><path d="M96 36L140 96l-44 60-44-60L96 36z" fill="#fff" opacity=".9"/><circle cx="96" cy="96" r="20" fill="#fff"/></svg></div>
<span class="tb-title">GTM Filter</span>
<span class="tb-badge" id="tb-badge">—</span>
<button class="tb-hdr-btn" id="tb-col">▾</button>
</div>
<div class="tb-wrap ${collapsed ? 'closed' : 'open'}" id="tb-wrap">
<div class="tb-tabs">
<button class="tb-tab" data-tab="filter">🔎 Filtr</button>
<button class="tb-tab" data-tab="params" title="Standardowe parametry tagów: Google tag, GA4, Consent, Ads, Meta…">🏷️ Parametry</button>
</div>
<div class="tb-panel" id="tb-panel-filter">
<div class="tb-label">Szukaj po nazwie</div>
<div style="font-size:10px;color:#9ca3af;margin:-2px 0 5px 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="Regex: GA4|FB|cHTML · ID: 1|2, 100-120, >50 albo URL elementu">regex, np. GA4|FB|cHTML · ID, np. 1|2 · 100-120 · >50</div>
<div class="tb-input-wrap">
<input class="tb-input" id="tb-name-search" style="padding-right:38px">
<span class="tb-search-mode tb-search-mode--text" id="tb-search-mode">TEXT</span>
</div>
<div id="tb-search-hint" class="tb-search-hint" style="display:none"></div>
<div class="tb-acc-hd" id="tb-opt-acc-hd" style="margin-top:4px">
<span class="tb-acc-arr">▸</span>
<span class="tb-label" style="margin:0;flex:1">Opcje wyświetlania</span>
<span class="tb-acc-on" id="tb-opt-acc-on" style="display:none"></span>
</div>
<div class="tb-acc-body" id="tb-opt-acc-body" style="display:none">
<div class="tb-idtoggle${showIds ? ' on' : ''}" id="tb-ids-toggle" title="Dokleja #ID przy nazwach tagów / reguł / zmiennych (klik w #ID = kopiuj)"><div class="tb-chk">✓</div><span>Pokaż #ID na liście</span></div>
<div class="tb-idtoggle${showVendorIds ? ' on' : ''}" id="tb-vids-toggle" title="G-…, AW-…, GT-…, GTM-…, piksel Meta, {{zmienna}} z ID — z ustawień tagu i z Custom HTML (klik = kopiuj). Tagi i zmienne."><div class="tb-chk">✓</div><span>Pokaż ID vendorów</span></div>
<div class="tb-idtoggle${showFiring ? ' on' : ''}" id="tb-fire-toggle" title="Opcja uruchamiania tagu: raz na zdarzenie / raz na stronę / bez limitu. Tylko tagi."><div class="tb-chk">✓</div><span>Pokaż opcję uruchamiania</span></div>
<div class="tb-idtoggle${showEvents ? ' on' : ''}" id="tb-evt-toggle" title="Nazwa eventu (GA4, Meta, Custom HTML…) i liczba parametrów; klik w „N param.” rozwija listę pod nazwą tagu."><div class="tb-chk">✓</div><span>Pokaż event i parametry</span></div>
<div class="tb-idtoggle${showConsent ? ' on' : ''}" id="tb-consent-toggle" title="Ustawienia zgody tagu (Consent Settings): wymagane dodatkowe zgody, „bez dodatkowych” albo „nie ustawiono”. Tylko tagi."><div class="tb-chk">✓</div><span>Pokaż consent</span></div>
<div class="tb-idtoggle${showVarUses ? ' on' : ''}" id="tb-uses-toggle" title="Na liście Zmiennych: kolumna z liczbą tagów, w których użyto zmiennej (+N = reguły / inne zmienne). 0 na czerwono = zmienna nieużywana."><div class="tb-chk">✓</div><span>Kolumna „Tagi” przy zmiennych</span></div>
</div>
<div class="tb-note" id="tb-meta-note" style="display:none;margin:-2px 0 6px 4px"></div>
<div class="tb-label" style="margin-top:8px" id="tb-filt-label">Filtry</div>
<div class="tb-idtoggle tb-filt" id="tb-notrig-toggle" title="Tagi bez żadnej reguły uruchamiającej (nigdy się nie odpalą)."><div class="tb-chk">✓</div><span>Tylko bez reguły uruchamiającej</span><span class="tb-fcnt" id="tb-notrig-cnt" style="margin-left:auto"></span></div>
<div class="tb-idtoggle tb-filt" id="tb-active-toggle" title="Tagi: ukryj zapauzowane."><div class="tb-chk">✓</div><span>Tylko niezapauzowane</span></div>
<div class="tb-idtoggle tb-filt" id="tb-paused-toggle" title="Tagi: pokaż tylko zapauzowane (Pause w GTM). Licznik po prawej = ile tagów jest zapauzowanych."><div class="tb-chk">⏸</div><span>Tylko zapauzowane</span><span class="tb-fcnt" id="tb-active-cnt" style="margin-left:auto"></span></div>
<div class="tb-idtoggle tb-filt" id="tb-dup-toggle" title="Tagi: tylko te, które wysyłają ten sam event co inny tag tego samego typu (np. dwa tagi GA4 Event z eventem purchase). Zapauzowane też się liczą; grupy z ≥2 aktywnymi tagami są na czerwono. Porównanie po nazwie eventu, nie po nazwie tagu; duplikaty są grupowane jeden pod drugim. Licznik = ile tagów ma duplikat."><div class="tb-chk">✓</div><span>Tylko zduplikowane</span><span class="tb-fcnt" id="tb-dup-cnt" style="margin-left:auto"></span></div>
<div class="tb-idtoggle tb-filt" id="tb-unused-toggle" title="Zmienne: tylko te, których nie używa żaden tag, reguła, zmienna (w SST także klient / transformacja). Reguły: tylko te, których nie używa żaden tag (ani jako uruchamiająca, ani jako wyjątek)."><div class="tb-chk">✓</div><span>Tylko nieużywane (0 użyć)</span><span class="tb-fcnt" id="tb-unused-cnt" style="margin-left:auto"></span></div>
<div class="tb-label" style="margin-top:6px">Filtruj po typie</div>
<div id="tb-type-list"></div>
<div id="tb-folder-section" style="display:none">
<div class="tb-label" style="margin-top:10px">Filtruj po folderze</div>
<div id="tb-folder-list"></div>
</div>
<div id="tb-trig-section" style="display:none">
<div class="tb-acc-hd" id="tb-trig-acc-hd">
<span class="tb-acc-arr" id="tb-trig-acc-arr">▸</span>
<span class="tb-label" style="margin:0;flex:1">Reguły uruchamiające</span>
<span class="tb-acc-on" id="tb-trig-acc-on" style="display:none">aktywny</span>
</div>
<div class="tb-acc-body" id="tb-trig-acc-body" style="display:none">
<div class="tb-label">Szukaj po nazwie reguły uruchamiającej</div>
<div style="font-size:10px;color:#9ca3af;margin:-2px 0 5px 2px">obsługuje regex, np. CE - |Click · ID reguły, np. 9|12</div>
<div class="tb-input-wrap">
<input class="tb-input" id="tb-trig-search" style="padding-right:38px">
<span class="tb-search-mode tb-search-mode--text" id="tb-trig-mode">TEXT</span>
</div>
<div id="tb-trig-hint" class="tb-search-hint" style="display:none"></div>
<div class="tb-label" style="margin-top:6px">Filtruj po typie reguły uruchamiającej</div>
<div id="tb-trig-list"></div>
<div class="tb-note" id="tb-trig-note" style="display:none"></div>
</div>
</div>
</div>
<div class="tb-panel" id="tb-panel-params" style="display:none">
<div class="tb-input-wrap" style="margin-bottom:5px">
<input class="tb-input" id="tb-prm-q" placeholder="np. Google Analytics, GA4, zgody, FB, ecommerce, meta|ads…" style="padding-right:30px">
<span class="tb-search-mode tb-search-mode--clear" id="tb-prm-clear" style="display:none">✕</span>
</div>
<div id="tb-prm-chips"></div>
<div class="tb-search-hint" style="margin:2px 0 8px 2px">Klik w wiersz = kopiuje <b style="color:#9ca3af">nazwę</b> · klik w <b style="color:#9ca3af">= …</b> = przykładowa wartość</div>
<div id="tb-prm-list"></div>
</div>
<div class="tb-foot">
<span class="tb-foot-txt" id="tb-foot-txt"></span>
<span style="display:flex;gap:2px;align-items:center">
<button class="tb-foot-btn${tidy ? ' on' : ''}" id="tb-tidy" title="Porządkuj (Tagi): zapauzowane tagi na szaro i na samym dole listy. Klik = włącz / wyłącz.">⇅ Porządkuj</button>
<button class="tb-foot-btn" id="tb-refresh" title="Pobierz ponownie dane tagów / zmiennych / reguł z GTM (ID vendorów, opcja uruchamiania, kolumna Tagi). Odświeża się też samo po zapisie zmian.">⟳ Odśwież</button>
<button class="tb-foot-btn" id="tb-clear" title="Wyczyść wszystko: filtry, wyszukiwarki i opcje wyświetlania (#ID, ID vendorów, event, consent…)">Wyczyść</button>
</span>
</div>
</div>
<div class="tb-resize" id="tb-resize" title="Przeciągnij dolną krawędź, aby zmienić wysokość okna (dwuklik = domyślna)"></div>
`;
document.body.appendChild(widget);
widget.addEventListener('click', () => bumpUi(), true);
widget.addEventListener('input', () => bumpUi(), true);
const colBtn = document.getElementById('tb-col');
// strzałka: zwija cały widget do samego paska nagłówka (stan zapamiętany)
function applyCollapsed() {
widget.classList.toggle('tb-min', collapsed);
document.getElementById('tb-wrap').classList.toggle('open', !collapsed);
document.getElementById('tb-wrap').classList.toggle('closed', collapsed);
colBtn.textContent = collapsed ? '▸' : '▾';
colBtn.title = collapsed ? 'Rozwiń widget' : 'Zwiń do paska';
}
applyCollapsed();
colBtn.addEventListener('click', (e) => {
e.stopPropagation();
collapsed = !collapsed; try { localStorage.setItem(LS_COL, collapsed ? '1' : '0'); } catch (x) {}
updateExpandDirection();
applyCollapsed();
// po rozwinięciu okno może nie mieścić się na ekranie — dosuwamy
keepOnScreen();
});
document.getElementById('tb-name-search').addEventListener('input', (e) => {
compileSearch(e.target.value);
updateSearchIndicator();
applyAllFilters();
updateFooter();
});
document.getElementById('tb-search-mode').addEventListener('click', () => {
if (!nameSearch) return;
const input = document.getElementById('tb-name-search');
input.value = '';
compileSearch('');
updateSearchIndicator();
applyAllFilters();
updateFooter();
input.focus();
});
const idsToggle = document.getElementById('tb-ids-toggle');
idsToggle.addEventListener('click', () => {
showIds = !showIds;
try { localStorage.setItem(LS_IDS, showIds ? '1' : '0'); } catch (e) {}
idsToggle.classList.toggle('on', showIds);
renderIdBadges();
});
const vidsToggle = document.getElementById('tb-vids-toggle');
vidsToggle.addEventListener('click', () => {
showVendorIds = !showVendorIds;
try { localStorage.setItem(LS_VIDS, showVendorIds ? '1' : '0'); } catch (e) {}
vidsToggle.classList.toggle('on', showVendorIds);
renderIdBadges();
});
const unusedToggle = document.getElementById('tb-unused-toggle');
unusedToggle.addEventListener('click', () => {
onlyUnused = !onlyUnused;
unusedToggle.classList.toggle('on', onlyUnused);
applyAllFilters();
updateFooter();
});
// dwa checkboxy wykluczające się: włączenie jednego wyłącza drugi
document.getElementById('tb-active-toggle').addEventListener('click', () => {
pausedMode = pausedMode === 1 ? 0 : 1;
setDup(false);
renderPausedToggle(); applyAllFilters(); renderIdBadges(); updateFooter();
});
// trzy filtry wykluczające się (jak radio): niezapauzowane / zapauzowane / zduplikowane
const setDup = v => { onlyDup = v; document.getElementById('tb-dup-toggle').classList.toggle('on', v); };
document.getElementById('tb-dup-toggle').addEventListener('click', () => {
const v = !onlyDup;
if (currentSection === 'tags') { pausedMode = 0; renderPausedToggle(); }
setDup(v);
applyAllFilters(); renderIdBadges(); updateFooter();
});
document.getElementById('tb-paused-toggle').addEventListener('click', () => {
pausedMode = pausedMode === 2 ? 0 : 2;
setDup(false);
renderPausedToggle(); applyAllFilters(); renderIdBadges(); updateFooter();
});
const usesToggle = document.getElementById('tb-uses-toggle');
usesToggle.addEventListener('click', () => {
showVarUses = !showVarUses;
try { localStorage.setItem(LS_VAR_USES, showVarUses ? '1' : '0'); } catch (e) {}
usesToggle.classList.toggle('on', showVarUses);
renderIdBadges();
});
const consentToggle = document.getElementById('tb-consent-toggle');
consentToggle.addEventListener('click', () => {
showConsent = !showConsent;
try { localStorage.setItem(LS_CONSENT, showConsent ? '1' : '0'); } catch (e) {}
consentToggle.classList.toggle('on', showConsent);
renderIdBadges();
});
document.getElementById('tb-opt-acc-hd').addEventListener('click', () => {
optAccOpen = !optAccOpen;
try { localStorage.setItem(LS_OPT_ACC, optAccOpen ? '1' : '0'); } catch (e) {}
updateOptAcc();
});
updateOptAcc();
document.getElementById('tb-notrig-toggle').addEventListener('click', () => {
onlyNoTrig = !onlyNoTrig;
document.getElementById('tb-notrig-toggle').classList.toggle('on', onlyNoTrig);
applyAllFilters(); updateFooter();
});
const evtToggle = document.getElementById('tb-evt-toggle');
evtToggle.addEventListener('click', () => {
showEvents = !showEvents;
try { localStorage.setItem(LS_EVT, showEvents ? '1' : '0'); } catch (e) {}
evtToggle.classList.toggle('on', showEvents);
renderIdBadges();
});
const fireToggle = document.getElementById('tb-fire-toggle');
fireToggle.addEventListener('click', () => {
showFiring = !showFiring;
try { localStorage.setItem(LS_FIRE, showFiring ? '1' : '0'); } catch (e) {}
fireToggle.classList.toggle('on', showFiring);
renderIdBadges();
});
document.getElementById('tb-trig-acc-hd').addEventListener('click', () => {
trigAccOpen = !trigAccOpen;
try { localStorage.setItem(LS_TRIG_ACC, trigAccOpen ? '1' : '0'); } catch (e) {}
updateTrigAcc();
});
updateTrigAcc();
updateSectionToggles();
document.getElementById('tb-trig-search').addEventListener('input', (e) => {
compileTrigSearch(e.target.value);
updateTrigSearchIndicator();
applyAllFilters();
updateFooter();
});
document.getElementById('tb-trig-mode').addEventListener('click', () => {
if (!trigNameSearch) return;
const input = document.getElementById('tb-trig-search');
input.value = '';
compileTrigSearch('');
updateTrigSearchIndicator();
applyAllFilters();
updateFooter();
input.focus();
});
const tidyBtn = document.getElementById('tb-tidy');
tidyBtn.addEventListener('click', () => {
tidy = !tidy;
try { localStorage.setItem(LS_TIDY, tidy ? '1' : '0'); } catch (e) {}
tidyBtn.classList.toggle('on', tidy);
renderIdBadges(); syncOrder(); updateFooter();
});
const refBtn = document.getElementById('tb-refresh');
refBtn.addEventListener('click', () => {
invalidateApi(0);
refBtn.textContent = '⟳ …';
setTimeout(() => { refBtn.textContent = '⟳ Odśwież'; }, 1200);
});
document.getElementById('tb-clear').addEventListener('click', () => {
clearAllFilters();
document.getElementById('tb-name-search').value = '';
document.getElementById('tb-trig-search').value = '';
// odznacz też wszystkie opcje wyświetlania (stan zapamiętany → wyłączone)
const opts = [
['tb-ids-toggle', () => { showIds = false; }, LS_IDS],
['tb-vids-toggle', () => { showVendorIds = false; }, LS_VIDS],
['tb-fire-toggle', () => { showFiring = false; }, LS_FIRE],
['tb-evt-toggle', () => { showEvents = false; }, LS_EVT],
['tb-consent-toggle', () => { showConsent = false; }, LS_CONSENT],
['tb-uses-toggle', () => { showVarUses = false; }, LS_VAR_USES],
];
opts.forEach(([id, off, key]) => { off(); const el = document.getElementById(id); if (el) el.classList.remove('on'); try { localStorage.setItem(key, '0'); } catch (e) {} });
openParams.clear();
updateSearchIndicator();
updateTrigSearchIndicator();
renderFilterTab();
renderTrigTypeList();
renderFolderList();
renderIdBadges();
updateFooter();
});
widget.querySelectorAll('.tb-tab').forEach(b => b.addEventListener('click', () => switchTab(b.dataset.tab)));
const prmQ = document.getElementById('tb-prm-q');
const prmClear = document.getElementById('tb-prm-clear');
prmQ.addEventListener('input', () => {
prmQuery = prmQ.value.trim();
prmClear.style.display = prmQuery ? '' : 'none';
renderParams();
});
prmClear.addEventListener('click', () => {
prmQ.value = ''; prmQuery = '';
prmClear.style.display = 'none';
renderParams();
prmQ.focus();
});
makeDraggable(widget, document.getElementById('tb-hdr'));
// uchwyt wysokości w stopce: ciągnięcie w dół = wyżej okno (zakotwiczone górą podczas zmiany)
(function () {
const grip = document.getElementById('tb-resize');
grip.addEventListener('dblclick', e => { e.preventDefault(); e.stopPropagation(); widget.style.removeProperty('--tb-h'); widget.style.height = ''; try { localStorage.removeItem(LS_H); } catch (x) {} keepOnScreen(); });
grip.addEventListener('mousedown', e => {
e.preventDefault(); e.stopPropagation();
const r = widget.getBoundingClientRect();
widget.style.top = r.top + 'px'; widget.style.bottom = 'auto'; widget.style.left = r.left + 'px'; widget.style.right = 'auto';
const sy = e.clientY, sh = r.height;
const mv = ev => {
const h = Math.max(240, Math.min(window.innerHeight - 16, sh + (ev.clientY - sy)));
widget.style.setProperty('--tb-h', h + 'px');
widget.style.height = h + 'px';
// gdy dół wyszedłby poza ekran — przesuwamy okno w górę (rośnie „do góry”)
const top = Math.max(8, Math.min(r.top, window.innerHeight - 8 - h));
widget.style.top = top + 'px';
};
const up = () => {
document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up);
try { const r2 = widget.getBoundingClientRect(); localStorage.setItem(LS_H, String(Math.round(r2.height))); localStorage.setItem(LS_POS, JSON.stringify({ l: Math.round(r2.left), t: Math.round(r2.top) })); } catch (x) {}
};
document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up);
});
})();
try {
const h = parseInt(localStorage.getItem(LS_H) || '', 10);
if (h >= 240) { widget.style.setProperty('--tb-h', h + 'px'); widget.style.height = 'min(calc(100vh - 24px), ' + h + 'px)'; }
const pos = JSON.parse(localStorage.getItem(LS_POS) || 'null');
if (pos && typeof pos.l === 'number' && typeof pos.t === 'number') {
widget.style.left = pos.l + 'px'; widget.style.top = pos.t + 'px'; widget.style.right = 'auto'; widget.style.bottom = 'auto';
keepOnScreen();
}
} catch (e) {}
switchTab(activeTab);
}
function updateSearchIndicator() {
const input = document.getElementById('tb-name-search');
const badge = document.getElementById('tb-search-mode');
const hint = document.getElementById('tb-search-hint');
input.classList.remove('tb-input--regex', 'tb-input--error', 'tb-input--id');
badge.className = 'tb-search-mode';
hint.style.display = 'none';
if (!nameSearch) {
badge.className = 'tb-search-mode tb-search-mode--text';
badge.textContent = 'TEXT';
return;
}
if (nameSearchIds) {
input.classList.add('tb-input--id');
badge.className = 'tb-search-mode tb-search-mode--id';
badge.textContent = 'ID';
badge.title = 'Wyczyść';
hint.style.display = 'block';
hint.className = 'tb-search-hint';
hint.textContent = 'Tryb ID · liczbę w nazwie znajdziesz regexem, np. (404)';
} else if (nameSearchError) {
input.classList.add('tb-input--regex', 'tb-input--error');
badge.className = 'tb-search-mode tb-search-mode--error';
badge.textContent = '✕';
hint.style.display = 'block';
hint.className = 'tb-search-error';
hint.textContent = nameSearchError;
} else if (nameSearchRegex) {
input.classList.add('tb-input--regex');
badge.className = 'tb-search-mode tb-search-mode--regex';
badge.textContent = '✕';
hint.style.display = 'block';
hint.className = 'tb-search-hint';
hint.textContent = 'Tryb regex · użyj | jako OR, np. GA4|FB|cHTML';
} else {
badge.className = 'tb-search-mode tb-search-mode--clear';
badge.textContent = '✕';
}
}
// Opcje widoczne tylko tam, gdzie mają sens: ID vendorów / opcja uruchamiania → Tagi, kolumna „Tagi” → Zmienne
function renderPausedToggle() {
const a = document.getElementById('tb-active-toggle'), z = document.getElementById('tb-paused-toggle');
if (a) a.classList.toggle('on', pausedMode === 1);
if (z) z.classList.toggle('on', pausedMode === 2);
}
function updateSectionToggles() {
const show = (id, on) => { const el = document.getElementById(id); if (el) el.style.display = on ? '' : 'none'; };
show('tb-vids-toggle', currentSection === 'tags');
show('tb-fire-toggle', currentSection === 'tags');
show('tb-evt-toggle', currentSection === 'tags' || currentSection === 'triggers' || currentSection === 'variables');
{
const el = document.getElementById('tb-evt-toggle');
const L = {
tags: ['Pokaż event i parametry', 'Nazwa eventu (GA4, Meta, Custom HTML…) i liczba parametrów; klik w „N param.” rozwija listę pod nazwą tagu. Klik w nazwę eventu = edycja.'],
triggers: ['Pokaż nazwę eventu', 'Nazwa eventu z warunku Custom Event ({{_event}} równa się / pasuje do regex) przy nazwie reguły. Klik = kopiuj.'],
variables: ['Pokaż wartość i parametry', 'Wartość zmiennej (stała, klucz dataLayer, nazwa cookie, tabela…) i pozostałe parametry; klik w wartość = edycja, „N param.” rozwija listę.'],
}[currentSection];
if (el && L) { el.querySelector('span').textContent = L[0]; el.title = L[1]; }
}
show('tb-consent-toggle', currentSection === 'tags');
show('tb-notrig-toggle', currentSection === 'tags');
show('tb-tidy', currentSection === 'tags');
updateOptAcc();
renderFolderList();
show('tb-uses-toggle', currentSection === 'variables');
show('tb-unused-toggle', currentSection === 'variables' || currentSection === 'triggers');
show('tb-active-toggle', currentSection === 'tags');
show('tb-paused-toggle', currentSection === 'tags');
show('tb-dup-toggle', currentSection === 'tags' || currentSection === 'variables');
const dl = document.querySelector('#tb-dup-toggle > span:not(.tb-fcnt)');
if (dl) dl.textContent = currentSection === 'variables' ? 'Tylko zduplikowane zmienne' : 'Tylko zduplikowane eventy';
if (currentSection !== 'tags') show('tb-meta-note', false);
// liczniki (pętle po wszystkich wierszach) — tylko gdy zmieniły się dane, wiersze albo ustawienia
const rowsC = getDataRows();
const ck = [currentSection, rowsC.length, dataGen, uiGen, hydrating.size, Object.values(apiStores).map(st => st.at).join(',')].join('|');
if (ck === lastCountKey) return;
lastCountKey = ck;
const nc = document.getElementById('tb-notrig-cnt');
if (nc) {
if (currentSection === 'tags') {
let n = 0, pending = false;
getDataRows().forEach(r => { const v = isRowNoTrigger(r); if (v === null) pending = true; else if (v) n++; });
nc.textContent = pending ? '…' : String(n);
nc.style.color = n ? C.red : '';
} else nc.textContent = '';
}
const dc = document.getElementById('tb-dup-cnt');
if (dc) {
if (currentSection === 'tags' || currentSection === 'variables') {
let n = 0, hot = 0, pending = false;
getDataRows().forEach(r => { const di = rowDupInfo(r); if (!di) pending = true; else if (di.dup) { n++; if (di.hot) hot++; } });
dc.textContent = pending ? '…' : (hot ? n + ' · ⚠' + hot : String(n));
dc.style.color = hot ? C.red : (n ? C.yellow : '');
dc.title = hot ? hot + ' tagów wysyła ten sam event co inny AKTYWNY tag (podwójne zdarzenia!) · ' + n + ' duplikatów łącznie' : (n ? n + ' duplikatów, ale w każdej parze co najwyżej jeden tag jest aktywny' : '');
} else dc.textContent = '';
}
const ac = document.getElementById('tb-active-cnt');
if (ac) {
if (currentSection === 'tags') {
let n = 0, pending = false;
getDataRows().forEach(r => { const pz = isRowPaused(r); if (pz === null) pending = true; else if (pz) n++; });
ac.textContent = pending ? '…' : (n ? n + ' ⏸' : '0');
ac.style.color = n ? C.yellow : '';
} else ac.textContent = '';
}
const uc = document.getElementById('tb-unused-cnt');
if (uc) {
if (currentSection === 'variables' || currentSection === 'triggers') {
const rows = getDataRows();
let n = 0, pending = false;
rows.forEach(r => { const u = isRowUnused(r); if (u === null) pending = true; else if (u) n++; });
uc.textContent = pending ? '…' : String(n);
uc.style.color = n ? C.red : '';
} else uc.textContent = '';
}
}
let lastCountKey = '';
function updateOptAcc() {
const hd = document.getElementById('tb-opt-acc-hd'), body = document.getElementById('tb-opt-acc-body'), on = document.getElementById('tb-opt-acc-on');
if (!hd || !body) return;
hd.classList.toggle('open', optAccOpen);
body.style.display = optAccOpen ? '' : 'none';
// ile opcji włączonych (widocznych w tej sekcji) — plakietka na zwiniętej harmonijce
const ids = ['tb-ids-toggle', 'tb-vids-toggle', 'tb-fire-toggle', 'tb-evt-toggle', 'tb-consent-toggle', 'tb-uses-toggle'];
const n = ids.filter(id => { const el = document.getElementById(id); return el && el.style.display !== 'none' && el.classList.contains('on'); }).length;
on.textContent = n ? 'włączone: ' + n : '';
on.style.display = (!optAccOpen && n) ? '' : 'none';
}
let folderListKey = '';
function renderFolderList() {
const sec = document.getElementById('tb-folder-section'), list = document.getElementById('tb-folder-list');
if (!sec || !list) return;
if (!currentSection || !API_ID_FIELD[currentSection] || currentSection === 'folders') { sec.style.display = 'none'; return; }
const fm = apiMapFor('folders'), m = apiMapFor(currentSection);
if (!fm || !m) { sec.style.display = 'none'; return; }
const counts = {};
m.forEach(it => { const f = folderNameOf(it); counts[f] = (counts[f] || 0) + 1; });
const names = Object.keys(counts);
if (names.length < 2) { sec.style.display = 'none'; activeFolderFilters.clear(); return; }
sec.style.display = '';
const key = currentSection + '|' + JSON.stringify(counts) + '|' + [...activeFolderFilters].join(',');
if (key === folderListKey) return;
folderListKey = key;
list.innerHTML = '';
names.sort((a, b) => counts[b] - counts[a] || a.localeCompare(b)).forEach(name => {
const row = document.createElement('div');
row.className = 'tb-frow' + (activeFolderFilters.has(name) ? ' on' : '');
row.innerHTML = `<div class="tb-chk">✓</div><div class="tb-dot" style="background:${tc('folder:' + name)}"></div><span class="tb-fname" title="${escHtml(name)}">${escHtml(name)}</span><span class="tb-fcnt">${counts[name]}</span>`;
row.addEventListener('click', () => {
if (activeFolderFilters.has(name)) { activeFolderFilters.delete(name); row.classList.remove('on'); }
else { activeFolderFilters.add(name); row.classList.add('on'); }
folderListKey = '';
applyAllFilters(); updateFooter();
});
list.appendChild(row);
});
}
function updateTrigAcc() {
const hd = document.getElementById('tb-trig-acc-hd');
const body = document.getElementById('tb-trig-acc-body');
const on = document.getElementById('tb-trig-acc-on');
if (!hd || !body) return;
hd.classList.toggle('open', trigAccOpen);
body.style.display = trigAccOpen ? '' : 'none';
// zwinięta harmonijka z aktywnym filtrem → plakietka, żeby nie zapomnieć o ukrytym filtrze
const n = (trigNameSearch ? 1 : 0) + activeTrigTypeFilters.size;
on.textContent = n > 1 ? 'aktywne: ' + n : 'aktywny';
on.style.display = (!trigAccOpen && n) ? '' : 'none';
}
function updateTrigSearchIndicator() {
updateTrigAcc();
const input = document.getElementById('tb-trig-search');
const badge = document.getElementById('tb-trig-mode');
const hint = document.getElementById('tb-trig-hint');
if (!input || !badge) return;
input.classList.remove('tb-input--regex', 'tb-input--error', 'tb-input--id');
badge.className = 'tb-search-mode';
hint.style.display = 'none';
if (!trigNameSearch) {
badge.className = 'tb-search-mode tb-search-mode--text';
badge.textContent = 'TEXT';
return;
}
if (trigNameIds) {
input.classList.add('tb-input--id');
badge.className = 'tb-search-mode tb-search-mode--id';
badge.textContent = 'ID';
hint.style.display = 'block';
hint.className = 'tb-search-hint';
hint.textContent = 'Tryb ID reguły · pokazuje tagi uruchamiane przez te reguły';
} else if (trigNameError) {
input.classList.add('tb-input--regex', 'tb-input--error');
badge.className = 'tb-search-mode tb-search-mode--error';
badge.textContent = '✕';
hint.style.display = 'block';
hint.className = 'tb-search-error';
hint.textContent = trigNameError;
} else if (trigNameRegex) {
input.classList.add('tb-input--regex');
badge.className = 'tb-search-mode tb-search-mode--regex';
badge.textContent = '✕';
hint.style.display = 'block';
hint.className = 'tb-search-hint';
hint.textContent = 'Tryb regex · użyj | jako OR, np. CE - |Click';
} else {
badge.className = 'tb-search-mode tb-search-mode--clear';
badge.textContent = '✕';
}
}
function renderFilterTab() {
const list = document.getElementById('tb-type-list'); list.innerHTML = '';
Object.entries(typeMap).sort((a, b) => b[1] - a[1]).forEach(([name, count]) => {
const row = document.createElement('div');
row.className = 'tb-frow' + (activeTypeFilters.has(name) ? ' on' : '');
row.innerHTML = `<div class="tb-chk">✓</div><div class="tb-dot" style="background:${tc(name)}"></div><span class="tb-fname" title="${escHtml(name)}">${escHtml(name)}</span><span class="tb-fcnt">${count}</span>`;
row.addEventListener('click', () => {
if (activeTypeFilters.has(name)) { activeTypeFilters.delete(name); row.classList.remove('on'); }
else { activeTypeFilters.add(name); row.classList.add('on'); }
applyAllFilters(); updateFooter();
});
list.appendChild(row);
});
updateFooter();
}
function renderTrigTypeList() {
const section = document.getElementById('tb-trig-section');
const list = document.getElementById('tb-trig-list');
const note = document.getElementById('tb-trig-note');
if (!section || !list) return;
if (currentSection !== 'tags' || Object.keys(trigTypeMap).length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
list.innerHTML = '';
Object.entries(trigTypeMap)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.forEach(([name, count]) => {
const row = document.createElement('div');
row.className = 'tb-frow' + (activeTrigTypeFilters.has(name) ? ' on' : '');
row.innerHTML = `<div class="tb-chk">✓</div><div class="tb-dot" style="background:${tc('trig:' + name)}"></div><span class="tb-fname" title="${escHtml(name)}">${escHtml(name)}</span><span class="tb-fcnt">${count}</span>`;
row.addEventListener('click', () => {
if (activeTrigTypeFilters.has(name)) { activeTrigTypeFilters.delete(name); row.classList.remove('on'); }
else { activeTrigTypeFilters.add(name); row.classList.add('on'); }
applyAllFilters(); updateFooter();
});
list.appendChild(row);
});
// podpowiedź, gdy część reguł nie ma jeszcze znanego typu
if (trigTypeMap[UNKNOWN_TYPE]) {
note.style.display = '';
note.textContent = 'Część reguł ma nieznany typ — wejdź raz na kartę Reguły, żeby je zeskanować.';
} else {
note.style.display = 'none';
}
}
function updateFooter() {
updateTrigAcc();
updateSectionToggles();
if (activeTab === 'params') {
const ftp = document.getElementById('tb-foot-txt');
if (ftp) ftp.textContent = `${paramsCount()} parametrów tagów` + (prmUsedDesc ? ' · dopasowane po opisie' : '') + ((prmSel.plat.size + prmSel.topic.size) ? ` · kategorie: ${prmSel.plat.size + prmSel.topic.size}` : '') + (hasActiveFilters() ? ' · filtr listy aktywny' : '');
return;
}
const rows = getDataRows(), total = rows.length, visible = rows.filter(r => !r.hasAttribute('data-tb-hidden')).length;
const ft = document.getElementById('tb-foot-txt');
ft.textContent = !hasActiveFilters()
? `${Object.keys(typeMap).length} typów · ${total} elementów`
: `${visible} z ${total} widocznych`;
// stan API GTM (etykiety, duplikaty, kolumna Tagi, akcje ✎⧉ zależą od niego)
const bad = Object.entries(apiStores).filter(([k, st]) => /błąd/.test(st.status || '')).map(([k, st]) => k.split('/').pop() + ': ' + st.status);
let ap = document.getElementById('tb-foot-api');
if (!ap) { ap = document.createElement('span'); ap.id = 'tb-foot-api'; ap.style.cssText = 'color:' + C.red + ';font-weight:700;margin-left:6px;cursor:help'; ft.after(ap); }
ap.textContent = bad.length ? '⚠ API' : '';
ap.title = bad.length ? 'GTM nie odpowiedział na część zapytań — etykiety i filtry mogą być niepełne. Kliknij ⟳ Odśwież.\n' + bad.join('\n') : '';
}
/* ═══════════════════ MODUŁ 12: POZYCJA / DRAG ═══════════════════ */
function updateExpandDirection() {
if (!widget) return;
const rect = widget.getBoundingClientRect();
const hdrH = document.getElementById('tb-hdr').offsetHeight;
const hdrMid = rect.top + hdrH / 2;
const viewMid = window.innerHeight / 2;
if (hdrMid > viewMid) {
widget.style.bottom = Math.max(8, window.innerHeight - rect.bottom) + 'px';
widget.style.top = 'auto';
} else {
widget.style.top = Math.max(8, rect.top) + 'px';
widget.style.bottom = 'auto';
}
keepOnScreen();
}
// Nagłówek widgetu musi być zawsze w oknie (inaczej nie da się go złapać i przesunąć)
function keepOnScreen() {
if (!widget) return;
const r = widget.getBoundingClientRect();
const hdrH = (document.getElementById('tb-hdr') || {}).offsetHeight || 44;
const vw = window.innerWidth, vh = window.innerHeight;
let fix = false;
if (r.top < 0 || r.top > vh - hdrH) { widget.style.top = Math.max(8, Math.min(r.top, vh - hdrH - 8)) + 'px'; widget.style.bottom = 'auto'; fix = true; }
if (r.left < 0 || r.left > vw - 60) { widget.style.left = Math.max(0, Math.min(r.left, vw - r.width)) + 'px'; widget.style.right = 'auto'; fix = true; }
if (fix && r.top < 0) { widget.style.top = '8px'; widget.style.bottom = 'auto'; }
}
window.addEventListener('resize', () => { try { keepOnScreen(); } catch (e) {} });
/* ═══════════════════ 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();
try { const r2 = el.getBoundingClientRect(); localStorage.setItem(LS_POS, JSON.stringify({ l: Math.round(r2.left), t: Math.round(r2.top) })); } catch (e) {}
};
document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up);
});
}
/* ═══════════════════ MODUŁ 13: PĘTLA / OBSERVER ═══════════════════ */
function poll() { return timed('poll', pollCore); }
function pollCore() {
try { keepOnScreen(); } catch (e) {}
// zmiana kontenera → przeładuj mapę typów reguł
if (containerKey() !== lastContainerKey) loadTrigTypeStore();
const section = detectSection();
if (section !== currentSection) {
currentSection = section;
clearAllFilters();
const si = document.getElementById('tb-name-search');
if (si) si.value = '';
const ts = document.getElementById('tb-trig-search');
if (ts) ts.value = '';
updateSearchIndicator();
updateTrigSearchIndicator();
}
document.getElementById('tb-badge').textContent = sectionLabel(currentSection);
// ciężkie rzeczy (liczniki, skan typów, filtry, etykiety, kolejność) tylko gdy coś się zmieniło od ostatniego cyklu
const rows0 = currentSection ? getDataRows() : [];
const fp = [currentSection, rows0.length, rows0[0], rows0[rows0.length - 1], dataGen, uiGen].map(x => (x && x.nodeType) ? (x.__tbId || (x.__tbId = ++domIdSeq)) : String(x)).join('|');
if (fp === lastPollFp) { updateFooter(); return; }
lastPollFp = fp;
timed('updateSectionToggles', updateSectionToggles);
if (!currentSection) {
typeMap = {}; trigTypeMap = {}; lastScanKey = '';
renderFilterTab(); renderTrigTypeList();
return;
}
// na stronie Reguł: buduj mapę nazwa→typ do wykorzystania na Tagach
harvestTriggerTypes();
const newTypes = scanTypes();
const newTrigTypes = scanTriggerTypes();
const key = JSON.stringify([newTypes, newTrigTypes]);
if (key !== lastScanKey) {
lastScanKey = key;
typeMap = newTypes;
trigTypeMap = newTrigTypes;
renderFilterTab();
renderTrigTypeList();
}
if (hasActiveFilters()) applyAllFilters();
renderIdBadges();
syncOrder();
updateFooter();
}
let lastPollFp = '', domIdSeq = 0, dataGen = 0, uiGen = 0;
function bumpData() { dataGen++; lastPollFp = ''; }
function bumpUi() { uiGen++; lastPollFp = ''; invalidateRows(); }
/* ═══════════════════ TABLE OBSERVER ═══════════════════ */
let tableObserver = null, obsT = null;
const observedTables = new WeakSet();
function watchTable() {
if (!tableObserver) {
tableObserver = new MutationObserver(muts => {
// własne zmiany (etykiety, ikonki, listy parametrów) nie wymagają ponownego renderu
const ours = n => n.nodeType === 1 ? /(^|\s)(tb-|gtm-tb)/.test(n.className || '') : (n.parentElement && !!n.parentElement.closest('.tb-meta,.tb-plist,.tb-act,.tb-uses-col,.' + ID_BADGE));
const own = muts.every(m => (m.target.nodeType === 1 && m.target.closest && m.target.closest('.tb-meta,.tb-plist,.tb-act,.tb-uses-col')) || ([...m.addedNodes, ...m.removedNodes].length > 0 && [...m.addedNodes, ...m.removedNodes].every(ours)));
if (own) return;
invalidateRows();
clearTimeout(obsT);
obsT = setTimeout(() => timed('observer', () => { if (hasActiveFilters()) applyAllFilters(); renderIdBadges(); }), 120);
});
}
findDataTables().forEach(table => {
if (observedTables.has(table)) return;
observedTables.add(table);
tableObserver.observe(table, { childList: true, subtree: true });
});
}
/* ═══════════════════ MODUŁ 14: DEBUG ═══════════════════ */
// W konsoli: gtmTbDebug() — pokaże co skrypt widzi
window.gtmTbDebug = function () {
const tables = findDataTables();
console.log('[GTM Filter] sekcja:', currentSection, '· kontener:', containerKey());
console.log('[GTM Filter] tabel:', tables.length, tables);
tables.forEach((table, ti) => {
const cols = resolveColumns(table);
console.log(`[GTM Filter] tabela #${ti} — name:`, cols.nameIdx, 'type:', cols.typeIdx, 'trig:', cols.trigIdx);
});
const rows = getDataRows();
console.log('[GTM Filter] wierszy łącznie:', rows.length);
rows.slice(0, 8).forEach(r => console.log(' →', '#' + idFromRow(r), nameFromRow(r), '|', typeFromRow(r), '|', triggersFromRow(r).map(t => `${t.name} [${t.type}]`).join(', ')));
const noId = rows.filter(r => idFromRow(r) === null);
console.log('[GTM Filter] wierszy bez ID:', noId.length);
if (noId.length) {
console.log('[GTM Filter] pierwszy wiersz bez ID (HTML):', noId[0].outerHTML.slice(0, 1500));
const ng = window.angular;
console.log('[GTM Filter] angular dostępny:', !!(ng && ng.element));
if (ng && ng.element) [noId[0], ...noId[0].querySelectorAll('*')].forEach(el => {
let d; try { d = ng.element(el).data(); } catch (e) { return; }
if (!d || !Object.keys(d).length) return;
Object.keys(d).forEach(k => {
const v = d[k];
let ks = ''; try { ks = v && typeof v === 'object' ? Object.keys(v).join(', ') : String(v); } catch (e) {}
console.log(' <' + el.tagName.toLowerCase() + '> data.' + k + ' → {' + ks.slice(0, 300) + '}');
});
});
}
if (rows[0]) {
const ng = window.angular;
console.log('[GTM Filter] angular dostępny:', !!(ng && ng.element), '· dane Angulara 1. wiersza:');
if (ng && ng.element) [rows[0], ...rows[0].querySelectorAll('*')].forEach(el => {
let d; try { d = ng.element(el).data(); } catch (e) { return; }
if (!d || !Object.keys(d).length) return;
Object.keys(d).forEach(k => {
const v = d[k];
let ks = '';
try {
ks = v && typeof v === 'object' ? Object.keys(v).map(kk => {
let vv; try { vv = v[kk]; } catch (e) { return kk; }
if (vv && typeof vv === 'object' && !vv.nodeType) { try { return kk + ':{' + Object.keys(vv).slice(0, 15).join(',') + '}'; } catch (e) {} }
return kk;
}).join(', ') : String(v);
} catch (e) {}
console.log(' <' + el.tagName.toLowerCase() + '> data.' + k + ' → {' + ks.slice(0, 600) + '}');
});
});
const r0 = rows[0];
console.log('[GTM Filter] HTML 1. wiersza:', r0.outerHTML.slice(0, 2500));
const desc = c => c === undefined ? '—' : Array.isArray(c) ? 'tablica[' + c.length + ']' : typeof c === 'object' ? 'obiekt{' + Object.keys(c).slice(0, 10).join(',') + '}' : typeof c + ' ' + String(c).slice(0, 40);
[r0, ...r0.querySelectorAll('*')].forEach(el => {
let own = [];
try { own = Object.getOwnPropertyNames(el).filter(k => !/^on/.test(k)); } catch (e) {}
if (own.length) console.log(' <' + el.tagName.toLowerCase() + '> własne pola: ' + own.join(', ') + ' · __ngContext__: ' + desc(el.__ngContext__));
});
console.log('[GTM Filter] window.ng:', typeof window.ng, '· getAllAngularRootElements:', typeof window.getAllAngularRootElements);
try {
const urls = performance.getEntriesByType('resource').map(e => e.name)
.filter(u => /tagmanager\.google\.com\/(?!.*\.(js|css|png|svg|woff2?)(\?|$))/.test(u));
console.log('[GTM Filter] ostatnie requesty do tagmanager.google.com:\n' + urls.slice(-25).map(u => ' ' + u.slice(0, 220)).join('\n'));
} catch (e) {}
try {
const c = window.ng && window.ng.getContext ? window.ng.getContext(r0) : null;
console.log('[GTM Filter] ng.getContext(wiersz):', c ? desc(c) : '—', '· $implicit:', c && c.$implicit ? desc(c.$implicit) : '—');
} catch (e) { console.log('[GTM Filter] ng.getContext błąd:', e && e.message); }
const base = wsApiBase();
if (base && API_ID_FIELD[currentSection]) {
const url = base + '/' + currentSection + '?hl=en';
fetch(url, { credentials: 'same-origin' }).then(r => r.text().then(t => {
console.log('[GTM Filter] API ' + url + ' → HTTP ' + r.status + ', ' + t.length + ' zn.; początek:\n' + t.slice(0, 1500));
try {
const map = collectApiItems(parseApiText(t), API_ID_FIELD[currentSection]);
console.log('[GTM Filter] API: elementów z ' + API_ID_FIELD[currentSection] + ':', map.size);
const first = map.get(idFromRow(r0)) || map.values().next().value;
const fo = {};
map.forEach(it => {
const d = it.data || {};
const k = 'tagFiringOption' in d ? String(d.tagFiringOption) : '(brak)';
(fo[k] = fo[k] || []).push(apiItemName(it));
});
if (currentSection === 'tags') console.log('[GTM Filter] tagFiringOption w API → tagi:\n' + Object.entries(fo).map(([k, arr]) => ' ' + k + ' (' + arr.length + '): ' + arr.slice(0, 4).join(' · ')).join('\n'));
if (first) {
console.log('[GTM Filter] API 1. element (JSON, 3000 zn.):', JSON.stringify(first).slice(0, 3000));
console.log('[GTM Filter] API → ID vendorów:', vendorIdsFromItem(first), '· opcja uruchamiania:', firingFromItem(first));
}
} catch (e) { console.log('[GTM Filter] API parse błąd:', e && e.message); }
})).catch(e => console.log('[GTM Filter] API fetch błąd:', e && e.message));
}
try {
const vst = apiStores[(wsApiBase() || '') + '/variables'];
console.log('[GTM Filter] API zmiennych:', vst ? (vst.status || '—') : 'nie pobrane', vst && vst.map ? '· elementów: ' + vst.map.size : '');
if (vst && vst.sample) console.log('[GTM Filter] API zmiennych — początek odpowiedzi:\n' + vst.sample.slice(0, 900));
const vr = varResolveMap();
if (vr) console.log('[GTM Filter] zmienne → ID (pierwsze 15):\n' + [...vr.entries()].filter(([, r]) => r.ids.length || r.raw).slice(0, 15)
.map(([n, r]) => ' ' + n + ' → ' + (r.ids.map(x => x.v + ' ' + x.id).join(', ') || '—') + (r.raw ? ' · wartość: ' + r.raw : '')).join('\n'));
else console.log('[GTM Filter] zmienne: API zmiennych jeszcze się ładuje');
} catch (e) {}
console.log('[GTM Filter] stan API w skrypcie:', apiStore.status || '—', apiStore.loading ? '(ładowanie)' : '');
const it = ngItemFromRow(rows[0]);
console.log('[GTM Filter] obiekt elementu (1. wiersz) znaleziony:', !!it);
if (it) {
console.log('[GTM Filter] ID vendorów:', vendorIdsFromItem(it), '· opcja uruchamiania:', firingFromItem(it));
const seen = new WeakSet();
let js = '';
try { js = JSON.stringify(it, (k, v) => { if (k.charAt(0) === '$') return undefined; if (v && typeof v === 'object') { if (seen.has(v)) return '[cykl]'; seen.add(v); } return v; }); } catch (e) { js = String(e); }
console.log('[GTM Filter] obiekt (JSON, 3000 zn.):', js.slice(0, 3000));
}
}
{
const tok = xsrfToken();
console.log('[GTM Filter] token XSRF:', tok ? (tok.slice(0, 12) + '… (źródło: ' + (xsrfSeen.from || 'nagłówek/ciasteczko') + ')') : 'NIE ZNALEZIONY');
console.log('[GTM Filter] ciasteczka (nazwy):', document.cookie.split(';').map(c => c.trim().split('=')[0]).filter(Boolean).join(', ') || '(brak czytelnych)');
const cands = [];
document.querySelectorAll('script:not([src])').forEach((sc, i) => { const t = sc.textContent || ''; let m; const re = /.{0,60}(xsrf|csrf).{0,120}/ig; while ((m = re.exec(t)) && cands.length < 8) cands.push('script#' + i + ': ' + m[0].replace(/\s+/g, ' ')); });
const html = document.documentElement.outerHTML; let m; const re2 = /.{0,60}(xsrf|csrf).{0,120}/ig; let n = 0;
while ((m = re2.exec(html)) && n++ < 6) cands.push('html: ' + m[0].replace(/\s+/g, ' '));
console.log('[GTM Filter] wystąpienia „xsrf/csrf” na stronie:\n' + (cands.join('\n') || ' (brak)'));
try { const ks = Object.keys(window).filter(k => /xsrf|csrf|token/i.test(k)); console.log('[GTM Filter] globalne zmienne z xsrf/csrf/token:', ks.join(', ') || '(brak)'); } catch (e) {}
}
if (currentSection === 'tags') {
const rs = getDataRows();
const tmx = apiMapFor('tags');
const line = r => { const id = idFromRow(r); const it = tmx && id !== null ? tmx.get(id) : null; return '#' + id + ' ' + nameFromRow(r).slice(0, 40) + ' · DOM:' + rowPausedDom(r) + ' · API:' + (it ? String((it.data || {}).paused) : '—') + ' · wynik:' + isRowPaused(r); };
console.log('[GTM Filter] pauza (pierwsze 10 wierszy):\n' + rs.slice(0, 10).map(line).join('\n'));
console.log('[GTM Filter] pauza: tryb filtra =', pausedMode, '(0 wszystkie, 1 tylko niezapauzowane, 2 tylko zapauzowane) · ukrytych wierszy:', rs.filter(r => r.hasAttribute('data-tb-hidden')).length);
}
if (currentSection === 'variables') {
const vm = apiMapFor('variables');
if (vm) {
let lack = 0, user = 0;
vm.forEach(it => { if (!it.isBuiltIn) { user++; if (itemLacksParams(it)) lack++; } });
console.log('[GTM Filter] zmienne: użytkownika ' + user + ', bez parametrów (nie dociągnięte) ' + lack + ' · dociąganie trwa: ' + hydrating.has((wsApiBase() || '') + '/variables') + ' · cache pełnych: ' + fullCache.size);
const first = [...vm.values()].find(it => !it.isBuiltIn && !itemLacksParams(it));
if (first) console.log('[GTM Filter] zmienna z parametrami (JSON, 2500 zn.):', JSON.stringify(first).slice(0, 2500), '\n → info:', JSON.stringify(variableInfoFromItem(first)).slice(0, 600));
const uv = [...vm.entries()].find(([, it]) => !it.isBuiltIn);
if (uv) fetchFullItem('variables', uv[0]).then(full => {
console.log('[GTM Filter] pojedyncza zmienna GET …/variables/' + uv[0] + ' (JSON, 3000 zn.):', JSON.stringify(full).slice(0, 3000));
console.log('[GTM Filter] data.vendorTemplate.param:', full && full.data && full.data.vendorTemplate ? (full.data.vendorTemplate.param || []).length : '—', '· properties.vendorTemplate.param:', full && full.properties && full.properties.vendorTemplate ? (full.properties.vendorTemplate.param || []).length : '—');
}).catch(e => console.log('[GTM Filter] pojedyncza zmienna: błąd', e && e.message));
const dm = dupEventMap();
if (dm) { const grp = [...dm.entries()].filter(([, a]) => a.length > 1).slice(0, 6); console.log('[GTM Filter] duplikaty zmiennych (klucz → nazwy):\n' + (grp.map(([k, a]) => ' ' + k.slice(0, 160) + ' → ' + a.join(' · ')).join('\n') || ' (brak)')); }
else console.log('[GTM Filter] duplikaty zmiennych: jeszcze nie policzone (dociąganie)');
}
}
console.log('[GTM Filter] czas (ms): ' + Object.entries(perf).map(([k, p]) => k + ' ×' + p.n + ' = ' + p.ms.toFixed(0) + ' (śr. ' + (p.ms / p.n).toFixed(1) + ', max ' + p.max.toFixed(0) + ')').join(' · '));
const w = window.gtmTbWrites || [];
console.log('[GTM Filter] zapisy do API w tej sesji (' + w.length + '):');
w.forEach(x => console.log(' ' + x.time.slice(11, 19) + ' ' + x.via + ' ' + x.method + ' ' + x.url + (x.status ? ' → HTTP ' + x.status : '') + '\n nagłówki: ' + JSON.stringify(x.headers) + '\n body (' + x.bodyLen + ' zn.): ' + x.body.slice(0, 2500) + (x.resp ? '\n odpowiedź: ' + x.resp.slice(0, 800) : '')));
console.log('[GTM Filter] typeMap:', typeMap);
console.log('[GTM Filter] trigTypeMap:', trigTypeMap);
console.log('[GTM Filter] trigTypeStore (nazwa→typ):', trigTypeStore);
};
/* ═══════════════════ MODUŁ 15: INIT ═══════════════════ */
function init() {
loadTrigTypeStore();
buildWidget(); updateExpandDirection(); poll(); watchTable();
setInterval(() => { poll(); watchTable(); }, POLL_MS);
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(() => { poll(); watchTable(); }, 600);
}
}).observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'complete') setTimeout(init, 1500);
else window.addEventListener('load', () => setTimeout(init, 1500));
})();

