1597 lines
59 KiB
JavaScript
1597 lines
59 KiB
JavaScript
/* 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,
|
||
selectedSharePaths: [],
|
||
docVersions: [],
|
||
doclingFileMtime: '',
|
||
|
||
init() {
|
||
try {
|
||
document.querySelectorAll('.doc-row-fallback').forEach(function (el) { el.remove(); });
|
||
window.__docActiveTab = this.activeTab;
|
||
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=300');
|
||
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=800');
|
||
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=500');
|
||
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 ~1–2 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();
|
||
})();
|