superlocalmemory 2.3.2 → 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/app.js ADDED
@@ -0,0 +1,654 @@
1
+ // SuperLocalMemory V2 - UI Application
2
+ // Note: All data from API is from our own trusted local database.
3
+ // All user-facing strings are escaped via escapeHtml() before DOM insertion.
4
+ // innerHTML usage is safe here: all dynamic values are sanitized, and no
5
+ // external/untrusted input reaches the DOM.
6
+
7
+ let graphData = { nodes: [], links: [] };
8
+ let currentMemoryDetail = null; // Memory currently shown in modal
9
+ let lastSearchResults = null; // Cached search results for export
10
+
11
+ // ============================================================================
12
+ // Dark Mode
13
+ // ============================================================================
14
+
15
+ function initDarkMode() {
16
+ var saved = localStorage.getItem('slm-theme');
17
+ var theme;
18
+ if (saved) {
19
+ theme = saved;
20
+ } else {
21
+ // Respect system preference on first load
22
+ theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
23
+ }
24
+ applyTheme(theme);
25
+ }
26
+
27
+ function applyTheme(theme) {
28
+ document.documentElement.setAttribute('data-bs-theme', theme);
29
+ var icon = document.getElementById('theme-icon');
30
+ if (icon) {
31
+ icon.className = theme === 'dark' ? 'bi bi-moon-stars-fill' : 'bi bi-sun-fill';
32
+ }
33
+ }
34
+
35
+ function toggleDarkMode() {
36
+ var current = document.documentElement.getAttribute('data-bs-theme');
37
+ var next = current === 'dark' ? 'light' : 'dark';
38
+ localStorage.setItem('slm-theme', next);
39
+ applyTheme(next);
40
+ }
41
+
42
+ // ============================================================================
43
+ // Animated Counter
44
+ // ============================================================================
45
+
46
+ function animateCounter(elementId, target) {
47
+ var el = document.getElementById(elementId);
48
+ if (!el) return;
49
+ var duration = 600;
50
+ var startTime = null;
51
+
52
+ function step(timestamp) {
53
+ if (!startTime) startTime = timestamp;
54
+ var progress = Math.min((timestamp - startTime) / duration, 1);
55
+ var eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
56
+ el.textContent = Math.floor(eased * target).toLocaleString();
57
+ if (progress < 1) {
58
+ requestAnimationFrame(step);
59
+ } else {
60
+ el.textContent = target.toLocaleString();
61
+ }
62
+ }
63
+
64
+ if (target === 0) {
65
+ el.textContent = '0';
66
+ } else {
67
+ requestAnimationFrame(step);
68
+ }
69
+ }
70
+
71
+ // ============================================================================
72
+ // HTML Escaping — all dynamic text MUST pass through this before DOM insertion
73
+ // ============================================================================
74
+
75
+ function escapeHtml(text) {
76
+ if (!text) return '';
77
+ var div = document.createElement('div');
78
+ div.appendChild(document.createTextNode(String(text)));
79
+ return div.innerHTML;
80
+ }
81
+
82
+ // ============================================================================
83
+ // Loading / Empty State helpers
84
+ // ============================================================================
85
+
86
+ function showLoading(containerId, message) {
87
+ var el = document.getElementById(containerId);
88
+ if (!el) return;
89
+ // Build DOM nodes instead of innerHTML for loading state
90
+ el.textContent = '';
91
+ var wrapper = document.createElement('div');
92
+ wrapper.className = 'loading';
93
+ var spinner = document.createElement('div');
94
+ spinner.className = 'spinner-border text-primary';
95
+ spinner.setAttribute('role', 'status');
96
+ var msg = document.createElement('div');
97
+ msg.textContent = message || 'Loading...';
98
+ wrapper.appendChild(spinner);
99
+ wrapper.appendChild(msg);
100
+ el.appendChild(wrapper);
101
+ }
102
+
103
+ function showEmpty(containerId, icon, message) {
104
+ var el = document.getElementById(containerId);
105
+ if (!el) return;
106
+ el.textContent = '';
107
+ var wrapper = document.createElement('div');
108
+ wrapper.className = 'empty-state';
109
+ var iconEl = document.createElement('i');
110
+ iconEl.className = 'bi bi-' + icon + ' d-block';
111
+ var p = document.createElement('p');
112
+ p.textContent = message;
113
+ wrapper.appendChild(iconEl);
114
+ wrapper.appendChild(p);
115
+ el.appendChild(wrapper);
116
+ }
117
+
118
+ // ============================================================================
119
+ // Safe HTML builder — constructs sanitized HTML strings from trusted templates
120
+ // and escaped dynamic values. Used for table/card rendering where DOM-node-by-
121
+ // node construction would be impractical for 50+ row tables.
122
+ // ============================================================================
123
+
124
+ function safeHtml(templateParts) {
125
+ // Tagged template literal helper: safeHtml`<b>${userValue}</b>`
126
+ // All interpolated values are auto-escaped.
127
+ var args = Array.prototype.slice.call(arguments, 1);
128
+ var result = '';
129
+ for (var i = 0; i < templateParts.length; i++) {
130
+ result += templateParts[i];
131
+ if (i < args.length) {
132
+ result += escapeHtml(String(args[i]));
133
+ }
134
+ }
135
+ return result;
136
+ }
137
+
138
+ // ============================================================================
139
+ // Stats
140
+ // ============================================================================
141
+
142
+ async function loadStats() {
143
+ try {
144
+ var response = await fetch('/api/stats');
145
+ var data = await response.json();
146
+ animateCounter('stat-memories', data.overview.total_memories);
147
+ animateCounter('stat-clusters', data.overview.total_clusters);
148
+ animateCounter('stat-nodes', data.overview.graph_nodes);
149
+ animateCounter('stat-edges', data.overview.graph_edges);
150
+ populateFilters(data.categories, data.projects);
151
+ } catch (error) {
152
+ console.error('Error loading stats:', error);
153
+ }
154
+ }
155
+
156
+ function populateFilters(categories, projects) {
157
+ var categorySelect = document.getElementById('filter-category');
158
+ var projectSelect = document.getElementById('filter-project');
159
+ categories.forEach(function(cat) {
160
+ if (cat.category) {
161
+ var option = document.createElement('option');
162
+ option.value = cat.category;
163
+ option.textContent = cat.category + ' (' + cat.count + ')';
164
+ categorySelect.appendChild(option);
165
+ }
166
+ });
167
+ projects.forEach(function(proj) {
168
+ if (proj.project_name) {
169
+ var option = document.createElement('option');
170
+ option.value = proj.project_name;
171
+ option.textContent = proj.project_name + ' (' + proj.count + ')';
172
+ projectSelect.appendChild(option);
173
+ }
174
+ });
175
+ }
176
+
177
+ // ============================================================================
178
+ // Graph
179
+ // ============================================================================
180
+
181
+ async function loadGraph() {
182
+ var maxNodes = document.getElementById('graph-max-nodes').value;
183
+ try {
184
+ var response = await fetch('/api/graph?max_nodes=' + maxNodes);
185
+ graphData = await response.json();
186
+ renderGraph(graphData);
187
+ } catch (error) {
188
+ console.error('Error loading graph:', error);
189
+ }
190
+ }
191
+
192
+ function renderGraph(data) {
193
+ var container = document.getElementById('graph-container');
194
+ container.textContent = '';
195
+ var width = container.clientWidth || 1200;
196
+ var height = 600;
197
+ var svg = d3.select('#graph-container').append('svg').attr('width', width).attr('height', height);
198
+ var tooltip = d3.select('body').append('div').attr('class', 'tooltip-custom').style('opacity', 0);
199
+ var colorScale = d3.scaleOrdinal(d3.schemeCategory10);
200
+ var simulation = d3.forceSimulation(data.nodes).force('link', d3.forceLink(data.links).id(function(d) { return d.id; }).distance(100)).force('charge', d3.forceManyBody().strength(-200)).force('center', d3.forceCenter(width / 2, height / 2)).force('collision', d3.forceCollide().radius(20));
201
+ var link = svg.append('g').selectAll('line').data(data.links).enter().append('line').attr('class', 'link').attr('stroke-width', function(d) { return Math.sqrt(d.weight * 2); });
202
+ var node = svg.append('g').selectAll('circle').data(data.nodes).enter().append('circle').attr('class', 'node').attr('r', function(d) { return 5 + (d.importance || 5); }).attr('fill', function(d) { return colorScale(d.cluster_id || 0); }).call(d3.drag().on('start', dragStarted).on('drag', dragged).on('end', dragEnded)).on('mouseover', function(event, d) { tooltip.transition().duration(200).style('opacity', .9); tooltip.text((d.category || 'Uncategorized') + ': ' + (d.content_preview || d.summary || 'No content')).style('left', (event.pageX + 10) + 'px').style('top', (event.pageY - 28) + 'px'); }).on('mouseout', function() { tooltip.transition().duration(500).style('opacity', 0); }).on('click', function(event, d) { openMemoryDetail(d); });
203
+ simulation.on('tick', function() { link.attr('x1', function(d) { return d.source.x; }).attr('y1', function(d) { return d.source.y; }).attr('x2', function(d) { return d.target.x; }).attr('y2', function(d) { return d.target.y; }); node.attr('cx', function(d) { return d.x; }).attr('cy', function(d) { return d.y; }); });
204
+ function dragStarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
205
+ function dragged(event, d) { d.fx = event.x; d.fy = event.y; }
206
+ function dragEnded(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }
207
+ }
208
+
209
+ // ============================================================================
210
+ // Memories
211
+ // ============================================================================
212
+
213
+ async function loadMemories() {
214
+ var category = document.getElementById('filter-category').value;
215
+ var project = document.getElementById('filter-project').value;
216
+ var url = '/api/memories?limit=50';
217
+ if (category) url += '&category=' + encodeURIComponent(category);
218
+ if (project) url += '&project_name=' + encodeURIComponent(project);
219
+
220
+ showLoading('memories-list', 'Loading memories...');
221
+ try {
222
+ var response = await fetch(url);
223
+ var data = await response.json();
224
+ lastSearchResults = null; // Clear search cache when browsing
225
+ var exportBtn = document.getElementById('export-search-btn');
226
+ if (exportBtn) exportBtn.style.display = 'none';
227
+ renderMemoriesTable(data.memories, false);
228
+ } catch (error) {
229
+ console.error('Error loading memories:', error);
230
+ showEmpty('memories-list', 'exclamation-triangle', 'Failed to load memories');
231
+ }
232
+ }
233
+
234
+ function renderMemoriesTable(memories, showScores) {
235
+ var container = document.getElementById('memories-list');
236
+ if (!memories || memories.length === 0) {
237
+ showEmpty('memories-list', 'journal-x', 'No memories found. Try a different search or filter.');
238
+ return;
239
+ }
240
+
241
+ // Store memories for row-click access
242
+ window._slmMemories = memories;
243
+
244
+ var scoreHeader = showScores ? '<th>Score</th>' : '';
245
+
246
+ // All dynamic values below are escaped via escapeHtml() — safe for innerHTML.
247
+ var rows = '';
248
+ memories.forEach(function(mem, idx) {
249
+ var content = mem.summary || mem.content || '';
250
+ var contentPreview = content.length > 100 ? content.substring(0, 100) + '...' : content;
251
+ var importance = mem.importance || 5;
252
+ var importanceClass = importance >= 8 ? 'success' : importance >= 5 ? 'warning' : 'secondary';
253
+
254
+ var scoreCell = '';
255
+ if (showScores) {
256
+ var score = mem.score || 0;
257
+ var pct = Math.round(score * 100);
258
+ var barColor = pct >= 70 ? '#43e97b' : pct >= 40 ? '#f9c74f' : '#f94144';
259
+ scoreCell = '<td><span class="score-label">' + escapeHtml(String(pct)) + '%</span>'
260
+ + '<div class="score-bar-container"><div class="score-bar">'
261
+ + '<div class="score-bar-fill" style="width:' + pct + '%;background:' + barColor + '"></div>'
262
+ + '</div></div></td>';
263
+ }
264
+
265
+ rows += '<tr data-mem-idx="' + idx + '">'
266
+ + '<td>' + escapeHtml(String(mem.id)) + '</td>'
267
+ + '<td><span class="badge bg-primary">' + escapeHtml(mem.category || 'None') + '</span></td>'
268
+ + '<td><small>' + escapeHtml(mem.project_name || '-') + '</small></td>'
269
+ + '<td class="memory-content" title="' + escapeHtml(content) + '">' + escapeHtml(contentPreview) + '</td>'
270
+ + scoreCell
271
+ + '<td><span class="badge bg-' + importanceClass + ' badge-importance">' + escapeHtml(String(importance)) + '</span></td>'
272
+ + '<td>' + escapeHtml(String(mem.cluster_id || '-')) + '</td>'
273
+ + '<td><small>' + escapeHtml(formatDate(mem.created_at)) + '</small></td>'
274
+ + '</tr>';
275
+ });
276
+
277
+ var html = '<table class="table table-hover memory-table"><thead><tr>'
278
+ + '<th>ID</th><th>Category</th><th>Project</th><th>Content</th>'
279
+ + scoreHeader
280
+ + '<th>Importance</th><th>Cluster</th><th>Created</th>'
281
+ + '</tr></thead><tbody>' + rows + '</tbody></table>';
282
+
283
+ // Safe: all interpolated values above are escaped via escapeHtml()
284
+ container.innerHTML = html; // nosemgrep: innerHTML-xss — all values escaped
285
+
286
+ // Attach click handlers via delegation
287
+ var table = container.querySelector('table');
288
+ if (table) {
289
+ table.addEventListener('click', function(e) {
290
+ var row = e.target.closest('tr[data-mem-idx]');
291
+ if (row) {
292
+ var idx = parseInt(row.getAttribute('data-mem-idx'), 10);
293
+ if (window._slmMemories && window._slmMemories[idx]) {
294
+ openMemoryDetail(window._slmMemories[idx]);
295
+ }
296
+ }
297
+ });
298
+ }
299
+ }
300
+
301
+ // ============================================================================
302
+ // Search
303
+ // ============================================================================
304
+
305
+ async function searchMemories() {
306
+ var query = document.getElementById('search-query').value;
307
+ if (!query.trim()) { loadMemories(); return; }
308
+
309
+ showLoading('memories-list', 'Searching...');
310
+ try {
311
+ var response = await fetch('/api/search', {
312
+ method: 'POST',
313
+ headers: { 'Content-Type': 'application/json' },
314
+ body: JSON.stringify({ query: query, limit: 20, min_score: 0.3 })
315
+ });
316
+ var data = await response.json();
317
+
318
+ // Sort by relevance score descending
319
+ var results = data.results || [];
320
+ results.sort(function(a, b) { return (b.score || 0) - (a.score || 0); });
321
+
322
+ // Cache for export
323
+ lastSearchResults = results;
324
+
325
+ // Show export search results button
326
+ var exportBtn = document.getElementById('export-search-btn');
327
+ if (exportBtn) exportBtn.style.display = results.length > 0 ? '' : 'none';
328
+
329
+ renderMemoriesTable(results, true);
330
+ } catch (error) {
331
+ console.error('Error searching:', error);
332
+ showEmpty('memories-list', 'exclamation-triangle', 'Search failed. Please try again.');
333
+ }
334
+ }
335
+
336
+ // ============================================================================
337
+ // Memory Detail Modal
338
+ // ============================================================================
339
+
340
+ function openMemoryDetail(mem) {
341
+ currentMemoryDetail = mem;
342
+ var body = document.getElementById('memory-detail-body');
343
+ if (!mem) {
344
+ body.textContent = 'No memory data';
345
+ return;
346
+ }
347
+
348
+ var content = mem.content || mem.summary || '(no content)';
349
+ var tags = mem.tags || '';
350
+ var importance = mem.importance || 5;
351
+ var importanceClass = importance >= 8 ? 'success' : importance >= 5 ? 'warning' : 'secondary';
352
+
353
+ var scoreBlock = '';
354
+ if (typeof mem.score === 'number') {
355
+ var pct = Math.round(mem.score * 100);
356
+ var barColor = pct >= 70 ? '#43e97b' : pct >= 40 ? '#f9c74f' : '#f94144';
357
+ scoreBlock = '<dt>Relevance Score</dt><dd><span class="score-label">'
358
+ + escapeHtml(String(pct)) + '%</span>'
359
+ + '<div class="score-bar-container"><div class="score-bar">'
360
+ + '<div class="score-bar-fill" style="width:' + pct + '%;background:' + barColor + '"></div>'
361
+ + '</div></div></dd>';
362
+ }
363
+
364
+ // All values escaped — safe for innerHTML
365
+ var html = '<div class="memory-detail-content">' + escapeHtml(content) + '</div>'
366
+ + '<hr>'
367
+ + '<dl class="memory-detail-meta row">'
368
+ + '<div class="col-md-6">'
369
+ + '<dt>ID</dt><dd>' + escapeHtml(String(mem.id || '-')) + '</dd>'
370
+ + '<dt>Category</dt><dd><span class="badge bg-primary">' + escapeHtml(mem.category || 'None') + '</span></dd>'
371
+ + '<dt>Project</dt><dd>' + escapeHtml(mem.project_name || '-') + '</dd>'
372
+ + '<dt>Tags</dt><dd>' + (tags ? formatTags(tags) : '<span class="text-muted">None</span>') + '</dd>'
373
+ + '</div>'
374
+ + '<div class="col-md-6">'
375
+ + '<dt>Importance</dt><dd><span class="badge bg-' + importanceClass + '">' + escapeHtml(String(importance)) + '/10</span></dd>'
376
+ + '<dt>Cluster</dt><dd>' + escapeHtml(String(mem.cluster_id || '-')) + '</dd>'
377
+ + '<dt>Created</dt><dd>' + escapeHtml(formatDateFull(mem.created_at)) + '</dd>'
378
+ + (mem.updated_at ? '<dt>Updated</dt><dd>' + escapeHtml(formatDateFull(mem.updated_at)) + '</dd>' : '')
379
+ + scoreBlock
380
+ + '</div>'
381
+ + '</dl>';
382
+
383
+ body.innerHTML = html; // nosemgrep: innerHTML-xss — all values escaped
384
+
385
+ var modal = new bootstrap.Modal(document.getElementById('memoryDetailModal'));
386
+ modal.show();
387
+ }
388
+
389
+ function formatTags(tags) {
390
+ if (!tags) return '';
391
+ var tagList = typeof tags === 'string' ? tags.split(',') : tags;
392
+ return tagList.map(function(t) {
393
+ var tag = t.trim();
394
+ return tag ? '<span class="badge bg-secondary me-1">' + escapeHtml(tag) + '</span>' : '';
395
+ }).join('');
396
+ }
397
+
398
+ // ============================================================================
399
+ // Copy / Export from Modal
400
+ // ============================================================================
401
+
402
+ function copyMemoryToClipboard() {
403
+ if (!currentMemoryDetail) return;
404
+ var text = currentMemoryDetail.content || currentMemoryDetail.summary || '';
405
+ navigator.clipboard.writeText(text).then(function() {
406
+ showToast('Copied to clipboard');
407
+ }).catch(function() {
408
+ // Fallback for older browsers
409
+ var ta = document.createElement('textarea');
410
+ ta.value = text;
411
+ document.body.appendChild(ta);
412
+ ta.select();
413
+ document.execCommand('copy');
414
+ document.body.removeChild(ta);
415
+ showToast('Copied to clipboard');
416
+ });
417
+ }
418
+
419
+ function exportMemoryAsMarkdown() {
420
+ if (!currentMemoryDetail) return;
421
+ var mem = currentMemoryDetail;
422
+ var md = '# Memory #' + (mem.id || 'unknown') + '\n\n';
423
+ md += '**Category:** ' + (mem.category || 'None') + ' \n';
424
+ md += '**Project:** ' + (mem.project_name || '-') + ' \n';
425
+ md += '**Importance:** ' + (mem.importance || 5) + '/10 \n';
426
+ md += '**Tags:** ' + (mem.tags || 'None') + ' \n';
427
+ md += '**Created:** ' + (mem.created_at || '-') + ' \n';
428
+ if (mem.cluster_id) md += '**Cluster:** ' + mem.cluster_id + ' \n';
429
+ md += '\n---\n\n';
430
+ md += mem.content || mem.summary || '(no content)';
431
+ md += '\n\n---\n*Exported from SuperLocalMemory V2*\n';
432
+
433
+ downloadFile('memory-' + (mem.id || 'export') + '.md', md, 'text/markdown');
434
+ }
435
+
436
+ // ============================================================================
437
+ // Export All / Search Results
438
+ // ============================================================================
439
+
440
+ function exportAll(format) {
441
+ // Trigger browser download from the API endpoint
442
+ var url = '/api/export?format=' + encodeURIComponent(format);
443
+ var category = document.getElementById('filter-category').value;
444
+ var project = document.getElementById('filter-project').value;
445
+ if (category) url += '&category=' + encodeURIComponent(category);
446
+ if (project) url += '&project_name=' + encodeURIComponent(project);
447
+ window.location.href = url;
448
+ }
449
+
450
+ function exportSearchResults() {
451
+ if (!lastSearchResults || lastSearchResults.length === 0) {
452
+ showToast('No search results to export');
453
+ return;
454
+ }
455
+ var content = JSON.stringify({
456
+ exported_at: new Date().toISOString(),
457
+ query: document.getElementById('search-query').value,
458
+ total: lastSearchResults.length,
459
+ results: lastSearchResults
460
+ }, null, 2);
461
+ downloadFile('search-results-' + Date.now() + '.json', content, 'application/json');
462
+ }
463
+
464
+ // ============================================================================
465
+ // File Download helper
466
+ // ============================================================================
467
+
468
+ function downloadFile(filename, content, mimeType) {
469
+ var blob = new Blob([content], { type: mimeType });
470
+ var url = URL.createObjectURL(blob);
471
+ var a = document.createElement('a');
472
+ a.href = url;
473
+ a.download = filename;
474
+ document.body.appendChild(a);
475
+ a.click();
476
+ document.body.removeChild(a);
477
+ URL.revokeObjectURL(url);
478
+ }
479
+
480
+ // ============================================================================
481
+ // Toast notification
482
+ // ============================================================================
483
+
484
+ function showToast(message) {
485
+ var toast = document.createElement('div');
486
+ toast.style.cssText = 'position:fixed;bottom:24px;right:24px;background:#333;color:#fff;padding:10px 20px;border-radius:8px;font-size:0.9rem;z-index:9999;opacity:0;transition:opacity 0.3s;';
487
+ toast.textContent = message;
488
+ document.body.appendChild(toast);
489
+ requestAnimationFrame(function() { toast.style.opacity = '1'; });
490
+ setTimeout(function() {
491
+ toast.style.opacity = '0';
492
+ setTimeout(function() {
493
+ if (toast.parentNode) document.body.removeChild(toast);
494
+ }, 300);
495
+ }, 2000);
496
+ }
497
+
498
+ // ============================================================================
499
+ // Clusters
500
+ // ============================================================================
501
+
502
+ async function loadClusters() {
503
+ showLoading('clusters-list', 'Loading clusters...');
504
+ try {
505
+ var response = await fetch('/api/clusters');
506
+ var data = await response.json();
507
+ renderClusters(data.clusters);
508
+ } catch (error) {
509
+ console.error('Error loading clusters:', error);
510
+ showEmpty('clusters-list', 'collection', 'Failed to load clusters');
511
+ }
512
+ }
513
+
514
+ function renderClusters(clusters) {
515
+ var container = document.getElementById('clusters-list');
516
+ if (!clusters || clusters.length === 0) {
517
+ showEmpty('clusters-list', 'collection', 'No clusters found. Run "slm build-graph" to generate clusters.');
518
+ return;
519
+ }
520
+ var colors = ['#667eea', '#f093fb', '#4facfe', '#43e97b', '#fa709a'];
521
+
522
+ // All dynamic values escaped — safe for innerHTML
523
+ var html = '';
524
+ clusters.forEach(function(cluster, idx) {
525
+ var color = colors[idx % colors.length];
526
+ html += '<div class="card cluster-card" style="border-color: ' + color + '">'
527
+ + '<div class="card-body">'
528
+ + '<h6 class="card-title">Cluster ' + escapeHtml(String(cluster.cluster_id))
529
+ + ' <span class="badge bg-secondary float-end">' + escapeHtml(String(cluster.member_count)) + ' memories</span></h6>'
530
+ + '<p class="mb-2"><strong>Avg Importance:</strong> ' + escapeHtml(parseFloat(cluster.avg_importance).toFixed(1)) + '</p>'
531
+ + '<p class="mb-2"><strong>Categories:</strong> ' + escapeHtml(cluster.categories || 'None') + '</p>'
532
+ + '<div><strong>Top Entities:</strong><br/>';
533
+ if (cluster.top_entities && cluster.top_entities.length > 0) {
534
+ cluster.top_entities.forEach(function(e) {
535
+ html += '<span class="badge bg-info entity-badge">' + escapeHtml(e.entity) + ' (' + escapeHtml(String(e.count)) + ')</span> ';
536
+ });
537
+ } else {
538
+ html += '<span class="text-muted">No entities</span>';
539
+ }
540
+ html += '</div></div></div>';
541
+ });
542
+ container.innerHTML = html; // nosemgrep: innerHTML-xss — all values escaped
543
+ }
544
+
545
+ // ============================================================================
546
+ // Patterns
547
+ // ============================================================================
548
+
549
+ async function loadPatterns() {
550
+ showLoading('patterns-list', 'Loading patterns...');
551
+ try {
552
+ var response = await fetch('/api/patterns');
553
+ var data = await response.json();
554
+ renderPatterns(data.patterns);
555
+ } catch (error) {
556
+ console.error('Error loading patterns:', error);
557
+ showEmpty('patterns-list', 'puzzle', 'Failed to load patterns');
558
+ }
559
+ }
560
+
561
+ function renderPatterns(patterns) {
562
+ var container = document.getElementById('patterns-list');
563
+ if (!patterns || Object.keys(patterns).length === 0) {
564
+ showEmpty('patterns-list', 'puzzle', 'No patterns learned yet. Use SuperLocalMemory for a while to build patterns.');
565
+ return;
566
+ }
567
+
568
+ // All dynamic values escaped — safe for innerHTML
569
+ var html = '';
570
+ for (var type in patterns) {
571
+ if (!patterns.hasOwnProperty(type)) continue;
572
+ var items = patterns[type];
573
+ html += '<h6 class="mt-3 text-capitalize">' + escapeHtml(type.replace(/_/g, ' ')) + '</h6><div class="list-group mb-3">';
574
+ items.forEach(function(pattern) {
575
+ var confidence = (pattern.confidence * 100).toFixed(0);
576
+ html += '<div class="list-group-item">'
577
+ + '<div class="d-flex justify-content-between align-items-center">'
578
+ + '<strong>' + escapeHtml(pattern.key) + '</strong>'
579
+ + '<span class="badge bg-success">' + escapeHtml(confidence) + '% confidence</span>'
580
+ + '</div>'
581
+ + '<div class="mt-1"><small class="text-muted">' + escapeHtml(JSON.stringify(pattern.value)) + '</small></div>'
582
+ + '<small class="text-muted">Evidence: ' + escapeHtml(String(pattern.evidence_count)) + ' memories</small>'
583
+ + '</div>';
584
+ });
585
+ html += '</div>';
586
+ }
587
+ container.innerHTML = html; // nosemgrep: innerHTML-xss — all values escaped
588
+ }
589
+
590
+ // ============================================================================
591
+ // Timeline
592
+ // ============================================================================
593
+
594
+ async function loadTimeline() {
595
+ showLoading('timeline-chart', 'Loading timeline...');
596
+ try {
597
+ var response = await fetch('/api/timeline?days=30');
598
+ var data = await response.json();
599
+ renderTimeline(data.timeline);
600
+ } catch (error) {
601
+ console.error('Error loading timeline:', error);
602
+ showEmpty('timeline-chart', 'clock-history', 'Failed to load timeline');
603
+ }
604
+ }
605
+
606
+ function renderTimeline(timeline) {
607
+ var container = document.getElementById('timeline-chart');
608
+ if (!timeline || timeline.length === 0) {
609
+ showEmpty('timeline-chart', 'clock-history', 'No timeline data for the last 30 days.');
610
+ return;
611
+ }
612
+ var margin = { top: 20, right: 20, bottom: 50, left: 50 };
613
+ var width = container.clientWidth - margin.left - margin.right;
614
+ var height = 300 - margin.top - margin.bottom;
615
+ container.textContent = '';
616
+ var svg = d3.select('#timeline-chart').append('svg').attr('width', width + margin.left + margin.right).attr('height', height + margin.top + margin.bottom).append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
617
+ var x = d3.scaleBand().range([0, width]).domain(timeline.map(function(d) { return d.date || d.period; })).padding(0.1);
618
+ var y = d3.scaleLinear().range([height, 0]).domain([0, d3.max(timeline, function(d) { return d.count; })]);
619
+ svg.append('g').attr('transform', 'translate(0,' + height + ')').call(d3.axisBottom(x)).selectAll('text').attr('transform', 'rotate(-45)').style('text-anchor', 'end');
620
+ svg.append('g').call(d3.axisLeft(y));
621
+ svg.selectAll('.bar').data(timeline).enter().append('rect').attr('class', 'bar').attr('x', function(d) { return x(d.date || d.period); }).attr('y', function(d) { return y(d.count); }).attr('width', x.bandwidth()).attr('height', function(d) { return height - y(d.count); }).attr('fill', '#667eea').attr('rx', 3);
622
+ }
623
+
624
+ // ============================================================================
625
+ // Date Formatters
626
+ // ============================================================================
627
+
628
+ function formatDate(dateString) {
629
+ if (!dateString) return '-';
630
+ var date = new Date(dateString);
631
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
632
+ }
633
+
634
+ function formatDateFull(dateString) {
635
+ if (!dateString) return '-';
636
+ var date = new Date(dateString);
637
+ return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
638
+ }
639
+
640
+ // ============================================================================
641
+ // Event Listeners
642
+ // ============================================================================
643
+
644
+ document.getElementById('memories-tab').addEventListener('shown.bs.tab', loadMemories);
645
+ document.getElementById('clusters-tab').addEventListener('shown.bs.tab', loadClusters);
646
+ document.getElementById('patterns-tab').addEventListener('shown.bs.tab', loadPatterns);
647
+ document.getElementById('timeline-tab').addEventListener('shown.bs.tab', loadTimeline);
648
+ document.getElementById('search-query').addEventListener('keypress', function(e) { if (e.key === 'Enter') searchMemories(); });
649
+
650
+ window.addEventListener('DOMContentLoaded', function() {
651
+ initDarkMode();
652
+ loadStats();
653
+ loadGraph();
654
+ });