personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

convey: promote escapeHtml + renderMarkdown to AppServices

Adds two public methods to window.AppServices in convey/static/app.js
and deletes 11 escapeHtml + 7 renderMarkdown duplicates across
apps/**/*.html. Removes four redundant per-app marked <script> tags
(shell already loads marked at convey/templates/app.html:100). Retires
the private AppServices._escapeHtml in favor of the public name;
updates the six external consumers (entities, todos, status_pane) to
the renamed method in the same diff.

Behavior tightens in three files (settings, transcripts, import) that
previously used a 4-char regex missing `'` — they now escape `'` →
&#39; via the DOM-based canonical form. Identical in-element render;
closes a latent XSS vector where escaped output was interpolated into
single-quoted attributes.

Extends the shell-consolidation pattern shipped in bed62e6a
(DOMPurify).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+48 -137
+2 -12
apps/activities/_day.html
··· 504 504 </div> 505 505 506 506 <script src="{{ url_for('root.static', filename='colors.js') }}"></script> 507 - <script src="{{ vendor_lib('marked') }}"></script> 508 507 <script> 509 508 (function() { 510 509 const day = '{{ day }}'; 510 + const escapeHtml = window.AppServices.escapeHtml; 511 511 512 512 // Breadcrumb month link 513 513 const monthLink = document.getElementById('breadcrumbMonth'); ··· 575 575 const facet = window.selectedFacet; 576 576 if (!facet) return list; 577 577 return list.filter(e => e.facet === facet); 578 - } 579 - 580 - function escapeHtml(text) { 581 - const d = document.createElement('div'); 582 - d.textContent = text; 583 - return d.innerHTML; 584 - } 585 - 586 - function renderMarkdown(raw) { 587 - return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 588 578 } 589 579 590 580 function fmtTime(ts) { ··· 861 851 862 852 let rendered; 863 853 if (data.format === 'md') { 864 - rendered = `<div class="rendered-markdown">${renderMarkdown(data.content)}</div>`; 854 + rendered = `<div class="rendered-markdown">${window.AppServices.renderMarkdown(data.content)}</div>`; 865 855 } else if (data.format === 'json') { 866 856 let pretty; 867 857 try { pretty = JSON.stringify(JSON.parse(data.content), null, 2); } catch (e) { pretty = data.content; }
+4 -4
apps/entities/workspace.html
··· 1699 1699 console.error('Error loading entity:', error); 1700 1700 document.getElementById('detail-name').textContent = 'Error'; 1701 1701 document.getElementById('detail-description-container').innerHTML = 1702 - `<div class="error-message">${window.AppServices._escapeHtml(error.message)}</div>`; 1702 + `<div class="error-message">${window.AppServices.escapeHtml(error.message)}</div>`; 1703 1703 }); 1704 1704 } 1705 1705 ··· 1725 1725 console.error('Error loading journal entity:', error); 1726 1726 document.getElementById('journal-detail-name').textContent = 'Error'; 1727 1727 document.getElementById('journal-detail-facets').innerHTML = 1728 - `<div class="error-message">${window.AppServices._escapeHtml(error.message)}</div>`; 1728 + `<div class="error-message">${window.AppServices.escapeHtml(error.message)}</div>`; 1729 1729 }); 1730 1730 } 1731 1731 ··· 2671 2671 .catch(error => { 2672 2672 console.error('Error loading entities:', error); 2673 2673 document.getElementById('entities-loading').innerHTML = 2674 - `<div class="error-message">${window.AppServices._escapeHtml(error.message || 'Network error')}</div>`; 2674 + `<div class="error-message">${window.AppServices.escapeHtml(error.message || 'Network error')}</div>`; 2675 2675 }); 2676 2676 } 2677 2677 ··· 2699 2699 .catch(error => { 2700 2700 console.error('Error loading journal entities:', error); 2701 2701 document.getElementById('entities-loading').innerHTML = 2702 - `<div class="error-message">${window.AppServices._escapeHtml(error.message || 'Network error')}</div>`; 2702 + `<div class="error-message">${window.AppServices.escapeHtml(error.message || 'Network error')}</div>`; 2703 2703 }); 2704 2704 } 2705 2705
+1 -5
apps/graph/workspace.html
··· 650 650 return d.toISOString().slice(0,10).replace(/-/g,''); 651 651 } 652 652 653 - function escapeHtml(text) { 654 - const div = document.createElement('div'); 655 - div.textContent = text || ''; 656 - return div.innerHTML; 657 - } 653 + const escapeHtml = window.AppServices.escapeHtml; 658 654 659 655 function formatDay(d) { 660 656 if (!d || d.length < 8) return d || '';
-6
apps/health/workspace.html
··· 1427 1427 return String(id).slice(-4); 1428 1428 } 1429 1429 1430 - function escapeHtml(text) { 1431 - const div = document.createElement('div'); 1432 - div.textContent = String(text ?? ''); 1433 - return div.innerHTML; 1434 - } 1435 - 1436 1430 function formatNextRun(epochMs) { 1437 1431 if (!epochMs) return ''; 1438 1432 const delta = epochMs - Date.now();
+4 -8
apps/home/workspace.html
··· 1342 1342 var skillContent = {{ skills_content|default({})|tojson|safe }}; 1343 1343 var sectionOrder = ['your_day', 'yesterday', 'forward_look', 'reading']; 1344 1344 1345 - function renderMarkdown(raw) { 1346 - return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 1347 - } 1348 - 1349 1345 if (narrativeRaw) { 1350 1346 const el = document.getElementById('pulse-narrative-content'); 1351 - if (el) el.innerHTML = renderMarkdown(narrativeRaw); 1347 + if (el) el.innerHTML = window.AppServices.renderMarkdown(narrativeRaw); 1352 1348 } 1353 1349 1354 1350 function renderBriefingSections(sections) { ··· 1360 1356 el.innerHTML = ''; 1361 1357 return; 1362 1358 } 1363 - el.innerHTML = renderMarkdown(raw); 1359 + el.innerHTML = window.AppServices.renderMarkdown(raw); 1364 1360 if (key === 'reading') { 1365 1361 el.querySelectorAll('li').forEach(function(li) { 1366 1362 var strong = li.querySelector('strong'); ··· 1417 1413 if (!detail) return; 1418 1414 if (detail.style.display === 'none') { 1419 1415 if (skillContent[skillId]) { 1420 - detail.innerHTML = renderMarkdown(skillContent[skillId]); 1416 + detail.innerHTML = window.AppServices.renderMarkdown(skillContent[skillId]); 1421 1417 } 1422 1418 detail.style.display = 'block'; 1423 1419 } else { ··· 1618 1614 .then(data => { 1619 1615 const el = document.getElementById('pulse-narrative-content'); 1620 1616 if (el && data.narrative_content) { 1621 - el.innerHTML = renderMarkdown(data.narrative_content); 1617 + el.innerHTML = window.AppServices.renderMarkdown(data.narrative_content); 1622 1618 const hdr = document.querySelector('.pulse-narrative .pulse-section-header'); 1623 1619 if (hdr && data.narrative_header) hdr.textContent = data.narrative_header; 1624 1620 const meta = document.querySelector('.pulse-narrative .pulse-narrative-meta');
+1 -5
apps/import/_detail.html
··· 176 176 let contentMonth = ''; 177 177 let searchDebounce = null; 178 178 179 - function renderMarkdown(raw) { 180 - return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 181 - } 182 - 183 179 tabs.forEach(tab => { 184 180 tab.addEventListener('click', () => { 185 181 const target = tab.dataset.target; ··· 333 329 334 330 return data.content.map(part => { 335 331 if (part.type === 'markdown') { 336 - return `<div class="import-content-markdown">${renderMarkdown(part.content)}</div>`; 332 + return `<div class="import-content-markdown">${window.AppServices.renderMarkdown(part.content)}</div>`; 337 333 } 338 334 const speaker = part.speaker || 'Unknown'; 339 335 const cls = speaker === 'Human' ? 'human' : 'assistant';
+2 -17
apps/import/workspace.html
··· 639 639 </div> 640 640 </div> 641 641 642 - <script src="{{ vendor_lib('marked') }}"></script> 643 642 <script> 644 643 const facetSelect = document.getElementById('facetSelect'); 645 644 const settingInput = document.getElementById('settingInput'); ··· 671 670 let currentSourceFilter = ''; 672 671 let currentPage = 1; 673 672 let totalImports = 0; 674 - 675 - function escapeHtml(str) { 676 - if (str === null || str === undefined) { 677 - return ''; 678 - } 679 - return String(str) 680 - .replace(/&/g, '&amp;') 681 - .replace(/</g, '&lt;') 682 - .replace(/>/g, '&gt;') 683 - .replace(/"/g, '&quot;'); 684 - } 685 - 686 - function renderMarkdown(raw) { 687 - return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 688 - } 673 + const escapeHtml = window.AppServices.escapeHtml; 689 674 690 675 function navigateTo(view, options = {}) { 691 676 const { updateHash = true } = options; ··· 1237 1222 const response = await fetch(`/app/import/api/guide/${sourceName}`); 1238 1223 if (response.ok) { 1239 1224 const markdown = await response.text(); 1240 - guideHtml = `<li><div class="import-step"><div class="import-step-number">Step 1</div><div class="import-guide-content">${renderMarkdown(markdown)}</div></div></li>`; 1225 + guideHtml = `<li><div class="import-step"><div class="import-step-number">Step 1</div><div class="import-guide-content">${window.AppServices.renderMarkdown(markdown)}</div></div></li>`; 1241 1226 } 1242 1227 } 1243 1228
+1 -6
apps/observer/workspace.html
··· 617 617 const doneBtn = document.getElementById('doneBtn'); 618 618 const keyModalClose = document.getElementById('keyModalClose'); 619 619 const revealKeyBtn = document.getElementById('revealKeyBtn'); 620 + const escapeHtml = window.AppServices.escapeHtml; 620 621 let keyModalTrigger = null; 621 622 // Error display state 622 623 const wsErrorContainer = document.getElementById('wsErrorContainer'); ··· 996 997 } 997 998 } 998 999 } 999 - } 1000 - 1001 - function escapeHtml(text) { 1002 - const div = document.createElement('div'); 1003 - div.textContent = text; 1004 - return div.innerHTML; 1005 1000 } 1006 1001 1007 1002 async function revokeObserver(keyPrefix, name) {
+1 -5
apps/reflections/workspace.html
··· 203 203 const copyButton = document.getElementById('copyReflectionButton'); 204 204 const rawUrl = {{ raw_url|tojson|safe }}; 205 205 206 - function renderMarkdown(raw) { 207 - return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 208 - } 209 - 210 206 function copyText(text) { 211 207 if (navigator.clipboard && navigator.clipboard.writeText) { 212 208 return navigator.clipboard.writeText(text); ··· 224 220 } 225 221 226 222 if (reflectionBody) { 227 - reflectionBody.innerHTML = renderMarkdown(markdownSource); 223 + reflectionBody.innerHTML = window.AppServices.renderMarkdown(markdownSource); 228 224 } 229 225 230 226 if (copyButton) {
+1 -6
apps/search/workspace.html
··· 588 588 const searchUrl = '{{ url_for("app:search.search_journal_api") }}'; 589 589 const dayResultsUrl = '{{ url_for("app:search.day_results_api") }}'; 590 590 const activitiesDayBase = '{{ url_for("app:activities.activities_day", day="") }}'; 591 + const escapeHtml = window.AppServices.escapeHtml; 591 592 592 593 // State 593 594 let currentQuery = ''; ··· 1014 1015 } 1015 1016 } 1016 1017 1017 - function escapeHtml(text) { 1018 - if (!text) return ''; 1019 - const div = document.createElement('div'); 1020 - div.textContent = text; 1021 - return div.innerHTML; 1022 - } 1023 1018 })(); 1024 1019 </script>
+1 -5
apps/settings/workspace.html
··· 2964 2964 let logNextCursor = null; 2965 2965 let insightsData = null; 2966 2966 let keyValidationData = {}; 2967 + const escapeHtml = window.AppServices.escapeHtml; 2967 2968 2968 2969 // ========== MODAL ACCESSIBILITY ========== 2969 2970 function setupModalAccessibility(modalId) { ··· 5752 5753 notifyError('Reset Failed', err.message); 5753 5754 } 5754 5755 }); 5755 - } 5756 - 5757 - function escapeHtml(str) { 5758 - if (!str) return ''; 5759 - return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 5760 5756 } 5761 5757 5762 5758 // ========== STORAGE ==========
+3 -14
apps/sol/workspace.html
··· 1159 1159 </div> 1160 1160 </div> 1161 1161 1162 - <script src="{{ vendor_lib('marked') }}"></script> 1163 - 1164 1162 <!-- Preview Modal --> 1165 1163 <div id="preview-modal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="preview-modal-title"> 1166 1164 <div class="modal-content"> ··· 2088 2086 if (data.format === 'md') { 2089 2087 const markdown = document.createElement('div'); 2090 2088 markdown.className = 'rendered-markdown'; 2091 - markdown.innerHTML = renderMarkdown(data.content); 2089 + markdown.innerHTML = window.AppServices.renderMarkdown(data.content); 2092 2090 container.appendChild(markdown); 2093 2091 } else { 2094 2092 const pre = document.createElement('pre'); ··· 2121 2119 return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); 2122 2120 } 2123 2121 2124 - // Escape HTML to prevent XSS 2125 - function escapeHtml(text) { 2126 - const div = document.createElement('div'); 2127 - div.textContent = text; 2128 - return div.innerHTML; 2129 - } 2130 - 2131 - function renderMarkdown(raw) { 2132 - return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 2133 - } 2122 + const escapeHtml = window.AppServices.escapeHtml; 2134 2123 2135 2124 // Render agent events as a rich interactive timeline 2136 2125 function renderEventTimeline(events) { ··· 2314 2303 if (event.result && event.result.trim()) { 2315 2304 const resultDiv = document.createElement('div'); 2316 2305 resultDiv.className = 'finish-result'; 2317 - resultDiv.innerHTML = renderMarkdown(event.result); 2306 + resultDiv.innerHTML = window.AppServices.renderMarkdown(event.result); 2318 2307 block.appendChild(resultDiv); 2319 2308 } else { 2320 2309 const empty = document.createElement('div');
+1 -8
apps/speakers/workspace.html
··· 1098 1098 1099 1099 <script> 1100 1100 (() => { 1101 + const escapeHtml = window.AppServices.escapeHtml; 1101 1102 const emptyIcons = { 1102 1103 segment: '<svg viewBox="0 0 24 24"><rect x="4" y="8" width="2" height="8" rx="1"></rect><rect x="8" y="5" width="2" height="14" rx="1"></rect><rect x="12" y="3" width="2" height="18" rx="1"></rect><rect x="16" y="6" width="2" height="12" rx="1"></rect><rect x="20" y="9" width="2" height="6" rx="1"></rect></svg>', 1103 1104 cursor: '<svg viewBox="0 0 24 24"><path d="M4 4l7.07 17 2.51-7.39L21 11.07z"></path></svg>', ··· 2241 2242 .replace(/_/g, ' '); 2242 2243 } 2243 2244 2244 - function escapeHtml(value) { 2245 - return String(value ?? '') 2246 - .replace(/&/g, '&amp;') 2247 - .replace(/</g, '&lt;') 2248 - .replace(/>/g, '&gt;') 2249 - .replace(/"/g, '&quot;') 2250 - .replace(/'/g, '&#39;'); 2251 - } 2252 2245 })(); 2253 2246 </script>
+1 -1
apps/todos/workspace.html
··· 1358 1358 window.addEventListener('todo.added', (e) => { 1359 1359 const todo = e.detail; 1360 1360 const facetName = todo.facet; 1361 - const escape = window.AppServices._escapeHtml; 1361 + const escape = window.AppServices.escapeHtml; 1362 1362 1363 1363 // Find or create facet section 1364 1364 let facetSection = document.querySelector(`.facet-section[data-facet="${facetName}"]`);
+1 -8
apps/tokens/workspace.html
··· 579 579 } 580 580 581 581 // Escape HTML special characters for safe innerHTML interpolation 582 - function escapeHtml(val) { 583 - return String(val ?? '') 584 - .replace(/&/g, '&amp;') 585 - .replace(/</g, '&lt;') 586 - .replace(/>/g, '&gt;') 587 - .replace(/"/g, '&quot;') 588 - .replace(/'/g, '&#39;'); 589 - } 582 + const escapeHtml = window.AppServices.escapeHtml; 590 583 591 584 // Create percent bar 592 585 function createPercentBar(percent, color) {
+4 -17
apps/transcripts/workspace.html
··· 1449 1449 </div> 1450 1450 </div> 1451 1451 1452 - <script src="{{ vendor_lib('marked') }}"></script> 1453 1452 <script> 1454 1453 (() => { 1454 + const escapeHtml = window.AppServices.escapeHtml; 1455 1455 // Timeline bounds - computed dynamically from content 1456 1456 const DEFAULT_START = 8 * 60; // 8:00 AM default 1457 1457 const DEFAULT_END = 20 * 60; // 8:00 PM default ··· 2515 2515 } else if (tabId.startsWith('md-')) { 2516 2516 const stem = tabId.slice(3); 2517 2517 const content = (segmentData.md_files || {})[stem] || ''; 2518 - pane.innerHTML = `<div class="tr-md-content">${renderMarkdown(content)}</div>`; 2518 + pane.innerHTML = `<div class="tr-md-content">${window.AppServices.renderMarkdown(content)}</div>`; 2519 2519 } 2520 2520 } 2521 2521 ··· 2595 2595 } else if (item.type === 'screen') { 2596 2596 if (textOnlyScreen) { 2597 2597 const timeStr = item.time || ''; 2598 - const markdown = renderMarkdown(item.markdown) || 'Screen activity'; 2598 + const markdown = window.AppServices.renderMarkdown(item.markdown) || 'Screen activity'; 2599 2599 html += '<div class="tr-entry" role="listitem">'; 2600 2600 html += `<div class="tr-entry-time">${timeStr}</div>`; 2601 2601 html += '<div class="tr-entry-content">'; ··· 2903 2903 if (monitorPos) html += `<span class="tr-entry-badge tr-entry-badge-monitor">${monitorPos}</span>`; 2904 2904 } 2905 2905 if (chunk.markdown) { 2906 - html += renderMarkdown(chunk.markdown); 2906 + html += window.AppServices.renderMarkdown(chunk.markdown); 2907 2907 } 2908 2908 html += '</div>'; 2909 2909 2910 2910 html += '</div></div>'; 2911 2911 return html; 2912 - } 2913 - 2914 - function renderMarkdown(raw) { 2915 - return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 2916 - } 2917 - 2918 - function escapeHtml(str) { 2919 - if (!str) return ''; 2920 - return str 2921 - .replace(/&/g, '&amp;') 2922 - .replace(/</g, '&lt;') 2923 - .replace(/>/g, '&gt;') 2924 - .replace(/"/g, '&quot;'); 2925 2912 } 2926 2913 2927 2914 function openImageModal(frameIndex) {
+16 -8
convey/static/app.js
··· 1873 1873 const relativeTime = this._getRelativeTime(n.timestamp); 1874 1874 card.innerHTML = ` 1875 1875 <div class="notification-header"> 1876 - <span class="notification-app-icon">${window.AppServices._escapeHtml(n.icon)}</span> 1877 - <span class="notification-app-name">${window.AppServices._escapeHtml(n.app)}</span> 1876 + <span class="notification-app-icon">${window.AppServices.escapeHtml(n.icon)}</span> 1877 + <span class="notification-app-name">${window.AppServices.escapeHtml(n.app)}</span> 1878 1878 ${n.dismissible ? `<button class="notification-close" onclick="event.preventDefault(); event.stopPropagation(); window.AppServices.notifications.dismiss(${n.id});">×</button>` : ''} 1879 1879 </div> 1880 1880 <div class="notification-body"> 1881 - <div class="notification-title">${window.AppServices._escapeHtml(n.title)}</div> 1882 - ${n.message ? `<div class="notification-message">${window.AppServices._escapeHtml(n.message)}</div>` : ''} 1881 + <div class="notification-title">${window.AppServices.escapeHtml(n.title)}</div> 1882 + ${n.message ? `<div class="notification-message">${window.AppServices.escapeHtml(n.message)}</div>` : ''} 1883 1883 ${n.badge ? `<span class="notification-badge">${n.badge}</span>` : ''} 1884 1884 </div> 1885 1885 <div class="notification-footer"> ··· 2076 2076 }, 2077 2077 2078 2078 /** 2079 - * Escape HTML to prevent XSS 2080 - * @private 2079 + * Escape a value for safe interpolation into HTML. DOM-based (routes 2080 + * through textContent/innerHTML). Nullish-safe: null/undefined become ''. 2081 2081 */ 2082 - _escapeHtml(text) { 2082 + escapeHtml(value) { 2083 2083 const div = document.createElement('div'); 2084 - div.textContent = text; 2084 + div.textContent = String(value ?? ''); 2085 2085 return div.innerHTML; 2086 + }, 2087 + 2088 + /** 2089 + * Render user-supplied markdown into sanitized HTML. Calls marked + DOMPurify. 2090 + * Throws if `marked` or `DOMPurify` isn't loaded (shell is broken; fail loudly). 2091 + */ 2092 + renderMarkdown(raw) { 2093 + return DOMPurify.sanitize(marked.parse(String(raw || ''), { breaks: true, gfm: true })); 2086 2094 }, 2087 2095 2088 2096 /**
+2 -2
convey/templates/status_pane.html
··· 281 281 return; 282 282 } 283 283 284 - const escape = window.AppServices._escapeHtml; 284 + const escape = window.AppServices.escapeHtml; 285 285 list.innerHTML = notifs.map(n => { 286 286 const timeAgo = window.AppServices.notifications._getRelativeTime(n.ts); 287 287 const src = escape(n.source || ''); ··· 301 301 const history = window.AppServices.notifications.getHistory(); 302 302 if (!statusPaneOpen && history.length === _lastHistoryLen) return; 303 303 _lastHistoryLen = history.length; 304 - const escape = window.AppServices._escapeHtml; 304 + const escape = window.AppServices.escapeHtml; 305 305 306 306 if (history.length === 0) { 307 307 container.innerHTML = '<span style="color: #9ca3af;">no recent activity</span>';
+2
docs/APPS.md
··· 174 174 175 175 **Core Methods:** 176 176 - `AppServices.register(appName, service)` - Register background service 177 + - `AppServices.escapeHtml(value)` - DOM-based HTML escaping helper; null/undefined become `''` 178 + - `AppServices.renderMarkdown(raw)` - `marked` + `DOMPurify` markdown rendering with `{ breaks: true, gfm: true }`; requires both libraries loaded 177 179 178 180 **Badge Methods:** 179 181