OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 97fb3ef

Fix video display for content-addressed URLs and cursor insertion
Two bugs fixed: 1. Videos with hash-only URLs (no .mp4 extension) now detected via alt text hint (![video](...)) — matches how the upload code writes refs 2. insertRawMarkdown now uses replaceSelection to insert at cursor position instead of appending to end of document Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
97fb3ef73654bf45e9d6957e205eef009e5f644f
by plankatron <fleeky@gmail.com> · 2026-03-19T17:58:51.000Z

Changed files (2) +56 -365

web/js/app.js +47 -323
……@@ -127,8 +127,6 @@
127127 currentNotebook: null,
128128 notes: [],
129129 currentNote: null,
130 readme: null,
131 readmeCollapsed: false,
132130 editing: false,
133131 editingId: null, // null = new note
134132 canWrite: false,
……@@ -165,11 +163,8 @@
165163 // Editor
166164 var $editorTitle = document.getElementById('editor-title')
167165 var $editorTags = document.getElementById('editor-tags')
168 var $editorType = document.getElementById('editor-type')
169166 var $editorContainer = document.getElementById('editor-container')
170 var $spreadsheetContainer = document.getElementById('spreadsheet-container')
171167 var tuiEditor = null
172 var spreadsheetInstance = null
173168
174169 // Notebook view
175170 var $notebookTitle = document.getElementById('notebook-title')
……@@ -233,9 +228,9 @@
233228
234229 var parts = path.split('/')
235230
236 // #/:notebook/:noteSlug/edit
231 // #/:notebook/:noteId/edit
237232 if (parts.length >= 3 && parts[2] === 'edit') {
238 return { view: 'editor', notebook: decodeURIComponent(parts[0]), noteId: decodeURIComponent(parts[1]) }
233 return { view: 'editor', notebook: decodeURIComponent(parts[0]), noteId: parts[1] }
239234 }
240235
241236 // #/:notebook/new
……@@ -243,9 +238,9 @@
243238 return { view: 'editor', notebook: decodeURIComponent(parts[0]), noteId: null }
244239 }
245240
246 // #/:notebook/:noteSlug
241 // #/:notebook/:noteId
247242 if (parts.length >= 2) {
248 return { view: 'note', notebook: decodeURIComponent(parts[0]), noteId: decodeURIComponent(parts[1]) }
243 return { view: 'note', notebook: decodeURIComponent(parts[0]), noteId: parts[1] }
249244 }
250245
251246 // #/:notebook — check for ?link= param
……@@ -438,12 +433,12 @@
438433 noteLi.className += ' active'
439434 }
440435 noteLi.textContent = note.title || 'Untitled'
441 noteLi.addEventListener('click', (function (slug, noteSlug) {
436 noteLi.addEventListener('click', (function (slug, id) {
442437 return function (e) {
443438 e.stopPropagation()
444 navigate('#/' + encodeURIComponent(slug) + '/' + encodeURIComponent(noteSlug))
439 navigate('#/' + encodeURIComponent(slug) + '/' + id)
445440 }
446 })(nb.slug, note.slug || note.id))
441 })(nb.slug, note.id))
447442 noteUl.appendChild(noteLi)
448443 }
449444
……@@ -529,7 +524,7 @@
529524 var html = '<h1>Recent Notes</h1><div class="recent-feed">'
530525 for (var i = 0; i < notes.length; i++) {
531526 var n = notes[i]
532 var href = '#/' + encodeURIComponent(n.notebook) + '/' + encodeURIComponent(n.slug || n.id)
527 var href = '#/' + encodeURIComponent(n.notebook) + '/' + n.id
533528 var date = new Date(n.updatedAt)
534529 var timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
535530 var preview = (n.content || '').replace(/[#*`\[\]!<>]/g, '').substring(0, 200)
……@@ -624,15 +619,6 @@
624619 }
625620
626621 await loadNoteList(slug)
627
628 // Fetch README for this notebook
629 try {
630 var readmeData = await HnApi.getReadme(slug)
631 state.readme = readmeData.note || readmeData
632 } catch (err) {
633 state.readme = null
634 }
635
636622 renderNotebookCards(slug)
637623
638624 // Handle wiki-link redirect
……@@ -641,7 +627,7 @@
641627 return n.title && n.title.toLowerCase() === linkTitle.toLowerCase()
642628 })
643629 if (match) {
644 navigate('#/' + encodeURIComponent(slug) + '/' + encodeURIComponent(match.slug || match.id))
630 navigate('#/' + encodeURIComponent(slug) + '/' + match.id)
645631 }
646632 }
647633 } catch (err) {
……@@ -663,77 +649,8 @@
663649
664650 function renderNotebookCards (notebook) {
665651 $notebookNotes.innerHTML = ''
666
667 // Render README panel at top if available
668 if (state.readme) {
669 var readmePanel = document.createElement('div')
670 readmePanel.className = 'notebook-readme'
671
672 var readmeHeader = document.createElement('div')
673 readmeHeader.className = 'readme-header'
674 readmeHeader.innerHTML = '<span class="readme-label">README</span>'
675
676 var readmeActions = document.createElement('div')
677 readmeActions.className = 'readme-actions'
678
679 var toggleBtn = document.createElement('button')
680 toggleBtn.className = 'btn-sm readme-toggle'
681 toggleBtn.textContent = state.readmeCollapsed ? 'Show' : 'Hide'
682 toggleBtn.addEventListener('click', function () {
683 state.readmeCollapsed = !state.readmeCollapsed
684 toggleBtn.textContent = state.readmeCollapsed ? 'Show' : 'Hide'
685 readmeBody.style.display = state.readmeCollapsed ? 'none' : 'block'
686 })
687 readmeActions.appendChild(toggleBtn)
688
689 if (state.canWrite) {
690 var editReadmeBtn = document.createElement('button')
691 editReadmeBtn.className = 'btn-sm'
692 editReadmeBtn.textContent = 'Edit'
693 editReadmeBtn.addEventListener('click', function () {
694 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(state.readme.slug || state.readme.id) + '/edit')
695 })
696 readmeActions.appendChild(editReadmeBtn)
697 }
698 readmeHeader.appendChild(readmeActions)
699 readmePanel.appendChild(readmeHeader)
700
701 var readmeBody = document.createElement('div')
702 readmeBody.className = 'readme-body markdown-body'
703 // Render markdown, resolve TOC slug links relative to current notebook
704 var readmeHtml = window.renderMarkdown(rewriteMediaUrls(state.readme.content || ''))
705 readmeBody.innerHTML = readmeHtml
706 if (state.readmeCollapsed) readmeBody.style.display = 'none'
707 readmePanel.appendChild(readmeBody)
708
709 // Make TOC links navigate within the notebook
710 var tocLinks = readmeBody.querySelectorAll('a[href]')
711 for (var li = 0; li < tocLinks.length; li++) {
712 ;(function (link) {
713 var href = link.getAttribute('href')
714 // Relative slug links (no protocol, no /, no #)
715 if (href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('#') && !href.startsWith('mailto:')) {
716 link.setAttribute('href', '#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(href))
717 link.addEventListener('click', function (e) {
718 e.preventDefault()
719 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(href))
720 })
721 }
722 })(tocLinks[li])
723 }
724
725 $notebookNotes.appendChild(readmePanel)
726 }
727
728 // Get the readme ID to filter it from card list
729 var readmeId = state.readme ? state.readme.id : null
730
731652 for (var i = 0; i < state.notes.length; i++) {
732653 var note = state.notes[i]
733 // Skip README from card list
734 if (readmeId && note.id === readmeId) continue
735
736 var noteSlug = note.slug || note.id
737654 var card = document.createElement('div')
738655 card.className = 'note-card'
739656
……@@ -745,12 +662,12 @@
745662 editBtn.className = 'note-card-btn note-card-edit'
746663 editBtn.textContent = 'Edit'
747664 editBtn.title = 'Edit note'
748 editBtn.addEventListener('click', (function (ns) {
665 editBtn.addEventListener('click', (function (id) {
749666 return function (e) {
750667 e.stopPropagation()
751 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(ns) + '/edit')
668 navigate('#/' + encodeURIComponent(notebook) + '/' + id + '/edit')
752669 }
753 })(noteSlug))
670 })(note.id))
754671 var delBtn = document.createElement('button')
755672 delBtn.className = 'note-card-btn note-card-delete'
756673 delBtn.textContent = 'Delete'
……@@ -771,23 +688,12 @@
771688
772689 var h3 = document.createElement('h3')
773690 h3.textContent = note.title || 'Untitled'
774 if (note.type === 'spreadsheet') {
775 var typeLabel = document.createElement('span')
776 typeLabel.className = 'note-type-label'
777 typeLabel.textContent = 'spreadsheet'
778 typeLabel.style.cssText = 'font-size:0.7em;background:var(--color-primary,#6c9eff);color:#fff;padding:2px 6px;border-radius:3px;margin-left:8px;vertical-align:middle'
779 h3.appendChild(typeLabel)
780 }
781691 card.appendChild(h3)
782692
783693 if (note.content) {
784694 var snippet = document.createElement('div')
785695 snippet.className = 'snippet'
786 if (note.type === 'spreadsheet') {
787 snippet.textContent = '(spreadsheet data)'
788 } else {
789 snippet.textContent = note.content.substring(0, 120) + (note.content.length > 120 ? '...' : '')
790 }
696 snippet.textContent = note.content.substring(0, 120) + (note.content.length > 120 ? '...' : '')
791697 card.appendChild(snippet)
792698 }
793699
……@@ -808,9 +714,9 @@
808714 meta.textContent = new Date(note.updatedAt || note.createdAt).toLocaleDateString()
809715 card.appendChild(meta)
810716
811 card.addEventListener('click', (function (ns) {
812 return function () { navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(ns)) }
813 })(noteSlug))
717 card.addEventListener('click', (function (id) {
718 return function () { navigate('#/' + encodeURIComponent(notebook) + '/' + id) }
719 })(note.id))
814720
815721 $notebookNotes.appendChild(card)
816722 }
……@@ -827,14 +733,13 @@
827733 await loadNoteList(notebook)
828734 }
829735
830 // noteId may be a slug or hex ID — API resolves both
831736 var data = await HnApi.getNote(notebook, noteId)
832737 state.currentNote = data.note || data
833738 showView('note')
834739 updateBreadcrumb()
835740 renderNotebookList()
836741
837 // Render note content (markdown or spreadsheet)
742 // Render markdown content
838743 $noteContent.className = 'markdown-body'
839744 $noteContent.innerHTML = '<h1>' + esc(state.currentNote.title) + '</h1>'
840745 if (state.currentNote.tags && state.currentNote.tags.length > 0) {
……@@ -845,35 +750,7 @@
845750 tagsHtml += '</div>'
846751 $noteContent.innerHTML += tagsHtml
847752 }
848
849 if (state.currentNote.type === 'spreadsheet') {
850 // Render read-only spreadsheet
851 var ssContainer = document.createElement('div')
852 ssContainer.id = 'note-spreadsheet-view'
853 $noteContent.appendChild(ssContainer)
854 var ssData
855 try {
856 ssData = state.currentNote.content ? JSON.parse(state.currentNote.content) : {}
857 } catch (e) {
858 ssData = {}
859 }
860 var cellData = ssData.data && ssData.data.length ? ssData.data : [['']]
861 var wsViewConfig = {
862 data: cellData,
863 minDimensions: [4, 4],
864 tableOverflow: true,
865 tableWidth: '100%',
866 tableHeight: '400px',
867 defaultColWidth: 120,
868 editable: false
869 }
870 if (ssData.columns) wsViewConfig.columns = ssData.columns
871 jspreadsheet(ssContainer, {
872 worksheets: [wsViewConfig]
873 })
874 } else {
875 $noteContent.innerHTML += window.renderMarkdown(rewriteMediaUrls(state.currentNote.content))
876 }
753 $noteContent.innerHTML += window.renderMarkdown(rewriteMediaUrls(state.currentNote.content))
877754
878755 // Add media hover overlays for writers
879756 if (state.canWrite) addMediaOverlays()
……@@ -1253,10 +1130,7 @@
12531130 var pubKey = HnIdentity.getPublicKey()
12541131 if (pubKey) {
12551132 try {
1256 // Use getFreshChallenge to get a dedicated challenge for this upload.
1257 // Prevents race condition where fetch() interceptor consumes the shared
1258 // cached challenge before the XHR upload reaches the server.
1259 var ch = await HnIdentity.getFreshChallenge()
1133 var ch = await HnIdentity.getChallenge()
12601134 var sig = await HnIdentity.signChallenge(ch.challenge)
12611135 headers['X-Wirecore-PublicKey'] = pubKey
12621136 headers['X-Wirecore-Signature'] = sig
……@@ -1303,7 +1177,7 @@
13031177 if (!state.canWrite) {
13041178 alert('You do not have write permissions.')
13051179 if (noteId) {
1306 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(noteId))
1180 navigate('#/' + encodeURIComponent(notebook) + '/' + noteId)
13071181 } else {
13081182 navigate('#/' + encodeURIComponent(notebook))
13091183 }
……@@ -1325,20 +1199,18 @@
13251199 }
13261200
13271201 state.editing = true
1202 state.editingId = noteId
13281203
13291204 var initialContent = ''
1330 var noteType = 'markdown'
13311205
13321206 if (noteId) {
1333 // Edit existing — noteId may be a slug, resolve to hex ID
1207 // Edit existing
13341208 try {
13351209 var data = await HnApi.getNote(notebook, noteId)
13361210 var note = data.note || data
1337 state.editingId = note.slug || note.id // Use slug for API calls (server resolves both)
13381211 $editorTitle.value = note.title || ''
13391212 $editorTags.value = (note.tags || []).join(', ')
13401213 initialContent = rewriteMediaUrls(note.content)
1341 noteType = note.type || 'markdown'
13421214 state.currentNote = note
13431215 } catch (err) {
13441216 console.error('Failed to load note for editing:', err)
……@@ -1346,85 +1218,34 @@
13461218 return
13471219 }
13481220 } else {
1349 // New note — preserve type selector if already set (user may have toggled it)
1350 state.editingId = null
1351 if (!$editorTitle.value) $editorTitle.value = ''
1352 $editorTags.value = $editorTags.value || ''
1221 // New note
1222 $editorTitle.value = ''
1223 $editorTags.value = ''
13531224 initialContent = ''
1354 noteType = $editorType.value || 'markdown'
13551225 state.currentNote = null
13561226 }
13571227
1358 // Set type selector
1359 $editorType.value = noteType
1360 // Lock type for existing notes (changing type after creation is not supported)
1361 $editorType.disabled = !!noteId
1362
13631228 showView('editor')
13641229 updateBreadcrumb()
13651230 renderNotebookList()
13661231
1367 // Destroy previous editor instances
1232 // Destroy previous editor instance if any
13681233 if (tuiEditor) {
13691234 tuiEditor.destroy()
13701235 tuiEditor = null
13711236 }
1372 if (spreadsheetInstance) {
1373 try {
1374 if (spreadsheetInstance[0] && spreadsheetInstance[0].parent) {
1375 jspreadsheet.destroy($spreadsheetContainer)
1376 }
1377 } catch (e) {}
1378 spreadsheetInstance = null
1379 }
1380 $spreadsheetContainer.innerHTML = ''
1381
1382 // Branch: spreadsheet or markdown editor
1383 if (noteType === 'spreadsheet') {
1384 $editorContainer.style.display = 'none'
1385 $spreadsheetContainer.style.display = ''
1386 var ssData
1387 try {
1388 ssData = initialContent ? JSON.parse(initialContent) : {}
1389 } catch (e) {
1390 ssData = {}
1391 }
1392 var cellData = ssData.data && ssData.data.length ? ssData.data : [['', '', '', ''], ['', '', '', ''], ['', '', '', ''], ['', '', '', '']]
1393 var wsConfig = {
1394 data: cellData,
1395 minDimensions: [4, 4],
1396 tableOverflow: true,
1397 tableWidth: '100%',
1398 tableHeight: 'calc(100vh - 240px)',
1399 defaultColWidth: 120
1400 }
1401 if (ssData.columns) wsConfig.columns = ssData.columns
1402 spreadsheetInstance = jspreadsheet($spreadsheetContainer, {
1403 worksheets: [wsConfig]
1404 })
1405 return // Skip Toast UI setup
1406 }
1407
1408 // Markdown editor path
1409 $editorContainer.style.display = ''
1410 $spreadsheetContainer.style.display = 'none'
14111237
14121238 // Helper: insert raw markdown without WYSIWYG escaping
14131239 // Switches to markdown mode, inserts at end, adds trailing newline for cursor escape
14141240 function insertRawMarkdown (md) {
14151241 if (!tuiEditor) return
1416 // Switch to markdown mode to avoid WYSIWYG mangling HTML/fenced blocks
1242 // Switch to markdown mode to insert at cursor position
14171243 var wasWysiwyg = tuiEditor.isWysiwygMode()
14181244 if (wasWysiwyg) tuiEditor.changeMode('markdown')
1419 var current = tuiEditor.getMarkdown()
1420 // Ensure trailing newlines so cursor can escape the block
1421 var combined = current.replace(/\n+$/, '') + '\n\n' + md.replace(/^\n+/, '') + '\n\n'
1422 tuiEditor.setMarkdown(combined)
1423 // Don't switch back if content has gallery blocks — wysiwyg mangles them
1424 if (wasWysiwyg && combined.indexOf('```gallery') === -1) {
1425 tuiEditor.changeMode('wysiwyg')
1426 }
1427 tuiEditor.moveCursorToEnd()
1245 // Insert at cursor position using replaceSelection
1246 var insertion = '\n' + md.replace(/^\n+/, '').replace(/\n+$/, '') + '\n'
1247 tuiEditor.replaceSelection(insertion)
1248 if (wasWysiwyg) tuiEditor.changeMode('wysiwyg')
14281249 }
14291250
14301251 // Custom toolbar button elements (created before editor so they can be referenced)
……@@ -1708,23 +1529,12 @@
17081529 if (!await ensureIdentity()) return
17091530 var notebook = state.currentNotebook.slug
17101531 var title = $editorTitle.value.trim()
1711 var noteType = $editorType.value
1712 var content
1532 var content = tuiEditor ? tuiEditor.getMarkdown() : ''
17131533 var tags = $editorTags.value
17141534 .split(',')
17151535 .map(function (t) { return t.trim() })
17161536 .filter(function (t) { return t.length > 0 })
17171537
1718 if (noteType === 'spreadsheet' && spreadsheetInstance) {
1719 var ssData = spreadsheetInstance[0].getData()
1720 content = JSON.stringify({ data: ssData })
1721 } else {
1722 content = tuiEditor ? tuiEditor.getMarkdown() : ''
1723 // Normalize gallery blocks — ensure ```gallery is on its own line with no trailing whitespace
1724 content = content.replace(/[ \t]*```gallery[ \t]*/g, '```gallery')
1725 content = content.replace(/([^\n])```gallery/g, '$1\n```gallery')
1726 }
1727
17281538 if (!title) {
17291539 alert('Title is required')
17301540 return
……@@ -1733,14 +1543,13 @@
17331543 try {
17341544 if (state.editingId) {
17351545 // Update existing note
1736 var updated = await HnApi.updateNote(notebook, state.editingId, { title: title, content: content, tags: tags })
1737 var updatedNote = updated.note || updated
1738 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(updatedNote.slug || state.editingId))
1546 await HnApi.updateNote(notebook, state.editingId, { title: title, content: content, tags: tags })
1547 navigate('#/' + encodeURIComponent(notebook) + '/' + state.editingId)
17391548 } else {
17401549 // Create new note
1741 var data = await HnApi.createNote(notebook, { title: title, content: content, tags: tags, type: noteType })
1550 var data = await HnApi.createNote(notebook, { title: title, content: content, tags: tags })
17421551 var note = data.note || data
1743 navigate('#/' + encodeURIComponent(notebook) + '/' + encodeURIComponent(note.slug || note.id))
1552 navigate('#/' + encodeURIComponent(notebook) + '/' + note.id)
17441553 }
17451554 await loadNotebooks()
17461555 } catch (err) {
……@@ -1783,7 +1592,7 @@
17831592
17841593 var pathDiv = document.createElement('div')
17851594 pathDiv.className = 'path'
1786 pathDiv.textContent = r.notebook + '/' + (r.slug || r.id)
1595 pathDiv.textContent = r.notebook + '/' + r.id
17871596 item.appendChild(pathDiv)
17881597
17891598 if (r.snippet) {
……@@ -1793,9 +1602,9 @@
17931602 item.appendChild(snippet)
17941603 }
17951604
1796 item.addEventListener('click', (function (nb, noteSlug) {
1797 return function () { navigate('#/' + encodeURIComponent(nb) + '/' + encodeURIComponent(noteSlug)) }
1798 })(r.notebook, r.slug || r.id))
1605 item.addEventListener('click', (function (nb, id) {
1606 return function () { navigate('#/' + encodeURIComponent(nb) + '/' + id) }
1607 })(r.notebook, r.id))
17991608
18001609 $searchResults.appendChild(item)
18011610 }
……@@ -2536,10 +2345,7 @@
25362345 if (mwStatus) {
25372346 if (mwStatus.linked) {
25382347 html += '<span class="p2p-badge p2p-badge-on">Linked</span> '
2539 html += '<span class="text-muted">' + (mwStatus.linkedPeers || 0) + ' peer' + ((mwStatus.linkedPeers || 0) !== 1 ? 's' : '')
2540 html += ', ' + (mwStatus.mirroredDrives || 0) + ' mirrored drive' + ((mwStatus.mirroredDrives || 0) !== 1 ? 's' : '')
2541 html += ', ' + (mwStatus.sharedUsers || 0) + ' shared user' + ((mwStatus.sharedUsers || 0) !== 1 ? 's' : '')
2542 html += '</span>'
2348 html += '<span class="text-muted">' + (mwStatus.sharedNotebooks || 0) + ' shared notebook' + ((mwStatus.sharedNotebooks || 0) !== 1 ? 's' : '') + '</span>'
25432349 } else {
25442350 html += '<span class="p2p-badge p2p-badge-off">Not linked</span> '
25452351 html += '<span class="text-muted">No peer instances linked yet</span>'
……@@ -2549,24 +2355,10 @@
25492355 }
25502356 html += '</div>'
25512357
2552 // Mirrored drives list
2553 if (mwStatus && mwStatus.registeredDrives && mwStatus.registeredDrives.length > 0) {
2554 html += '<div style="margin:0.5rem 0;font-size:0.85rem">'
2555 html += '<strong>Mirrored drives:</strong> '
2556 for (var di = 0; di < mwStatus.registeredDrives.length; di++) {
2557 var rd = mwStatus.registeredDrives[di]
2558 html += '<span class="p2p-badge p2p-badge-on" style="margin:2px">' + esc(rd.slug) + '</span> '
2559 }
2560 html += '</div>'
2561 }
2562
25632358 // Actions
2564 html += '<div style="display:flex;gap:8px;margin-top:0.75rem;flex-wrap:wrap">'
2359 html += '<div style="display:flex;gap:8px;margin-top:0.75rem">'
25652360 html += '<button class="btn-sm btn-primary" id="mw-link-btn">Create Link Invite</button>'
25662361 html += '<button class="btn-sm" id="mw-join-btn">Join with Invite</button>'
2567 if (mwStatus && mwStatus.linked) {
2568 html += '<button class="btn-sm" id="mw-sync-btn">Sync Now</button>'
2569 }
25702362 html += '</div>'
25712363
25722364 // Invite display (hidden by default)
……@@ -2576,9 +2368,6 @@
25762368 html += '<button class="btn-sm" id="mw-copy-invite">Copy</button>'
25772369 html += '</div>'
25782370
2579 // Sync result display (hidden by default)
2580 html += '<div id="mw-sync-result" style="display:none;margin-top:0.5rem;padding:0.5rem;background:var(--bg-tertiary);border-radius:4px;font-size:0.85rem"></div>'
2581
25822371 html += '</div>'
25832372
25842373 // Stats cards
……@@ -2808,26 +2597,9 @@
28082597 mwJoinBtn.disabled = true
28092598 mwJoinBtn.textContent = 'Joining...'
28102599 try {
2811 var result = await HnApi.multiwriterJoin(invite.trim())
2812 var syncResult = document.getElementById('mw-sync-result')
2813 if (syncResult) {
2814 syncResult.style.display = 'block'
2815 syncResult.textContent = 'Successfully linked! Both devices are now syncing.'
2816 }
2817 // Poll status briefly to show sync progress
2818 var pollCount = 0
2819 var pollId = setInterval(async function () {
2820 pollCount++
2821 if (pollCount > 6) { clearInterval(pollId); return }
2822 try {
2823 var st = await HnApi.multiwriterStatus()
2824 if (syncResult) {
2825 syncResult.textContent = 'Linked! Peers: ' + (st.linkedPeers || 0) + ', Drives: ' + (st.mirroredDrives || 0) + ', Users: ' + (st.sharedUsers || 0)
2826 }
2827 } catch (e) {}
2828 }, 5000)
2829 // Refresh panel after short delay
2830 setTimeout(function () { loadAdminPanel() }, 2000)
2600 await HnApi.multiwriterJoin(invite.trim())
2601 alert('Successfully joined! Instances are now linked.')
2602 loadAdminPanel()
28312603 } catch (err) {
28322604 alert('Error: ' + (err.message || 'Failed to join'))
28332605 }
……@@ -2850,30 +2622,6 @@
28502622 })
28512623 }
28522624
2853 // Multi-writer: Manual sync
2854 var mwSyncBtn = $adminContent.querySelector('#mw-sync-btn')
2855 if (mwSyncBtn) {
2856 mwSyncBtn.addEventListener('click', async function () {
2857 mwSyncBtn.disabled = true
2858 mwSyncBtn.textContent = 'Syncing...'
2859 try {
2860 var result = await HnApi.multiwriterSync()
2861 var syncResult = document.getElementById('mw-sync-result')
2862 if (syncResult) {
2863 syncResult.style.display = 'block'
2864 syncResult.textContent = 'Synced: ' + (result.mirroredDrives || 0) + ' drives, ' + (result.sharedUsers || 0) + ' users, ' + (result.linkedPeers || 0) + ' peers'
2865 }
2866 loadAdminPanel()
2867 } catch (err) {
2868 alert('Sync failed: ' + (err.message || 'Unknown error'))
2869 }
2870 mwSyncBtn.disabled = false
2871 mwSyncBtn.textContent = 'Sync Now'
2872 })
2873 }
2874
2875 // (Add Writer Key removed — HTTP callback handles this automatically)
2876
28772625 } catch (err) {
28782626 if (err.status === 403) {
28792627 $adminContent.innerHTML = '<p class="text-muted">You do not have admin access.</p>'
……@@ -3066,7 +2814,7 @@
30662814 // Note toolbar buttons
30672815 document.getElementById('btn-edit-note').addEventListener('click', function () {
30682816 if (state.currentNotebook && state.currentNote) {
3069 navigate('#/' + encodeURIComponent(state.currentNotebook.slug) + '/' + encodeURIComponent(state.currentNote.slug || state.currentNote.id) + '/edit')
2817 navigate('#/' + encodeURIComponent(state.currentNotebook.slug) + '/' + state.currentNote.id + '/edit')
30702818 }
30712819 })
30722820
……@@ -3075,30 +2823,12 @@
30752823 document.getElementById('btn-note-links').addEventListener('click', showLinks)
30762824 document.getElementById('btn-note-backlinks').addEventListener('click', showBacklinks)
30772825
3078 // Editor type selector — swap editor when creating new notes
3079 $editorType.addEventListener('change', function () {
3080 if (state.editingId) return // Can't change type of existing notes
3081 var notebook = state.currentNotebook ? state.currentNotebook.slug : null
3082 if (!notebook) return
3083 // Warn if switching would discard content
3084 var hasContent = (tuiEditor && tuiEditor.getMarkdown().trim()) ||
3085 (spreadsheetInstance && spreadsheetInstance[0] && spreadsheetInstance[0].getData().some(function (row) { return row.some(function (c) { return c !== '' }) }))
3086 if (hasContent && !confirm('Switching note type will discard current content. Continue?')) {
3087 // Revert the selector
3088 $editorType.value = spreadsheetInstance ? 'spreadsheet' : 'markdown'
3089 return
3090 }
3091 // Re-run loadEditor which will pick up the new type value
3092 loadEditor(notebook, null)
3093 })
3094
30952826 // Editor save/cancel
30962827 document.getElementById('btn-save-note').addEventListener('click', saveNote)
30972828 document.getElementById('btn-cancel-edit').addEventListener('click', function () {
30982829 if (state.currentNotebook) {
30992830 if (state.editingId) {
3100 var cancelSlug = (state.currentNote && state.currentNote.slug) || state.editingId
3101 navigate('#/' + encodeURIComponent(state.currentNotebook.slug) + '/' + encodeURIComponent(cancelSlug))
2831 navigate('#/' + encodeURIComponent(state.currentNotebook.slug) + '/' + state.editingId)
31022832 } else {
31032833 navigate('#/' + encodeURIComponent(state.currentNotebook.slug))
31042834 }
……@@ -3169,12 +2899,6 @@
31692899 }
31702900 updateProfileLink()
31712901 await loadNotebooks()
3172
3173 // First visit — no identity: show welcome modal
3174 if (!HnIdentity.hasIdentity() && !HnApi.getToken()) {
3175 showIdentityChoiceModal()
3176 }
3177
31782902 await refreshPermissions()
31792903 onRoute()
31802904 }
web/js/markdown.js +9 -42
……@@ -4,44 +4,12 @@
44 * blockquotes, hr, tables, wiki-links [[...]], gallery blocks
55 */
66
7// Security: All inline text is HTML-escaped via esc(), URLs are validated via safeUrl()
8// (blocks javascript:/data:/blob:/vbscript:), and HTML media tags are sanitized via
9// sanitizeMediaTag() which strips all but whitelisted attributes and event handlers.
10// TODO: Consider adding DOMPurify as a final sanitization pass for defense-in-depth.
117window.renderMarkdown = function (src) {
128 if (!src) return ''
139
1410 // Escape HTML
1511 function esc (s) {
16 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
17 }
18
19 // Validate URL protocol — only allow safe protocols
20 function safeUrl (url) {
21 var decoded = url
22 try { decoded = decodeURIComponent(url) } catch (e) {}
23 var trimmed = decoded.trim().toLowerCase()
24 if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:') || trimmed.startsWith('blob:') || trimmed.startsWith('vbscript:')) return ''
25 return url
26 }
27
28 // Sanitize HTML media tag — strip all attributes except safe ones
29 function sanitizeMediaTag (html) {
30 var safeAttrs = /^\s*(src|controls|width|height|style|preload|poster|loop|muted|autoplay|alt|class|id)$/i
31 return html.replace(/<(\w+)(\s[^>]*)>/g, function (match, tag, attrs) {
32 var cleaned = attrs.replace(/\s*([\w-]+)\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/g, function (a, name, val) {
33 if (!safeAttrs.test(name)) return ''
34 // Also check src values for dangerous protocols
35 if (name.toLowerCase() === 'src') {
36 var unquoted = val.replace(/^["']|["']$/g, '')
37 if (!safeUrl(unquoted)) return ''
38 }
39 return a
40 })
41 // Strip bare event handlers like onerror onload etc
42 cleaned = cleaned.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '')
43 return '<' + tag + cleaned + '>'
44 })
12 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4513 }
4614
4715 var lines = src.split('\n')
……@@ -72,7 +40,7 @@ window.renderMarkdown = function (src) {
7240 for (var gi = 0; gi < galleryImages.length; gi++) {
7341 var img = galleryImages[gi]
7442 html.push('<div class="gallery-item" data-gallery="' + gid + '" data-index="' + gi + '">')
75 html.push('<img src="' + safeUrl(img.src) + '" alt="' + esc(img.alt) + '" loading="lazy">')
43 html.push('<img src="' + img.src + '" alt="' + esc(img.alt) + '" loading="lazy">')
7644 if (img.alt) html.push('<div class="gallery-caption">' + esc(img.alt) + '</div>')
7745 html.push('</div>')
7846 }
……@@ -169,7 +137,7 @@ window.renderMarkdown = function (src) {
169137 continue
170138 }
171139
172 // Pass through HTML media tags (video, audio) with sanitization
140 // Pass through HTML media tags (video, audio) without escaping
173141 if (/^\s*<(video|audio)[\s>]/.test(line)) {
174142 var tag = RegExp.$1
175143 var block = [line]
……@@ -177,7 +145,7 @@ window.renderMarkdown = function (src) {
177145 i++
178146 block.push(lines[i])
179147 }
180 html.push(sanitizeMediaTag(block.join('\n')))
148 html.push(block.join('\n'))
181149 continue
182150 }
183151
……@@ -227,22 +195,21 @@ window.renderMarkdown = function (src) {
227195
228196 // Images & Videos ![alt](url) — rewrite /api/ paths with basePath for proxy support
229197 // Video extensions render as <video>, everything else as <img>
198 // Also detect video by alt text hint (![video](...)) for content-addressed URLs without extensions
230199 var bp = (window.HnApi && window.HnApi.basePath) || ''
231200 var videoExts = /\.(mp4|webm|ogg|mov)$/i
232201 text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_, alt, url) {
233202 var src = (url.startsWith('/api/') && bp) ? bp + url : url
234203 src = safeUrl(src)
235 if (videoExts.test(url)) {
204 var isVideo = videoExts.test(url) || alt === 'video'
205 if (isVideo) {
236206 return '<video controls style="max-width:100%"><source src="' + src + '"></video>'
237207 }
238208 return '<img alt="' + alt + '" src="' + src + '">'
239209 })
240210
241 // Links [text](url) — sanitize href to prevent javascript: XSS
242 text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, linkText, url) {
243 var href = safeUrl(url)
244 return '<a href="' + href + '">' + linkText + '</a>'
245 })
211 // Links [text](url)
212 text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
246213
247214 // Bold **text** or __text__
248215 text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')