From ac118cfde76ee655b44743d50794e8fe4a05fcbf Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 6 Apr 2026 11:24:01 +0100 Subject: [PATCH] fix: preserve ordered list numbering across bullet interruptions in export (#7470) * fix: preserve ordered list numbering across unordered list interruptions in export When ordered lists were interrupted by unordered lists, each new
    segment started at 1 instead of continuing the previous numbering. Track running counts per indent level and emit start attributes. Fixes #6471 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: respect explicit start attributes and reset counters per level - line.start takes priority over counter-based continuation when present - Counter is seeded from line.start to keep subsequent continuations aligned - Counters for closed indent levels are cleared when list depth decreases Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/node/utils/ExportHtml.ts | 47 +++++++++++- src/tests/backend/specs/export_list.ts | 101 ++++++++++++++++++++++--- 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/src/node/utils/ExportHtml.ts b/src/node/utils/ExportHtml.ts index e2fde19bf..2d8e6a3a6 100644 --- a/src/node/utils/ExportHtml.ts +++ b/src/node/utils/ExportHtml.ts @@ -309,6 +309,9 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string } let openLists: openList[] = []; + // Track running ordered-list item counts per indent level so that when an
      + // is reopened after an unordered-list interruption we can emit start="N". + const olItemCounts: MapArrayType = {}; for (let i = 0; i < textLines.length; i++) { let context; const line = _analyzeLine(textLines[i], attribLines[i], apool); @@ -388,10 +391,25 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string } if (line.listTypeName === 'number') { - if (line.start) { - pieces.push(`
        `); - } else { + if (olItemCounts[line.listLevel] != null && olItemCounts[line.listLevel] > 0) { + // Continue numbering after an unordered-list interruption + const startNum = olItemCounts[line.listLevel] + 1; + pieces.push(`
          `); + } else if (olItemCounts[line.listLevel] != null) { + // Counter exists but is 0 — level was explicitly reset (e.g. a + // nested list was closed). Start fresh without a start attribute. pieces.push(`
            `); + } else { + // No counter yet. Use explicit start attribute when present + // (e.g. from import or internal logic) and seed the counter so + // subsequent continuations stay aligned. + const explicitStart = Number(line.start); + if (Number.isFinite(explicitStart) && explicitStart > 0) { + pieces.push(`
              `); + olItemCounts[line.listLevel] = explicitStart - 1; + } else { + pieces.push(`
                `); + } } } else if (line.listTypeName === 'indent') { // Indent lines are plain indented text, not list items. @@ -406,6 +424,14 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string // if we're going up a level we shouldn't be adding.. if (context.lineContent) { pieces.push('
              1. ', context.lineContent); + // Track ordered-list item counts so we can continue numbering after interruptions + if (line.listTypeName === 'number') { + if (!olItemCounts[line.listLevel]) { + olItemCounts[line.listLevel] = 1; + } else { + olItemCounts[line.listLevel]++; + } + } } // To close list elements @@ -431,6 +457,8 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string if (nextLine && nextLine.listLevel) { nextLevel = nextLine.listLevel; } + // The actual depth the next line lives at (ignoring type changes) + const actualNextLevel = (nextLine && nextLine.listLevel) ? nextLine.listLevel : 0; if (nextLine && line.listTypeName !== nextLine.listTypeName) { nextLevel = 0; } @@ -438,6 +466,15 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string for (let diff = nextLevel; diff < line.listLevel; diff++) { openLists = openLists.filter((el) => el.level !== diff && el.type !== line.listTypeName); + // Reset counter for levels that are genuinely closing (depth decrease), + // not merely changing type at the same depth. Type changes should + // preserve counters so numbering can continue after interruptions. + // Use 0 as sentinel (not delete) so the ol-opening logic knows this + // level was explicitly reset and won't fall back to line.start. + if (diff + 1 > actualNextLevel) { + olItemCounts[diff + 1] = 0; + } + if (pieces[pieces.length - 1].indexOf(''); @@ -451,6 +488,10 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string } } } else { + // outside any list — reset ordered-list counters for all levels + for (const key of Object.keys(olItemCounts)) { + delete olItemCounts[key]; + } // outside any list, need to close line.listLevel of lists context = { line, diff --git a/src/tests/backend/specs/export_list.ts b/src/tests/backend/specs/export_list.ts index 8a6c63c48..c06c1f4cf 100644 --- a/src/tests/backend/specs/export_list.ts +++ b/src/tests/backend/specs/export_list.ts @@ -34,7 +34,7 @@ describe(__filename, function () { }); // Regression test for https://github.com/ether/etherpad-lite/issues/6471 - it('ordered list numbering preserved across bullet interruptions', async function () { + it('ordered list numbering preserved across bullet interruptions (round-trip)', async function () { const padId = `exportOlBullet_${common.randomString()}`; const pad = await padManager.getPad(padId, 'placeholder'); @@ -47,15 +47,98 @@ describe(__filename, function () { const html = await exportHtml.getPadHTML(pad, undefined); - // The second ol should have a start value > 1, showing the numbering continues + // The second ol should have a start value of 2, showing the numbering continues // after the bullet interruption (not reset to 1) - const startMatches = html.match(/start="(\d+)"/g) || []; - assert(startMatches.length >= 2, - `Expected at least 2 ol start attributes in: ${html}`); - // Verify at least one start value is > 1 - const hasHighStart = startMatches.some((m: string) => parseInt(m.match(/\d+/)![0]) > 1); - assert(hasHighStart, - `Expected a start value > 1 for continued numbering in: ${html}`); + assert(html.includes('start="2"'), + `Expected start="2" for continued numbering in: ${html}`); + + await pad.remove(); + }); + + // Regression test for https://github.com/ether/etherpad-lite/issues/6471 + // Tests that the export counter-based fix works even when the pad content + // does not carry explicit start attributes (e.g. content created without + // import start values). + it('ordered list numbering preserved across bullet interruptions (no explicit start)', async function () { + const padId = `exportOlBulletNoStart_${common.randomString()}`; + const pad = await padManager.getPad(padId, 'placeholder'); + + // Import HTML without start attributes — the second
                  has no start="2" + await importHtml.setPadHTML(pad, + '' + + '
                  1. First
                  ' + + '
                  • Bullet A
                  ' + + '
                  1. Second
                  ' + + ''); + + const html = await exportHtml.getPadHTML(pad, undefined); + + // Even though the import had no start attribute, the export should add + // start="2" to continue the numbering after the bullet interruption + assert(html.includes('start="2"'), + `Expected start="2" for continued numbering in: ${html}`); + + await pad.remove(); + }); + + // Regression test for https://github.com/ether/etherpad-lite/issues/6471 + // Tests multiple ordered list items before and after bullet interruptions. + it('ordered list numbering preserved with multiple items', async function () { + const padId = `exportOlMulti_${common.randomString()}`; + const pad = await padManager.getPad(padId, 'placeholder'); + + await importHtml.setPadHTML(pad, + '' + + '
                  1. First
                  2. Second
                  ' + + '
                  • Bullet
                  ' + + '
                  1. Third
                  ' + + ''); + + const html = await exportHtml.getPadHTML(pad, undefined); + + // After two ordered items then a bullet, the next ol should start at 3 + assert(html.includes('start="3"'), + `Expected start="3" for continued numbering in: ${html}`); + + await pad.remove(); + }); + + // Regression test: counters for closed indent levels must be cleared + // when list depth decreases so re-entering the same level under a + // different parent starts fresh numbering. + it('nested ordered list counters reset when closing levels', async function () { + const padId = `exportOlNested_${common.randomString()}`; + const pad = await padManager.getPad(padId, 'placeholder'); + + // Structure: + // 1. Parent A (level 1) + // 1. Child A1 (level 2) + // 2. Child A2 (level 2) + // 2. Parent B (level 1) + // 1. Child B1 (level 2) <-- should restart at 1, not 3 + await importHtml.setPadHTML(pad, + '' + + '
                  1. Parent A' + + '
                    1. Child A1
                    2. Child A2
                    ' + + '
                  2. Parent B' + + '
                    1. Child B1
                    ' + + '
                  ' + + ''); + + const html = await exportHtml.getPadHTML(pad, undefined); + + // The inner ol under Parent B should NOT have start="3". + // It must either have no start attribute (defaulting to 1) or start="1". + // Count how many inner
                    ]*class="number"[^>]*>/g) || []; + // There should be at least 3 ol tags (outer + 2 nested). + assert(innerOlMatches.length >= 3, + `Expected at least 3 ol tags, got ${innerOlMatches.length} in: ${html}`); + // The last nested ol (for Child B1) should not have start="3" + const lastInnerOl = innerOlMatches[innerOlMatches.length - 1]; + assert(!lastInnerOl.includes('start="3"'), + `Nested ol under Parent B should not continue numbering from Parent A's children: ${html}`); await pad.remove(); });