diff --git a/app/templates/pages.js b/app/templates/pages.js index e7953b8..be252e9 100644 --- a/app/templates/pages.js +++ b/app/templates/pages.js @@ -10,7 +10,7 @@ const { } = require('./shared'); const { noteMetaFragment } = require('./fragments'); -const ASSET_VERSION = '20260731checkboxenter1'; +const ASSET_VERSION = '20260805blanklinetext1'; const noteFontFamilyCSS = (settings) => { const f = settings.noteFontFamily || 'sans'; diff --git a/public/app.js b/public/app.js index 8028a40..e18edbe 100644 --- a/public/app.js +++ b/public/app.js @@ -766,12 +766,22 @@ var _tinymcePostLoad=false; // "Edited" → real save of the round-tripped markdown. See "Edited flash after // restore" investigation. var _tinymcePostLoadUntil=0; +// Set by genuine user interaction inside the TinyMCE iframe (keystroke, paste, +// cut, drop). Reset on every programmatic content load. The post-load reconcile +// uses it to tell a REAL edit made during the quiet window apart from the +// editor merely normalising the note on load: markdown->HTML->markdown is not +// always the identity (e.g. an indented ``` fence loses its indent, a blank line +// after an ATX heading is collapsed), and adopting those cosmetic rewrites as +// "the user edited the note" produced an "Edited" flash plus a useless autosave +// every single time such a note was opened. +var _tinymceUserTypedSinceLoad=false; var _pendingSearchHighlight=false; var _tinymceShowRequested=false; function _setTinyMCEContent(html){ if(!_tinymceEditor)return; _dbgline('_setTinyMCEContent begin len='+((html||'').length)); _tinymceSuppressEdits=true; + _tinymceUserTypedSinceLoad=false; try{ _tinymceEditor.setContent(html||''); if(_tinymceEditor.undoManager)_tinymceEditor.undoManager.clear(); @@ -808,9 +818,23 @@ function _setTinyMCEContent(html){ if(_tinymceReadonly)return; if(typeof _lazyTinyMCESyncBeforeSave==='function')_lazyTinyMCESyncBeforeSave(); if(typeof formHash==='function'&&formHash(form)!==_savedHash){ - _dbgline('post-load reconcile: form dirty, marking edited'); - if(typeof markEdited==='function')markEdited(); - if(typeof scheduleSave==='function')scheduleSave(); + // The form differs from the snapshot taken before the editor + // loaded. Two very different reasons are possible: + // 1. the user really typed during the 800ms quiet window — that + // edit was absorbed (no markEdited fired) and must be saved; + // 2. nobody touched the note and the difference is purely + // TinyMCE/Turndown load normalisation (indented fence indent + // dropped, blank line after a heading collapsed, ...). + // Only (1) is an edit. Treating (2) as an edit flashed "Edited" + // and wrote a useless save every time such a note was opened. + if(_tinymceUserTypedSinceLoad){ + _dbgline('post-load reconcile: user typed during window, marking edited'); + if(typeof markEdited==='function')markEdited(); + if(typeof scheduleSave==='function')scheduleSave(); + }else{ + _dbgline('post-load reconcile: load normalisation only, re-baselining hash'); + if(typeof snapshotHash==='function')snapshotHash(); + } }else{ _dbgline('post-load reconcile: form clean, nothing to do'); } @@ -1467,6 +1491,22 @@ function initPersistentTinyMCE(){ // auto-populate while typing in TinyMCE. autoTitleFromTinyMCE(editor); } + // Record GENUINE user interaction so the post-load reconcile can tell a + // real edit apart from load normalisation. Deliberately uses keydown / + // paste / cut / drop: those are never fired by editor.setContent(), + // whereas 'input' and 'SetContent' are, so they cannot be used here. + // Navigation-only keys are filtered out — pressing an arrow key is not + // an edit and must not promote a cosmetic rewrite into a saved change. + editor.on('keydown',function(e){ + if(!e)return; + if(e.ctrlKey||e.metaKey)return; // shortcuts; paste/cut fire their own events + var k=e.key; + if(typeof k==='string'&&(k.length===1||k==='Enter'||k==='Backspace'||k==='Delete'||k==='Tab')) + _tinymceUserTypedSinceLoad=true; + }); + editor.on('paste',function(){_tinymceUserTypedSinceLoad=true}); + editor.on('cut',function(){_tinymceUserTypedSinceLoad=true}); + editor.on('drop',function(){_tinymceUserTypedSinceLoad=true}); editor.on('input',function(){onEdit('input')}); editor.on('change',function(){onEdit('change')}); // TinyMCE's built-in commands (blocks dropdown -> FormatBlock, lists, @@ -3923,18 +3963,64 @@ function htmlToMarkdown(el){ } return out } +// Turndown computes an element's flanking whitespace from node.textContent, which +// skips atomic children like (they contribute no text). For +//  Label it therefore reports a LEADING whitespace run even though +// that whitespace is interior to the produced markdown (which starts with +// "![alt](:/id)"). replacementForNode() then does content.trim() — which cannot +// remove an interior space — and still prepends the expelled whitespace, so the +// space is DUPLICATED. That grows by one character on every +// markdown->render->markdown cycle, i.e. every time the note is opened in rendered +// mode, which makes the post-load reconcile in _setTinyMCEContent see a phantom +// edit ("Edited" flash + useless autosave) and slowly corrupts the stored body. +// +// Hide such interior whitespace behind a sentinel so Turndown does not see a +// flanking edge, then restore the EXACT original characters afterwards. The +// character code is encoded in the sentinel because the run is frequently NBSP +// (\u00a0) rather than a plain space — restoring a generic ' ' would itself change +// the note body and keep the phantom-edit loop alive. +var _INLINE_TAGS_RE_SRC='a|abbr|b|bdi|bdo|cite|code|del|em|i|ins|kbd|label|mark|q|s|small|span|strong|sub|sup|time|u|var'; +function _encodeProtectedSpace(ws){ + return ws.replace(/[^\S\r\n]/g,function(c){return '\u2764S'+c.charCodeAt(0)+'\u2764'}); +} +function _protectInlineLeadingSpace(html){ + // Whitespace AFTER atomic children that open an inline element. + html=html.replace(new RegExp('(<(?:'+_INLINE_TAGS_RE_SRC+')\\b[^>]*>(?:<(?:img|br)\\b[^>]*>)+)([^\\S\\r\\n]+)(?=\\S)','gi'), + function(_m,open,ws){return open+_encodeProtectedSpace(ws)}); + // Symmetric case: whitespace BEFORE atomic children that close an inline element. + html=html.replace(new RegExp('([^\\S\\r\\n]+)((?:<(?:img|br)\\b[^>]*>)+)','gi'), + function(_m,ws,tail){return _encodeProtectedSpace(ws)+tail}); + return html; +} +function _restoreProtectedSpace(md){ + return md.replace(/\u2764S(\d+)\u2764/g,function(_m,code){return String.fromCharCode(parseInt(code,10))}); +} function tinymceToMarkdown(html){ if(!html)return ''; html=html.replace(/\u200b/g,''); // Normalise blank-line markers. TinyMCE strips the
from //


on setContent, leaving an empty //

— which Turndown drops entirely (empty block - // = no output), swallowing the blank line. Rewrite any md-blank-line paragraph - // (empty or not) to the sentinel shape the blankLine rule reliably matches. - html=html.replace(/]*\bclass="[^"]*\bmd-blank-line\b[^"]*"[^>]*)>[\s\S]*?<\/p>/gi,'\u2764BR\u2764

'); + // = no output), swallowing the blank line. Rewrite the marker to the sentinel + // shape the blankLine rule reliably matches. + // + // ONLY rewrite markers that are still genuinely blank. A blank-line marker is a + // real, focusable paragraph in the iframe, so the caret lands in it whenever the + // user clicks on the empty space between two blocks — typing there puts the new + // text INSIDE the marker. Rewriting unconditionally replaced that text with the + // sentinel and silently destroyed it (typed a line, left the note, came back, + // gone). When the marker holds real content, leave the paragraph verbatim: with + // text present neither the blankLine nor the emptyP rule matches it, so Turndown + // converts it as an ordinary paragraph and the text survives. + html=html.replace(/]*\bclass="[^"]*\bmd-blank-line\b[^"]*"[^>]*)>([\s\S]*?)<\/p>/gi,function(whole,attrs,inner){ + var plain=String(inner||'').replace(/<[^>]*>/g,'').replace(/ | | /gi,''); + if(plain.replace(/[\s\u00a0\u200b]+/g,''))return whole; + return '\u2764BR\u2764

'; + }); // tinyMCE appends a trailing
to every non-empty

block. Strip before conversion // so it doesn't become a spurious newline. Do NOT strip from


(blank lines). html=html.replace(/

((?:[^<]|<(?!\/p>))+?)\s*<\/p>/gi,'

$1

'); + html=_protectInlineLeadingSpace(html); // Convert
(line breaks within paragraphs and blank-line divs) to a // sentinel Turndown won't touch. Restore as \n after conversion. // For md-blank-line divs the blankLine rule matches the sentinel text. @@ -3950,8 +4036,23 @@ function tinymceToMarkdown(html){ md=_applyHeadingSpacing(md); // Normalise blank-line sentinels (md-blank-line divs + empty paragraphs) md=md.replace(/\n*(?:\x00BL\x00\n*)+/g,function(m){var count=(m.match(/\x00BL\x00/g)||[]).length;return nl+nl+Array(count+1).join(nl)}); - // Restore line-break sentinels as \n (soft breaks within paragraphs) - md=md.split('\u2764BR\u2764').join(nl); + // Restore line-break sentinels as \n (soft breaks within paragraphs). + // Inside a blockquote the sentinel must also re-apply the quote prefix: + // Turndown prefixes the lines it emits, but these newlines are injected + // afterwards, so '> b
c' would come back as '> b\nc' — the second line + // escaping the blockquote entirely (real content corruption, and a permanent + // dirty-on-open diff). Carry the leading '>' run of the line across the break. + md=md.split(nl).map(function(lineText){ + if(lineText.indexOf('\u2764BR\u2764')<0)return lineText; + var qm=lineText.match(/^((?:[ \t]*>)+[ \t]?)/); + return lineText.split('\u2764BR\u2764').join(nl+(qm?qm[1]:'')); + }).join(nl); + md=_restoreProtectedSpace(md); + // Turndown prefixes every line of a blockquote with '> ', including the empty + // lines between paragraphs, yielding '> ' where the source had a bare '>'. + // Trailing whitespace on a quote-only line is insignificant in CommonMark, so + // strip it — otherwise the note is reported dirty on every open. + md=md.replace(/^([ \t]*>+)[ \t]+$/gm,'$1'); var out=''; for(var i=0;i { diff --git a/tests/appRuntime.test.js b/tests/appRuntime.test.js index 1ca5bc6..2b25f83 100644 --- a/tests/appRuntime.test.js +++ b/tests/appRuntime.test.js @@ -59,6 +59,14 @@ function runWithDeps(ctx, ...fns) { fns = [...fns]; fns.splice(fns.indexOf('getTurndown') >= 0 ? fns.indexOf('getTurndown') + 1 : 0, 0, '_applyHeadingSpacing'); } + if (fns.includes('tinymceToMarkdown') && !fns.includes('_protectInlineLeadingSpace')) { + fns = [...fns]; + // _protectInlineLeadingSpace needs the shared inline-tag list plus the + // encode/restore pair that round-trips the exact whitespace characters. + vm.runInContext("var _INLINE_TAGS_RE_SRC='a|abbr|b|bdi|bdo|cite|code|del|em|i|ins|kbd|label|mark|q|s|small|span|strong|sub|sup|time|u|var';", ctx); + const at = fns.indexOf('getTurndown') >= 0 ? fns.indexOf('getTurndown') + 1 : 0; + fns.splice(at, 0, '_encodeProtectedSpace', '_protectInlineLeadingSpace', '_restoreProtectedSpace'); + } for (const fn of fns) vm.runInContext(extractFn(fn), ctx); } @@ -143,6 +151,181 @@ test('tinymceToMarkdown returns empty string for empty input', () => { assert.equal(result.trim(), ''); }); +test('tinymceToMarkdown does not duplicate space after inline image in link', () => { + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const nbsp = '\u00a0'.repeat(12); + const imageId = 'a'.repeat(32); + const html = '

++' + nbsp + 'ic Label++

'; + const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx); + assert.equal(result, '++' + nbsp + '[![ic](:/' + imageId + ') Label](https://e.com)++'); + const whitespace = result.match(/^\+\+([^\[]*)\[/); + assert.ok(whitespace, `expected link after opening markers, got: ${JSON.stringify(result)}`); + assert.equal(whitespace[1].length, 12); +}); + +test('tinymceToMarkdown is idempotent for rendered inline image link', () => { + const { renderMarkdown } = require('../app/templates'); + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const md = '++' + '\u00a0'.repeat(12) + '[![xlsx icon](:/20b2d03aaad113c6181386805ae8c6c4) Hybrid Savings commercials.xlsx](https://example.com/x.xlsx)++'; + const results = []; + let current = md; + for (let i = 0; i < 5; i += 1) { + const html = renderMarkdown(current); + current = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx); + results.push(current); + } + assert.equal(results[0], md); + assert.equal(results[4], md); + for (const result of results) { + const whitespace = result.match(/^\+\+([^\[]*)\[/); + assert.ok(whitespace, `expected link after opening markers, got: ${JSON.stringify(result)}`); + assert.equal(whitespace[1].length, 12); + } +}); + +test('tinymceToMarkdown keeps genuine leading link whitespace outside brackets', () => { + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const html = ' Label'; + const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx); + assert.equal(result, '[Label](https://e.com)'); +}); + +test('tinymceToMarkdown restores an NBSP (not a plain space) after an inline image', () => { + // The real-world shape (Outlook-pasted link with an inline xlsx icon) separates + // the image from the label with a NON-BREAKING space, not an ASCII space. + // Turndown's edgeWhitespace uses \s, which matches NBSP, so the NBSP was expelled + // and duplicated. Restoring the sentinel as a generic ' ' would also change the + // body (NBSP -> space) and keep the phantom-edit loop alive, so the exact + // character must come back. + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const imageId = 'b'.repeat(32); + const html = '

ic\u00a0Label

'; + const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx); + assert.equal(result, '[![ic](:/' + imageId + ')\u00a0Label](https://e.com)'); + assert.ok(result.indexOf('\u00a0') !== -1, 'NBSP must survive as NBSP'); +}); + +test('tinymceToMarkdown is idempotent for the real NBSP-separated icon link over 5 opens', () => { + // Regression for the reported bug: opening the note flashed "Edited" and fired a + // useless autosave because the whitespace run grew by one character per open + // (12 -> 13 -> 14 ...). Mirrors the stored body of the affected note. + const { renderMarkdown } = require('../app/templates'); + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const md = '++' + '\u00a0'.repeat(12) + + '[![xlsx icon](:/20b2d03aaad113c6181386805ae8c6c4)\u00a0Hybrid Savings commercials.xlsx](https://example.com/x.xlsx)++'; + let current = md; + for (let i = 0; i < 5; i += 1) { + current = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(renderMarkdown(current)) + ')', ctx); + assert.equal(current, md, `round-trip ${i + 1} must be byte-identical, got: ${JSON.stringify(current)}`); + } +}); + +test('tinymceToMarkdown keeps text typed INTO a blank-line marker (data-loss regression)', () => { + // A

is a real, focusable paragraph in the TinyMCE + // iframe, so clicking the empty space between two blocks puts the caret inside + // it and typing puts the new text there. The blank-line normalisation used to + // rewrite the paragraph's content unconditionally, which silently DESTROYED + // that text: type a line after a checklist, leave the note, come back, gone. + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const shapes = [ + '

hello there

', // typed over the
+ '

hello there

', // typed, TinyMCE re-added
+ '

hello there

', // with inline markup + ]; + for (const p of shapes) { + const html = '
 GPU
\n' + p + '\n

April

'; + const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx); + assert.ok(result.indexOf('hello') !== -1, + `text typed into a blank-line marker must survive, lost with ${p} -> ${JSON.stringify(result)}`); + assert.ok(result.indexOf('- [ ] GPU') !== -1, 'the checklist item must still convert'); + } +}); + +test('tinymceToMarkdown still collapses a genuinely blank blank-line marker', () => { + // The counterpart guard: an untouched marker (or one whose
TinyMCE stripped) + // must still become a real blank line, otherwise spacing collapses. + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + for (const p of ['


', '

', '

 

']) { + const html = '
 GPU
\n' + p + '\n

April

'; + const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx); + assert.equal(result, '- [ ] GPU\n\n\n## April', + `blank marker ${p} must still produce a blank line, got: ${JSON.stringify(result)}`); + } +}); + +test('typing after a checklist survives a full render -> edit -> markdown -> reopen cycle', () => { + // End-to-end shape of the reported bug, including stability on reopen. + const { renderMarkdown } = require('../app/templates'); + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const body = '- [ ] GPU\n- [ ] Do not include AI\n\n\n## April\n'; + let html = renderMarkdown(body); + const marker = '


'; + const at = html.indexOf(marker); + assert.ok(at !== -1, 'expected a blank-line marker between the checklist and the heading'); + html = html.slice(0, at) + '

hello there

' + html.slice(at + marker.length); + const saved = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx); + assert.ok(saved.indexOf('hello there') !== -1, `typed text must be saved, got: ${JSON.stringify(saved)}`); + assert.ok(/- \[ \] Do not include AI/.test(saved), 'checklist must be intact'); + // Reopening must not change it again (no phantom edit, no further loss). + const reopened = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(renderMarkdown(saved)) + ')', ctx); + assert.equal(reopened, saved, 'the saved body must be stable on reopen'); +}); + +test('tinymceToMarkdown does not add a trailing space to a bare > blockquote line', () => { + // Turndown prefixes every blockquote line with '> ', so the empty line between + // two quoted paragraphs came back as '> ' where the source had a bare '>'. + // Trailing whitespace there is insignificant in CommonMark, and leaving it in + // made the note look dirty on every open. + const { renderMarkdown } = require('../app/templates'); + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const md = '> quote\n>\n> more'; + const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(renderMarkdown(md)) + ')', ctx); + assert.equal(result, md); + assert.ok(!/>[ \t]+$/m.test(result), `no quote line may end in whitespace, got: ${JSON.stringify(result)}`); +}); + +test('tinymceToMarkdown keeps the > prefix on soft-wrapped blockquote lines', () => { + // '> b\n> c' is ONE quoted paragraph with a soft break, so the renderer emits + // '

b
c

'. The
sentinel is restored to a + // newline AFTER Turndown has prefixed its own lines, so without carrying the + // quote prefix across the break the second line came back as bare 'c' — + // escaping the blockquote entirely on the next render. + const { renderMarkdown } = require('../app/templates'); + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + for (const md of ['> b\n> c', '> a\n>\n> b\n> c']) { + const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(renderMarkdown(md)) + ')', ctx); + assert.equal(result, md, `blockquote must round-trip, got: ${JSON.stringify(result)}`); + } +}); + +test('tinymceToMarkdown still turns a plain paragraph soft break into a bare newline', () => { + // Guard: the quote-prefix carry must not leak into non-quoted paragraphs. + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const result = vm.runInContext('tinymceToMarkdown("

a
b

")', ctx); + assert.equal(result, 'a\nb'); +}); + +test('tinymceToMarkdown does not duplicate whitespace before a trailing inline image', () => { + // Symmetric case: whitespace immediately before an that closes the link. + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const imageId = 'c'.repeat(32); + const html = '

Label\u00a0ic

'; + const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx); + assert.equal(result, '[Label\u00a0![ic](:/' + imageId + ')](https://e.com)'); +}); + test('tinymceToMarkdown preserves a blank line INSIDE a code block (C #include is not a heading)', () => { const ctx = makeTurndownCtx(); runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); diff --git a/tests/tinymceOnEditSync.test.js b/tests/tinymceOnEditSync.test.js index 4880997..6984133 100644 --- a/tests/tinymceOnEditSync.test.js +++ b/tests/tinymceOnEditSync.test.js @@ -382,3 +382,107 @@ test('app.js: _tinymceFirstBlockText / autoTitleFromTinyMCE exist and are DOM-on 'autoTitleFromTinyMCE must read via _tinymceFirstBlockText'); }); + +// --------------------------------------------------------------------------- +// Post-load reconcile: load normalisation must NOT be mistaken for a user edit +// --------------------------------------------------------------------------- + +// Build a context that runs _setTinyMCEContent's reconcile with controllable timers. +function makeReconcileCtx({ userTyped, normalisedDiffers }) { + const dom = new JSDOM('
' + + '
'); + const timers = []; + const editor = { + setContent() {}, + undoManager: { clear() {} }, + getBody: () => dom.window.document.body, + }; + const ctx = vm.createContext({ + document: dom.window.document, + window: dom.window, + Date, + _tinymceEditor: editor, + _tinymceSuppressEdits: false, + _tinymceReadonly: false, + _tinymcePostLoad: false, + _tinymcePostLoadUntil: 0, + _tinymceUserTypedSinceLoad: false, + _pendingSearchHighlight: false, + _savedHash: 111, + _edited: 0, + _saved: 0, + _snapshots: 0, + _logs: [], + setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, + }); + vm.runInContext(` + function activeEditorForm(){return document.getElementById('note-editor-form')} + function _dbgline(){_logs.push(Array.from(arguments).join(' '))} + function _log(){} + function markEdited(){_edited++} + function scheduleSave(){_saved++} + function snapshotHash(){_snapshots++; _savedHash=formHash()} + function formHash(){return ${normalisedDiffers ? 222 : 111}} + function _lazyTinyMCESyncBeforeSave(){return true} + function ensureTinyMCEEditableAfterPre(){} + function initTinyMCECodeCopyButtons(){} + function _applyTinyMCESpellcheck(){} + function activeSearchTerm(){return ''} + function applySearchHighlight(){} + `, ctx); + vm.runInContext(extractFn('_setTinyMCEContent'), ctx); + vm.runInContext('_setTinyMCEContent("

x

")', ctx); + if (userTyped) vm.runInContext('_tinymceUserTypedSinceLoad=true', ctx); + // Run the 820ms reconcile callback (the last scheduled timer). + const reconcile = timers.find(t => t.ms === 820); + assert.ok(reconcile, 'expected a 820ms reconcile timer to be scheduled'); + reconcile.fn(); + return { + edited: vm.runInContext('_edited', ctx), + saved: vm.runInContext('_saved', ctx), + snapshots: vm.runInContext('_snapshots', ctx), + logs: vm.runInContext('_logs.join("|")', ctx), + }; +} + +test('post-load reconcile: pure load normalisation re-baselines instead of flashing Edited', () => { + // Reported bug: opening a note whose markdown is not round-trip identical + // (indented ``` fence loses its indent, blank line after an ATX heading is + // collapsed) flashed "Edited" and fired a useless autosave EVERY open. + const r = makeReconcileCtx({ userTyped: false, normalisedDiffers: true }); + assert.equal(r.edited, 0, 'markEdited must not fire for load normalisation'); + assert.equal(r.saved, 0, 'no autosave may be scheduled for load normalisation'); + assert.equal(r.snapshots, 1, 'the normalised content must become the new clean baseline'); +}); + +test('post-load reconcile: a real edit during the quiet window still saves', () => { + // The reconcile exists so an edit absorbed by the 800ms post-load window is + // not silently lost. That safety net must survive the fix above. + const r = makeReconcileCtx({ userTyped: true, normalisedDiffers: true }); + assert.equal(r.edited, 1, 'a genuine edit must still mark the note edited'); + assert.equal(r.saved, 1, 'a genuine edit must still schedule a save'); +}); + +test('post-load reconcile: identical content does nothing at all', () => { + const r = makeReconcileCtx({ userTyped: false, normalisedDiffers: false }); + assert.equal(r.edited, 0); + assert.equal(r.saved, 0); + assert.equal(r.snapshots, 0); +}); + +test('app.js: user-typed flag is wired to keydown/paste/cut/drop, never to input/SetContent', () => { + // editor.setContent() fires 'input' and 'SetContent', so those events cannot + // be used to detect real user interaction — using them would reintroduce the + // phantom "Edited" on open. + const idx = appSrc.indexOf('_tinymceUserTypedSinceLoad=true'); + assert.ok(idx !== -1, '_tinymceUserTypedSinceLoad must be set somewhere'); + for (const evt of ['keydown', 'paste', 'cut', 'drop']) { + assert.ok(new RegExp(`editor\\.on\\('${evt}'`).test(appSrc), + `expected an editor.on('${evt}') handler to exist`); + } + const setLine = appSrc.slice(appSrc.lastIndexOf('\n', idx - 200), idx + 40); + assert.ok(!/editor\.on\('(?:input|SetContent)'[^)]*\)\s*\{[^}]*_tinymceUserTypedSinceLoad=true/.test(setLine), + '_tinymceUserTypedSinceLoad must not be set from input/SetContent handlers'); + assert.ok(extractFn('_setTinyMCEContent').includes('_tinymceUserTypedSinceLoad=false'), + '_setTinyMCEContent must reset the user-typed flag on every programmatic load'); +});