SNAD / Business Central - SCAPU - Simple Copy and Paste Utility

// ==UserScript==
// @name         Business Central - SCAPU - Simple Copy and Paste Utility
// @namespace    https://allmar.com/business_systems
// @version      1.14
// @description  BC data extraction overlay — press Ctrl+Alt+X to toggle open/close
// @author       SNAD
// @match        *://businesscentral.dynamics.com/*
// @match        *://businesscentral.dynamics.com/
// @updateURL    https://openuserjs.org/meta/SNAD/Business_Central_-_SCAPU_-_Simple_Copy_and_Paste_Utility.meta.js
// @downloadURL  https://openuserjs.org/install/SNAD/Business_Central_-_SCAPU_-_Simple_Copy_and_Paste_Utility.user.js
// @copyright    2026, SNAD (https://openuserjs.org/users/SNAD)
// @license      GPL-3.0-or-later
// @grant        none
// @run-at       document-idle
// ==/UserScript==
//
// On-premise BC: add your server URL as an extra @match line above, e.g.:
//   @match  https://your-bc-server.company.com/*
//
// HOTKEY: Ctrl + Alt + X  →  open or close the panel
//
// Changes in v1.14:
//   - Added: right-clicking a column header now includes "Only select [ColName]" at
//     the top of the context menu. Clicking it deselects all other columns and enables
//     only that one — useful for quickly isolating a single column's output.
//
// Changes in v1.13:
//   - Speed: renderTable now builds rows via DocumentFragment — single DOM write
//     instead of one-per-element, eliminating redundant reflows during table build.
//   - Speed: row/column elements are cached after renderTable so refreshUI (every
//     click) iterates arrays rather than re-running querySelectorAll on the document.
//   - Speed: scrapeGrid now breaks out of the grid-scoring loop the moment a
//     topmost grid is found. elementFromPoint forces a layout recalculation; the
//     early exit avoids calling it for every remaining grid once the winner is
//     confirmed, which matters most on multi-grid SPA pages with popup pages open.
//   - Added: right-click a data cell to filter the SCAPU table to rows matching
//     that cell's value, without affecting each row's enabled/disabled (ON/OFF)
//     state. Multiple column filters can be stacked — each right-click adds to them.
//     Filtered rows are hidden in the table and excluded from output, but their
//     ON/OFF toggle state is preserved so re-clearing the filter restores them fully.
//   - Added: right-click a column header to clear the filter on that specific column.
//     Also available from any cell context menu. "Clear all filters" removes every
//     active filter at once. Active filters are indicated by a ▼ marker and yellow
//     colouring on the header; the status bar shows how many filters are active.
//   - Added: Append now detects and skips duplicate rows. Before merging fresh rows
//     into the existing table, SCAPU fingerprints existing data using the fresh page's
//     column set. Any fresh row whose values (across that column set) already exist is
//     silently dropped. Rows that are genuinely new are still appended as before. A
//     brief "N duplicates skipped" note appears in the status bar for 3 seconds.
//     This prevents double-entries when you scroll and hit Append while BC still shows
//     rows that are already loaded.
//
// Changes in v1.12:
//   - Fixed: "Line No." column-trim regex was too broad — /^line\s*no/i matched any
//     header containing the substring, including "Submittal Line No.", "Document Line
//     No.", etc. On pages that have those columns (e.g. the APEX_LEGACY Material List
//     page) SCAPU was trimming the first 70+ visible columns and only showing the
//     handful that followed the false match. Fixed by anchoring the regex to the
//     start of the header string (/^line\s*no/i) so only a column literally named
//     "Line No." (or "Line No" etc.) triggers the trim. Same anchor applied to the
//     grid-scoring "hasLineNo" check so grids with "Submittal Line No." are no longer
//     given the "Line No." presence bonus.

