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 <ol>
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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-04-06 11:24:01 +01:00 committed by GitHub
parent ef0b257d3e
commit ac118cfde7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 136 additions and 12 deletions

View File

@ -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 <ol>
// is reopened after an unordered-list interruption we can emit start="N".
const olItemCounts: MapArrayType<number> = {};
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(`<ol start="${Number(line.start)}" class="${line.listTypeName}">`);
} 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(`<ol start="${startNum}" class="${line.listTypeName}">`);
} 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(`<ol class="${line.listTypeName}">`);
} 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(`<ol start="${explicitStart}" class="${line.listTypeName}">`);
olItemCounts[line.listLevel] = explicitStart - 1;
} else {
pieces.push(`<ol class="${line.listTypeName}">`);
}
}
} 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('<li>', 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('</ul') === 0 ||
pieces[pieces.length - 1].indexOf('</ol') === 0) {
pieces.push('</li>');
@ -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,

View File

@ -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 <ol> has no start="2"
await importHtml.setPadHTML(pad,
'<html><body>' +
'<ol class="number"><li>First</li></ol>' +
'<ul class="bullet"><li>Bullet A</li></ul>' +
'<ol class="number"><li>Second</li></ol>' +
'</body></html>');
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,
'<html><body>' +
'<ol class="number"><li>First</li><li>Second</li></ol>' +
'<ul class="bullet"><li>Bullet</li></ul>' +
'<ol class="number"><li>Third</li></ol>' +
'</body></html>');
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,
'<html><body>' +
'<ol class="number"><li>Parent A' +
'<ol class="number"><li>Child A1</li><li>Child A2</li></ol>' +
'</li><li>Parent B' +
'<ol class="number"><li>Child B1</li></ol>' +
'</li></ol>' +
'</body></html>');
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 <ol tags appear — the second nested ol must not
// carry a stale counter from the first nested list.
const innerOlMatches = html.match(/<ol[^>]*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();
});