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('- ', 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,
+ '' +
+ '- First
' +
+ '' +
+ '- 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,
+ '' +
+ '- First
- Second
' +
+ '' +
+ '- 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,
+ '' +
+ '- Parent A' +
+ '
- Child A1
- Child A2
' +
+ ' - Parent B' +
+ '
- 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();
});