superlocalmemory 2.3.7 → 2.4.0

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 CHANGED
@@ -199,7 +199,7 @@ function renderGraph(data) {
199
199
  var colorScale = d3.scaleOrdinal(d3.schemeCategory10);
200
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
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); });
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); var label = d.category || d.project_name || 'Memory #' + d.id; tooltip.text(label + ': ' + (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
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
204
  function dragStarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
205
205
  function dragged(event, d) { d.fx = event.x; d.fy = event.y; }
@@ -275,9 +275,14 @@ function renderMemoriesTable(memories, showScores) {
275
275
  });
276
276
 
277
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>'
278
+ + '<th class="sortable" data-sort="id">ID</th>'
279
+ + '<th class="sortable" data-sort="category">Category</th>'
280
+ + '<th class="sortable" data-sort="project">Project</th>'
281
+ + '<th>Content</th>'
279
282
  + scoreHeader
280
- + '<th>Importance</th><th>Cluster</th><th>Created</th>'
283
+ + '<th class="sortable" data-sort="importance">Importance</th>'
284
+ + '<th>Cluster</th>'
285
+ + '<th class="sortable" data-sort="created">Created</th>'
281
286
  + '</tr></thead><tbody>' + rows + '</tbody></table>';
282
287
 
283
288
  // Safe: all interpolated values above are escaped via escapeHtml()
@@ -287,6 +292,12 @@ function renderMemoriesTable(memories, showScores) {
287
292
  var table = container.querySelector('table');
288
293
  if (table) {
289
294
  table.addEventListener('click', function(e) {
295
+ // Check if clicking a sortable header
296
+ var th = e.target.closest('th.sortable');
297
+ if (th) {
298
+ handleSort(th);
299
+ return;
300
+ }
290
301
  var row = e.target.closest('tr[data-mem-idx]');
291
302
  if (row) {
292
303
  var idx = parseInt(row.getAttribute('data-mem-idx'), 10);
@@ -565,26 +576,78 @@ function renderPatterns(patterns) {
565
576
  return;
566
577
  }
567
578
 
568
- // All dynamic values escaped safe for innerHTML
569
- var html = '';
579
+ var typeIcons = { preference: 'heart', style: 'palette', terminology: 'code-slash' };
580
+ var typeLabels = { preference: 'Preferences', style: 'Coding Style', terminology: 'Terminology' };
581
+
582
+ // Build using DOM for safety
583
+ container.textContent = '';
584
+
570
585
  for (var type in patterns) {
571
586
  if (!patterns.hasOwnProperty(type)) continue;
572
587
  var items = patterns[type];
573
- html += '<h6 class="mt-3 text-capitalize">' + escapeHtml(type.replace(/_/g, ' ')) + '</h6><div class="list-group mb-3">';
588
+
589
+ var header = document.createElement('h6');
590
+ header.className = 'mt-3 mb-2';
591
+ var icon = document.createElement('i');
592
+ icon.className = 'bi bi-' + (typeIcons[type] || 'puzzle') + ' me-1';
593
+ header.appendChild(icon);
594
+ header.appendChild(document.createTextNode(typeLabels[type] || type));
595
+ var countBadge = document.createElement('span');
596
+ countBadge.className = 'badge bg-secondary ms-2';
597
+ countBadge.textContent = items.length;
598
+ header.appendChild(countBadge);
599
+ container.appendChild(header);
600
+
601
+ var group = document.createElement('div');
602
+ group.className = 'list-group mb-3';
603
+
574
604
  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>';
605
+ var pct = Math.round(pattern.confidence * 100);
606
+ var barColor = pct >= 60 ? '#43e97b' : pct >= 40 ? '#f9c74f' : '#6c757d';
607
+ var badgeClass = pct >= 60 ? 'bg-success' : pct >= 40 ? 'bg-warning text-dark' : 'bg-secondary';
608
+
609
+ var item = document.createElement('div');
610
+ item.className = 'list-group-item';
611
+
612
+ var topRow = document.createElement('div');
613
+ topRow.className = 'd-flex justify-content-between align-items-center';
614
+ var keyEl = document.createElement('strong');
615
+ keyEl.textContent = pattern.key;
616
+ var badge = document.createElement('span');
617
+ badge.className = 'badge ' + badgeClass;
618
+ badge.textContent = pct + '%';
619
+ topRow.appendChild(keyEl);
620
+ topRow.appendChild(badge);
621
+ item.appendChild(topRow);
622
+
623
+ // Confidence bar
624
+ var barContainer = document.createElement('div');
625
+ barContainer.className = 'confidence-bar';
626
+ var barFill = document.createElement('div');
627
+ barFill.className = 'confidence-fill';
628
+ barFill.style.width = pct + '%';
629
+ barFill.style.background = barColor;
630
+ barContainer.appendChild(barFill);
631
+ item.appendChild(barContainer);
632
+
633
+ var valueEl = document.createElement('div');
634
+ valueEl.className = 'mt-1';
635
+ var valueSmall = document.createElement('small');
636
+ valueSmall.className = 'text-muted';
637
+ valueSmall.textContent = typeof pattern.value === 'string' ? pattern.value : JSON.stringify(pattern.value);
638
+ valueEl.appendChild(valueSmall);
639
+ item.appendChild(valueEl);
640
+
641
+ var evidenceEl = document.createElement('small');
642
+ evidenceEl.className = 'text-muted';
643
+ evidenceEl.textContent = 'Evidence: ' + (pattern.evidence_count || '?') + ' memories';
644
+ item.appendChild(evidenceEl);
645
+
646
+ group.appendChild(item);
584
647
  });
585
- html += '</div>';
648
+
649
+ container.appendChild(group);
586
650
  }
587
- container.innerHTML = html; // nosemgrep: innerHTML-xss — all values escaped
588
651
  }
589
652
 
590
653
  // ============================================================================
@@ -645,10 +708,456 @@ document.getElementById('memories-tab').addEventListener('shown.bs.tab', loadMem
645
708
  document.getElementById('clusters-tab').addEventListener('shown.bs.tab', loadClusters);
646
709
  document.getElementById('patterns-tab').addEventListener('shown.bs.tab', loadPatterns);
647
710
  document.getElementById('timeline-tab').addEventListener('shown.bs.tab', loadTimeline);
711
+ document.getElementById('settings-tab').addEventListener('shown.bs.tab', loadSettings);
648
712
  document.getElementById('search-query').addEventListener('keypress', function(e) { if (e.key === 'Enter') searchMemories(); });
649
713
 
714
+ document.getElementById('profile-select').addEventListener('change', function() {
715
+ switchProfile(this.value);
716
+ });
717
+
718
+ document.getElementById('add-profile-btn').addEventListener('click', function() {
719
+ createProfile();
720
+ });
721
+
722
+ var newProfileInput = document.getElementById('new-profile-name');
723
+ if (newProfileInput) {
724
+ newProfileInput.addEventListener('keypress', function(e) {
725
+ if (e.key === 'Enter') createProfile();
726
+ });
727
+ }
728
+
650
729
  window.addEventListener('DOMContentLoaded', function() {
651
730
  initDarkMode();
731
+ loadProfiles();
652
732
  loadStats();
653
733
  loadGraph();
654
734
  });
735
+
736
+ // ============================================================================
737
+ // Profile Management
738
+ // ============================================================================
739
+
740
+ async function loadProfiles() {
741
+ try {
742
+ var response = await fetch('/api/profiles');
743
+ var data = await response.json();
744
+ var select = document.getElementById('profile-select');
745
+ select.textContent = '';
746
+ var profiles = data.profiles || [];
747
+ var active = data.active_profile || 'default';
748
+
749
+ profiles.forEach(function(p) {
750
+ var opt = document.createElement('option');
751
+ opt.value = p.name;
752
+ opt.textContent = p.name + (p.memory_count ? ' (' + p.memory_count + ')' : '');
753
+ if (p.name === active) opt.selected = true;
754
+ select.appendChild(opt);
755
+ });
756
+ } catch (error) {
757
+ console.error('Error loading profiles:', error);
758
+ }
759
+ }
760
+
761
+ async function createProfile(nameOverride) {
762
+ var name = nameOverride || document.getElementById('new-profile-name').value.trim();
763
+ if (!name) {
764
+ // Prompt with a simple browser dialog if called from the "+" button
765
+ name = prompt('Enter new profile name:');
766
+ if (!name || !name.trim()) return;
767
+ name = name.trim();
768
+ }
769
+
770
+ // Validate: alphanumeric, dashes, underscores only
771
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
772
+ showToast('Invalid name. Use letters, numbers, dashes, underscores.');
773
+ return;
774
+ }
775
+
776
+ try {
777
+ var response = await fetch('/api/profiles/create', {
778
+ method: 'POST',
779
+ headers: { 'Content-Type': 'application/json' },
780
+ body: JSON.stringify({ profile_name: name })
781
+ });
782
+ var data = await response.json();
783
+ if (response.status === 409) {
784
+ showToast('Profile "' + name + '" already exists');
785
+ return;
786
+ }
787
+ if (!response.ok) {
788
+ showToast(data.detail || 'Failed to create profile');
789
+ return;
790
+ }
791
+ showToast('Profile "' + name + '" created');
792
+ var input = document.getElementById('new-profile-name');
793
+ if (input) input.value = '';
794
+ loadProfiles();
795
+ loadProfilesTable();
796
+ } catch (error) {
797
+ console.error('Error creating profile:', error);
798
+ showToast('Error creating profile');
799
+ }
800
+ }
801
+
802
+ async function deleteProfile(name) {
803
+ if (name === 'default') {
804
+ showToast('Cannot delete the default profile');
805
+ return;
806
+ }
807
+ if (!confirm('Delete profile "' + name + '"?\nIts memories will be moved to the default profile.')) {
808
+ return;
809
+ }
810
+ try {
811
+ var response = await fetch('/api/profiles/' + encodeURIComponent(name), {
812
+ method: 'DELETE'
813
+ });
814
+ var data = await response.json();
815
+ if (!response.ok) {
816
+ showToast(data.detail || 'Failed to delete profile');
817
+ return;
818
+ }
819
+ showToast(data.message || 'Profile deleted');
820
+ loadProfiles();
821
+ loadProfilesTable();
822
+ loadStats();
823
+ } catch (error) {
824
+ console.error('Error deleting profile:', error);
825
+ showToast('Error deleting profile');
826
+ }
827
+ }
828
+
829
+ async function loadProfilesTable() {
830
+ var container = document.getElementById('profiles-table');
831
+ if (!container) return;
832
+ try {
833
+ var response = await fetch('/api/profiles');
834
+ var data = await response.json();
835
+ var profiles = data.profiles || [];
836
+ var active = data.active_profile || 'default';
837
+
838
+ if (profiles.length === 0) {
839
+ showEmpty('profiles-table', 'people', 'No profiles found.');
840
+ return;
841
+ }
842
+
843
+ var table = document.createElement('table');
844
+ table.className = 'table table-sm mb-0';
845
+ var thead = document.createElement('thead');
846
+ var headRow = document.createElement('tr');
847
+ ['Name', 'Memories', 'Status', 'Actions'].forEach(function(h) {
848
+ var th = document.createElement('th');
849
+ th.textContent = h;
850
+ headRow.appendChild(th);
851
+ });
852
+ thead.appendChild(headRow);
853
+ table.appendChild(thead);
854
+
855
+ var tbody = document.createElement('tbody');
856
+ profiles.forEach(function(p) {
857
+ var row = document.createElement('tr');
858
+
859
+ var nameCell = document.createElement('td');
860
+ var nameIcon = document.createElement('i');
861
+ nameIcon.className = 'bi bi-person me-1';
862
+ nameCell.appendChild(nameIcon);
863
+ nameCell.appendChild(document.createTextNode(p.name));
864
+ row.appendChild(nameCell);
865
+
866
+ var countCell = document.createElement('td');
867
+ countCell.textContent = (p.memory_count || 0) + ' memories';
868
+ row.appendChild(countCell);
869
+
870
+ var statusCell = document.createElement('td');
871
+ if (p.name === active) {
872
+ var badge = document.createElement('span');
873
+ badge.className = 'badge bg-success';
874
+ badge.textContent = 'Active';
875
+ statusCell.appendChild(badge);
876
+ } else {
877
+ var switchBtn = document.createElement('button');
878
+ switchBtn.className = 'btn btn-sm btn-outline-primary';
879
+ switchBtn.textContent = 'Switch';
880
+ switchBtn.addEventListener('click', (function(n) {
881
+ return function() { switchProfile(n); };
882
+ })(p.name));
883
+ statusCell.appendChild(switchBtn);
884
+ }
885
+ row.appendChild(statusCell);
886
+
887
+ var actionsCell = document.createElement('td');
888
+ if (p.name !== 'default') {
889
+ var delBtn = document.createElement('button');
890
+ delBtn.className = 'btn btn-sm btn-outline-danger btn-delete-profile';
891
+ delBtn.title = 'Delete profile';
892
+ var delIcon = document.createElement('i');
893
+ delIcon.className = 'bi bi-trash';
894
+ delBtn.appendChild(delIcon);
895
+ delBtn.addEventListener('click', (function(n) {
896
+ return function() { deleteProfile(n); };
897
+ })(p.name));
898
+ actionsCell.appendChild(delBtn);
899
+ } else {
900
+ var protectedBadge = document.createElement('span');
901
+ protectedBadge.className = 'badge bg-secondary';
902
+ protectedBadge.textContent = 'Protected';
903
+ actionsCell.appendChild(protectedBadge);
904
+ }
905
+ row.appendChild(actionsCell);
906
+
907
+ tbody.appendChild(row);
908
+ });
909
+ table.appendChild(tbody);
910
+
911
+ container.textContent = '';
912
+ container.appendChild(table);
913
+ } catch (error) {
914
+ console.error('Error loading profiles table:', error);
915
+ showEmpty('profiles-table', 'exclamation-triangle', 'Failed to load profiles');
916
+ }
917
+ }
918
+
919
+ async function switchProfile(profileName) {
920
+ try {
921
+ var response = await fetch('/api/profiles/' + encodeURIComponent(profileName) + '/switch', {
922
+ method: 'POST'
923
+ });
924
+ var data = await response.json();
925
+ if (data.success || data.active_profile) {
926
+ showToast('Switched to profile: ' + profileName);
927
+ loadProfiles();
928
+ loadStats();
929
+ loadGraph();
930
+ loadProfilesTable();
931
+ var activeTab = document.querySelector('#mainTabs .nav-link.active');
932
+ if (activeTab) activeTab.click();
933
+ } else {
934
+ showToast('Failed to switch profile');
935
+ }
936
+ } catch (error) {
937
+ console.error('Error switching profile:', error);
938
+ showToast('Error switching profile');
939
+ }
940
+ }
941
+
942
+ // ============================================================================
943
+ // Settings & Backup
944
+ // ============================================================================
945
+
946
+ async function loadSettings() {
947
+ loadProfilesTable();
948
+ loadBackupStatus();
949
+ loadBackupList();
950
+ }
951
+
952
+ async function loadBackupStatus() {
953
+ try {
954
+ var response = await fetch('/api/backup/status');
955
+ var data = await response.json();
956
+ renderBackupStatus(data);
957
+ document.getElementById('backup-interval').value = data.interval_hours <= 24 ? '24' : '168';
958
+ document.getElementById('backup-max').value = data.max_backups || 10;
959
+ document.getElementById('backup-enabled').checked = data.enabled !== false;
960
+ } catch (error) {
961
+ var container = document.getElementById('backup-status');
962
+ var alert = document.createElement('div');
963
+ alert.className = 'alert alert-warning mb-0';
964
+ alert.textContent = 'Auto-backup not available. Update to v2.4.0+.';
965
+ container.textContent = '';
966
+ container.appendChild(alert);
967
+ }
968
+ }
969
+
970
+ function renderBackupStatus(data) {
971
+ var container = document.getElementById('backup-status');
972
+ container.textContent = '';
973
+
974
+ var lastBackup = data.last_backup ? formatDateFull(data.last_backup) : 'Never';
975
+ var nextBackup = data.next_backup || 'N/A';
976
+ if (nextBackup === 'overdue') nextBackup = 'Overdue';
977
+ else if (nextBackup !== 'N/A' && nextBackup !== 'unknown') nextBackup = formatDateFull(nextBackup);
978
+
979
+ var statusColor = data.enabled ? 'text-success' : 'text-secondary';
980
+ var statusText = data.enabled ? 'Active' : 'Disabled';
981
+
982
+ // Build DOM nodes for safety
983
+ var row = document.createElement('div');
984
+ row.className = 'row g-2 mb-2';
985
+
986
+ var stats = [
987
+ { value: statusText, label: 'Status', cls: statusColor },
988
+ { value: String(data.backup_count || 0), label: 'Backups', cls: '' },
989
+ { value: (data.total_size_mb || 0) + ' MB', label: 'Storage', cls: '' }
990
+ ];
991
+
992
+ stats.forEach(function(s) {
993
+ var col = document.createElement('div');
994
+ col.className = 'col-4';
995
+ var stat = document.createElement('div');
996
+ stat.className = 'backup-stat';
997
+ var val = document.createElement('div');
998
+ val.className = 'value ' + s.cls;
999
+ val.textContent = s.value;
1000
+ var lbl = document.createElement('div');
1001
+ lbl.className = 'label';
1002
+ lbl.textContent = s.label;
1003
+ stat.appendChild(val);
1004
+ stat.appendChild(lbl);
1005
+ col.appendChild(stat);
1006
+ row.appendChild(col);
1007
+ });
1008
+ container.appendChild(row);
1009
+
1010
+ var details = [
1011
+ { label: 'Last backup:', value: lastBackup },
1012
+ { label: 'Next backup:', value: nextBackup },
1013
+ { label: 'Interval:', value: data.interval_display || '-' }
1014
+ ];
1015
+ details.forEach(function(d) {
1016
+ var div = document.createElement('div');
1017
+ div.className = 'small text-muted';
1018
+ var strong = document.createElement('strong');
1019
+ strong.textContent = d.label + ' ';
1020
+ div.appendChild(strong);
1021
+ div.appendChild(document.createTextNode(d.value));
1022
+ container.appendChild(div);
1023
+ });
1024
+ }
1025
+
1026
+ async function saveBackupConfig() {
1027
+ try {
1028
+ var response = await fetch('/api/backup/configure', {
1029
+ method: 'POST',
1030
+ headers: { 'Content-Type': 'application/json' },
1031
+ body: JSON.stringify({
1032
+ interval_hours: parseInt(document.getElementById('backup-interval').value),
1033
+ max_backups: parseInt(document.getElementById('backup-max').value),
1034
+ enabled: document.getElementById('backup-enabled').checked
1035
+ })
1036
+ });
1037
+ var data = await response.json();
1038
+ renderBackupStatus(data);
1039
+ showToast('Backup settings saved');
1040
+ } catch (error) {
1041
+ console.error('Error saving backup config:', error);
1042
+ showToast('Failed to save backup settings');
1043
+ }
1044
+ }
1045
+
1046
+ async function createBackupNow() {
1047
+ showToast('Creating backup...');
1048
+ try {
1049
+ var response = await fetch('/api/backup/create', { method: 'POST' });
1050
+ var data = await response.json();
1051
+ if (data.success) {
1052
+ showToast('Backup created: ' + data.filename);
1053
+ loadBackupStatus();
1054
+ loadBackupList();
1055
+ } else {
1056
+ showToast('Backup failed');
1057
+ }
1058
+ } catch (error) {
1059
+ console.error('Error creating backup:', error);
1060
+ showToast('Backup failed');
1061
+ }
1062
+ }
1063
+
1064
+ async function loadBackupList() {
1065
+ try {
1066
+ var response = await fetch('/api/backup/list');
1067
+ var data = await response.json();
1068
+ renderBackupList(data.backups || []);
1069
+ } catch (error) {
1070
+ var container = document.getElementById('backup-list');
1071
+ container.textContent = 'Backup list unavailable';
1072
+ }
1073
+ }
1074
+
1075
+ function renderBackupList(backups) {
1076
+ var container = document.getElementById('backup-list');
1077
+ if (!backups || backups.length === 0) {
1078
+ showEmpty('backup-list', 'archive', 'No backups yet. Create your first backup above.');
1079
+ return;
1080
+ }
1081
+
1082
+ // Build table using DOM nodes
1083
+ var table = document.createElement('table');
1084
+ table.className = 'table table-sm';
1085
+ var thead = document.createElement('thead');
1086
+ var headRow = document.createElement('tr');
1087
+ ['Filename', 'Size', 'Age', 'Created'].forEach(function(h) {
1088
+ var th = document.createElement('th');
1089
+ th.textContent = h;
1090
+ headRow.appendChild(th);
1091
+ });
1092
+ thead.appendChild(headRow);
1093
+ table.appendChild(thead);
1094
+
1095
+ var tbody = document.createElement('tbody');
1096
+ backups.forEach(function(b) {
1097
+ var row = document.createElement('tr');
1098
+ var age = b.age_hours < 48 ? Math.round(b.age_hours) + 'h ago' : Math.round(b.age_hours / 24) + 'd ago';
1099
+ var cells = [b.filename, b.size_mb + ' MB', age, formatDateFull(b.created)];
1100
+ cells.forEach(function(text) {
1101
+ var td = document.createElement('td');
1102
+ td.textContent = text;
1103
+ row.appendChild(td);
1104
+ });
1105
+ tbody.appendChild(row);
1106
+ });
1107
+ table.appendChild(tbody);
1108
+
1109
+ container.textContent = '';
1110
+ container.appendChild(table);
1111
+ }
1112
+
1113
+ // ============================================================================
1114
+ // Column Sorting
1115
+ // ============================================================================
1116
+
1117
+ var currentSort = { column: null, direction: 'asc' };
1118
+
1119
+ function handleSort(th) {
1120
+ var col = th.getAttribute('data-sort');
1121
+ if (!col) return;
1122
+
1123
+ if (currentSort.column === col) {
1124
+ currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
1125
+ } else {
1126
+ currentSort.column = col;
1127
+ currentSort.direction = 'asc';
1128
+ }
1129
+
1130
+ // Update header classes
1131
+ document.querySelectorAll('#memories-list th.sortable').forEach(function(h) {
1132
+ h.classList.remove('sort-asc', 'sort-desc');
1133
+ });
1134
+ th.classList.add('sort-' + currentSort.direction);
1135
+
1136
+ // Sort the data
1137
+ if (!window._slmMemories) return;
1138
+ var memories = window._slmMemories.slice();
1139
+ var dir = currentSort.direction === 'asc' ? 1 : -1;
1140
+
1141
+ memories.sort(function(a, b) {
1142
+ var av, bv;
1143
+ switch (col) {
1144
+ case 'id': return ((a.id || 0) - (b.id || 0)) * dir;
1145
+ case 'importance': return ((a.importance || 0) - (b.importance || 0)) * dir;
1146
+ case 'category':
1147
+ av = (a.category || '').toLowerCase(); bv = (b.category || '').toLowerCase();
1148
+ return av < bv ? -dir : av > bv ? dir : 0;
1149
+ case 'project':
1150
+ av = (a.project_name || '').toLowerCase(); bv = (b.project_name || '').toLowerCase();
1151
+ return av < bv ? -dir : av > bv ? dir : 0;
1152
+ case 'created':
1153
+ av = a.created_at || ''; bv = b.created_at || '';
1154
+ return av < bv ? -dir : av > bv ? dir : 0;
1155
+ case 'score': return ((a.score || 0) - (b.score || 0)) * dir;
1156
+ default: return 0;
1157
+ }
1158
+ });
1159
+
1160
+ window._slmMemories = memories;
1161
+ var showScores = memories.length > 0 && typeof memories[0].score === 'number';
1162
+ renderMemoriesTable(memories, showScores);
1163
+ }