Files
dobetternorge-tools/assets/js/citations.js
T
daveadmin bffc714541 Add My Docs picker to deep-research, advocate, barnevernet, korrespond, citations
- PHP: Add docPickerSection (button + chips + hidden input) to all 5 tool pages
- JS: Send doc_ids in payload for deep-research, advocate, barnevernet, korrespond
- Backend: Inject selected corpus doc content into paste_text/narrative/notes via dbnToolsInjectDocContent
- Citations: Add upload zone (file → api/extract.php → textarea) + paste textarea with live Norwegian legal reference extraction (regex) + ref chips → title search; doc picker populates titleInput via MutationObserver

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:04:45 +02:00

410 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
if (document.body.dataset.activeTool !== 'citations') return;
const form = document.getElementById('citationsForm');
const titleInput = document.getElementById('titleInput');
const docIdInput = document.getElementById('docIdHidden');
const acList = document.getElementById('citAutocomplete');
const depthRow = document.getElementById('depthRow');
const depthRange = document.getElementById('depthRange');
const depthValue = document.getElementById('depthValue');
const runBtn = document.getElementById('citeRunBtn');
const statusEl = document.getElementById('citStatus');
const resultsDiv = document.getElementById('citationResults');
const sourceCard = document.getElementById('citSourceCard');
const resultsArea = document.getElementById('citResultsArea');
const trailList = document.getElementById('trailList');
const trail = [];
// ── Utilities ─────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function setStatus(msg, kind) {
statusEl.textContent = msg;
statusEl.style.color = kind === 'error' ? '#b41e1e' : kind === 'ok' ? 'var(--teal-dark)' : 'var(--muted)';
}
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
function authorityLabel(atype) {
const map = {
case_law: 'Case law', guidance: 'Guidance', report: 'Report',
law: 'Statute', treaty: 'Treaty', ombudsman: 'Ombudsman',
tribunal: 'Tribunal', regulatory: 'Regulatory',
};
return map[atype] || atype || '';
}
function relChipClass(action, relType) {
if (action === 'chain') return 'rel-chip--reachable';
if (action === 'cited_by') return 'rel-chip--cited-by';
if (action === 'cites') return 'rel-chip--cites';
const rt = (relType || '').toUpperCase();
if (rt === 'REPEALS') return 'rel-chip--repeals';
return 'rel-chip--implements';
}
function relChipLabel(action, relType) {
if (action === 'chain') return 'Reachable';
if (action === 'cited_by') return 'Cited by';
if (action === 'cites') return 'Cites';
if (relType) return relType.replace(/_/g, ' ');
return 'Implements';
}
function yearFrom(dateStr) {
if (!dateStr) return '';
return String(dateStr).slice(0, 4);
}
// ── Autocomplete ──────────────────────────────────────────────────────────
function hideAc() { acList.hidden = true; }
function renderAc(docs) {
if (!docs.length) { hideAc(); return; }
acList.innerHTML = docs.map(d => {
const al = authorityLabel(d.authority_type);
return `<li role="option" data-id="${esc(String(d.id))}" data-title="${esc(d.title || '')}">`
+ esc(d.title || '(Untitled)')
+ (al ? `<span class="cit-ac-atype">${esc(al)}</span>` : '')
+ `</li>`;
}).join('');
acList.hidden = false;
}
const fetchAc = debounce(async (q) => {
if (!q || q.length < 2) { hideAc(); return; }
try {
const res = await fetch('api/corpus-documents.php?' + new URLSearchParams({ title: q, limit: 15 }), { credentials: 'same-origin' });
if (!res.ok) { hideAc(); return; }
const data = await res.json();
renderAc(data.documents || []);
} catch { hideAc(); }
}, 300);
titleInput.addEventListener('input', (e) => {
const val = e.target.value.trim();
if (/^\d+$/.test(val)) {
docIdInput.value = val;
hideAc();
} else {
docIdInput.value = '';
fetchAc(val);
}
});
acList.addEventListener('mousedown', (e) => {
const li = e.target.closest('li[data-id]');
if (!li) return;
e.preventDefault();
titleInput.value = li.dataset.title;
docIdInput.value = li.dataset.id;
hideAc();
});
titleInput.addEventListener('blur', () => setTimeout(hideAc, 150));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideAc(); });
// ── Legal reference extraction ─────────────────────────────────────────────
const REF_PATTERNS = [
/\bLOV-\d{4}-\d{2}-\d{2}-\d+\b/g,
/\bFOR-\d{4}-\d{2}-\d{2}-\d+\b/g,
/\bHR-\d{4}-\d{3,5}-[AUS]\b/g,
/\bLB-\d{4}-\d+\b/g,
/\bLA-\d{4}-\d+\b/g,
/\bLE-\d{4}-\d+\b/g,
/\bLF-\d{4}-\d+\b/g,
/\bLG-\d{4}-\d+\b/g,
/\bRt\.\s*\d{4}\s+\d+\b/g,
];
const refChipsEl = document.getElementById('citRefChips');
function extractRefs(text) {
const found = new Set();
REF_PATTERNS.forEach((rx) => {
const matches = text.match(new RegExp(rx.source, rx.flags)) || [];
matches.forEach((m) => found.add(m.replace(/\s+/g, ' ').trim()));
});
return Array.from(found).slice(0, 20);
}
function renderRefChips(refs) {
if (!refChipsEl) return;
refChipsEl.innerHTML = refs.map((ref) => {
const safe = ref.replace(/"/g, '&quot;');
return `<button type="button" class="doc-chip" style="cursor:pointer" data-ref="${safe}">`
+ `<span class="doc-chip__label">${ref}</span>`
+ `</button>`;
}).join('');
refChipsEl.querySelectorAll('.doc-chip').forEach((btn) => {
btn.addEventListener('click', () => {
titleInput.value = btn.dataset.ref;
docIdInput.value = '';
fetchAc(btn.dataset.ref);
titleInput.focus();
});
});
}
const citPasteInput = document.getElementById('citPasteInput');
if (citPasteInput) {
citPasteInput.addEventListener('input', debounce(() => {
const refs = extractRefs(citPasteInput.value);
renderRefChips(refs);
}, 400));
}
// ── File upload → extract text → populate paste area ─────────────────────
const citUploadZone = document.getElementById('citUploadZone');
const citUploadInput = document.getElementById('citUploadInput');
const citUploadPrompt = document.getElementById('citUploadPrompt');
const citUploadFileInfo = document.getElementById('citUploadFileInfo');
const citUploadFileList = document.getElementById('citUploadFileList');
const citUploadClear = document.getElementById('citUploadClear');
function handleCitFile(file) {
if (!file) return;
citUploadFileList.innerHTML = `<li><span class="upload-filename">${esc(file.name)}</span> <em class="upload-chars">Extracting…</em></li>`;
citUploadPrompt.classList.add('is-hidden');
citUploadFileInfo.classList.remove('is-hidden');
const fd = new FormData();
fd.append('file', file);
fetch('api/extract.php', { method: 'POST', body: fd, credentials: 'same-origin' })
.then((r) => r.json())
.then((data) => {
const text = data.text || '';
if (citPasteInput) {
citPasteInput.value = text.slice(0, 8000);
citPasteInput.dispatchEvent(new Event('input'));
}
const chars = data.chars || text.length;
citUploadFileList.innerHTML = `<li><span class="upload-filename">${esc(file.name)}</span> <span class="upload-chars">${chars.toLocaleString()} chars extracted</span></li>`;
})
.catch(() => {
citUploadFileList.innerHTML = `<li><span class="upload-filename">${esc(file.name)}</span> <em class="upload-chars" style="color:var(--coral)">Extraction failed</em></li>`;
});
}
if (citUploadInput) {
citUploadInput.addEventListener('change', () => handleCitFile(citUploadInput.files[0]));
}
if (citUploadZone) {
citUploadZone.addEventListener('dragover', (e) => { e.preventDefault(); citUploadZone.classList.add('is-dragover'); });
citUploadZone.addEventListener('dragleave', () => citUploadZone.classList.remove('is-dragover'));
citUploadZone.addEventListener('drop', (e) => {
e.preventDefault();
citUploadZone.classList.remove('is-dragover');
const file = e.dataTransfer?.files?.[0];
if (file) handleCitFile(file);
});
}
if (citUploadClear) {
citUploadClear.addEventListener('click', () => {
if (citUploadInput) citUploadInput.value = '';
if (citUploadFileInfo) citUploadFileInfo.classList.add('is-hidden');
if (citUploadPrompt) citUploadPrompt.classList.remove('is-hidden');
if (citPasteInput) { citPasteInput.value = ''; renderRefChips([]); }
});
}
// ── Doc picker → populate title input ─────────────────────────────────────
// doc-picker.js handles the modal; we hook the confirm callback by listening
// for changes to the hidden docPickerIds input and reading the chip labels.
const docPickerChipsEl = document.getElementById('docPickerChips');
if (docPickerChipsEl) {
const observer = new MutationObserver(() => {
const firstChip = docPickerChipsEl.querySelector('.doc-chip__label');
if (firstChip && firstChip.textContent.trim()) {
titleInput.value = firstChip.textContent.trim();
docIdInput.value = '';
fetchAc(titleInput.value);
}
});
observer.observe(docPickerChipsEl, { childList: true, subtree: true });
}
// ── Action radios + depth slider ──────────────────────────────────────────
document.querySelectorAll('input[name="citAction"]').forEach(r => {
r.addEventListener('change', () => { depthRow.hidden = r.value !== 'chain'; });
});
depthRange.addEventListener('input', () => { depthValue.textContent = depthRange.value; });
// ── Form submit ───────────────────────────────────────────────────────────
form.addEventListener('submit', async (e) => {
e.preventDefault();
let docId = parseInt(docIdInput.value, 10) || parseInt(titleInput.value.trim(), 10);
if (!docId || docId <= 0) {
setStatus('Enter a document title or numeric ID.', 'error');
titleInput.focus();
return;
}
const action = document.querySelector('input[name="citAction"]:checked').value;
const params = { action, doc_id: docId, limit: 50 };
if (action === 'chain') params.depth = parseInt(depthRange.value, 10);
setStatus('Querying citation graph…', 'busy');
runBtn.disabled = true;
try {
const url = 'https://ai.bluenotelogic.com/api/graph-search.php?' + new URLSearchParams(params);
const res = await fetch(url, { credentials: 'omit' });
if (res.status === 404) {
setStatus('', '');
renderNotFound(docId);
return;
}
if (!res.ok) {
setStatus('Graph error (' + res.status + ').', 'error');
return;
}
const data = await res.json();
if (data.error) { setStatus(data.error, 'error'); return; }
setStatus('', '');
renderResults(data, action);
addToTrail(data.source);
} catch (err) {
setStatus('Network error: ' + err.message, 'error');
} finally {
runBtn.disabled = false;
}
});
// ── Render ────────────────────────────────────────────────────────────────
function renderNotFound(docId) {
resultsDiv.hidden = false;
sourceCard.innerHTML = '';
resultsArea.innerHTML = '<p class="cit-no-results">Document '
+ esc(String(docId))
+ ' is not in the citation graph yet. The graph grows as new documents are scraped and resolved.</p>';
}
function renderResults(data, action) {
const src = data.source;
const atLabel = authorityLabel(src.atype);
const year = yearFrom(src.valid_from);
sourceCard.innerHTML =
'<p class="cit-source-card__title">' + esc(src.title || '(Untitled)') + '</p>'
+ '<div class="cit-source-card__meta">'
+ (atLabel ? '<span class="source-badge badge--teal">' + esc(atLabel) + '</span>' : '')
+ (year ? '<span>' + esc(year) + '</span>' : '')
+ (src.jurisdiction ? '<span>' + esc(src.jurisdiction) + '</span>' : '')
+ '<span style="color:var(--muted)">ID ' + esc(String(src.doc_id)) + '</span>'
+ (src.source_url ? '<a href="' + esc(src.source_url) + '" target="_blank" rel="noopener" style="color:var(--teal)">Source ↗</a>' : '')
+ '</div>';
const results = data.results || [];
const count = data.count ?? results.length;
const actionLabels = {
cites: 'Documents cited',
cited_by: 'Documents citing this',
implements: 'Documents implementing / amending',
chain: 'Reachable documents (citation chain)',
};
let html = '<div class="cit-results-header">'
+ '<p class="eyebrow">' + esc(actionLabels[action] || action) + '</p>'
+ '<span class="cit-results-count">' + esc(String(count)) + ' document' + (count !== 1 ? 's' : '') + '</span>'
+ '</div>';
if (results.length === 0) {
html += '<p class="cit-no-results">No connected documents found for this action.</p>';
} else {
html += '<div class="cit-grid">';
for (const r of results) {
const chipCls = relChipClass(action, r.rel_type);
const chipLbl = relChipLabel(action, r.rel_type);
const unresolved = r.doc_id === null || r.doc_id === undefined;
const cardTitle = r.title || '';
const atl = authorityLabel(r.atype);
const yr = yearFrom(r.valid_from);
html += '<div class="cit-card' + (unresolved ? ' cit-card--unresolved' : '') + '">'
+ '<span class="rel-chip ' + esc(chipCls) + '">' + esc(chipLbl) + '</span>'
+ (cardTitle ? '<span class="cit-card__title">' + esc(cardTitle) + '</span>' : '')
+ (!cardTitle && r.ref ? '<span class="cit-card__ref">' + esc(r.ref) + '</span>' : '')
+ '<div class="cit-card__meta">'
+ (atl ? '<span class="source-badge badge--muted">' + esc(atl) + '</span>' : '')
+ (yr ? '<span>' + esc(yr) + '</span>' : '')
+ '</div>'
+ (!unresolved ? '<button class="cit-explore-btn" type="button" data-explore-id="' + esc(String(r.doc_id)) + '" data-explore-title="' + esc(cardTitle) + '">Explore →</button>' : '')
+ '</div>';
}
html += '</div>';
}
resultsArea.innerHTML = html;
resultsDiv.hidden = false;
resultsArea.querySelectorAll('.cit-explore-btn').forEach(btn => {
btn.addEventListener('click', () => exploreDoc(parseInt(btn.dataset.exploreId, 10), btn.dataset.exploreTitle));
});
}
// ── Trail ─────────────────────────────────────────────────────────────────
function addToTrail(source) {
if (trail.length && trail[trail.length - 1].doc_id === source.doc_id) return;
trail.push({ doc_id: source.doc_id, title: source.title || '(Untitled)', atype: source.atype });
renderTrail();
}
function renderTrail() {
const placeholder = document.getElementById('trailPlaceholder');
if (placeholder) placeholder.remove();
trailList.innerHTML = '';
for (let i = trail.length - 1; i >= 0; i--) {
const entry = trail[i];
const isCurrent = i === trail.length - 1;
const li = document.createElement('li');
li.className = 'cit-trail-item' + (isCurrent ? ' is-active' : '');
li.innerHTML =
'<span class="trace-status ' + (isCurrent ? 'running' : 'complete') + '"></span>'
+ '<div>'
+ '<strong>' + esc(entry.title) + '</strong>'
+ '<p>' + esc(authorityLabel(entry.atype) || 'Document') + ' · ID ' + esc(String(entry.doc_id)) + '</p>'
+ '</div>';
if (!isCurrent) {
li.addEventListener('click', () => exploreDoc(entry.doc_id, entry.title));
}
trailList.appendChild(li);
}
}
// ── Navigation ────────────────────────────────────────────────────────────
function exploreDoc(docId, title) {
titleInput.value = title || String(docId);
docIdInput.value = String(docId);
hideAc();
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
})();