Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions src/node/utils/ExportHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -431,13 +457,24 @@ 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;
}

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>');
Expand All @@ -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];
}
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
// outside any list, need to close line.listLevel of lists
context = {
line,
Expand Down
101 changes: 92 additions & 9 deletions src/tests/backend/specs/export_list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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();
});
Expand Down
Loading