diff --git a/src/comparison/comparisonPresentation.ts b/src/comparison/comparisonPresentation.ts new file mode 100644 index 00000000000..abc94771666 --- /dev/null +++ b/src/comparison/comparisonPresentation.ts @@ -0,0 +1,64 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComparisonAttributeCode as AttributeCode, ComparisonMarkCode as MarkCode, ComparisonSignal as Signal } from './markdownComparisonTypes.ts' + +import { t } from '@nextcloud/l10n' + +type Label = () => string +const attributes: Record = { + 'image-target': [219, () => t('text', 'Image changed')], + 'image-alt': [218, () => t('text', 'Image description changed')], + 'link-target': [217, () => t('text', 'Link target changed')], + link: [216, () => t('text', 'Link changed')], + 'mention-identity': [215, () => t('text', 'Mention changed')], + mathematics: [214, () => t('text', 'Mathematics changed')], + 'preview-target': [213, () => t('text', 'Link preview changed')], + 'footnote-reference': [212, () => t('text', 'Footnote changed')], + 'task-state': [211, () => t('text', 'Task state changed')], + 'heading-level': [210, () => t('text', 'Heading level changed')], + 'list-start': [209, () => t('text', 'List start changed')], + 'code-language': [208, () => t('text', 'Code language changed')], + 'text-direction': [207, () => t('text', 'Text direction changed')], + 'table-span': [206, () => t('text', 'Table structure changed')], + 'table-alignment': [205, () => t('text', 'Table alignment changed')], + 'callout-type': [204, () => t('text', 'Callout type changed')], + 'details-state': [203, () => t('text', 'Details state changed')], + 'unknown-attribute': [202, () => t('text', 'Attribute changed')], +} + +const marks: Record = { + bold: [106, () => t('text', 'Bold')], + italic: [105, () => t('text', 'Italic')], + strike: [104, () => t('text', 'Strikethrough')], + highlight: [103, () => t('text', 'Highlight')], + underline: [102, () => t('text', 'Underline')], + 'inline-code': [101, () => t('text', 'Inline code')], +} + +export function selectComparisonSignal(signals: readonly Signal[]): Signal | undefined { + return signals.reduce((selected, signal) => ( + !selected || signalPriority(signal) > signalPriority(selected) ? signal : selected + ), undefined) +} + +export function comparisonSignalLabel(signal: Signal) { + if (signal.type === 'attribute') { + return attributes[signal.attribute][1]() + } + if (signal.type === 'mark') { + return t('text', '{formatting} changed', { formatting: marks[signal.mark][1]() }) + } +} + +function signalPriority(signal: Signal) { + if (signal.type === 'attribute') { + return attributes[signal.attribute][0] + } + if (signal.type === 'mark') { + return marks[signal.mark][0] + } + return 150 +} diff --git a/src/comparison/comparisonSections.ts b/src/comparison/comparisonSections.ts new file mode 100644 index 00000000000..e3ec07a0af6 --- /dev/null +++ b/src/comparison/comparisonSections.ts @@ -0,0 +1,157 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { ComparisonEdit } from './markdownComparisonTypes.ts' + +import { increasingSubsequence } from './comparisonAlignment.ts' + +export interface ComparisonHeading { + from: number + text: string +} + +export interface ComparisonSection { + id: string + title: string + edits: readonly ComparisonEdit[] +} +type Heading = ComparisonHeading + +export function headingLocations(doc: Node): readonly Heading[] { + const headings: Heading[] = [] + doc.forEach((node, from) => { + const text = node.textContent.trim() + if (node.type.name === 'heading' && text) { + headings.push({ from, text }) + } + }) + return headings +} + +function nearestHeadingIndex(headings: readonly Heading[], position: number) { + let lower = 0 + let upper = headings.length + while (lower < upper) { + const middle = Math.floor((lower + upper) / 2) + if (headings[middle]!.from <= position) { + lower = middle + 1 + } else { + upper = middle + } + } + return lower - 1 +} +export function nearestHeading(headings: readonly Heading[], position: number) { + return headings[nearestHeadingIndex(headings, position)]?.text ?? '' +} + +interface HeadingIndex { + headings: readonly Heading[] + keys: readonly string[] +} + +function indexByUniqueTitle(headings: readonly Heading[]) { + const indexes = new Map() + const repeated = new Set() + headings.forEach(({ text }, index) => { + if (indexes.has(text)) { + repeated.add(text) + } else { + indexes.set(text, index) + } + }) + for (const text of repeated) { + indexes.delete(text) + } + return indexes +} + +function headingAnchors(before: readonly Heading[], after: readonly Heading[]) { + const beforeIndexes = indexByUniqueTitle(before) + const afterIndexes = indexByUniqueTitle(after) + const pairs: Array = [] + after.forEach(({ text }, afterIndex) => { + const beforeIndex = beforeIndexes.get(text) + if (beforeIndex !== undefined && afterIndexes.get(text) === afterIndex) { + pairs.push([beforeIndex, afterIndex]) + } + }) + return increasingSubsequence(pairs.map(([index]) => index)).indices.map((index) => pairs[index]!) +} + +function correlateHeadings(before: readonly Heading[], after: readonly Heading[]) { + const beforeKeys: string[] = [] + const afterKeys: string[] = [] + let next = 0 + let row = 0 + let column = 0 + + function pairGap(rowEnd: number, columnEnd: number) { + const rowCount = rowEnd - row + const columnCount = columnEnd - column + if (rowCount !== columnCount || rowCount > 1) { + while (row < rowEnd) { + beforeKeys[row++] = `#${next++}` + } + while (column < columnEnd) { + afterKeys[column++] = `#${next++}` + } + return + } + while (row < rowEnd) { + const key = `#${next++}` + beforeKeys[row++] = key + afterKeys[column++] = key + } + } + + for (const [anchorRow, anchorColumn] of headingAnchors(before, after)) { + pairGap(anchorRow, anchorColumn) + const key = `#${next++}` + beforeKeys[row++] = key + afterKeys[column++] = key + } + pairGap(before.length, after.length) + return { before: beforeKeys, after: afterKeys } +} + +function resolveSection(edit: ComparisonEdit, before: HeadingIndex, after: HeadingIndex) { + const descriptor = edit.primary + const deleted = descriptor.operation === 'delete' + const side = deleted ? before : after + const position = deleted + ? descriptor.context.before?.from ?? descriptor.before.from + : descriptor.context.after?.from ?? descriptor.after.from + return side.keys[nearestHeadingIndex(side.headings, position)] ?? '' +} + +export function buildComparisonSections(edits: readonly ComparisonEdit[], beforeDocument: Node, afterDocument: Node): readonly ComparisonSection[] { + const beforeHeadings = headingLocations(beforeDocument) + const afterHeadings = headingLocations(afterDocument) + const correlation = correlateHeadings(beforeHeadings, afterHeadings) + const before: HeadingIndex = { headings: beforeHeadings, keys: correlation.before } + const after: HeadingIndex = { headings: afterHeadings, keys: correlation.after } + const titleByKey = new Map() + beforeHeadings.forEach((heading, index) => titleByKey.set(correlation.before[index]!, heading.text)) + afterHeadings.forEach((heading, index) => titleByKey.set(correlation.after[index]!, heading.text)) + + const sections: Array<{ id: string, key: string, title: string, edits: ComparisonEdit[] }> = [] + for (const edit of edits) { + const key = resolveSection(edit, before, after) + const title = titleByKey.get(key) ?? '' + const current = sections.at(-1) + if (current?.key === key) { + current.edits.push(edit) + } else { + sections.push({ id: edit.id, key, title, edits: [edit] }) + } + } + return sections.map(({ id, title, edits: sectionEdits }) => ({ + id, + title, + edits: sectionEdits, + })) +} diff --git a/src/components/ComparisonChangeList.vue b/src/components/ComparisonChangeList.vue new file mode 100644 index 00000000000..5246b903044 --- /dev/null +++ b/src/components/ComparisonChangeList.vue @@ -0,0 +1,474 @@ + + + + + + + diff --git a/src/tests/comparison/ComparisonChangeList.spec.ts b/src/tests/comparison/ComparisonChangeList.spec.ts new file mode 100644 index 00000000000..246e4914c97 --- /dev/null +++ b/src/tests/comparison/ComparisonChangeList.spec.ts @@ -0,0 +1,173 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComparisonDescriptor, ComparisonEdit } from '../../comparison/markdownComparisonTypes.ts' + +import * as l10n from '@nextcloud/l10n' +import { mount } from '@vue/test-utils' +import { schema } from 'prosemirror-schema-basic' +import { describe, expect, it, vi } from 'vitest' +import ComparisonChangeList from '../../components/ComparisonChangeList.vue' + +function descriptor(id: string, text: string, facets: ComparisonDescriptor['facets'] = ['text']): ComparisonDescriptor { + return { + id, + operation: 'replace', + detail: 'inline', + facets, + before: { from: 0, to: 1 }, + after: { from: 0, to: 1 }, + context: { before: null, after: null }, + preview: { before: { kind: 'text', text }, after: { kind: 'text', text } }, + signals: [], + } +} + +const document = schema.node('doc', null, [schema.node('paragraph', null, schema.text('Body'))]) + +describe('ComparisonChangeList', () => { + it('AUD-14 renders only the bounded page containing a high-cardinality current edit', () => { + const edits = Array.from({ length: 10_000 }, (_value, index): ComparisonEdit => { + const primary = descriptor(`member-${index}`, `preview-${index}`) + return { id: `edit-${index}`, kind: 'content', primary, descriptors: [primary] } + }) + const wrapper = mount(ComparisonChangeList, { + props: { edits, currentId: 'edit-9999', beforeDocument: document, afterDocument: document }, + }) + const rows = wrapper.findAll('[data-comparison-select]') + + expect(rows).toHaveLength(80) + expect(rows[0]!.attributes('data-comparison-select')).toBe('edit-9920') + expect(rows.at(-1)!.attributes('data-comparison-select')).toBe('edit-9999') + expect(wrapper.find('[data-comparison-select="edit-9999"]').attributes('aria-current')).toBe('true') + }) + + it('preserves the current change label when paging away from its row', async () => { + const edits = Array.from({ length: 160 }, (_value, index): ComparisonEdit => { + const primary = descriptor(`member-${index}`, `preview-${index}`) + return { id: `edit-${index}`, kind: 'content', primary, descriptors: [primary] } + }) + const wrapper = mount(ComparisonChangeList, { + props: { edits, currentId: 'edit-159', beforeDocument: document, afterDocument: document }, + }) + const initialLabel = wrapper.emitted('currentLabel')!.at(-1)![0] + + await wrapper.findAll('.text-comparison__change-pages button')[0]!.trigger('click') + + expect(wrapper.findAll('[data-comparison-select]')[0]!.attributes('data-comparison-select')).toBe('edit-0') + expect(wrapper.find('[aria-current="true"]').exists()).toBe(false) + expect(wrapper.emitted('currentLabel')!.at(-1)![0]).toBe(initialLabel) + expect(initialLabel).not.toBe('') + }) + + it('AUD-14 unmounts records in collapsed sections', async () => { + const primary = descriptor('member', 'preview') + const edit: ComparisonEdit = { id: 'edit', kind: 'content', primary, descriptors: [primary] } + const wrapper = mount(ComparisonChangeList, { + props: { edits: [edit], beforeDocument: document, afterDocument: document }, + }) + + await wrapper.get('.text-comparison__section-toggle').trigger('click') + + expect(wrapper.findAll('[data-comparison-select]')).toHaveLength(0) + }) + + it('translates only the selected lookup entry for each record', () => { + const translate = vi.spyOn(l10n, 't') + const primary: ComparisonDescriptor = { + ...descriptor('member', 'preview', ['attribute']), + signals: [{ type: 'attribute', attribute: 'link', change: 'changed' }], + } + const edit: ComparisonEdit = { id: 'edit', kind: 'content', primary, descriptors: [primary] } + + mount(ComparisonChangeList, { + props: { edits: [edit], beforeDocument: document, afterDocument: document }, + }) + + const messages = translate.mock.calls.map(([, message]) => message) + expect(messages).toContain('Link changed') + expect(messages).not.toContain('Heading level changed') + translate.mockRestore() + }) + + it('V01 renders one row per first-class edit using explicit primary and all member descriptors', async () => { + const first = descriptor('member-first', 'wrong', ['formatting']) + const primary = descriptor('member-primary', 'primary') + const edit: ComparisonEdit = { id: 'edit-row', kind: 'content', primary, descriptors: [first, primary] } + const wrapper = mount(ComparisonChangeList, { + props: { edits: [edit], currentId: 'edit-row', beforeDocument: document, afterDocument: document }, + }) + const rows = wrapper.findAll('[data-comparison-select]') + expect(rows).toHaveLength(1) + expect(rows[0]!.attributes('data-comparison-select')).toBe('edit-row') + expect(rows[0]!.find('.text-comparison__change-item-content').exists()).toBe(true) + expect(rows[0]!.text()).toContain('primary') + expect(rows[0]!.text()).toContain('2 edits') + await rows[0]!.trigger('click') + expect(wrapper.emitted('select')).toEqual([['edit-row']]) + }) + + it('renders explicit before and after previews for a replacement', () => { + const primary: ComparisonDescriptor = { + ...descriptor('member', 'unused'), + preview: { + before: { kind: 'text', text: 'before text' }, + after: { kind: 'text', text: 'after text' }, + }, + } + const edit: ComparisonEdit = { id: 'edit', kind: 'content', primary, descriptors: [primary] } + const wrapper = mount(ComparisonChangeList, { + props: { edits: [edit], beforeDocument: document, afterDocument: document }, + }) + + expect(wrapper.get('del.text-comparison__preview-before').text()).toBe('before text') + expect(wrapper.get('ins.text-comparison__preview-after').text()).toBe('after text') + }) + + it('labels a coarse composite change by its structural scope instead of an incidental task attribute', () => { + const primary: ComparisonDescriptor = { + ...descriptor('member', 'nested content', ['text', 'attribute', 'structure']), + coarseReason: 'ambiguous-attribution', + signals: [ + { type: 'attribute', attribute: 'task-state', change: 'changed' }, + { type: 'node' }, + ], + } + const edit: ComparisonEdit = { id: 'edit', kind: 'content', primary, descriptors: [primary] } + const wrapper = mount(ComparisonChangeList, { + props: { edits: [edit], beforeDocument: document, afterDocument: document }, + }) + + expect(wrapper.get('[data-comparison-select]').text()).toContain('Structure changed') + expect(wrapper.get('[data-comparison-select]').text()).not.toContain('Task state changed') + }) + + it.each([ + ['insert', 'inline', 'Paragraph changed', 'Paragraph added'], + ['delete', 'inline', 'Paragraph changed', 'Paragraph removed'], + ['insert', 'block', 'Paragraph added', 'Paragraph changed'], + ['delete', 'block', 'Paragraph removed', 'Paragraph changed'], + ] as const)('labels a %s %s operation at the correct altitude', (operation, detail, expected, rejected) => { + const primary: ComparisonDescriptor = { + ...descriptor('member', 's'), + operation, + detail, + context: { + before: { code: 'paragraph', path: [], from: 0, to: 1 }, + after: { code: 'paragraph', path: [], from: 0, to: 1 }, + }, + preview: operation === 'insert' + ? { before: null, after: { kind: 'text', text: 's' } } + : { before: { kind: 'text', text: 's' }, after: null }, + } + const edit: ComparisonEdit = { id: 'edit', kind: 'content', primary, descriptors: [primary] } + const wrapper = mount(ComparisonChangeList, { + props: { edits: [edit], beforeDocument: document, afterDocument: document }, + }) + + expect(wrapper.get('[data-comparison-select]').text()).toContain(expected) + expect(wrapper.get('[data-comparison-select]').text()).not.toContain(rejected) + }) +}) diff --git a/src/tests/comparison/comparisonPresentation.spec.ts b/src/tests/comparison/comparisonPresentation.spec.ts new file mode 100644 index 00000000000..92be57d563f --- /dev/null +++ b/src/tests/comparison/comparisonPresentation.spec.ts @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComparisonAttributeCode, ComparisonSignal } from '../../comparison/markdownComparisonTypes.ts' + +import { describe, expect, it } from 'vitest' +import { selectComparisonSignal } from '../../comparison/comparisonPresentation.ts' + +const bold: ComparisonSignal = { type: 'mark', mark: 'bold', change: 'added' } +const attribute = (code: ComparisonAttributeCode): ComparisonSignal => ({ type: 'attribute', attribute: code, change: 'changed' }) + +describe('AUD-10 comparison presentation', () => { + it('selects the same strongest attribute regardless of descriptor storage order', () => { + const signals = [bold, attribute('image-alt'), attribute('image-target')] + expect(selectComparisonSignal(signals)).toEqual(attribute('image-target')) + expect(selectComparisonSignal([...signals].reverse())).toEqual(attribute('image-target')) + }) + + it('prioritizes every specific attribute and node structure over generic formatting', () => { + for (const code of ['link', 'task-state', 'heading-level', 'table-span', 'unknown-attribute'] as const) { + expect(selectComparisonSignal([bold, attribute(code)])).toEqual(attribute(code)) + } + const node: ComparisonSignal = { type: 'node' } + expect(selectComparisonSignal([bold, node])).toEqual(node) + }) +}) diff --git a/src/tests/comparison/comparisonSections.spec.ts b/src/tests/comparison/comparisonSections.spec.ts new file mode 100644 index 00000000000..2dd787ba6f4 --- /dev/null +++ b/src/tests/comparison/comparisonSections.spec.ts @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { ComparisonDescriptor, ComparisonEdit } from '../../comparison/markdownComparisonTypes.ts' + +import { schema } from 'prosemirror-schema-basic' +import { describe, expect, it } from 'vitest' +import { buildComparisonSections, headingLocations, nearestHeading } from '../../comparison/comparisonSections.ts' + +function doc(...blocks: Array<[type: 'heading' | 'paragraph', text: string]>): Node { + return schema.node('doc', null, blocks.map(([type, text]) => schema.node(type, type === 'heading' ? { level: 1 } : null, text ? schema.text(text) : undefined))) +} + +function descriptor(id: string, operation: ComparisonDescriptor['operation'], before: number, after: number): ComparisonDescriptor { + return { + id, + operation, + detail: 'inline', + facets: ['text'], + before: { from: before, to: before + 1 }, + after: { from: after, to: after + 1 }, + context: { + before: { code: 'paragraph', path: [1], from: before, to: before + 1 }, + after: { code: 'paragraph', path: [1], from: after, to: after + 1 }, + }, + preview: { before: null, after: null }, + signals: [], + } +} + +function edit(id: string, primary: ComparisonDescriptor, members: ComparisonDescriptor[] = [primary]): ComparisonEdit { + return { id, kind: 'content', primary, descriptors: members } +} + +describe('edit-first comparison sections', () => { + it('finds ordered headings and resolves the nearest preceding heading', () => { + const document = doc(['paragraph', 'Intro'], ['heading', 'One'], ['paragraph', 'Body'], ['heading', 'Two']) + const headings = headingLocations(document) + expect(headings.map(({ text }) => text)).toEqual(['One', 'Two']) + expect(nearestHeading(headings, 0)).toBe('') + expect(nearestHeading(headings, headings[1]!.from)).toBe('Two') + }) + + it('builds one row source per edit', () => { + const before = doc(['heading', 'Alpha'], ['paragraph', 'Before']) + const after = doc(['heading', 'Alpha'], ['paragraph', 'After']) + const headingEnd = after.child(0).nodeSize + const primary = descriptor('d-primary', 'replace', headingEnd, headingEnd) + const formatting = { ...descriptor('d-format', 'replace', headingEnd, headingEnd), facets: ['formatting'] as const } + const sections = buildComparisonSections([edit('edit-1', primary, [formatting, primary])], before, after) + + expect(sections).toEqual([expect.objectContaining({ + id: 'edit-1', + title: 'Alpha', + edits: [expect.objectContaining({ id: 'edit-1', primary })], + })]) + }) + + it('uses Before context for deletions and the After title for a correlated rename', () => { + const before = doc(['heading', 'Old'], ['paragraph', 'Before']) + const after = doc(['heading', 'New'], ['paragraph', 'After']) + const beforeBody = before.child(0).nodeSize + const afterBody = after.child(0).nodeSize + const rename = descriptor('rename', 'replace', 0, 0) + rename.context.before = { code: 'heading', path: [0], from: 0, to: beforeBody } + rename.context.after = { code: 'heading', path: [0], from: 0, to: afterBody } + const deletion = descriptor('delete', 'delete', beforeBody, afterBody) + const sections = buildComparisonSections([edit('rename-edit', rename), edit('delete-edit', deletion)], before, after) + + expect(sections.map(({ title }) => title)).toEqual(['New']) + expect(sections[0]!.edits.map(({ id }) => id)).toEqual(['rename-edit', 'delete-edit']) + }) +})