// ==UserScript==
// @name Legal Acts Finder
// @namespace http://tampermonkey.net/
// @version 1.9
// @description Detect Acts/Bills/Rules/Regulations/Orders/Sections, highlight mentions, show a smooth slide-out sidebar with PDFs (or Google fallback). Dedup by name+year, keep order, smooth scroll & flash, batch-open PDFs. Draggable vertically. Session-persistent per page.
// @author iamnobody
// @license MIT
// @match *://*/*
// @grant GM_xmlhttpRequest
// @icon https://greasyfork.s3.us-east-2.amazonaws.com/hin0zyjrsudy75bgad4hggtwu2ib
// @banner https://greasyfork.s3.us-east-2.amazonaws.com/raibg6tl78seouyv8nbjhis9qgju
// ==/UserScript==
(function () {
'use strict';
/* ---------- Config ---------- */
const MIN_WIDTH_PX = 250;
const MAX_WIDTH_PCT = 30;
const TRANS_MS = 320;
const FETCH_CONCURRENCY = 4;
const OPEN_DELAY_MS = 300;
const STORAGE_PREFIX = 'laf_v1_'; // session keys prefix
/* ---------- Regexes ---------- */
// Matches multi-word names ending with Act/Bill/Rules/Regulation/Order/Code/Law (with optional ", 2005" or "of 2005" or "year 2005")
const ENTITY_REGEX = /\b([A-Z][A-Za-z0-9&\-\s\.]{2,}?(?:\b(?:Act|Bill|Rules|Regulation|Regulations|Order|Code|Law)\b)(?:[\s,]*(?:of|year)?[\s,]*\d{4})?)\b/gi;
// Section references like "Section 7 of Companies Act, 1956" (covers Sec. as well)
const SECTION_REGEX = /\b(Sec(?:tion)?\.?\s+\d+[A-Za-z]?\s+of\s+[A-Z][A-Za-z0-9&\-\s\.]{2,}?(?:\b(?:Act|Bill|Rules|Regulation|Regulations|Order|Code|Law)\b)(?:[\s,]*\d{4})?)\b/gi;
const YEAR_EXTRACT = /\b(18|19|20)\d{2}\b/;
/* ---------- Utilities ---------- */
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const uid = (s) => 'uid_' + Math.random().toString(36).slice(2, 9);
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
/* ---------- Data structures ---------- */
// uid -> { name, year|null, key, firstIndex, pdfUrl|null, type: 'pdf'|'search'|'pending' }
const uniqueMap = new Map();
const uidOrder = []; // array of uids in first-appearance order
let mentionCount = 0;
/* ---------- Fetch queue (limited concurrency) ---------- */
function createQueue(concurrency = FETCH_CONCURRENCY) {
const q = [];
let running = 0;
const runNext = () => {
if (running >= concurrency || q.length === 0) return;
const { query, resolve } = q.shift();
running++;
fetchPdf(query).then(res => {
running--;
resolve(res);
runNext();
}).catch(err => {
running--;
resolve({ url: `https://www.google.com/search?q=${encodeURIComponent(query + ' pdf')}`, type: 'search' });
runNext();
});
};
return (query) => new Promise(res => { q.push({ query, resolve: res }); runNext(); });
}
const queuedFetch = createQueue();
function fetchPdf(query) {
return new Promise((resolve) => {
const googleUrl = `https://www.google.com/search?q=${encodeURIComponent(query + ' pdf')}`;
try {
GM_xmlhttpRequest({
method: 'GET',
url: googleUrl,
headers: { 'User-Agent': navigator.userAgent },
onload(response) {
const html = response && response.responseText ? response.responseText : '';
const pdfMatch = html.match(/https?:\/\/[^"'>\s]+?\.pdf\b/gi);
if (pdfMatch && pdfMatch.length) {
const url = pdfMatch[0].replace(/\\u0026/g, '&');
resolve({ url, type: 'pdf' });
} else {
resolve({ url: googleUrl, type: 'search' });
}
},
onerror() { resolve({ url: googleUrl, type: 'search' }); },
timeout: 15000
});
} catch (e) {
resolve({ url: googleUrl, type: 'search' });
}
});
}
/* ---------- DOM walking & replacement ---------- */
function isIgnorableNode(node) {
if (!node) return true;
const tag = node.nodeName;
return (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'IFRAME' || tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT');
}
function normalizeName(raw) {
// Trim, collapse spaces, remove leading/trailing punctuation
let s = raw.replace(/\s+/g, ' ').trim();
s = s.replace(/^[\W_]+|[\W_]+$/g, '').trim();
return s;
}
function createKey(name, year) {
return (name.toLowerCase()) + '|' + (year || '__NOYEAR__');
}
// walk all text nodes and replace matches with spans
function scanAndAnnotate(root = document.body) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
const textNodes = [];
while (walker.nextNode()) {
const n = walker.currentNode;
if (!n.nodeValue || !n.nodeValue.trim()) continue;
const parent = n.parentNode;
if (parent && isIgnorableNode(parent)) continue;
textNodes.push(n);
}
// process each text node
for (let i = 0; i < textNodes.length; i++) {
const tn = textNodes[i];
const original = tn.nodeValue;
let frag = document.createDocumentFragment();
let lastIndex = 0;
const combinedRegex = new RegExp(`${ENTITY_REGEX.source}|${SECTION_REGEX.source}`, 'gi');
let m;
combinedRegex.lastIndex = 0;
while ((m = combinedRegex.exec(original)) !== null) {
const matched = m[0];
const start = m.index;
const end = combinedRegex.lastIndex;
if (start > lastIndex) {
frag.appendChild(document.createTextNode(original.slice(lastIndex, start)));
}
const norm = normalizeName(matched);
const yearMatch = norm.match(YEAR_EXTRACT);
const year = yearMatch ? yearMatch[0] : null;
const baseName = year ? norm.replace(YEAR_EXTRACT, '').replace(/\b(of|year)\b/gi, '').trim() : norm;
const key = createKey(baseName, year);
let entryUid;
if (uniqueMap.has(key)) {
entryUid = uniqueMap.get(key).uid;
} else {
entryUid = uid(baseName + (year || ''));
uniqueMap.set(key, { uid: entryUid, name: baseName, year: year, key, pdfUrl: null, type: 'pending', firstIndex: uidOrder.length });
uidOrder.push(entryUid);
}
// create span
const span = document.createElement('span');
span.className = 'la-mention';
span.dataset.uid = entryUid;
span.dataset.matchText = matched;
span.textContent = matched;
// click handler opens link in new tab (if available), else queue fetch then open
span.addEventListener('click', async (e) => {
e.stopPropagation();
const mapEntry = [...uniqueMap.values()].find(it => it.uid === entryUid);
if (!mapEntry) return;
if (mapEntry.type === 'pdf' && mapEntry.pdfUrl) {
window.open(mapEntry.pdfUrl, '_blank', 'noopener');
return;
}
if (mapEntry.type === 'search' && mapEntry.pdfUrl) {
window.open(mapEntry.pdfUrl, '_blank', 'noopener');
return;
}
// else fetch now
const res = await queuedFetch(mapEntry.year ? `${mapEntry.name} ${mapEntry.year}` : mapEntry.name);
mapEntry.pdfUrl = res.url; mapEntry.type = res.type;
updateSidebarEntry(mapEntry.uid); // update UI if already built
window.open(res.url, '_blank', 'noopener');
});
frag.appendChild(span);
mentionCount++;
lastIndex = end;
}
if (lastIndex < original.length) {
frag.appendChild(document.createTextNode(original.slice(lastIndex)));
}
if (frag.childNodes.length > 0) {
tn.parentNode.replaceChild(frag, tn);
}
}
}
/* ---------- Styles ---------- */
function injectStyles() {
const css = `
.la-mention{border-bottom:2px dotted rgba(200,30,30,0.9);cursor:pointer;transition:background 220ms}
.la-mention.la-flash{animation:laf-flash 900ms ease forwards}
@keyframes laf-flash{0%{background:rgba(255,255,0,0.95)}60%{background:rgba(255,255,0,0.5)}100%{background:transparent}}
/* Sidebar */
#laf-container{position:fixed;right:0;top:50%;transform:translateY(-50%);z-index:2147483646}
#laf-panel{position:fixed;right:0;top:50%;transform:translateX(100%) translateY(-50%);transition:transform ${TRANS_MS}ms cubic-bezier(.2,.9,.2,1),opacity ${TRANS_MS}ms;opacity:0;box-shadow:0 12px 30px rgba(0,0,0,0.18);border-radius:12px 0 0 12px;overflow:hidden;display:flex;flex-direction:column;max-height:80vh;background:var(--laf-bg);color:var(--laf-fg)}
#laf-panel.open{transform:translateX(0) translateY(-50%);opacity:1}
#laf-header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;cursor:grab;border-bottom:1px solid rgba(0,0,0,0.06)}
#laf-title{font-weight:700;font-size:15px;display:flex;align-items:center;gap:10px}
#laf-controls{display:flex;gap:8px;align-items:center}
#laf-openall{padding:6px 10px;border-radius:8px;border:1px solid rgba(0,0,0,0.06);background:transparent;cursor:pointer}
#laf-openall[disabled]{opacity:0.45;cursor:not-allowed}
#laf-close{background:transparent;border:0;font-size:18px;cursor:pointer;padding:6px 8px}
#laf-list{padding:10px;overflow:auto;flex:1 1 auto}
.laf-item{padding:8px 10px;border-radius:8px;margin-bottom:8px;transition:background 160ms}
.laf-item:hover{background:rgba(0,0,0,0.03)}
.laf-item a{color:var(--laf-accent);text-decoration:none;font-weight:600;display:block}
.laf-meta{font-size:12px;color:rgba(0,0,0,0.55);margin-top:6px}
#laf-accordion{padding:10px;border-top:1px solid rgba(0,0,0,0.04)}
#laf-acc-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none}
#laf-acc-arrow{transition:transform ${TRANS_MS}ms}
#laf-acc-content{overflow:hidden;max-height:0;transition:max-height ${TRANS_MS}ms cubic-bezier(.2,.9,.2,1);padding-top:8px}
#laf-footer{padding:8px 12px;border-top:1px solid rgba(0,0,0,0.05);font-size:12px;display:flex;justify-content:space-between;align-items:center}
#laf-tab{position:absolute;right:0;top:50%;transform:translateY(-50%);width:40px;height:84px;border-radius:10px 0 0 10px;background:var(--laf-accent);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;cursor:pointer;box-shadow:0 8px 20px rgba(0,0,0,0.18)}
@media (prefers-color-scheme:dark){
:root{--laf-bg:#07101a;--laf-fg:#e6eef8;--laf-accent:#ff9933}
}
@media (prefers-color-scheme:light){
:root{--laf-bg:#ffffff;--laf-fg:#0b1220;--laf-accent:#ff9933}
}
@media(max-width:520px){
#laf-panel{min-width:220px}
#laf-tab{height:64px;width:36px}
}`;
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
/* ---------- Sidebar UI ---------- */
function buildUI() {
// container and panel
const container = document.createElement('div');
container.id = 'laf-container';
document.body.appendChild(container);
const tab = document.createElement('div');
tab.id = 'laf-tab';
tab.textContent = '‹';
container.appendChild(tab);
const panel = document.createElement('aside');
panel.id = 'laf-panel';
// compute width responsive
const setPanelWidth = () => {
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const px = Math.max(MIN_WIDTH_PX, Math.floor((MAX_WIDTH_PCT / 100) * vw));
panel.style.width = Math.min(px + 'px', '100%');
};
setPanelWidth();
window.addEventListener('resize', setPanelWidth);
// header
const header = document.createElement('div');
header.id = 'laf-header';
const title = document.createElement('div');
title.id = 'laf-title';
title.innerHTML = `<span style="width:10px;height:10px;border-radius:50%;background:var(--laf-accent);display:inline-block"></span><span>Acts & Rules</span>`;
header.appendChild(title);
const controls = document.createElement('div');
controls.id = 'laf-controls';
const openAllBtn = document.createElement('button');
openAllBtn.id = 'laf-openall';
openAllBtn.textContent = 'Open All PDFs (0)';
openAllBtn.disabled = true;
controls.appendChild(openAllBtn);
const closeBtn = document.createElement('button');
closeBtn.id = 'laf-close';
closeBtn.textContent = '✕';
controls.appendChild(closeBtn);
header.appendChild(controls);
panel.appendChild(header);
// list
const listWrap = document.createElement('div');
listWrap.id = 'laf-list';
panel.appendChild(listWrap);
// accordion for individual PDFs
const accordion = document.createElement('div');
accordion.id = 'laf-accordion';
const accToggle = document.createElement('div');
accToggle.id = 'laf-acc-toggle';
accToggle.innerHTML = `<span id="laf-acc-arrow">►</span><span>View individual PDFs</span>`;
const accContent = document.createElement('div');
accContent.id = 'laf-acc-content';
accordion.appendChild(accToggle);
accordion.appendChild(accContent);
panel.appendChild(accordion);
// footer
const footer = document.createElement('div');
footer.id = 'laf-footer';
footer.innerHTML = `<div style="opacity:0.85">Tip: Alt+Shift+L toggles</div><div style="opacity:0.6;font-size:11px">Queue fetch & polite</div>`;
panel.appendChild(footer);
container.appendChild(panel);
// session persistence keys
const pageKey = STORAGE_PREFIX + (location.hostname + location.pathname);
const topKey = pageKey + '_top';
// restore state
const state = sessionStorage.getItem(pageKey);
if (state === 'open') {
panel.classList.add('open');
tab.style.display = 'none';
} else {
panel.classList.remove('open');
tab.style.display = 'flex';
// center tab
container.style.top = '50%';
container.style.transform = 'translateY(-50%)';
}
const topPx = sessionStorage.getItem(topKey);
if (topPx) {
container.style.top = topPx + 'px';
container.style.transform = 'none';
}
// toggle logic
function openPanel() {
panel.classList.add('open');
tab.style.display = 'none';
sessionStorage.setItem(pageKey, 'open');
}
function closePanel() {
panel.classList.remove('open');
tab.style.display = 'flex';
sessionStorage.setItem(pageKey, 'closed');
// reset center on close
container.style.top = '50%';
container.style.transform = 'translateY(-50%)';
sessionStorage.removeItem(topKey);
}
tab.addEventListener('click', () => openPanel());
closeBtn.addEventListener('click', () => closePanel());
// drag vertical when open
let dragging = false, dragStartY = 0, currentCenterY = null;
header.addEventListener('mousedown', (e) => {
if (!panel.classList.contains('open')) return;
dragging = true;
dragStartY = e.clientY;
const rect = container.getBoundingClientRect();
currentCenterY = rect.top + rect.height / 2;
container.style.transform = 'none';
container.style.top = currentCenterY + 'px';
header.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
const dy = e.clientY - dragStartY;
const newCenter = currentCenterY + dy;
const panelRect = panel.getBoundingClientRect();
const half = panelRect.height / 2;
const minCenter = half + 8;
const maxCenter = window.innerHeight - half - 8;
const clamped = clamp(newCenter, minCenter, maxCenter);
currentCenterY = clamped;
container.style.top = clamped + 'px';
dragStartY = e.clientY;
});
window.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
header.style.cursor = 'grab';
document.body.style.userSelect = '';
const pageKeyTop = topKey;
const val = container.getBoundingClientRect().top + container.getBoundingClientRect().height / 2;
sessionStorage.setItem(pageKeyTop, String(val));
});
// keyboard toggle Alt+Shift+L
window.addEventListener('keydown', (e) => {
if (!(e.altKey && e.shiftKey && e.key.toUpperCase() === 'L')) return;
const active = document.activeElement;
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) return;
e.preventDefault();
if (panel.classList.contains('open')) closePanel(); else openPanel();
});
// accordion toggle
let accOpen = false;
accToggle.addEventListener('click', () => {
accOpen = !accOpen;
const arrow = accToggle.querySelector('#laf-acc-arrow');
if (accOpen) {
arrow.style.transform = 'rotate(90deg)';
accContent.style.maxHeight = Math.min(accContent.scrollHeight + 40, window.innerHeight * 0.5) + 'px';
} else {
arrow.style.transform = 'rotate(0deg)';
accContent.style.maxHeight = '0';
}
});
// open all
openAllBtn.addEventListener('click', async () => {
const pdfList = uidOrder.map(u => [...uniqueMap.values()].find(it => it.uid === u)).filter(it => it && it.type === 'pdf' && it.pdfUrl);
const n = pdfList.length;
if (!n) return;
if (!confirm(`Open ${n} PDF(s) in new tabs?`)) return;
for (let i = 0; i < pdfList.length; i++) {
window.open(pdfList[i].pdfUrl, '_blank', 'noopener');
// polite delay
await new Promise(r => setTimeout(r, OPEN_DELAY_MS));
}
});
// expose some helpers
return {
panel,
container,
listWrap,
accContent,
openAllBtn,
openPanel,
closePanel,
updateEntryUI: updateSidebarEntry,
setPanelWidth
};
}
/* ---------- UI update helpers ---------- */
function updateSidebarEntry(u) {
// Called after a fetch resolves to update that entry's UI (if built)
const listItem = document.querySelector(`.laf-item[data-uid="${u}"]`);
if (!listItem) return;
const entry = [...uniqueMap.values()].find(it => it.uid === u);
if (!entry) return;
listItem.innerHTML = '';
const a = document.createElement('a');
a.href = entry.pdfUrl || (`https://www.google.com/search?q=${encodeURIComponent(entry.name + (entry.year ? ' ' + entry.year : '') + ' pdf')}`);
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = entry.year ? `${entry.name}, ${entry.year}` : entry.name;
listItem.appendChild(a);
const meta = document.createElement('div');
meta.className = 'laf-meta';
meta.textContent = entry.type === 'pdf' ? 'Direct PDF found' : 'No direct PDF — Google search';
listItem.appendChild(meta);
}
/* ---------- Build list UI after scanning ---------- */
function populateSidebar(ui) {
const listWrap = ui.listWrap;
listWrap.innerHTML = '';
if (uidOrder.length === 0) {
const none = document.createElement('div');
none.className = 'laf-item';
none.textContent = 'No Acts/Bills/Rules detected on this page.';
const hint = document.createElement('div');
hint.className = 'laf-meta';
const pageQuery = (document.title || location.hostname || '').trim() + ' law act pdf';
hint.innerHTML = `Try a web search: <a href="https://www.google.com/search?q=${encodeURIComponent(pageQuery)}" target="_blank" rel="noopener noreferrer">Search</a>`;
listWrap.appendChild(none); listWrap.appendChild(hint);
return;
}
// For each unique uid in order, create list item
uidOrder.forEach((u) => {
const entry = [...uniqueMap.values()].find(it => it.uid === u);
if (!entry) return;
const li = document.createElement('div');
li.className = 'laf-item';
li.dataset.uid = u;
// placeholder link text while fetching
const a = document.createElement('a');
a.href = '#';
a.textContent = entry.year ? `${entry.name}, ${entry.year}` : entry.name;
a.addEventListener('click', (e) => {
e.preventDefault();
// find first span with this uid
const span = document.querySelector(`span.la-mention[data-uid="${u}"]`);
if (span) {
const rect = span.getBoundingClientRect();
const targetY = rect.top + window.scrollY - (window.innerHeight / 2) + (rect.height / 2);
window.scrollTo({ top: Math.max(0, targetY), behavior: 'smooth' });
// flash highlight
span.classList.remove('la-flash');
// force reflow to restart animation
void span.offsetWidth;
span.classList.add('la-flash');
}
// open link if available
const mapEntry = [...uniqueMap.values()].find(it => it.uid === u);
if (!mapEntry) return;
if (mapEntry.type === 'pdf' && mapEntry.pdfUrl) {
window.open(mapEntry.pdfUrl, '_blank', 'noopener');
} else if (mapEntry.type === 'search' && mapEntry.pdfUrl) {
window.open(mapEntry.pdfUrl, '_blank', 'noopener');
} else {
// trigger fetch and open when ready
queuedFetch(mapEntry.year ? `${mapEntry.name} ${mapEntry.year}` : mapEntry.name).then(res => {
mapEntry.pdfUrl = res.url; mapEntry.type = res.type;
updateSidebarEntry(u);
window.open(res.url, '_blank', 'noopener');
});
}
});
li.appendChild(a);
const meta = document.createElement('div');
meta.className = 'laf-meta';
meta.textContent = 'Looking up PDF…';
li.appendChild(meta);
listWrap.appendChild(li);
});
}
/* ---------- After scan: start fetching for each unique entry ---------- */
async function resolveAllEntries(ui) {
const entries = uidOrder.map(u => [...uniqueMap.values()].find(it => it.uid === u));
for (const entry of entries) {
try {
const q = entry.year ? `${entry.name} ${entry.year}` : entry.name;
const res = await queuedFetch(q);
entry.pdfUrl = res.url;
entry.type = res.type;
updateSidebarEntry(entry.uid);
} catch (err) {
entry.pdfUrl = `https://www.google.com/search?q=${encodeURIComponent(entry.name + (entry.year ? ' ' + entry.year : '') + ' pdf')}`;
entry.type = 'search';
updateSidebarEntry(entry.uid);
}
updateOpenAllButton(ui);
buildAccordionContent(ui);
}
updateOpenAllButton(ui);
buildAccordionContent(ui);
}
function updateOpenAllButton(ui) {
const pdfCount = uidOrder.map(u => [...uniqueMap.values()].find(it => it.uid === u)).filter(e => e && e.type === 'pdf' && e.pdfUrl).length;
ui.openAllBtn.textContent = `Open All PDFs (${pdfCount})`;
ui.openAllBtn.disabled = pdfCount === 0;
}
function buildAccordionContent(ui) {
const acc = ui.accContent;
acc.innerHTML = '';
const pdfEntries = uidOrder.map(u => [...uniqueMap.values()].find(it => it.uid === u)).filter(e => e && e.type === 'pdf' && e.pdfUrl);
if (pdfEntries.length === 0) {
const hint = document.createElement('div');
hint.className = 'laf-meta';
hint.textContent = 'No direct PDFs found yet.';
acc.appendChild(hint);
return;
}
pdfEntries.forEach(pe => {
const row = document.createElement('div');
row.className = 'la-pdf-item';
const a = document.createElement('a');
a.href = pe.pdfUrl;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = pe.year ? `${pe.name}, ${pe.year}` : pe.name;
row.appendChild(a);
acc.appendChild(row);
});
}
/* ---------- Main init ---------- */
function init() {
try {
injectStyles();
scanAndAnnotate(document.body);
// build UI
const ui = buildUI();
// populate sidebar with placeholders (we'll update after fetching)
populateSidebar(ui);
// kick off fetches for entries
resolveAllEntries(ui);
// update sidebar when uniqueMap changes by mapping each uid to sidebar items
// also update open all button after fetches
// nothing else needed.
// Safety: if core scanning found zero items, leave tab present but show helpful message
if (uidOrder.length === 0) {
// still keep tab and panel with hint; nothing else to do
// no extra features added that might hurt detection
}
} catch (err) {
console.error('Legal Acts Finder init error:', err);
}
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(init, 300);
} else {
window.addEventListener('load', () => setTimeout(init, 300));
}
})();