@@ -347,7 +347,7 @@ function NoteForge(){
347347 dataRef . current = data ;
348348 const aNbRef = useRef ( null ) ; aNbRef . current = aNb ;
349349 const aSecRef = useRef ( null ) ; aSecRef . current = aSec ;
350- const nbPasswords = useRef ( new Map ( ) ) ;
350+ const nbKeys = useRef ( new Map ( ) ) ; // nbId -> opaque nbKeyId (main-process session key handle)
351351 const [ autoLockMin , setAutoLockMin ] = useState ( savedPrefs . current . autoLockMin || 15 ) ;
352352
353353 useEffect ( ( ) => { prefsStore . save ( { dark, navOpen, wrap, zoom, autoLockMin} ) } , [ dark , navOpen , wrap , zoom , autoLockMin ] ) ;
@@ -362,7 +362,7 @@ function NoteForge(){
362362 await store . set ( JSON . stringify ( sanitized ) ) ;
363363 }
364364 if ( window . electronAPI ?. lockApp ) await window . electronAPI . lockApp ( ) ;
365- nbPasswords . current . clear ( ) ;
365+ nbKeys . current . clear ( ) ;
366366 dataRef . current = null ; setData ( null ) ; setANb ( null ) ; setASec ( null ) ; setAPg ( null ) ;
367367 setUnlockedNbs ( new Set ( ) ) ;
368368 if ( encEnabled ) setAppPhase ( "needsPassword" ) ;
@@ -453,12 +453,13 @@ function NoteForge(){
453453 saveTimer . current = setTimeout ( async ( ) => {
454454 let toSave = nd ;
455455 // Step 1: Re-encrypt sections for any locked+unlocked-in-session notebooks
456- // so edits are captured in the encrypted blob before we strip plaintext
456+ // so edits are captured in the encrypted blob before we strip plaintext.
457+ // Uses cached main-process session key — no scrypt on the hot path.
457458 if ( hasElectronCrypto ( ) ) {
458459 const nbs = await Promise . all ( nd . notebooks . map ( async nb => {
459- if ( nb . locked && nb . sections ?. length > 0 && nbPasswords . current . has ( nb . id ) ) {
460- const pw = nbPasswords . current . get ( nb . id ) ;
461- const r = await window . electronAPI . encryptNotebookSections ( JSON . stringify ( nb . sections ) , pw ) ;
460+ if ( nb . locked && nb . sections ?. length > 0 && nbKeys . current . has ( nb . id ) ) {
461+ const nbKeyId = nbKeys . current . get ( nb . id ) ;
462+ const r = await window . electronAPI . reencryptNotebookSections ( JSON . stringify ( nb . sections ) , nbKeyId ) ;
462463 if ( r . success ) return { ...nb , encSections :r . blob } ;
463464 }
464465 return nb ;
@@ -708,7 +709,9 @@ function NoteForge(){
708709 const pc = nb ?( nb . sections || [ ] ) . reduce ( ( a , s ) => a + s . pages . length , 0 ) :0 ;
709710 if ( ! confirm ( pc > 0 ?`Delete "${ nb . name } " and all ${ pc } pages?` :`Delete "${ nb ?. name } "?` ) ) return ;
710711 persist ( { ...d , notebooks :d . notebooks . filter ( n => n . id !== nid ) } ) ;
711- nbPasswords . current . delete ( nid ) ;
712+ const keyId = nbKeys . current . get ( nid ) ;
713+ if ( keyId && window . electronAPI ?. forgetNotebookKey ) window . electronAPI . forgetNotebookKey ( keyId ) ;
714+ nbKeys . current . delete ( nid ) ;
712715 if ( aNb === nid ) { setANb ( null ) ; setASec ( null ) ; setAPg ( null ) }
713716 } ;
714717
@@ -719,7 +722,8 @@ function NoteForge(){
719722 if ( ! nb || ! nb . sections ?. length ) return { error :"Nothing to lock" } ;
720723 const r = await window . electronAPI . encryptNotebookSections ( JSON . stringify ( nb . sections ) , password ) ;
721724 if ( ! r . success ) return { error :r . error } ;
722- nbPasswords . current . set ( nbId , password ) ;
725+ // Store only the opaque handle to the main-process session key. Password is discarded here.
726+ if ( r . nbKeyId ) nbKeys . current . set ( nbId , r . nbKeyId ) ;
723727 // Keep sections in memory (user still has access this session).
724728 // sanitizeForDiskSync() strips them before every write — plaintext never reaches disk.
725729 const nd = { ...d , notebooks :d . notebooks . map ( n => n . id !== nbId ?n :{ ...n , locked :true , encSections :r . blob } ) } ;
@@ -736,7 +740,7 @@ function NoteForge(){
736740 if ( ! r . success ) return { error :r . error } ;
737741 try {
738742 const sections = JSON . parse ( r . sections ) ;
739- nbPasswords . current . set ( nbId , password ) ;
743+ if ( r . nbKeyId ) nbKeys . current . set ( nbId , r . nbKeyId ) ;
740744 const nd = { ...d , notebooks :d . notebooks . map ( n => n . id !== nbId ?n :{ ...n , sections} ) } ;
741745 dataRef . current = nd ;
742746 setData ( nd ) ;
@@ -754,7 +758,10 @@ function NoteForge(){
754758 const removeNotebookLock = ( nbId ) => {
755759 const d = dataRef . current ;
756760 const nd = { ...d , notebooks :d . notebooks . map ( n => n . id !== nbId ?n :{ ...n , locked :false , encSections :null } ) } ;
757- persist ( nd ) ; nbPasswords . current . delete ( nbId ) ;
761+ persist ( nd ) ;
762+ const keyId = nbKeys . current . get ( nbId ) ;
763+ if ( keyId && window . electronAPI ?. forgetNotebookKey ) window . electronAPI . forgetNotebookKey ( keyId ) ;
764+ nbKeys . current . delete ( nbId ) ;
758765 setUnlockedNbs ( p => { const s = new Set ( p ) ; s . delete ( nbId ) ; return s } ) ;
759766 } ;
760767
@@ -814,15 +821,21 @@ function NoteForge(){
814821 /* ═══ Export ═══════════════════════════════════════════════ */
815822 const doExportHTML = async ( ) => {
816823 if ( ! curPage ) return ;
817- if ( window . electronAPI ) await window . electronAPI . exportHTML ( curPage . title , curPage . content ) ;
824+ // Re-sanitize on export — storage may contain pre-DOMPurify HTML from older versions
825+ const cleanHTML = sanitizeHTML ( curPage . content || "" ) ;
826+ const parentNb = aNb ?dataRef . current ?. notebooks . find ( n => n . id === aNb ) :null ;
827+ const isLocked = ! ! ( parentNb ?. locked ) ;
828+ if ( window . electronAPI ) await window . electronAPI . exportHTML ( curPage . title , cleanHTML , isLocked ) ;
818829 else {
819- const doc = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${ escHtml ( curPage . title ) } </title><style>body{font-family:'DM Sans',sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7;color:#1a1a1a}h1,h2,h3,h4{margin:.5em 0 .3em}table{border-collapse:collapse;width:100%}td,th{border:1px solid #ddd;padding:8px}pre{background:#f5f5f5;padding:14px;border-radius:8px;overflow-x:auto}code{background:#f5f5f5;padding:2px 6px;border-radius:4px}blockquote{border-left:3px solid #6359d0;padding-left:14px;opacity:.85}</style></head><body>${ curPage . content } </body></html>` ;
830+ const doc = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${ escHtml ( curPage . title ) } </title><style>body{font-family:'DM Sans',sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7;color:#1a1a1a}h1,h2,h3,h4{margin:.5em 0 .3em}table{border-collapse:collapse;width:100%}td,th{border:1px solid #ddd;padding:8px}pre{background:#f5f5f5;padding:14px;border-radius:8px;overflow-x:auto}code{background:#f5f5f5;padding:2px 6px;border-radius:4px}blockquote{border-left:3px solid #6359d0;padding-left:14px;opacity:.85}</style></head><body>${ cleanHTML } </body></html>` ;
820831 const b = new Blob ( [ doc ] , { type :"text/html" } ) ; const a = document . createElement ( "a" ) ; a . href = URL . createObjectURL ( b ) ; a . download = curPage . title . replace ( / [ ^ a - z 0 - 9 ] / gi, "_" ) + ".html" ; a . click ( ) ; URL . revokeObjectURL ( a . href ) ;
821832 }
822833 } ;
823834 const doExportText = async ( ) => {
824835 if ( ! curPage || ! edRef . current ) return ; const text = edRef . current . innerText ;
825- if ( window . electronAPI ) await window . electronAPI . exportText ( curPage . title , text ) ;
836+ const parentNb = aNb ?dataRef . current ?. notebooks . find ( n => n . id === aNb ) :null ;
837+ const isLocked = ! ! ( parentNb ?. locked ) ;
838+ if ( window . electronAPI ) await window . electronAPI . exportText ( curPage . title , text , isLocked ) ;
826839 else { const b = new Blob ( [ text ] , { type :"text/plain" } ) ; const a = document . createElement ( "a" ) ; a . href = URL . createObjectURL ( b ) ; a . download = curPage . title . replace ( / [ ^ a - z 0 - 9 ] / gi, "_" ) + ".txt" ; a . click ( ) ; URL . revokeObjectURL ( a . href ) }
827840 } ;
828841
@@ -1031,14 +1044,26 @@ function NoteForge(){
10311044 onClick = { ( ) => {
10321045 if ( editId === nb . id ) return ;
10331046 if ( locked ) { setPwDialog ( { type :"unlock-nb" , nbId :nb . id , name :nb . name } ) ; return }
1034- setANb ( nb . id ) ; setExpNb ( p => ( { ...p , [ nb . id ] :! p [ nb . id ] } ) ) ;
1035- if ( ! expNb [ nb . id ] ) {
1047+ const switchingNb = aNb !== nb . id ;
1048+ setANb ( nb . id ) ; setShowTrash ( false ) ;
1049+ if ( switchingNb ) {
1050+ // Switched to a different notebook — sync sec/page panes, don't leave stale state
1051+ setPgFilter ( "" ) ;
10361052 const sec = nb . sections ?. [ 0 ] ;
1037- if ( sec ) { setASec ( sec . id ) ; setPgFilter ( "" ) ;
1038- const pg = sec . pages . find ( p => ! p . deleted ) ; setAPg ( pg ?. id || null )
1039- } else { setASec ( null ) ; setAPg ( null ) }
1053+ if ( sec ) {
1054+ setASec ( sec . id ) ;
1055+ const pg = sec . pages . find ( p => ! p . deleted ) ;
1056+ setAPg ( pg ?. id || null ) ;
1057+ } else {
1058+ setASec ( null ) ; setAPg ( null ) ;
1059+ }
1060+ // Force expand on switch so the user immediately sees the notebook's sections
1061+ setExpNb ( p => ( { ...p , [ nb . id ] :true } ) ) ;
1062+ } else {
1063+ // Same notebook clicked — just toggle expand, keep current section/page
1064+ setExpNb ( p => ( { ...p , [ nb . id ] :! p [ nb . id ] } ) ) ;
10401065 }
1041- setShowTrash ( false ) } }
1066+ } }
10421067 onContextMenu = { e => { e . preventDefault ( ) ; setCtx ( { x :e . clientX , y :e . clientY , id :nb . id } ) } } >
10431068 < div className = { `nf-nb-chevron${ expNb [ nb . id ] ?" open" :"" } ` } > < I n = "chev" s = { 11 } /> </ div >
10441069 < div className = "nf-nb-color" style = { { background :nb . color } } />
0 commit comments