Files
foodlinkk-command-center/tmp/foodlinkk-deploy/cockpit/static/js/documents-v2.js
T

1213 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Documents hub v2 — NAS, labels, Docling */
(function () {
const D = window.__DOC_DATA || {};
const topWords = D.topWords || [];
const summary = D.summary || {};
const allDocuments = D.documents || [];
let wordsChartInst = null;
let sentimentChartInst = null;
window.documentsDash = function documentsDash() {
return {
activeTab: 'overview',
filterQuery: '',
filterSentiment: '',
filterType: '',
filterLabel: '',
filteredDocs: allDocuments,
docTypes: [...new Set(allDocuments.map((d) => d.doc_type).filter(Boolean))],
labelVocabulary: [],
photoTaxonomy: [],
documentTaxonomy: [],
photoFilterLabel: '',
activeDoc: null,
docSelectedLabels: [],
docLabelsInput: '',
docNotesInput: '',
photos: [],
activePhoto: null,
photoModalOpen: false,
modalPhotoLabels: [],
activeOcr: '',
photoLabelsInput: '',
photoNotesInput: '',
activeItems: [],
detections: [],
liveRefresh: false,
liveTimer: null,
nasOcrBusy: false,
doclingStatus: 'Docling laden…',
doclingFiles: [],
doclingHistory: [],
doclingFilter: '',
doclingExtFilter: '',
doclingSelected: '',
doclingOpts: { ocr: true, tables: true, page_images: false, picture_images: true, force_full_page_ocr: false },
doclingFormats: ['markdown', 'html', 'json', 'text', 'doctags', 'yaml'],
doclingExportFormats: ['markdown', 'html', 'json', 'text', 'doctags', 'yaml'],
doclingBusy: false,
doclingResult: null,
doclingExports: {},
doclingTables: [],
doclingPictures: [],
doclingMeta: {},
doclingExportTab: 'markdown',
doclingPreviewSearch: '',
doclingFileStatus: {},
nasFileTotal: 0,
nasLoading: false,
doclingEditMode: true,
doclingEditContent: '',
doclingAutoConvert: true,
dlOptionsOpen: false,
doclingFileLoading: false,
doclingFileKind: '',
doclingSupportedSuffixes: [],
dlExtGroups: [
{ ext: '', label: 'Alles', icon: '' },
{ ext: '.pdf', label: 'PDF', icon: '📕' },
{ ext: '.docx', label: 'Word', icon: '📝' },
{ ext: '.doc', label: 'DOC', icon: '📝' },
{ ext: '.xlsx', label: 'Excel', icon: '📊' },
{ ext: '.xls', label: 'XLS', icon: '📊' },
{ ext: '.pptx', label: 'PPT', icon: '📽️' },
{ ext: '.ppt', label: 'PPT', icon: '📽️' },
{ ext: '.txt', label: 'Tekst', icon: '📄' },
{ ext: '.md', label: 'MD', icon: '📄' },
{ ext: '.csv', label: 'CSV', icon: '📋' },
{ ext: '.html', label: 'HTML', icon: '🌐' },
{ ext: '.json', label: 'JSON', icon: '{}' },
{ ext: '.png', label: 'IMG', icon: '🖼️' },
],
pptTitle: 'Foodlinkk Presentatie',
pptSubtitle: 'Foodlinkk',
pptFilename: '',
pptSlides: [],
pptSourcePdf: '',
pptLastOutput: '',
pptBusy: false,
pptDownloadPath: '',
chatMessages: [],
chatInput: '',
chatBusy: false,
chatStatus: 'RAG laden…',
chatSearchQ: '',
chatSearchHits: [],
chatScopeFile: false,
chatScopeClient: true,
chatUseHerman: false,
chatReindexing: false,
nasDiagHint: '',
autoSyncBusy: false,
autoSyncLabel: 'Second brain: —',
autoSyncTimer: null,
linkClients: [],
linkClientId: '',
linkProjectId: '',
linkPathInput: '',
linkProjects: [],
client360: { stats: {}, links: [], sentiment_breakdown: [], monthly_trends: [], top_words: [], stores: [], deals: [] },
init() {
try {
this.applyFilters();
this.renderCharts(window.__vizMode || 'neo-bars');
this.loadPhotos();
this.loadLabelVocabulary();
this.loadTaxonomy();
this.refreshShare(false);
this.loadLinkClients();
this.startAutoSyncLoop();
document.addEventListener('foodlinkk:vizmode', (ev) => this.renderCharts(ev.detail.mode));
} catch (e) {
console.error('documentsDash init:', e);
Cockpit.toast('Documenten UI gedeeltelijk geladen — ververs de pagina (Ctrl+F5)', 'error');
}
},
filteredPhotos() {
if (!this.photoFilterLabel) return this.photos;
return this.photos.filter((p) => (p.user_labels || []).includes(this.photoFilterLabel));
},
mergeShareItems(items) {
const byPath = new Map(allDocuments.map((d) => [d.storage_path, d]));
(items || []).forEach((f) => {
const path = f.path || f.storage_path;
if (!path) return;
if (byPath.has(path)) {
const ex = byPath.get(path);
if (!ex.analyzed_at && f.modified_at) ex.modified_at = f.modified_at;
return;
}
const row = {
filename: f.filename || path.split('/').pop(),
storage_path: path,
doc_type: f.doc_type || 'general',
word_count: f.word_count || 0,
sentiment_label: f.sentiment_label || 'neutral',
sentiment_compound: f.sentiment_compound || 0,
analyzed_at: f.analyzed_at || '',
modified_at: f.modified_at || '',
user_labels: f.user_labels || [],
label_notes: f.label_notes || '',
from_share: !f.analyzed_at,
};
allDocuments.push(row);
byPath.set(path, row);
});
this.docTypes = [...new Set(allDocuments.map((d) => d.doc_type).filter(Boolean))];
this.applyFilters();
},
async refreshShare(showToast) {
try {
if (showToast !== false) Cockpit.toast('Share scannen…', 'info');
await fetch('/api/admin/documents/trigger-scan', { method: 'POST' });
const r = await fetch('/api/admin/documents/share-files?limit=300');
const data = await r.json();
if (data.ok) {
this.mergeShareItems(data.items || []);
if (showToast !== false) Cockpit.toast((data.total || 0) + ' bestanden op share', 'success');
}
} catch (e) {
if (showToast !== false) Cockpit.toast('Share scan mislukt', 'error');
}
},
applyFilters() {
const q = this.filterQuery.toLowerCase();
this.filteredDocs = allDocuments.filter((d) => {
if (this.filterSentiment && d.sentiment_label !== this.filterSentiment) return false;
if (this.filterType && d.doc_type !== this.filterType) return false;
if (this.filterLabel && !(d.user_labels || []).includes(this.filterLabel)) return false;
if (q) {
const hay = ((d.filename || '') + ' ' + (d.storage_path || '') + ' ' + (d.user_labels || []).join(' ')).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
},
async loadLabelVocabulary() {
try {
const r = await fetch('/api/admin/labels/vocabulary?limit=60');
const data = await r.json();
if (data.ok) this.labelVocabulary = data.labels || [];
} catch (e) {}
},
async loadTaxonomy() {
try {
const r = await fetch('/api/admin/labels/taxonomy');
const data = await r.json();
if (data.ok) {
this.photoTaxonomy = data.photo || [];
this.documentTaxonomy = data.document || [];
}
} catch (e) {}
},
openDocLabel(doc) {
this.activeDoc = doc;
this.docSelectedLabels = [...(doc.user_labels || [])];
this.docLabelsInput = '';
this.docNotesInput = doc.label_notes || '';
},
toggleDocLabel(id) {
const idx = this.docSelectedLabels.indexOf(id);
if (idx >= 0) this.docSelectedLabels.splice(idx, 1);
else this.docSelectedLabels.push(id);
},
async saveDocLabels() {
if (!this.activeDoc) return;
const labels = [...this.docSelectedLabels];
this.docLabelsInput.split(',').map((s) => s.trim()).filter(Boolean).forEach((t) => {
if (!labels.includes(t)) labels.push(t);
});
try {
const r = await Cockpit.api('/documents/labels', {
method: 'PATCH',
body: JSON.stringify({
storage_path: this.activeDoc.storage_path,
labels,
notes: this.docNotesInput || null,
}),
});
const saved = r.document || {};
this.activeDoc.user_labels = saved.user_labels || labels;
this.activeDoc.label_notes = saved.label_notes || this.docNotesInput;
const idx = allDocuments.findIndex((d) => d.storage_path === this.activeDoc.storage_path);
if (idx >= 0) {
allDocuments[idx].user_labels = this.activeDoc.user_labels;
allDocuments[idx].label_notes = this.activeDoc.label_notes;
}
this.applyFilters();
await this.loadLabelVocabulary();
Cockpit.toast('Document gelabeld', 'success');
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
toggleModalLabel(id) {
const idx = this.modalPhotoLabels.indexOf(id);
if (idx >= 0) this.modalPhotoLabels.splice(idx, 1);
else this.modalPhotoLabels.push(id);
},
async openPhotoModal(id) {
this.activePhoto = id;
this.photoModalOpen = true;
document.body.style.overflow = 'hidden';
this.modalPhotoLabels = [];
const p = this.photos.find((x) => x.id === id);
if (p && p.user_labels) this.modalPhotoLabels = [...p.user_labels];
this.photoLabelsInput = '';
this.photoNotesInput = p ? (p.label_notes || '') : '';
this.activeOcr = p ? (p.ocr_preview || '') : '';
try {
const r = await Cockpit.api('/photos/' + id + '/detections');
this.detections = r.detections || [];
this.activeItems = r.extracted_items || [];
this.activeOcr = r.ocr_text || this.activeOcr;
this.modalPhotoLabels = [...(r.user_labels || this.modalPhotoLabels)];
this.photoNotesInput = r.label_notes || this.photoNotesInput;
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
closePhotoModal() {
this.photoModalOpen = false;
document.body.style.overflow = '';
},
async saveModalPhotoLabels() {
if (!this.activePhoto) return;
const labels = [...this.modalPhotoLabels];
this.photoLabelsInput.split(',').map((s) => s.trim()).filter(Boolean).forEach((t) => {
if (!labels.includes(t)) labels.push(t);
});
if (!labels.length) return Cockpit.toast('Kies minimaal één label-type', 'error');
try {
const r = await Cockpit.api('/photos/' + this.activePhoto + '/labels', {
method: 'PATCH',
body: JSON.stringify({ labels, notes: this.photoNotesInput || null }),
});
const saved = r.photo || {};
const p = this.photos.find((x) => x.id === this.activePhoto);
if (p) {
p.user_labels = saved.user_labels || labels;
p.label_notes = saved.label_notes || this.photoNotesInput;
}
await this.loadLabelVocabulary();
this.closePhotoModal();
Cockpit.toast('Foto gelabeld', 'success');
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
labelDisplayName(id) {
const t = this.photoTaxonomy.find((x) => x.id === id);
return t ? t.label : id;
},
async batchNasOcr(force) {
if (this.nasOcrBusy) return;
this.nasOcrBusy = true;
Cockpit.toast('NAS OCR gestart…', 'info');
try {
const url = '/api/admin/documents/nas-ocr' + (force ? '?force=true' : '');
const r = await fetch(url, { method: 'POST' });
const data = await r.json();
if (!r.ok || !data.ok) throw new Error(data.detail || data.error || 'OCR mislukt');
Cockpit.toast((data.images_extracted || 0) + ' afbeeldingen ge-OCR\'d', 'success');
await this.loadPhotos();
} catch (e) {
Cockpit.toast(e.message || 'NAS OCR mislukt', 'error');
} finally {
this.nasOcrBusy = false;
}
},
renderCharts(mode) {
mode = mode || window.__vizMode || 'neo-bars';
if (typeof Chart === 'undefined') return;
if (wordsChartInst) { wordsChartInst.destroy(); wordsChartInst = null; }
if (sentimentChartInst) { sentimentChartInst.destroy(); sentimentChartInst = null; }
const dash = this;
const wc = document.getElementById('wordsChart');
const sc = document.getElementById('sentimentChart');
if (!wc || !sc) return;
wordsChartInst = new Chart(wc, {
type: 'bar',
data: {
labels: topWords.slice(0, 10).map((w) => w.lemma),
datasets: [{
data: topWords.slice(0, 10).map((w) => w.total_count),
backgroundColor: 'rgba(0,229,255,0.75)',
borderWidth: 0,
borderRadius: 6,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
onClick: (_, els) => {
if (els[0]) {
dash.filterQuery = topWords[els[0].index].lemma;
dash.applyFilters();
}
},
scales: {
y: { beginAtZero: true, ticks: { color: '#64748b', font: { size: 9 } } },
x: { ticks: { color: '#64748b', font: { size: 9 } } },
},
},
});
sentimentChartInst = new Chart(sc, {
type: 'doughnut',
data: {
labels: ['Positief', 'Neutraal', 'Negatief'],
datasets: [{
data: [summary.positive, summary.neutral, summary.negative],
backgroundColor: ['#4ade80', '#64748b', '#fb7185'],
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#94a3b8', font: { size: 9 } } } },
onClick: (_, els) => {
if (!els[0]) return;
dash.filterSentiment = ['positive', 'neutral', 'negative'][els[0].index];
dash.applyFilters();
},
},
});
},
async loadPhotos() {
try {
const r = await Cockpit.api('/photos?limit=80');
this.photos = r.photos || [];
} catch (e) {}
},
toggleLive() {
if (this.liveTimer) clearInterval(this.liveTimer);
if (this.liveRefresh) {
this.liveTimer = setInterval(() => {
this.loadPhotos();
this.refreshShare(false);
}, 15000);
}
},
async uploadPhoto(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
const b64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result.split(',')[1]);
r.onerror = rej;
r.readAsDataURL(file);
});
try {
await Cockpit.api('/photos/analyze', {
method: 'POST',
body: JSON.stringify({ image_b64: b64, source: 'upload', filename: file.name }),
});
Cockpit.toast('Foto geanalyseerd', 'success');
await this.loadPhotos();
} catch (e) {
Cockpit.toast(e.message, 'error');
}
ev.target.value = '';
},
/* ── Docling ── */
doclingFilteredFiles() {
const q = (this.doclingFilter || '').toLowerCase();
return this.doclingFiles.filter((f) => {
if (this.doclingExtFilter) {
const ext = this.doclingExtFilter.startsWith('.') ? this.doclingExtFilter : '.' + this.doclingExtFilter;
const pathExt = this.fileExtension(f.path);
if (ext === '.png') {
if (!['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif'].includes(pathExt)) return false;
} else if (pathExt !== ext.toLowerCase()) return false;
}
if (!q) return true;
return ((f.path || '') + ' ' + (f.filename || '')).toLowerCase().includes(q);
});
},
fileExtension(path) {
const p = (path || '').toLowerCase();
const i = p.lastIndexOf('.');
return i >= 0 ? p.slice(i) : '';
},
fileIcon(path) {
const ext = this.fileExtension(path);
const map = {
'.pdf': '📕', '.docx': '📝', '.doc': '📝', '.xlsx': '📊', '.xls': '📊',
'.pptx': '📽️', '.ppt': '📽️', '.txt': '📄', '.md': '📄', '.csv': '📋',
'.html': '🌐', '.htm': '🌐', '.json': '{}', '.xml': '📰', '.yaml': '⚙️',
'.png': '🖼️', '.jpg': '🖼️', '.jpeg': '🖼️', '.webp': '🖼️', '.gif': '🖼️',
};
return map[ext] || '📁';
},
fileKind(path) {
const ext = this.fileExtension(path);
if (this.isPlainTextExt(ext)) return 'text';
if (this.isImageExt(ext)) return 'image';
if (ext === '.pdf') return 'pdf';
if (['.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt'].includes(ext)) return 'office';
return 'other';
},
fileKindLabel(path) {
const k = this.fileKind(path);
return { text: 'Tekst', image: 'Afbeelding', pdf: 'PDF', office: 'Office', other: 'Bestand' }[k] || 'Bestand';
},
isPlainTextExt(ext) {
return ['.txt', '.md', '.csv', '.json', '.xml', '.html', '.htm', '.yaml', '.yml', '.log', '.rtf'].includes(ext);
},
isImageExt(ext) {
return ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif'].includes(ext);
},
isDoclingSupported(path) {
const ext = this.fileExtension(path);
if (!ext) return false;
const supported = this.doclingSupportedSuffixes.length
? this.doclingSupportedSuffixes
: ['.pdf', '.docx', '.pptx', '.xlsx', '.html', '.htm', '.md', '.txt', '.png', '.jpg', '.jpeg', '.webp', '.tiff', '.tif', '.bmp', '.gif'];
return supported.includes(ext);
},
pptPdfFiles() {
return this.doclingFiles.filter((f) => (f.path || '').toLowerCase().endsWith('.pdf'));
},
pptPptxFiles() {
return this.doclingFiles.filter((f) => (f.path || '').toLowerCase().endsWith('.pptx'));
},
async loadAllNasFiles(showToast) {
this.nasLoading = true;
try {
if (showToast) Cockpit.toast('NAS bestanden laden… (kan ~1 min duren)', 'info');
const r = await fetch('/api/admin/documents/share-files?limit=1000');
const data = await r.json();
const byPath = new Map();
allDocuments.forEach((d) => {
byPath.set(d.storage_path, {
path: d.storage_path,
filename: d.filename,
word_count: d.word_count,
sentiment_label: d.sentiment_label,
user_labels: d.user_labels || [],
});
});
(data.items || []).forEach((f) => {
const path = f.path || f.storage_path;
if (!path) return;
const ex = byPath.get(path) || {};
byPath.set(path, {
...ex,
path,
filename: f.filename || path.split('/').pop(),
size: f.size,
modified_at: f.modified_at,
});
});
this.doclingFiles = Array.from(byPath.values()).sort((a, b) =>
(a.filename || '').localeCompare(b.filename || ''),
);
this.nasFileTotal = data.total || this.doclingFiles.length;
if (showToast) Cockpit.toast(this.nasFileTotal + ' bestanden op NAS', 'success');
} catch (e) {
if (showToast) Cockpit.toast('NAS laden mislukt', 'error');
} finally {
this.nasLoading = false;
}
},
fileDoclingStatus(path) {
return this.doclingFileStatus[path] || '';
},
selectDoclingFile(path) {
this.openDoclingFile(path);
},
async openDoclingFile(path) {
if (!path || (this.doclingFileLoading && this.doclingSelected === path)) return;
this.doclingSelected = path;
this.doclingFileKind = this.fileKind(path);
this.doclingEditMode = true;
this.doclingFileLoading = true;
this.doclingResult = null;
this.doclingExports = {};
this.doclingTables = [];
this.doclingPictures = [];
this.doclingEditContent = '';
const ext = this.fileExtension(path);
if (this.doclingFileKind === 'pdf') this.doclingExportTab = 'original';
else if (this.doclingFileKind === 'image') this.doclingExportTab = 'original';
else this.doclingExportTab = 'rendered';
try {
const hasDraft = await this.loadWorkspace(path);
if (hasDraft) {
this.doclingFileLoading = false;
return;
}
if (this.isPlainTextExt(ext)) {
await this.loadNasRawText(path);
this.doclingFileLoading = false;
return;
}
if (this.doclingFileKind === 'image') {
this.doclingFileLoading = false;
if (this.doclingAutoConvert && this.isDoclingSupported(path)) {
await this.runDoclingConvert(true);
}
return;
}
if (this.doclingFileKind === 'pdf') {
const cached = await this.loadDoclingCached();
this.doclingFileLoading = false;
if (!cached && this.doclingAutoConvert) await this.runDoclingConvert(true);
return;
}
const cached = await this.loadDoclingCached();
if (cached) {
this.doclingFileLoading = false;
return;
}
if (this.doclingAutoConvert && this.isDoclingSupported(path)) {
this.doclingFileLoading = false;
await this.runDoclingConvert(true);
} else if (!this.isDoclingSupported(path)) {
this.doclingEditContent = '# ' + path.split('/').pop() + '\n\nDit bestandstype wordt niet ondersteund voor Docling-conversie.\nOpen het origineel via de knop rechtsboven.';
Cockpit.toast('Geen Docling-support voor ' + ext, 'info');
}
} catch (e) {
Cockpit.toast(e.message || 'Laden mislukt', 'error');
} finally {
this.doclingFileLoading = false;
}
},
async loadNasRawText(path) {
const data = await Cockpit.api('/docling/nas-read?path=' + encodeURIComponent(path));
this.doclingEditContent = data.content || '';
this.doclingExports = { markdown: this.doclingEditContent, text: this.doclingEditContent };
this.doclingResult = { status: 'raw' };
this.doclingExportTab = 'rendered';
},
async loadWorkspace(path) {
if (!path) return false;
try {
const data = await Cockpit.api('/docling/workspace?path=' + encodeURIComponent(path));
if (data.workspace && data.workspace.content) {
this.doclingEditContent = data.workspace.content;
return true;
}
} catch (e) {}
const md = this.doclingExports.markdown;
if (typeof md === 'string') this.doclingEditContent = md;
return false;
},
async saveWorkspace() {
if (!this.doclingSelected) return;
try {
await Cockpit.api('/docling/workspace', {
method: 'PUT',
body: JSON.stringify({
storage_path: this.doclingSelected,
content: this.doclingEditContent,
source_format: 'markdown',
}),
});
if (this.doclingExports) this.doclingExports.markdown = this.doclingEditContent;
Cockpit.toast('Concept opgeslagen', 'success');
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
async saveToNas() {
if (!this.doclingSelected || !this.doclingEditContent) return;
try {
const data = await Cockpit.api('/docling/save-nas', {
method: 'POST',
body: JSON.stringify({
path: this.doclingSelected,
content: this.doclingEditContent,
subdir: 'Telegram/Exports',
}),
});
Cockpit.toast('Opgeslagen op NAS: ' + (data.path || data.filename), 'success');
await this.loadAllNasFiles(false);
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
sendToPowerpoint() {
this.activeTab = 'powerpoint';
this.importMarkdownToSlides(this.doclingEditContent || this.doclingExports.markdown || '');
},
async initPowerpoint() {
await this.loadAllNasFiles(false);
if (!this.pptSlides.length) {
this.pptSlides = [{ title: 'Intro', bullets: [], bulletsText: '' }];
}
},
markdownToSlides(md) {
const slides = [];
let current = { title: '', bullets: [], bulletsText: '' };
(md || '').split('\n').forEach((line) => {
const t = line.trim();
if (t.startsWith('## ')) {
if (current.title || current.bullets.length) slides.push({ ...current });
current = { title: t.slice(3).trim(), bullets: [], bulletsText: '' };
} else if (t.startsWith('# ') && !current.title) {
current.title = t.slice(2).trim();
} else if (/^[-*]\s/.test(t)) {
current.bullets.push(t.replace(/^[-*]\s+/, ''));
current.bulletsText = current.bullets.join('\n');
} else if (t.length > 20 && !t.startsWith('|')) {
current.bullets.push(t);
current.bulletsText = current.bullets.join('\n');
}
});
if (current.title || current.bullets.length) slides.push(current);
return slides.length ? slides : [{ title: 'Inhoud', bullets: [(md || '').slice(0, 500)], bulletsText: (md || '').slice(0, 500) }];
},
importMarkdownToSlides(md) {
const text = md || this.doclingEditContent || this.doclingExports.markdown || '';
if (!text) return Cockpit.toast('Geen markdown — eerst Docling convert', 'info');
this.pptSlides = this.markdownToSlides(text);
const firstLine = (text.split('\n').find((l) => l.startsWith('# ')) || '').replace(/^#\s+/, '');
if (firstLine) this.pptTitle = firstLine.slice(0, 200);
Cockpit.toast(this.pptSlides.length + ' slides gegenereerd', 'success');
},
addPptSlide() {
this.pptSlides.push({ title: 'Nieuwe slide', bullets: [], bulletsText: '' });
},
removePptSlide(idx) {
this.pptSlides.splice(idx, 1);
},
syncSlideBullets(slide) {
slide.bullets = (slide.bulletsText || '').split('\n').map((s) => s.trim()).filter(Boolean);
},
async createPptx() {
if (!this.pptSlides.length) return;
this.pptSlides.forEach((s) => this.syncSlideBullets(s));
this.pptBusy = true;
try {
const data = await Cockpit.api('/pptx/create', {
method: 'POST',
body: JSON.stringify({
title: this.pptTitle || 'Presentatie',
subtitle: this.pptSubtitle || 'Foodlinkk',
filename: this.pptFilename || this.pptTitle,
slides: this.pptSlides.map((s) => ({ title: s.title, bullets: s.bullets })),
}),
});
this.pptLastOutput = data.relative_output || data.output || data.path || 'klaar';
Cockpit.toast('PowerPoint aangemaakt', 'success');
await this.loadAllNasFiles(false);
} catch (e) {
Cockpit.toast(e.message, 'error');
} finally {
this.pptBusy = false;
}
},
async convertPdfToPptx() {
if (!this.pptSourcePdf) return;
this.pptBusy = true;
Cockpit.toast('PDF → PPTX conversie…', 'info');
try {
const data = await Cockpit.api('/pptx/pdf-to-pptx?path=' + encodeURIComponent(this.pptSourcePdf) + '&mode=smart');
this.pptLastOutput = data.relative_output || data.output || '';
Cockpit.toast('PPTX gegenereerd', 'success');
await this.loadAllNasFiles(false);
} catch (e) {
Cockpit.toast(e.message, 'error');
} finally {
this.pptBusy = false;
}
},
async loadDoclingHistory() {
try {
const data = await Cockpit.api('/docling/history?limit=40');
this.doclingHistory = data.items || [];
const statusMap = {};
(this.doclingHistory || []).forEach((h) => {
if (!statusMap[h.storage_path]) statusMap[h.storage_path] = h.status;
});
this.doclingFileStatus = statusMap;
} catch (e) {}
},
async initDocling() {
await this.loadAllNasFiles(false);
try {
const caps = await Cockpit.api('/docling/capabilities');
if (caps.installed) {
this.doclingStatus = 'Docling actief · ' + (caps.supported_suffixes || []).length + ' formaten · klik = open + convert';
if (caps.export_formats) this.doclingExportFormats = caps.export_formats;
if (caps.supported_suffixes) this.doclingSupportedSuffixes = caps.supported_suffixes;
} else {
this.doclingStatus = 'Docling niet geïnstalleerd';
}
} catch (e) {
this.doclingStatus = 'Docling offline: ' + e.message;
}
await this.loadDoclingHistory();
},
doclingPayload() {
return {
path: this.doclingSelected,
ocr: this.doclingOpts.ocr,
tables: this.doclingOpts.tables,
page_images: this.doclingOpts.page_images,
picture_images: this.doclingOpts.picture_images,
force_full_page_ocr: this.doclingOpts.force_full_page_ocr,
formats: this.doclingFormats.length ? this.doclingFormats : ['markdown', 'json'],
};
},
applyDoclingResult(data) {
const src = data.result || data;
this.doclingResult = src;
this.doclingExports = src.exports || data.exports || {};
this.doclingTables = src.tables_data || data.tables || [];
this.doclingPictures = src.pictures || data.pictures || [];
this.doclingMeta = {
page_count: src.page_count || data.page_count || 0,
metadata: src.metadata || data.metadata || {},
status: src.status || 'done',
};
const keys = this.doclingAvailableExports();
this.doclingExportTab = keys.includes('markdown') ? 'rendered' : (keys[0] || 'rendered');
if (typeof this.doclingExports.markdown === 'string') {
this.doclingEditContent = this.doclingExports.markdown;
}
},
doclingAvailableExports() {
return Object.keys(this.doclingExports || {}).filter((k) => this.doclingExports[k] != null);
},
doclingPreviewText() {
let val = this.doclingExports[this.doclingExportTab];
if (val == null) return '';
if (typeof val === 'string') val = val;
else if (typeof val === 'object' && val.error) return 'Error: ' + val.error;
else val = JSON.stringify(val, null, 2);
const q = (this.doclingPreviewSearch || '').trim().toLowerCase();
if (!q || typeof val !== 'string') return val;
const lines = val.split('\n');
const hits = lines.filter((l) => l.toLowerCase().includes(q));
return hits.length ? hits.join('\n') : '(geen matches voor "' + q + '")';
},
doclingPreviewHtml() {
const val = this.doclingExports.html;
return typeof val === 'string' ? val : '';
},
doclingRenderedMarkdown() {
const md = this.doclingExports.markdown;
if (typeof md !== 'string') return '';
return md
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\n\n/g, '</p><p>')
.replace(/^/, '<p>')
.replace(/$/, '</p>');
},
async copyDoclingExport() {
const text = this.doclingExportTab === 'html'
? this.doclingPreviewHtml()
: this.doclingPreviewText();
try {
await navigator.clipboard.writeText(text);
Cockpit.toast('Gekopieerd', 'success');
} catch (e) {
Cockpit.toast('Kopiëren mislukt', 'error');
}
},
downloadDoclingExport() {
const tab = this.doclingExportTab;
let content = tab === 'html' ? this.doclingPreviewHtml() : this.doclingPreviewText();
if (!content) return Cockpit.toast('Geen export', 'info');
const ext = tab === 'json' ? 'json' : tab === 'html' ? 'html' : tab === 'yaml' ? 'yaml' : 'md';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (this.doclingSelected || 'export').split('/').pop().replace(/\.[^.]+$/, '') + '.' + ext;
a.click();
URL.revokeObjectURL(a.href);
},
async runDoclingConvert(silent) {
if (!this.doclingSelected || this.doclingBusy) return;
this.doclingBusy = true;
if (!silent) Cockpit.toast('Docling converteert…', 'info');
try {
const data = await Cockpit.api('/docling/convert', {
method: 'POST',
body: JSON.stringify(this.doclingPayload()),
});
this.applyDoclingResult(data);
await this.loadDoclingHistory();
if (!silent) Cockpit.toast('Klaar · ' + (data.page_count || 0) + ' pagina\'s · ' + (this.doclingTables.length || 0) + ' tabellen', 'success');
} catch (e) {
if (!silent) Cockpit.toast(e.message, 'error');
else Cockpit.toast('Convert mislukt: ' + e.message, 'error');
} finally {
this.doclingBusy = false;
}
},
async runDoclingBatch() {
if (this.doclingBusy) return;
this.doclingBusy = true;
try {
const data = await Cockpit.api('/docling/batch', {
method: 'POST',
body: JSON.stringify({
limit: 10,
ext: this.doclingExtFilter || null,
formats: this.doclingFormats,
...this.doclingOpts,
}),
});
await this.loadDoclingHistory();
Cockpit.toast((data.converted || 0) + ' / ' + (data.total || 0) + ' geconverteerd', 'success');
if (this.doclingSelected && data.results) {
const hit = (data.results || []).find((r) => r.storage_path === this.doclingSelected && r.ok);
if (hit) this.applyDoclingResult(hit);
}
} catch (e) {
Cockpit.toast(e.message, 'error');
} finally {
this.doclingBusy = false;
}
},
async loadDoclingCached() {
if (!this.doclingSelected) return false;
try {
const data = await Cockpit.api('/docling/result?path=' + encodeURIComponent(this.doclingSelected));
if (data.ok && data.result) {
this.applyDoclingResult(data.result);
return true;
}
} catch (e) {}
return false;
},
openHistoryItem(item) {
if (!item || !item.storage_path) return;
this.openDoclingFile(item.storage_path);
},
stat360(key) {
const stats = this.client360 && this.client360.stats;
if (!stats || stats[key] == null) return 0;
return stats[key];
},
linkedClientName() {
const id = Number(this.linkClientId);
if (!id) return '';
const c = (this.linkClients || []).find((row) => Number(row.id) === id);
return 'Klant: ' + (c && c.name ? c.name : '—');
},
async initDocChat() {
this.chatStatus = 'Ollama RAG (snel)';
await this.loadLinkClients();
try {
const d = await Cockpit.api('/documents/nas-diagnostics');
const vis = d.visible_files || 0;
this.chatStatus = vis + ' bestanden zichtbaar · Chroma RAG actief';
if (vis < 30) {
this.nasDiagHint = d.hint || 'Weinig bestanden zichtbaar op NAS — controleer Synology rechten voor map CUCINA/Foodlinkk.';
} else {
this.nasDiagHint = '';
}
} catch (e) {
this.chatStatus = 'Diagnostics offline';
}
},
async reindexNas() {
this.chatReindexing = true;
Cockpit.toast('NAS indexeren voor RAG…', 'info');
try {
await Cockpit.api('/documents/trigger-scan', { method: 'POST' });
Cockpit.toast('Index scan gestart — Herman kan zo meer documenten zien', 'success');
} catch (e) {
Cockpit.toast(e.message, 'error');
} finally {
this.chatReindexing = false;
}
},
async runChatSearch() {
const q = (this.chatSearchQ || '').trim();
if (!q) return;
try {
const data = await Cockpit.api('/documents/search?q=' + encodeURIComponent(q) + '&limit=8');
this.chatSearchHits = data.results || [];
} catch (e) {
Cockpit.toast('Zoeken mislukt', 'error');
}
},
async sendDocChat() {
const msg = (this.chatInput || '').trim();
if (!msg || this.chatBusy) return;
this.chatBusy = true;
this.chatStatus = 'Bezig met denken… (kan ~12 min duren op CPU)';
this.chatMessages.push({ role: 'user', content: msg });
const savedInput = this.chatInput;
this.chatInput = '';
this.$nextTick(() => {
const log = document.getElementById('doc-chat-log');
if (log) log.scrollTop = log.scrollHeight;
});
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 180000);
try {
const history = this.chatMessages.slice(-8, -1).map((m) => ({ role: m.role, content: m.content }));
const payload = {
message: msg,
use_herman: !!this.chatUseHerman,
history,
path_prefix: this.chatScopeFile && this.doclingSelected ? this.doclingSelected : '',
client_id: this.chatScopeClient && this.linkClientId ? Number(this.linkClientId) : null,
project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
};
const data = await Cockpit.api('/documents/chat', {
method: 'POST',
body: JSON.stringify(payload),
signal: ctrl.signal,
});
if (!data.ok && !data.reply) throw new Error(data.detail || data.error || 'Chat mislukt');
this.chatMessages.push({
role: 'assistant',
content: data.reply || '(geen antwoord)',
agent: data.agent_label || 'Herman',
sources: data.rag_sources || [],
});
this.chatStatus = (data.rag_sources && data.rag_sources.length)
? data.rag_sources.length + ' bronnen gebruikt'
: 'Antwoord ontvangen';
} catch (e) {
const errMsg = e.name === 'AbortError'
? 'Timeout na 3 min — probeer een kortere vraag of zet Herman orchestrator uit'
: (e.message || e);
this.chatMessages.push({ role: 'assistant', content: 'Fout: ' + errMsg, agent: 'Systeem', sources: [] });
this.chatInput = savedInput;
this.chatStatus = 'Fout bij chat';
} finally {
clearTimeout(timer);
this.chatBusy = false;
this.$nextTick(() => {
const log = document.getElementById('doc-chat-log');
if (log) log.scrollTop = log.scrollHeight;
});
}
},
async loadLinkClients() {
try {
const data = await Cockpit.api('/clients');
this.linkClients = data.items || [];
} catch (e) {}
},
linkProjectsForClient() {
if (!this.linkClientId) return [];
return (this.client360.projects || []).length
? this.client360.projects
: this.linkProjects.filter((p) => p.client_id === Number(this.linkClientId));
},
async initAnalytics360() {
await this.loadLinkClients();
if (this.doclingSelected) this.linkPathInput = this.doclingSelected;
if (this.linkClientId) await this.loadClient360();
await this.refreshAutoSyncStatus();
},
async loadClient360() {
if (!this.linkClientId) return;
try {
const data = await Cockpit.api('/clients/' + this.linkClientId + '/360');
this.client360 = data;
this.linkProjects = data.projects || [];
} catch (e) {
Cockpit.toast('360 laden mislukt', 'error');
}
},
async linkSelectedFile() {
const path = (this.linkPathInput || this.doclingSelected || '').trim();
if (!path || !this.linkClientId) return;
try {
await Cockpit.api('/documents/links', {
method: 'POST',
body: JSON.stringify({
storage_path: path,
client_id: Number(this.linkClientId),
project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
is_folder: false,
}),
});
Cockpit.toast('Bestand gekoppeld', 'success');
await this.loadClient360();
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
async linkSelectedFolder() {
let path = (this.linkPathInput || this.doclingSelected || '').trim();
if (!path || !this.linkClientId) return;
if (!path.endsWith('/')) path = path.replace(/\/[^/]+$/, '') || path;
try {
await Cockpit.api('/documents/links', {
method: 'POST',
body: JSON.stringify({
storage_path: path,
client_id: Number(this.linkClientId),
project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
is_folder: true,
}),
});
Cockpit.toast('Map gekoppeld: ' + path, 'success');
await this.loadClient360();
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
async unlinkDocument(linkId) {
try {
await Cockpit.api('/documents/links/' + linkId, { method: 'DELETE' });
await this.loadClient360();
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
async runAutoSync(force) {
this.autoSyncBusy = true;
try {
const data = await Cockpit.api('/brain/auto-sync' + (force ? '?force=true' : ''), { method: 'POST' });
const brain = (data.steps || {}).brain_sync || {};
Cockpit.toast('Sync: ' + (brain.synced || 0) + ' docs → second brain', 'success');
await this.refreshAutoSyncStatus();
} catch (e) {
Cockpit.toast(e.message, 'error');
} finally {
this.autoSyncBusy = false;
}
},
async refreshAutoSyncStatus() {
try {
const data = await Cockpit.api('/brain/auto-sync/status');
const last = data.last;
if (last && last.created_at) {
this.autoSyncLabel = 'Laatste sync: ' + last.created_at.slice(0, 16) + ' (' + last.status + ')';
}
} catch (e) {}
},
startAutoSyncLoop() {
if (this.autoSyncTimer) clearInterval(this.autoSyncTimer);
this.autoSyncTimer = setInterval(() => this.runAutoSync(false), 5 * 60 * 1000);
setTimeout(() => this.runAutoSync(false), 15000);
},
};
};
window.wordSearch = function wordSearch() {
return {
query: '',
includeStopwords: false,
results: topWords.slice(0, 20),
loading: false,
async search() {
this.loading = true;
try {
const params = new URLSearchParams({ limit: '40', stopwords: this.includeStopwords ? 'true' : 'false' });
if (this.query.trim()) params.set('q', this.query.trim());
const res = await fetch('/api/admin/documents/words?' + params);
this.results = (await res.json()).items || [];
} catch (e) {
Cockpit.toast('Woord zoeken mislukt', 'error');
} finally {
this.loading = false;
}
},
init() {
this.search();
},
};
};
function registerDocumentsDash() {
if (window.Alpine && window.documentsDash) {
Alpine.data('documentsDash', window.documentsDash);
}
}
document.addEventListener('alpine:init', registerDocumentsDash);
if (window.Alpine) registerDocumentsDash();
})();