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

1608 lines
59 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,
llmProviders: [],
chatLlmProviderId: '',
nasDiagHint: '',
autoSyncBusy: false,
autoSyncLabel: 'Second brain: —',
autoSyncTimer: null,
linkClients: [],
linkClientId: '',
linkProjectId: '',
linkPathInput: '',
linkProjects: [],
client360: { stats: {}, links: [], sentiment_breakdown: [], monthly_trends: [], top_words: [], stores: [], deals: [] },
shareBrowsePath: '',
shareBrowseEntries: [],
shareBrowseLoading: false,
shareBrowseRecursive: true,
selectedSharePaths: [],
docVersions: [],
doclingFileMtime: '',
init() {
try {
document.querySelectorAll('.doc-row-fallback').forEach(function (el) { el.remove(); });
var tab = window.__docActiveTab || this.activeTab || 'overview';
this.activeTab = tab;
if (window.DocTabs && window.DocTabs.show) window.DocTabs.show(tab);
this.$watch('activeTab', function (v) {
if (v && window.DocTabs && window.DocTabs.getActive && window.DocTabs.getActive() !== v) {
window.DocTabs.show(v);
}
});
this.applyFilters();
this.renderCharts(window.__vizMode || 'neo-bars');
this.loadPhotos();
this.loadLabelVocabulary();
this.loadTaxonomy();
this.loadShareFiles(false);
this.browseShare('', false);
this.loadLinkClients();
this.loadLlmProviders();
this.refreshAutoSyncStatus();
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');
}
},
switchTab(tab) {
this.activeTab = tab;
window.__docActiveTab = tab;
if (window.DocTabs && window.DocTabs.show) window.DocTabs.show(tab);
this.onTabShown(tab);
},
onTabShown(tab) {
const runners = {
labels: () => this.loadPhotos(),
docling: () => this.initDocling(),
powerpoint: () => this.initPowerpoint(),
chat: () => this.initDocChat(),
analytics360: () => this.initAnalytics360(),
};
const fn = runners[tab];
if (fn) {
try {
const out = fn();
if (out && typeof out.catch === 'function') out.catch(() => {});
} catch (err) {
console.error('switchTab', tab, err);
}
}
},
formatTs(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso).slice(0, 16);
return d.toLocaleString('nl-NL', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
} catch (e) {
return String(iso).slice(0, 16);
}
},
async loadVersions(path) {
if (!path) { this.docVersions = []; return; }
try {
const data = await Cockpit.api('/docling/versions?path=' + encodeURIComponent(path));
this.docVersions = data.versions || [];
} catch (e) {
this.docVersions = [];
}
},
async loadVersion(versionId) {
if (!versionId || !this.doclingSelected) return;
try {
const data = await Cockpit.api('/docling/versions/' + versionId);
if (data.version && data.version.content != null) {
this.doclingEditContent = data.version.content;
Cockpit.toast('Versie geladen: ' + this.formatTs(data.version.created_at), 'info');
}
} catch (e) {
Cockpit.toast(e.message || 'Versie laden mislukt', 'error');
}
},
async recordVersion(label) {
if (!this.doclingSelected || !this.doclingEditContent) return;
try {
await Cockpit.api('/docling/versions', {
method: 'POST',
body: JSON.stringify({
storage_path: this.doclingSelected,
content: this.doclingEditContent,
source_format: 'markdown',
version_label: label || 'bewerking',
nas_mtime: this.doclingFileMtime || null,
}),
});
await this.loadVersions(this.doclingSelected);
} catch (e) { }
},
formatTs(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso).slice(0, 16);
return d.toLocaleString('nl-NL', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
} catch (e) {
return String(iso).slice(0, 16);
}
},
async loadVersions(path) {
if (!path) { this.docVersions = []; return; }
try {
const data = await Cockpit.api('/docling/versions?path=' + encodeURIComponent(path));
this.docVersions = data.versions || [];
} catch (e) {
this.docVersions = [];
}
},
async loadVersion(versionId) {
if (!versionId || !this.doclingSelected) return;
try {
const data = await Cockpit.api('/docling/versions/' + versionId);
if (data.version && data.version.content != null) {
this.doclingEditContent = data.version.content;
Cockpit.toast('Versie geladen: ' + this.formatTs(data.version.created_at), 'info');
}
} catch (e) {
Cockpit.toast(e.message || 'Versie laden mislukt', 'error');
}
},
async recordVersion(label) {
if (!this.doclingSelected || !this.doclingEditContent) return;
try {
await Cockpit.api('/docling/versions', {
method: 'POST',
body: JSON.stringify({
storage_path: this.doclingSelected,
content: this.doclingEditContent,
source_format: 'markdown',
version_label: label || 'bewerking',
nas_mtime: this.doclingFileMtime || null,
}),
});
await this.loadVersions(this.doclingSelected);
} catch (e) {}
},
async loadLlmProviders() {
try {
const data = await Cockpit.api('/api/settings/llm');
this.llmProviders = data.providers || [];
const def = data.default || this.llmProviders.find((p) => p.is_default);
if (def && !this.chatLlmProviderId) this.chatLlmProviderId = def.id;
const banner = document.getElementById('doc-hub-status');
if (banner && def && def.provider_type === 'ollama') {
banner.style.display = 'block';
banner.textContent = 'Tip: Ollama is traag op CPU (~2 min). Voeg DeepSeek/Gemini toe via Instellingen → AI / LLM voor snelle chat & RAG.';
}
const cloud = this.llmProviders.find((p) => p.provider_type !== 'ollama' && p.api_key_set);
if (cloud && def && def.provider_type === 'ollama') {
this.chatLlmProviderId = cloud.id;
}
} catch (e) {}
},
onChatProviderChange() {
const p = this.llmProviders.find((row) => Number(row.id) === Number(this.chatLlmProviderId));
this.chatStatus = p ? (p.label + ' · ' + p.model) : 'Standaard LLM';
},
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 (f.modified_at) ex.modified_at = f.modified_at;
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 loadShareFiles(showToast) {
try {
const r = await fetch('/api/admin/documents/share-files?limit=5000');
const data = await r.json();
if (data.ok !== false) {
this.mergeShareItems(data.items || []);
this.nasFileTotal = data.total || (data.items || []).length;
if (showToast) Cockpit.toast((this.nasFileTotal || 0) + ' bestanden op NAS', 'success');
} else if (showToast) {
Cockpit.toast(data.error || 'NAS laden mislukt', 'error');
}
} catch (e) {
if (showToast) Cockpit.toast('NAS bestanden laden mislukt', 'error');
}
},
async refreshShare(showToast) {
try {
if (showToast !== false) Cockpit.toast('NAS scan gestart (~30s)…', 'info');
const scanCtrl = new AbortController();
const scanTimer = setTimeout(() => scanCtrl.abort(), 90000);
try {
await fetch('/api/admin/documents/trigger-scan', { method: 'POST', signal: scanCtrl.signal });
} finally {
clearTimeout(scanTimer);
}
await this.loadShareFiles(showToast !== false);
} catch (e) {
if (showToast !== false) Cockpit.toast('Share scan mislukt — toon cache', 'error');
await this.loadShareFiles(false);
}
},
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) { }
},
formatTs(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso).slice(0, 16);
return d.toLocaleString('nl-NL', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
} catch (e) {
return String(iso).slice(0, 16);
}
},
async loadVersions(path) {
if (!path) { this.docVersions = []; return; }
try {
const data = await Cockpit.api('/docling/versions?path=' + encodeURIComponent(path));
this.docVersions = data.versions || [];
} catch (e) {
this.docVersions = [];
}
},
async loadVersion(versionId) {
if (!versionId || !this.doclingSelected) return;
try {
const data = await Cockpit.api('/docling/versions/' + versionId);
if (data.version && data.version.content != null) {
this.doclingEditContent = data.version.content;
Cockpit.toast('Versie geladen: ' + this.formatTs(data.version.created_at), 'info');
}
} catch (e) {
Cockpit.toast(e.message || 'Versie laden mislukt', 'error');
}
},
async recordVersion(label) {
if (!this.doclingSelected || !this.doclingEditContent) return;
try {
await Cockpit.api('/docling/versions', {
method: 'POST',
body: JSON.stringify({
storage_path: this.doclingSelected,
content: this.doclingEditContent,
source_format: 'markdown',
version_label: label || 'bewerking',
nas_mtime: this.doclingFileMtime || null,
}),
});
await this.loadVersions(this.doclingSelected);
} catch (e) {}
},
openDocLabel(doc) {
this.activeDoc = doc;
this.docSelectedLabels = [...(doc.user_labels || [])];
this.docLabelsInput = '';
this.docNotesInput = doc.label_notes || '';
},
isShareSelected(path) {
return this.selectedSharePaths.includes(path);
},
toggleShareSelect(path) {
const idx = this.selectedSharePaths.indexOf(path);
if (idx >= 0) this.selectedSharePaths.splice(idx, 1);
else this.selectedSharePaths.push(path);
},
openInWorkbench(path) {
if (!path) return;
const folder = path.includes('/') ? path.replace(/\/[^/]+$/, '') : '';
const open = async () => {
if (folder !== this.shareBrowsePath) {
await this.browseShare(folder, false);
}
this.switchTab('docling');
await this.openDoclingFile(path);
};
open().catch((e) => Cockpit.toast(e.message || 'Openen mislukt', 'error'));
},
openSelectedInWorkbench() {
const paths = [...this.selectedSharePaths];
if (!paths.length) return;
this.openInWorkbench(paths[0]);
if (paths.length > 1) {
Cockpit.toast(paths.length + ' geselecteerd — eerste geopend in Workbench', 'info');
}
},
sharePathParts() {
return (this.shareBrowsePath || '').split('/').filter(Boolean);
},
sharePathPrefix(idx) {
return this.sharePathParts().slice(0, idx + 1).join('/');
},
shareBrowseFolders() {
return (this.shareBrowseEntries || []).filter((e) => e.type === 'folder');
},
async browseShare(path, showToast) {
const rel = (path || '').replace(/^\/+/, '').replace(/\/+$/, '');
this.shareBrowsePath = rel;
this.shareBrowseLoading = true;
try {
const r = await fetch(
'/api/admin/documents/share-browse?path=' + encodeURIComponent(rel)
+ '&limit=3000&recursive=' + (this.shareBrowseRecursive ? 'true' : 'false'),
);
const data = await r.json();
if (data.ok !== false && Array.isArray(data.entries)) {
this.shareBrowseEntries = data.entries;
const files = data.entries.filter((e) => e.type === 'file');
files.forEach((f) => {
const ex = this.doclingFiles.find((x) => x.path === f.path);
if (!ex) {
this.doclingFiles.push({
path: f.path,
filename: f.filename || f.name,
size: f.size,
modified_at: f.modified_at,
});
}
});
if (showToast) Cockpit.toast((data.entries.length || 0) + ' items in ' + (rel || 'Share'), 'success');
} else {
await this.loadAllNasFiles(false);
this.buildShareEntriesFromFiles();
if (showToast) Cockpit.toast('Share map geladen (flat)', 'info');
}
} catch (e) {
await this.loadAllNasFiles(false);
this.buildShareEntriesFromFiles();
} finally {
this.shareBrowseLoading = false;
}
},
browseShareParent() {
const parts = this.sharePathParts();
parts.pop();
this.browseShare(parts.join('/'), false);
},
buildShareEntriesFromFiles() {
const prefix = this.shareBrowsePath ? this.shareBrowsePath + '/' : '';
const folders = new Set();
const files = [];
this.doclingFiles.forEach((f) => {
const path = f.path || '';
if (prefix && !path.startsWith(prefix)) return;
if (!prefix && path.includes('/')) {
folders.add(path.split('/')[0]);
return;
}
const rest = prefix ? path.slice(prefix.length) : path;
if (!rest) return;
const slash = rest.indexOf('/');
if (slash >= 0) folders.add(rest.slice(0, slash));
else files.push({ type: 'file', path, filename: f.filename, name: f.filename, size: f.size });
});
this.shareBrowseEntries = [
...[...folders].sort().map((name) => ({
type: 'folder',
name,
path: prefix ? prefix.replace(/\/$/, '') + (prefix.endsWith('/') ? '' : '/') + name : name,
})),
...files,
];
},
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();
const fromBrowse = (this.shareBrowseEntries || [])
.filter((e) => e.type === 'file')
.map((e) => ({
path: e.path,
filename: e.filename || e.name,
size: e.size,
modified_at: e.modified_at,
}));
const pool = fromBrowse.length ? fromBrowse : this.doclingFiles.filter((f) => {
const path = f.path || '';
const prefix = this.shareBrowsePath ? this.shareBrowsePath.replace(/\/?$/, '/') : '';
if (prefix) {
if (!path.startsWith(prefix)) return false;
if (path.slice(prefix.length).includes('/')) return false;
} else if (path.includes('/')) {
return false;
}
return true;
});
return pool.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…', 'info');
const r = await fetch('/api/admin/documents/share-files?limit=5000');
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;
this.buildShareEntriesFromFiles();
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.openInWorkbench(path);
},
async openDoclingFile(path) {
if (!path || (this.doclingFileLoading && this.doclingSelected === path)) return;
this.doclingSelected = path;
const fileMeta = this.doclingFiles.find((f) => f.path === path) || {};
this.doclingFileMtime = fileMeta.modified_at || '';
this.doclingFileKind = this.fileKind(path);
this.doclingEditMode = true;
this.doclingFileLoading = true;
this.doclingResult = null;
this.doclingExports = {};
this.doclingTables = [];
this.doclingPictures = [];
this.doclingEditContent = '';
await this.loadVersions(path);
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;
await this.recordVersion('concept opgeslagen');
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.recordVersion('NAS export');
await this.loadAllNasFiles(false);
} catch (e) {
Cockpit.toast(e.message, 'error');
}
},
sendToPowerpoint() {
this.switchTab('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() {
if (!this.doclingFiles.length) await this.loadAllNasFiles(false);
else if (!this.shareBrowseEntries.length) await this.browseShare(this.shareBrowsePath || '', false);
else this.nasLoading = 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() {
await this.loadLlmProviders();
const p = this.llmProviders.find((row) => Number(row.id) === Number(this.chatLlmProviderId));
this.chatStatus = p ? (p.label + ' · ' + p.model) : 'LLM laden…';
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(), 110000);
try {
const history = this.chatMessages.slice(-8, -1).map((m) => ({ role: m.role, content: m.content }));
const payload = {
message: msg,
use_herman: !!this.chatUseHerman,
llm_provider_id: this.chatLlmProviderId ? Number(this.chatLlmProviderId) : null,
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');
if (!data.ok) throw new Error(data.error || '360 laden mislukt');
this.client360 = data;
this.linkProjects = data.projects || [];
} catch (e) {
Cockpit.toast('360: ' + (e.message || e), '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), 10 * 60 * 1000);
},
};
};
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();
})();