mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-05-05 04:06:37 +02:00
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:
parent
ef0b257d3e
commit
ac118cfde7
@ -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,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user