(function () {
  'use strict';

  // ─────────────────────────────────────────────────────────
  // HOTKEY LISTENER
  // ─────────────────────────────────────────────────────────

  document.addEventListener('keydown', function (e) {
    if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'x') {
      e.preventDefault();
      e.stopPropagation();
      toggleSCAPU();
    }
  }, true);

  // ─────────────────────────────────────────────────────────
  // TOGGLE
  // ─────────────────────────────────────────────────────────

  function toggleSCAPU() {
    const existing = document.getElementById('scapu-overlay');
    if (existing) {
      existing.remove();
      document.getElementById('scapu-styles')?.remove();
      document.getElementById('scapu-ctx')?.remove();
      return;
    }
    launchSCAPU();
  }

  // ─────────────────────────────────────────────────────────
  // MAIN LAUNCH
  // ─────────────────────────────────────────────────────────

  function launchSCAPU() {

    // ───────────────────────────────────────────────────────
    // SCRAPING HELPERS
    // ───────────────────────────────────────────────────────

    function cleanText(el) {
      if (!el) return '';
      const c = el.cloneNode(true);
      c.querySelectorAll('svg, img, [aria-hidden="true"], .ms-Icon, i[class*="icon"]')
        .forEach(n => n.remove());
      c.querySelectorAll('button').forEach(btn => {
        if (!btn.textContent.trim()) btn.remove();
      });
      return c.textContent.replace(/\s+/g, ' ').trim();
    }

    function normalize(arr, len) {
      const r = [...arr];
      while (r.length < len) r.push('');
      return r.slice(0, len);
    }

    // ───────────────────────────────────────────────────────
    // SCRAPING STRATEGIES
    // ───────────────────────────────────────────────────────

    function isVisible(el) {
      let node = el;
      while (node && node !== document.documentElement) {
        if (node.getAttribute('aria-hidden') === 'true') return false;
        node = node.parentElement;
      }
      return true;
    }

    function getActiveDialog() {
      const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))
        .filter(d => isVisible(d));
      return dialogs[dialogs.length - 1] || null;
    }

    function isGridOnTop(grid) {
      const rect = grid.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return false;
      const cx = (rect.left + rect.right) / 2;
      const cy = (rect.top + rect.bottom) / 2;
      if (cx < 0 || cy < 0 || cx > window.innerWidth || cy > window.innerHeight) return false;
      const el = document.elementFromPoint(cx, cy);
      return !!el && (grid.contains(el) || !!el.closest('#scapu-overlay'));
    }

    function scrapeGrid() {
      const searchRoot = getActiveDialog() || document;
      const allGrids = Array.from(searchRoot.querySelectorAll('[role="grid"]'));
      if (!allGrids.length) return null;

      let grid = null;
      let bestScore = -1;
      let foundTop = false;

      for (const g of allGrids) {
        if (!isVisible(g)) continue;

        const colHeaders = Array.from(g.querySelectorAll('[role="columnheader"]'));
        const dataRows   = Array.from(g.querySelectorAll('[role="row"]'))
          .filter(r =>
            !r.querySelector('[role="columnheader"]') &&
            r.querySelectorAll('[role="gridcell"], [role="cell"]').length > 0
          );

        if (!dataRows.length) continue;

        const hasLineNo = colHeaders.some(th => /^line\s*no/i.test(cleanText(th)));
        // Only call isGridOnTop when we haven't confirmed a topmost grid yet —
        // elementFromPoint forces layout and is the main per-grid cost.
        const onTop = !foundTop && isGridOnTop(g);
        if (onTop) foundTop = true;

        const score = (onTop ? 10000000 : 0) + (hasLineNo ? 100000 : 0) + dataRows.length;

        if (score > bestScore) {
          bestScore = score;
          grid = g;
        }

        // A topmost grid scores ≥ 10 M; no remaining grid can exceed ~200 K.
        // Stop early to avoid extra elementFromPoint calls on remaining grids.
        if (foundTop) break;
      }

      if (!grid) grid = allGrids.find(g => isVisible(g)) || allGrids[0];
      if (!grid) return null;

      const allThs = [];
      const headers = [];
      const headerIdMap = {};
      const thIndexMap = new Map();
      const rows = [];
      const selectedIndices = new Set();

      grid.querySelectorAll('[role="columnheader"]').forEach(th => {
        const idx = headers.length;
        allThs.push(th);
        headers.push(cleanText(th));
        thIndexMap.set(th, idx);
        if (th.id) headerIdMap[th.id] = idx;
        th.querySelectorAll('[id]').forEach(child => {
          if (child.id) headerIdMap[child.id] = idx;
        });
      });

      const visIdxs = allThs.map((th, i) => (th.offsetWidth > 0 ? i : -1)).filter(i => i >= 0);

      function resolveLabel(lblAttr) {
        for (const id of lblAttr.split(/\s+/).filter(Boolean)) {
          if (headerIdMap[id] !== undefined) return headerIdMap[id];
          const ref = document.getElementById(id);
          if (!ref) continue;
          const direct = thIndexMap.get(ref);
          if (direct !== undefined) return direct;
          const ancestor = ref.closest('[role="columnheader"]');
          if (ancestor) {
            const ancIdx = thIndexMap.get(ancestor);
            if (ancIdx !== undefined) return ancIdx;
          }
        }
        return -1;
      }

      let ri = 0;
      grid.querySelectorAll('[role="row"]').forEach(row => {
        if (row.querySelector('[role="columnheader"]')) return;
        const cells = row.querySelectorAll('[role="gridcell"], [role="cell"]');
        if (!cells.length) return;

        const rowData = new Array(headers.length).fill('');
        const extra = [];
        let detectedOffset = null;
        const unplaced = [];

        Array.from(cells).forEach((cell, p) => {
          const input = cell.querySelector('input:not([type="checkbox"]):not([type="hidden"])');
          const textbox = cell.querySelector('[role="textbox"]');
          let val;
          if (input) {
            val = input.value.trim();
          } else if (textbox) {
            val = textbox.textContent.trim() || textbox.getAttribute('title') || '';
          } else {
            val = cleanText(cell);
          }

          const lblAttr = (
            (textbox?.hasAttribute('aria-labelledby')
              ? textbox.getAttribute('aria-labelledby') : null) ||
            cell.querySelector('[aria-labelledby]')?.getAttribute('aria-labelledby') ||
            cell.getAttribute('aria-labelledby') ||
            ''
          ).trim();

          const colIdx = lblAttr ? resolveLabel(lblAttr) : -1;

          if (colIdx >= 0) {
            if (colIdx < rowData.length) rowData[colIdx] = val;
            else extra.push(val);
            if (detectedOffset === null) detectedOffset = colIdx - p;
          } else {
            unplaced.push({ p, val });
          }
        });

        const offset = detectedOffset ?? 0;
        unplaced.forEach(({ p, val }) => {
          const t = offset + p;
          if (t >= 0 && t < rowData.length && rowData[t] === '') {
            rowData[t] = val;
          } else {
            extra.push(val);
          }
        });

        const visRow = visIdxs.map(i => rowData[i] || '');
        if (visRow.some(c => c !== '')) {
          const isSelected =
            row.getAttribute('aria-selected') === 'true' ||
            !!row.querySelector('input[type="checkbox"]:checked');
          if (isSelected) selectedIndices.add(ri);
          rows.push(visRow);
          ri++;
        }
      });

      if (!rows.length) return null;

      const visHeaders = visIdxs.map(i => headers[i]);
      return { headers: visHeaders, rows, selectedIndices };
    }

    function scrapeCard() {
      const headers = [];
      const values  = [];

      const searchRoot = getActiveDialog() || document;
      const labelEls = searchRoot.querySelectorAll(
        '[class*="caption"]:not([class*="group-caption"]), ' +
        'label[class*="label"], ' +
        '[class*="fieldCaption"]'
      );
      if (!labelEls.length) return null;

      labelEls.forEach(labelEl => {
        const label = cleanText(labelEl);
        if (!label || label.length > 80) return;

        let value = '';
        const container =
          labelEl.closest('[class*="field-group"], [class*="field "], [class*=" field"]') ||
          labelEl.parentElement;

        if (container) {
          const inp = container.querySelector(
            'input:not([type="checkbox"]):not([type="hidden"]), textarea'
          );
          const valEl = container.querySelector('[class*="value"], [class*="fieldValue"]');
          if (inp) value = inp.value.trim();
          else if (valEl) value = cleanText(valEl);
          else if (labelEl.nextElementSibling) value = cleanText(labelEl.nextElementSibling);
        }

        headers.push(label);
        values.push(value);
      });

      if (!headers.length) return null;
      return { headers, rows: [values], selectedIndices: new Set() };
    }

    function scrapeTable() {
      const searchRoot = getActiveDialog() || document;
      const table = searchRoot.querySelector('table');
      if (!table) return null;

      const headers = [];
      const rows    = [];
      const headerRow = table.querySelector('thead tr');
      if (headerRow) headerRow.querySelectorAll('th, td').forEach(c => headers.push(cleanText(c)));
      table.querySelectorAll('tbody tr').forEach(tr => {
        const row = Array.from(tr.querySelectorAll('td, th')).map(c => cleanText(c));
        if (row.some(c => c)) rows.push(row);
      });

      if (!rows.length) return null;
      const maxCols = Math.max(headers.length, ...rows.map(r => r.length));
      const normHeaders = normalize(headers, maxCols);
      for (let i = headers.length; i < maxCols; i++) normHeaders[i] = `Col ${i + 1}`;
      return { headers: normHeaders, rows: rows.map(r => normalize(r, maxCols)), selectedIndices: new Set() };
    }

    function scrapeBC() {
      const raw = scrapeGrid() || scrapeCard() || scrapeTable() || {
        headers: ['Note'],
        rows: [['No BC data detected — try refreshing after the page fully loads']],
        selectedIndices: new Set()
      };

      const lineNoIdx = raw.headers.findIndex(h => /^line\s*no/i.test(h));
      if (lineNoIdx > 0) {
        raw.headers = raw.headers.slice(lineNoIdx);
        raw.rows    = raw.rows.map(r => r.slice(lineNoIdx));
      }

      const namedCols = raw.headers.reduce((acc, h, i) => { if (h !== '') acc.push(i); return acc; }, []);
      if (namedCols.length < raw.headers.length) {
        raw.headers = namedCols.map(i => raw.headers[i]);
        raw.rows    = raw.rows.map(r => namedCols.map(i => r[i]));
      }

      return raw;
    }

    // ───────────────────────────────────────────────────────
    // TRUNCATE LOGIC
    // ───────────────────────────────────────────────────────

    function applyRowTruncate(selRows, colIdx) {
      if (!selRows.length) return '';
      const groups = [];
      let i = 0;
      while (i < selRows.length) {
        let j = i;
        while (j + 1 < selRows.length && selRows[j + 1] === selRows[j] + 1) j++;
        groups.push(selRows.slice(i, j + 1));
        i = j + 1;
      }
      return groups
        .map(group => {
          const vals = group
            .map(ri => rows[ri]?.[colIdx] ?? '')
            .filter(v => v !== '');
          if (!vals.length) return '';
          if (vals.length >= 2) return `${vals[0]}..${vals[vals.length - 1]}`;
          return vals[0];
        })
        .filter(s => s !== '')
        .join('|');
    }

    // ───────────────────────────────────────────────────────
    // STATE
    // ───────────────────────────────────────────────────────

    let scraped = scrapeBC();
    let headers = scraped.headers;
    let rows    = scraped.rows;

    let colSel     = new Array(headers.length).fill(true);
    let rowSel     = buildRowSel(scraped.selectedIndices, rows.length);
    let colFilters = {};   // { [colIndex]: filterValue } — active cell-value filters
    let skipMsg    = '';   // transient "N duplicates skipped" note

    function buildRowSel(bcSelected, total) {
      if (bcSelected && bcSelected.size >= 2) {
        const sel = new Array(total).fill(false);
        bcSelected.forEach(i => { if (i < total) sel[i] = true; });
        return sel;
      }
      return new Array(total).fill(true);
    }

    // True if row ri satisfies all active colFilters.
    function passesFilters(ri) {
      for (const k in colFilters) {
        if ((rows[ri]?.[+k] ?? '') !== colFilters[k]) return false;
      }
      return true;
    }

    let mode = 'space';
    let useNewlines = false;
    let prevColCategory = null;

    // ───────────────────────────────────────────────────────
    // COLUMN SELECTION MEMORY (localStorage)
    // ───────────────────────────────────────────────────────

    function getColStorageKey() {
      const pageId = new URLSearchParams(location.search).get('page') ||
                     location.pathname.replace(/\/$/, '').split('/').pop() ||
                     'unknown';
      return 'scapu_cols:' + pageId + ':' + headers.join('|').slice(0, 150);
    }

    function saveColSel() {
      try {
        const map = {};
        headers.forEach((h, i) => { map[h || `__col${i}`] = colSel[i]; });
        localStorage.setItem(getColStorageKey(), JSON.stringify(map));
      } catch (e) {}
    }

    function loadColSel() {
      try {
        const raw = localStorage.getItem(getColStorageKey());
        if (!raw) return;
        const map = JSON.parse(raw);
        headers.forEach((h, i) => {
          const k = h || `__col${i}`;
          if (k in map) colSel[i] = map[k];
        });
      } catch (e) {}
    }

    loadColSel();

    // ───────────────────────────────────────────────────────
    // OUTPUT
    // ───────────────────────────────────────────────────────

    function getOutput() {
      const selCols = colSel.reduce((a, s, i) => (s ? [...a, i] : a), []);
      // Rows must be ON and pass all active filters to appear in output.
      const selRows = rowSel.reduce((a, s, i) => (s && passesFilters(i) ? [...a, i] : a), []);
      const sep     = mode === 'tab' ? '\t' : mode === 'space' ? ' ' : '|';

      if (!selCols.length || !selRows.length) return '';

      if (selCols.length === 1) {
        const ci = selCols[0];
        if (mode === 'truncate') return applyRowTruncate(selRows, ci);
        const singleSep = useNewlines ? '\n' : sep;
        return selRows
          .map(ri => rows[ri]?.[ci] ?? '')
          .filter(v => v !== '')
          .join(singleSep);
      }

      const multiSep = mode === 'truncate' ? '|' : sep;
      const rowSep   = useNewlines ? '\n' : sep;
      return selRows
        .map(ri => {
          const vals = selCols.map(ci => rows[ri]?.[ci] ?? '');
          return vals.filter(v => v !== '').join(multiSep);
        })
        .filter(l => l !== '')
        .join(rowSep);
    }

    // ───────────────────────────────────────────────────────
    // STYLES
    // ───────────────────────────────────────────────────────

    const styleEl = document.createElement('style');
    styleEl.id = 'scapu-styles';
    styleEl.textContent = `
      #scapu-overlay {
        position: fixed; top: 24px; right: 24px;
        width: 700px; max-width: 94vw; max-height: 82vh;
        background: #1e1e2e; color: #cdd6f4;
        border: 2px solid #89b4fa; border-radius: 10px;
        box-shadow: 0 12px 48px rgba(0,0,0,.7);
        font-family: 'Segoe UI', system-ui, sans-serif; font-size: 13px;
        z-index: 2147483647; display: flex; flex-direction: column;
        overflow: hidden; resize: both;
      }
      #scapu-hdr {
        background: #313244; padding: 7px 12px;
        display: flex; align-items: center; gap: 8px;
        cursor: move; user-select: none;
        border-bottom: 1px solid #45475a; flex-shrink: 0;
      }
      #scapu-title  { font-weight: 700; font-size: 14px; color: #89b4fa; flex: 1; }
      #scapu-sub    { font-size: 11px; color: #a6adc8; }
      #scapu-hotkey { font-size: 10px; color: #6c7086; margin-right: 4px; }
      #scapu-x {
        background: #f38ba8; border: none; color: #1e1e2e;
        width: 22px; height: 22px; border-radius: 50%;
        cursor: pointer; font-weight: 700; font-size: 13px;
        display: flex; align-items: center; justify-content: center; padding: 0;
      }
      #scapu-opts {
        display: flex; gap: 5px; padding: 7px 10px;
        border-bottom: 1px solid #313244;
        flex-wrap: wrap; align-items: center; flex-shrink: 0;
      }
      #scapu-opts .lbl { font-size: 11px; color: #a6adc8; }
      #scapu-sel-bar {
        display: flex; gap: 5px; padding: 5px 10px;
        border-bottom: 1px solid #45475a;
        align-items: center; flex-shrink: 0;
        background: #181825;
      }
      #scapu-sel-bar .lbl { font-size: 11px; color: #a6adc8; }
      #scapu-sel-bar .div { width: 1px; height: 16px; background: #45475a; margin: 0 4px; }
      .scp-btn {
        background: #45475a; border: 1px solid #585b70; color: #cdd6f4;
        padding: 3px 10px; border-radius: 5px; cursor: pointer; font-size: 12px;
      }
      .scp-btn:hover { background: #585b70; }
      .scp-btn.on    { background: #89b4fa; color: #1e1e2e; border-color: #89b4fa; font-weight: 600; }
      .scp-sm {
        background: #45475a; border: 1px solid #585b70; color: #cdd6f4;
        padding: 3px 8px; border-radius: 4px; cursor: pointer; font-size: 11px;
      }
      .scp-sm:hover { background: #585b70; }
      #scapu-tbl-wrap { flex: 1; overflow: auto; min-height: 60px; }
      #scapu-tbl { border-collapse: collapse; width: 100%; font-size: 12px; }
      #scapu-tbl th,
      #scapu-tbl td  { border: 1px solid #313244; padding: 3px 8px; white-space: nowrap; }
      .scp-corner {
        background: #1e1e2e !important;
        position: sticky; top: 0; left: 0; z-index: 3;
        min-width: 28px; text-align: center; color: #585b70; font-size: 11px;
      }
      .scp-ch {
        background: #313244; color: #89b4fa;
        cursor: pointer; user-select: none;
        position: sticky; top: 0; text-align: left;
      }
      .scp-ch:hover { background: #45475a; }
      .scp-ch.off      { color: #6c7086; text-decoration: line-through; }
      .scp-ch.filtered { color: #f9e2af !important; }
      .scp-rn {
        background: #313244; color: #a6e3a1;
        cursor: pointer; user-select: none;
        text-align: center; font-weight: 600;
        position: sticky; left: 0; z-index: 2; min-width: 28px;
      }
      .scp-rn:hover { background: #45475a; }
      .scp-rn.off   { color: #6c7086; }
      .row-off td:not(.scp-rn) { opacity: .3; }
      .col-off { opacity: .3; }
      #scapu-tbl td:not(.scp-rn) { cursor: pointer; }
      #scapu-tbl td:not(.scp-rn):hover { background: #2a2a3e; }
      #scapu-status {
        padding: 3px 10px; font-size: 11px; color: #a6e3a1;
        border-top: 1px solid #45475a; flex-shrink: 0;
      }
      #scapu-out-wrap {
        padding: 8px 10px; border-top: 1px solid #45475a; flex-shrink: 0;
      }
      #scapu-out {
        width: 100%; height: 78px;
        background: #11111b; color: #a6e3a1;
        border: 1px solid #45475a; border-radius: 4px;
        font-family: Consolas, 'Courier New', monospace; font-size: 12px;
        padding: 5px 7px; resize: vertical; box-sizing: border-box;
      }
      #scapu-btns { display: flex; gap: 6px; margin-top: 5px; align-items: center; }
      #scapu-copy {
        background: #a6e3a1; color: #1e1e2e; border: none;
        padding: 4px 14px; border-radius: 5px;
        cursor: pointer; font-weight: 600; font-size: 12px;
      }
      #scapu-copy:hover { background: #94e2d5; }
      #scapu-copy.done  { background: #94e2d5; }
      #scapu-hint { font-size: 10px; color: #6c7086; margin-left: auto; }
      #scapu-ctx {
        display: none; position: fixed;
        z-index: 2147483647;
        background: #313244; border: 1px solid #89b4fa;
        border-radius: 6px; padding: 4px 0; min-width: 200px;
        box-shadow: 0 4px 20px rgba(0,0,0,.7);
        font-family: 'Segoe UI', system-ui, sans-serif; font-size: 12px; color: #cdd6f4;
      }
      .scapu-ctx-item {
        padding: 6px 14px; cursor: pointer; white-space: nowrap;
        overflow: hidden; text-overflow: ellipsis; max-width: 280px;
      }
      .scapu-ctx-item:hover  { background: #45475a; }
      .scapu-ctx-item.muted  { color: #6c7086; cursor: default; pointer-events: none; }
      .scapu-ctx-sep { height: 1px; background: #45475a; margin: 3px 6px; }
    `;
    document.head.appendChild(styleEl);

    // ───────────────────────────────────────────────────────
    // BUILD PANEL
    // ───────────────────────────────────────────────────────

    const panel = document.createElement('div');
    panel.id = 'scapu-overlay';
    panel.innerHTML = `
      <div id="scapu-hdr">
        <span id="scapu-title">SCAPU</span>
        <span id="scapu-sub">Simple Copy &amp; Paste Utility</span>
        <span id="scapu-hotkey">Ctrl+Alt+X</span>
        <button id="scapu-x" title="Close (Ctrl+Alt+X)">✕</button>
      </div>
      <div id="scapu-opts">
        <span class="lbl">Format:</span>
        <button class="scp-btn on" data-m="space">Space</button>
        <button class="scp-btn"    data-m="filter">Filterize (|)</button>
        <button class="scp-btn"    data-m="truncate">Truncate</button>
        <button class="scp-btn"    data-m="tab">Taberize (⇥)</button>
        <button class="scp-btn" id="scapu-newlines" title="Toggle newlines between values (single-column mode)">↵ Lines</button>
        <button class="scp-btn" id="scapu-append" style="margin-left:auto" title="Scrape current page and append rows to existing data">⊕ Append</button>
        <button class="scp-btn" id="scapu-refresh" title="Re-scrape the page and replace existing data">⟳ Refresh</button>
      </div>
      <div id="scapu-sel-bar">
        <span class="lbl">Rows:</span>
        <button class="scp-sm" id="scapu-rows-all">All ✓</button>
        <button class="scp-sm" id="scapu-rows-none">None ✗</button>
        <div class="div"></div>
        <span class="lbl">Cols:</span>
        <button class="scp-sm" id="scapu-cols-all">All ✓</button>
        <button class="scp-sm" id="scapu-cols-none">None ✗</button>
      </div>
      <div id="scapu-tbl-wrap">
        <table id="scapu-tbl">
          <thead id="scapu-thead"></thead>
          <tbody id="scapu-tbody"></tbody>
        </table>
      </div>
      <div id="scapu-status"></div>
      <div id="scapu-out-wrap">
        <textarea id="scapu-out" readonly spellcheck="false" placeholder="Output will appear here..."></textarea>
        <div id="scapu-btns">
          <button id="scapu-copy">Copy to Clipboard</button>
          <button class="scp-sm" id="scapu-all">Select All</button>
          <button class="scp-sm" id="scapu-none">Deselect All</button>
          <span id="scapu-hint">click row# / col header = toggle · click cell = copy · right-click = filter</span>
        </div>
      </div>
    `;
    document.body.appendChild(panel);

    // ───────────────────────────────────────────────────────
    // CONTEXT MENU
    // ───────────────────────────────────────────────────────

    const ctxMenu = document.createElement('div');
    ctxMenu.id = 'scapu-ctx';
    document.body.appendChild(ctxMenu);

    function hideCtx() {
      ctxMenu.style.display = 'none';
      ctxMenu.innerHTML = '';
    }

    function openCtxAt(e) {
      e.preventDefault();
      e.stopPropagation();
      ctxMenu.innerHTML = '';
      const x = Math.min(e.clientX + 2, window.innerWidth - 220);
      const y = Math.min(e.clientY + 2, window.innerHeight - 160);
      ctxMenu.style.left    = x + 'px';
      ctxMenu.style.top     = y + 'px';
      ctxMenu.style.display = 'block';
    }

    function ctxItem(label, onClick, muted) {
      const d = document.createElement('div');
      d.className = 'scapu-ctx-item' + (muted ? ' muted' : '');
      d.textContent = label;
      if (!muted && onClick) d.addEventListener('click', () => { hideCtx(); onClick(); });
      ctxMenu.appendChild(d);
    }

    function ctxSep() {
      const d = document.createElement('div');
      d.className = 'scapu-ctx-sep';
      ctxMenu.appendChild(d);
    }

    // Right-click on a data cell.
    function showCellCtx(e, ri, ci, val) {
      openCtxAt(e);
      const colName = headers[ci] || `Col ${ci + 1}`;
      const hasThisFilter = colFilters[ci] !== undefined;
      const totalFilters  = Object.keys(colFilters).length;

      if (val) {
        const label = val.length > 30 ? `Filter "${colName}" = "${val.slice(0, 28)}…"` : `Filter "${colName}" = "${val}"`;
        ctxItem(label, () => { colFilters[ci] = val; refreshUI(); });
      } else {
        ctxItem('(empty cell — no filter available)', null, true);
      }

      if (hasThisFilter || totalFilters > 0) ctxSep();

      if (hasThisFilter) {
        const cv = colFilters[ci];
        const cl = cv.length > 24 ? cv.slice(0, 22) + '…' : cv;
        ctxItem(`✕  Clear filter on "${colName}" (= "${cl}")`, () => { delete colFilters[ci]; refreshUI(); });
      }

      const othersCount = totalFilters - (hasThisFilter ? 1 : 0);
      if (othersCount > 0) {
        ctxItem(`✕  Clear all filters (${totalFilters})`, () => { colFilters = {}; refreshUI(); });
      }
    }

    // Right-click on a column header.
    function showHeaderCtx(e, ci, colName) {
      openCtxAt(e);
      const hasThisFilter = colFilters[ci] !== undefined;
      const totalFilters  = Object.keys(colFilters).length;
      const name = colName || `Col ${ci + 1}`;

      // Column selection option always shown first.
      ctxItem(`◎  Only select "${name}"`, () => {
        colSel.fill(false);
        colSel[ci] = true;
        refreshUI();
      });

      ctxSep();

      if (hasThisFilter) {
        const cv = colFilters[ci];
        const cl = cv.length > 28 ? cv.slice(0, 26) + '…' : cv;
        ctxItem(`✕  Clear filter: "${cl}"`, () => { delete colFilters[ci]; refreshUI(); });
      } else {
        ctxItem(`No active filter on "${name}"`, null, true);
      }

      if (totalFilters > 1 || (!hasThisFilter && totalFilters > 0)) {
        ctxSep();
        ctxItem(`✕  Clear all filters (${totalFilters})`, () => { colFilters = {}; refreshUI(); });
      }
    }

    // Dismiss context menu on outside click or Escape.
    document.addEventListener('click', e => {
      if (!ctxMenu.contains(e.target)) hideCtx();
    }, true);
    document.addEventListener('keydown', e => {
      if (e.key === 'Escape') hideCtx();
    }, true);

    // ───────────────────────────────────────────────────────
    // ELEMENT CACHES (populated by renderTable)
    // ───────────────────────────────────────────────────────

    let rowEls       = []; // rowEls[ri]     = <tr>
    let cellEls      = []; // cellEls[ri][ci] = <td>
    let colHeaderEls = []; // colHeaderEls[ci] = <th>

    // ───────────────────────────────────────────────────────
    // RENDER TABLE
    // ───────────────────────────────────────────────────────

    function renderTable() {
      const thead = document.getElementById('scapu-thead');
      const tbody = document.getElementById('scapu-tbody');
      rowEls = []; cellEls = []; colHeaderEls = [];

      // ── Header ──
      const hr = document.createElement('tr');
      const corner = document.createElement('th');
      corner.className = 'scp-corner';
      corner.textContent = '#';
      hr.appendChild(corner);

      headers.forEach((h, ci) => {
        const th = document.createElement('th');
        const isFiltered = colFilters[ci] !== undefined;
        th.className = 'scp-ch' + (colSel[ci] ? '' : ' off') + (isFiltered ? ' filtered' : '');
        th.textContent = (h || '—') + (isFiltered ? ' ▼' : '');
        th.title = 'Click: toggle column · Right-click: filter options';
        th.addEventListener('click', () => { colSel[ci] = !colSel[ci]; refreshUI(); });
        th.addEventListener('contextmenu', e => showHeaderCtx(e, ci, h));
        colHeaderEls[ci] = th;
        hr.appendChild(th);
      });

      const hFrag = document.createDocumentFragment();
      hFrag.appendChild(hr);
      thead.innerHTML = '';
      thead.appendChild(hFrag);

      // ── Body — built entirely in a DocumentFragment before touching the DOM ──
      const bFrag = document.createDocumentFragment();

      rows.forEach((row, ri) => {
        const passes = passesFilters(ri);
        const tr = document.createElement('tr');
        if (!rowSel[ri]) tr.classList.add('row-off');
        if (!passes) tr.style.display = 'none';

        const rn = document.createElement('td');
        rn.className = 'scp-rn' + (rowSel[ri] ? '' : ' off');
        rn.textContent = ri + 1;
        rn.title = 'Click to toggle this row';
        rn.addEventListener('click', () => { rowSel[ri] = !rowSel[ri]; refreshUI(); });
        tr.appendChild(rn);

        const rowCells = [];
        headers.forEach((_, ci) => {
          const td = document.createElement('td');
          const val = row[ci] ?? '';
          td.textContent = val;
          if (val) td.title = `${val}\n(click to copy · right-click to filter)`;
          if (!colSel[ci]) td.classList.add('col-off');

          td.addEventListener('click', () => {
            if (!val) return;
            navigator.clipboard.writeText(val).then(() => {
              const prev = { bg: td.style.background, col: td.style.color };
              td.style.cssText += ';background:#a6e3a1!important;color:#1e1e2e!important';
              setTimeout(() => { td.style.background = prev.bg; td.style.color = prev.col; }, 700);
            }).catch(() => {});
          });

          td.addEventListener('contextmenu', e => showCellCtx(e, ri, ci, val));
          rowCells[ci] = td;
          tr.appendChild(td);
        });

        cellEls[ri]  = rowCells;
        rowEls[ri]   = tr;
        bFrag.appendChild(tr);
      });

      tbody.innerHTML = '';
      tbody.appendChild(bFrag);
    }

    // ───────────────────────────────────────────────────────
    // REFRESH DISPLAY
    // ───────────────────────────────────────────────────────

    function refreshUI() {
      // Auto-toggle Lines when crossing the single↔multi column boundary.
      const selColCount = colSel.filter(Boolean).length;
      const curCategory = selColCount === 1 ? 'single' : 'multiple';
      if (curCategory !== prevColCategory) {
        useNewlines = (curCategory === 'multiple');
        document.getElementById('scapu-newlines').classList.toggle('on', useNewlines);
        prevColCategory = curCategory;
      }

      // Column headers — use cache to avoid querySelectorAll.
      colHeaderEls.forEach((th, ci) => {
        if (!th) return;
        const isFiltered = colFilters[ci] !== undefined;
        th.classList.toggle('off', !colSel[ci]);
        th.classList.toggle('filtered', isFiltered);
        th.textContent = (headers[ci] || '—') + (isFiltered ? ' ▼' : '');
      });

      // Rows — use cache.
      rowEls.forEach((tr, ri) => {
        if (!tr) return;
        tr.style.display = passesFilters(ri) ? '' : 'none';
        tr.classList.toggle('row-off', !rowSel[ri]);
        const rn = tr.querySelector('.scp-rn');
        if (rn) rn.classList.toggle('off', !rowSel[ri]);
        if (cellEls[ri]) {
          cellEls[ri].forEach((td, ci) => { if (td) td.classList.toggle('col-off', !colSel[ci]); });
        }
      });

      saveColSel();
      updateStatus();
      updateOutput();
    }

    function updateStatus() {
      const sr = rowSel.filter(Boolean).length;
      const sc = colSel.filter(Boolean).length;
      const fc = Object.keys(colFilters).length;
      const filterNote = fc ? ` · ${fc} filter${fc > 1 ? 's' : ''} active` : '';
      const skipNote   = skipMsg ? ` · ${skipMsg}` : '';
      document.getElementById('scapu-status').textContent =
        `${sr} of ${rows.length} rows · ${sc} of ${headers.length} cols selected${filterNote}${skipNote}`;
    }

    function updateOutput() {
      document.getElementById('scapu-out').value = getOutput();
    }

    // ───────────────────────────────────────────────────────
    // APPEND SCRAPE (with duplicate detection)
    // ───────────────────────────────────────────────────────

    function appendScrape() {
      const fresh = scrapeBC();

      // Map fresh column names → unified column indices (extend headers[] for new cols).
      const hMap = {};
      headers.forEach((h, i) => { if (h) hMap[h] = i; });

      const freshColMap = fresh.headers.map(h => {
        if (hMap[h] !== undefined) return hMap[h];
        hMap[h] = headers.length;
        headers.push(h);
        return hMap[h];
      });

      const totalCols = headers.length;

      // Pad existing rows and colSel to cover any newly added columns.
      rows = rows.map(r => {
        const nr = [...r];
        while (nr.length < totalCols) nr.push('');
        return nr;
      });
      while (colSel.length < totalCols) colSel.push(true);

      // Build fingerprint set from existing rows keyed on the fresh column layout.
      // Each key is the fresh-column values joined — comparable to fresh row keys.
      const existingKeys = new Set();
      rows.forEach(er => {
        existingKeys.add(freshColMap.map((ci) => er[ci] ?? '').join('\x00'));
      });

      let skipped = 0;
      const freshRowSel = buildRowSel(fresh.selectedIndices, fresh.rows.length);

      fresh.rows.forEach((r, rIdx) => {
        // Key in the same fresh-column order.
        const key = r.map(v => v ?? '').join('\x00');

        if (existingKeys.has(key)) {
          skipped++;
          return;
        }

        // Add to set so intra-batch duplicates are also caught.
        existingKeys.add(key);

        const nr = new Array(totalCols).fill('');
        fresh.headers.forEach((_, fi) => { nr[freshColMap[fi]] = r[fi] ?? ''; });
        rows.push(nr);
        rowSel.push(freshRowSel[rIdx] ?? true);
      });

      renderTable();

      prevColCategory = colSel.filter(Boolean).length === 1 ? 'single' : 'multiple';
      useNewlines = (prevColCategory === 'multiple');
      document.getElementById('scapu-newlines').classList.toggle('on', useNewlines);

      if (skipped > 0) {
        skipMsg = `${skipped} duplicate${skipped > 1 ? 's' : ''} skipped`;
        setTimeout(() => { skipMsg = ''; updateStatus(); }, 3000);
      } else {
        skipMsg = '';
      }

      updateStatus();
      updateOutput();
    }

    // ───────────────────────────────────────────────────────
    // EVENTS
    // ───────────────────────────────────────────────────────

    panel.querySelectorAll('.scp-btn[data-m]').forEach(btn => {
      btn.addEventListener('click', () => {
        mode = btn.dataset.m;
        panel.querySelectorAll('.scp-btn[data-m]').forEach(b => b.classList.remove('on'));
        btn.classList.add('on');
        updateOutput();
      });
    });

    document.getElementById('scapu-newlines').addEventListener('click', () => {
      useNewlines = !useNewlines;
      document.getElementById('scapu-newlines').classList.toggle('on', useNewlines);
      updateOutput();
    });

    document.getElementById('scapu-x').addEventListener('click', () => {
      panel.remove();
      styleEl.remove();
      ctxMenu.remove();
      document.removeEventListener('mousemove', onDrag);
      document.removeEventListener('mouseup', onDragEnd);
    });

    document.getElementById('scapu-copy').addEventListener('click', () => {
      const val = document.getElementById('scapu-out').value;
      if (!val) return;
      navigator.clipboard.writeText(val).then(() => {
        const btn = document.getElementById('scapu-copy');
        btn.textContent = 'Copied!';
        btn.classList.add('done');
        setTimeout(() => { btn.textContent = 'Copy to Clipboard'; btn.classList.remove('done'); }, 1500);
      }).catch(() => {});
    });

    // Row controls
    document.getElementById('scapu-rows-all').addEventListener('click',  () => { rowSel.fill(true);  refreshUI(); });
    document.getElementById('scapu-rows-none').addEventListener('click', () => { rowSel.fill(false); refreshUI(); });

    // Column controls
    document.getElementById('scapu-cols-all').addEventListener('click',  () => { colSel.fill(true);  refreshUI(); });
    document.getElementById('scapu-cols-none').addEventListener('click', () => { colSel.fill(false); refreshUI(); });

    // Select All / Deselect All (both)
    document.getElementById('scapu-all').addEventListener('click',  () => { colSel.fill(true);  rowSel.fill(true);  refreshUI(); });
    document.getElementById('scapu-none').addEventListener('click', () => { colSel.fill(false); rowSel.fill(false); refreshUI(); });

    // Refresh — re-scrape, replace all data, clear filters.
    document.getElementById('scapu-refresh').addEventListener('click', () => {
      const fresh = scrapeBC();
      headers    = fresh.headers;
      rows       = fresh.rows;
      colSel     = new Array(headers.length).fill(true);
      rowSel     = buildRowSel(fresh.selectedIndices, rows.length);
      colFilters = {};
      skipMsg    = '';
      loadColSel();
      renderTable();
      updateStatus();
      prevColCategory = colSel.filter(Boolean).length === 1 ? 'single' : 'multiple';
      useNewlines = (prevColCategory === 'multiple');
      document.getElementById('scapu-newlines').classList.toggle('on', useNewlines);
      updateOutput();
    });

    // Append — scrape current page and merge rows into existing data.
    document.getElementById('scapu-append').addEventListener('click', appendScrape);

    // ───────────────────────────────────────────────────────
    // DRAG
    // ───────────────────────────────────────────────────────

    const hdr = document.getElementById('scapu-hdr');
    let dragging = false, ox = 0, oy = 0;

    hdr.addEventListener('mousedown', e => {
      if (e.target.id === 'scapu-x') return;
      dragging = true;
      const r = panel.getBoundingClientRect();
      ox = e.clientX - r.left;
      oy = e.clientY - r.top;
      e.preventDefault();
    });

    function onDrag(e) {
      if (!dragging) return;
      panel.style.right = 'auto';
      panel.style.left  = (e.clientX - ox) + 'px';
      panel.style.top   = (e.clientY - oy) + 'px';
    }

    function onDragEnd() { dragging = false; }

    document.addEventListener('mousemove', onDrag);
    document.addEventListener('mouseup',   onDragEnd);

    // ───────────────────────────────────────────────────────
    // INIT
    // ───────────────────────────────────────────────────────

    renderTable();
    updateStatus();

    prevColCategory = colSel.filter(Boolean).length === 1 ? 'single' : 'multiple';
    useNewlines = (prevColCategory === 'multiple');
    document.getElementById('scapu-newlines').classList.toggle('on', useNewlines);

    updateOutput();

  } // end launchSCAPU

})();