Waze Checker Export

Export data from Waze Checker to TSV format.

// ==UserScript==
// @name         Waze Checker Export
// @version      0.1.2
// @description  Export data from Waze Checker to TSV format.
// @author       FalconTech
// @match        https://checker.waze.uz/checker/errorlist/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=labtool.pl
// @namespace    https://greasyfork.org/users/205544
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @connect      checker.waze.uz
// @connect      waze.com
// @connect      www.waze.com
// @connect      beta.waze.com
// @license      CC-BY-NC-ND-4.0
// ==/UserScript==

/* Changelog:
 *  0.1.1 - Beta testing
 */

(function() {
  'use strict';

  const TEXTAREA_ID = 'waze-export-textarea';
  const TOOLBAR_ID = 'waze-export-toolbar';

  let ROADTYPE_IDX = null;
  let LOCK_IDX = null;
  const STORAGE_DATA_KEY = 'waze-export-data';


  function waitFor(selector, root = document, timeoutMs = 15000) {
    return new Promise((resolve, reject) => {
      const el = root.querySelector(selector);
      if (el) return resolve(el);
      const obs = new MutationObserver(() => {
        const found = root.querySelector(selector);
        if (found) {
          obs.disconnect();
          resolve(found);
        }
      });
      obs.observe(root === document ? document.documentElement : root, { childList: true, subtree: true });
      if (timeoutMs) setTimeout(() => { obs.disconnect(); reject(new Error('Timeout waiting for ' + selector)); }, timeoutMs);
    });
  }

  function ensureToolbar(container) {
    if (document.getElementById(TOOLBAR_ID)) return document.getElementById(TOOLBAR_ID);
    const wrap = document.createElement('div');
    wrap.id = TOOLBAR_ID;
    wrap.style.margin = '12px 12px 6px';

    const baseButtonStyle = {
      border: '2px dotted var(--bs-border-color)',
      borderRadius: '8px',
      padding: '4px 30px',
      marginRight: '8px',
      cursor: 'pointer',
      background: 'transparent'
    };

    const applyButtonStyle = (btn) => {
      Object.assign(btn.style, baseButtonStyle);
    };

    const btnCopy = document.createElement('button');
    btnCopy.type = 'button';
    btnCopy.textContent = 'Copy TSV';
    applyButtonStyle(btnCopy);
    btnCopy.addEventListener('click', async () => {
      const ta = document.getElementById(TEXTAREA_ID);
      if (!ta) return;
      try {
        await navigator.clipboard.writeText(ta.value);
        btnCopy.textContent = 'Copied!';
        setTimeout(() => (btnCopy.textContent = 'Copy TSV'), 1200);
      } catch (e) {
        ta.focus();
        ta.select();
        alert(' zaznaczono tekst – skopiuj ręcznie (Ctrl/Cmd+C) ');
      }
    });

    const btnClear = document.createElement('button');
    btnClear.type = 'button';
    btnClear.textContent = 'Clear';
    applyButtonStyle(btnClear);
    btnClear.addEventListener('click', () => {
      const ta = document.getElementById(TEXTAREA_ID);
      if (ta) ta.value = '';
      try { localStorage.removeItem(STORAGE_DATA_KEY); } catch(_) {}
      // Uncheck all checkboxes without triggering exports
      document.querySelectorAll('input[type="checkbox"][data-gid]').forEach(cb => { cb.checked = false; });
    });

    wrap.appendChild(btnCopy);
    wrap.appendChild(btnClear);
    container.parentNode.insertBefore(wrap, container.nextSibling);
    return wrap;
  }

  function ensureTextarea(container) {
    let ta = document.getElementById(TEXTAREA_ID);
    if (ta) return ta;
    ensureToolbar(container);
    ta = document.createElement('textarea');
    ta.id = TEXTAREA_ID;
    ta.rows = 10;
    ta.wrap = 'off';
    ta.spellcheck = false;
    ta.style.width = '100%';
    ta.style.fontFamily = 'ui-monospace, Menlo, Consolas, monospace';
    ta.style.margin = '6px 0 14px 0';
    ta.style.whiteSpace = 'nowrap';
    container.parentNode.insertBefore(ta, document.getElementById(TOOLBAR_ID).nextSibling);
    const data = loadData();
    ta.value = getCombinedText(data);
    return ta;
  }

  function normalizeText(node) {
    return node.innerText.replace(/\s+/g, ' ').trim();
  }

  function absoluteUrl(href) {
    try {
      return new URL(href, location.origin).toString();
    } catch (e) {
      return href;
    }
  }

  function getCol3AsTabs(cell) {
    const nobrs = Array.from(cell.querySelectorAll('nobr'));
    const parts = nobrs.map(n => (n.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
    if (parts.length) return parts.slice(0,3).join('\t');
    return cell.innerHTML
      .split(/<br\s*\/?\s*>/i)
      .map(s => s.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim())
      .filter(Boolean)
      .slice(0,3)
      .join('\t');
  }

  function loadData() {
    try {
      const raw = localStorage.getItem(STORAGE_DATA_KEY);
      if (!raw) return { entries: [], version: 1 };
      const obj = JSON.parse(raw);
      if (!obj || !Array.isArray(obj.entries)) return { entries: [], version: 1 };
      return { entries: obj.entries.map(e => ({ gid: String(e.gid), line: String(e.line) })), version: 1 };
    } catch (_) { return { entries: [], version: 1 }; }
  }

  function saveData(data) {
    try { localStorage.setItem(STORAGE_DATA_KEY, JSON.stringify({ entries: data.entries, version: 1, ts: Date.now() })); } catch (_) {}
  }

  function dataHasGid(data, gid) {
    gid = String(gid);
    return data.entries.some(e => e.gid === gid);
  }

  function upsertEntry(gid, line) {
    const data = loadData();
    const g = String(gid);
    const idx = data.entries.findIndex(e => e.gid === g);
    if (idx >= 0) {
      data.entries[idx].line = line;
    } else {
      data.entries.push({ gid: g, line });
    }
    saveData(data);
    return data;
  }

  function removeEntry(gid) {
    const data = loadData();
    const g = String(gid);
    const next = { entries: data.entries.filter(e => e.gid !== g), version: 1 };
    saveData(next);
    return next;
  }

  function getCombinedText(data) {
    return data.entries.length ? data.entries.map(e => e.line).join('\n') + '\n' : '';
  }

  function renderTextareaFromStorage(container) {
    const ta = ensureTextarea(container);
    ta.value = getCombinedText(loadData());
  }

  function computeHeaderIndices(tableEl) {
    const ths = Array.from(tableEl.querySelectorAll('thead th'));
    ROADTYPE_IDX = null;
    LOCK_IDX = null;
    ths.forEach((th, i) => {
      const label = (th.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase();
      if (label.includes('roadtype')) ROADTYPE_IDX = i;
      if (label === 'lock' || label.includes(' lock')) LOCK_IDX = i;
    });
  }

  function extractGlobalIdFromHref(href) {
    // Expected /checker/go_waze/180/3/1057228237/0/
    const m = href.match(/\/go_waze\/\d+\/\d+\/(\d+)\//);
    if (m) return m[1];
    // Fallback: last long number in the path
    const m2 = href.match(/(\d{7,})/);
    return m2 ? m2[1] : null;
  }

  function getCellByHeaderIndex(tds, fallbackIndex, headerIndex) {
    if (Number.isInteger(headerIndex) && headerIndex >= 0 && headerIndex < tds.length) {
      return tds[headerIndex];
    }
    return tds[fallbackIndex];
  }

  function resolveEditorUrlGM(url) {
    return new Promise((resolve, reject) => {
      try {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          timeout: 15000,
          onload: function(res) {
            const final = res.finalUrl || url;
            let loc = '';
            if (res.responseHeaders) {
              const m = res.responseHeaders.match(/\nlocation:\s*(.+)\s*/i);
              if (m && m[1]) loc = m[1].trim();
            }
            const chosen = loc ? absoluteUrl(loc) : final;
            resolve(chosen);
          },
          onerror: function(err) {
            console.error('Waze Export GM request error', err);
            resolve(url);
          },
          ontimeout: function() {
            console.warn('Waze Export GM request timeout');
            resolve(url);
          }
        });
      } catch (e) {
        console.error('Waze Export GM request exception', e);
        resolve(url);
      }
    });
  }

  async function buildTsvLineForRow(tr, tableEl) {
    const tds = tr.querySelectorAll('td');
    const col2 = tds[1];
    const col3 = tds[2];
    const col6 = getCellByHeaderIndex(tds, 5, ROADTYPE_IDX);
    const col7 = getCellByHeaderIndex(tds, 6, LOCK_IDX);
    const firstLink = col2.querySelector('a[href]');
    const href = absoluteUrl((firstLink.getAttribute('href') || '').trim());
    const resolved = await resolveEditorUrlGM(href);
    const cityStreetTabs = getCol3AsTabs(col3);
    const roadtype = normalizeText(col6);
    const lockVal = normalizeText(col7);
    return `${cityStreetTabs}\t${roadtype}\t${lockVal}\t${resolved}`;
  }

  function addCheckboxToRow(tr, tableEl) {
    if (!tr || tr.dataset.wazeExportProcessed === '1') return;
    tr.dataset.wazeExportProcessed = '1';
    const firstTd = tr.querySelector('td');
    if (!firstTd) return;

    // Determine row's global ID from column 2 link
    const col2 = tr.querySelectorAll('td')[1];
    let gid = null;
    if (col2) {
      const a = col2.querySelector('a[href]');
      if (a) gid = extractGlobalIdFromHref(a.getAttribute('href') || '');
    }

    const wrapper = document.createElement('label');
    wrapper.style.display = 'inline-flex';
    wrapper.style.alignItems = 'center';
    wrapper.style.gap = '4px';
    wrapper.style.marginLeft = '6px';

    const cb = document.createElement('input');
    cb.type = 'checkbox';
    cb.title = 'Dodaj do eksportu (TSV)';
    if (gid) cb.dataset.gid = gid;

    cb.addEventListener('change', async () => {
      const g = cb.dataset.gid;
      if (!g) return;
      const tableContainer = tableEl.closest('.table-responsive') || tableEl;
      if (cb.checked) {
        // Build the TSV line for this row and upsert
        const line = await buildTsvLineForRow(tr, tableEl);
        upsertEntry(g, line);
      } else {
        removeEntry(g);
      }
      renderTextareaFromStorage(tableContainer);
    });

    const txt = document.createElement('span');
    txt.textContent = '';

    wrapper.appendChild(cb);
    wrapper.appendChild(txt);

    // Insert after the number inside the first cell
    firstTd.appendChild(wrapper);

    if (cb.dataset.gid && dataHasGid(loadData(), cb.dataset.gid)) {
      cb.checked = true;
    }
  }

  function processExistingRows(tableEl) {
    const rows = tableEl.querySelectorAll('tbody > tr');
    rows.forEach((tr) => addCheckboxToRow(tr, tableEl));
  }

  function observeTable(tableEl) {
    const tbody = tableEl.querySelector('tbody');
    if (!tbody) return;
    const obs = new MutationObserver(() => {
      computeHeaderIndices(tableEl);
      const rows = tableEl.querySelectorAll('tbody > tr');
      rows.forEach((tr) => addCheckboxToRow(tr, tableEl));
    });
    obs.observe(tbody, { childList: true, subtree: true });
  }

  async function init() {
    try {
      const table = await waitFor('div.table-responsive table.table-hover');
      computeHeaderIndices(table);
      // If there are saved entries, render textarea immediately
      if (loadData().entries.length) {
        const tableContainer = table.closest('.table-responsive') || table;
        ensureTextarea(tableContainer);
      }
      processExistingRows(table);
      observeTable(table);
    } catch (e) {
      // Table not found within timeout – silently ignore
      console.warn('Waze Export: table not found');
    }
  }

  if (document.readyState === 'complete' || document.readyState === 'interactive') {
    init();
  } else {
    window.addEventListener('DOMContentLoaded', init, { once: true });
  }
})();