NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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 & 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
})();