bffc714541
- 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>
410 lines
18 KiB
JavaScript
410 lines
18 KiB
JavaScript
(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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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, '"');
|
||
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 }));
|
||
}
|
||
|
||
})();
|