Skip to content

Commit 28da408

Browse files
committed
feat(comparison): present semantic changes
Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: Hoang Pham <hoangmaths96@gmail.com>
1 parent c599581 commit 28da408

6 files changed

Lines changed: 972 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { ComparisonAttributeCode as AttributeCode, ComparisonMarkCode as MarkCode, ComparisonSignal as Signal } from './markdownComparisonTypes.ts'
7+
8+
import { t } from '@nextcloud/l10n'
9+
10+
type Label = () => string
11+
const attributes: Record<AttributeCode, readonly [number, Label]> = {
12+
'image-target': [219, () => t('text', 'Image changed')],
13+
'image-alt': [218, () => t('text', 'Image description changed')],
14+
'link-target': [217, () => t('text', 'Link target changed')],
15+
link: [216, () => t('text', 'Link changed')],
16+
'mention-identity': [215, () => t('text', 'Mention changed')],
17+
mathematics: [214, () => t('text', 'Mathematics changed')],
18+
'preview-target': [213, () => t('text', 'Link preview changed')],
19+
'footnote-reference': [212, () => t('text', 'Footnote changed')],
20+
'task-state': [211, () => t('text', 'Task state changed')],
21+
'heading-level': [210, () => t('text', 'Heading level changed')],
22+
'list-start': [209, () => t('text', 'List start changed')],
23+
'code-language': [208, () => t('text', 'Code language changed')],
24+
'text-direction': [207, () => t('text', 'Text direction changed')],
25+
'table-span': [206, () => t('text', 'Table structure changed')],
26+
'table-alignment': [205, () => t('text', 'Table alignment changed')],
27+
'callout-type': [204, () => t('text', 'Callout type changed')],
28+
'details-state': [203, () => t('text', 'Details state changed')],
29+
'unknown-attribute': [202, () => t('text', 'Attribute changed')],
30+
}
31+
32+
const marks: Record<MarkCode, readonly [number, Label]> = {
33+
bold: [106, () => t('text', 'Bold')],
34+
italic: [105, () => t('text', 'Italic')],
35+
strike: [104, () => t('text', 'Strikethrough')],
36+
highlight: [103, () => t('text', 'Highlight')],
37+
underline: [102, () => t('text', 'Underline')],
38+
'inline-code': [101, () => t('text', 'Inline code')],
39+
}
40+
41+
export function selectComparisonSignal(signals: readonly Signal[]): Signal | undefined {
42+
return signals.reduce<Signal | undefined>((selected, signal) => (
43+
!selected || signalPriority(signal) > signalPriority(selected) ? signal : selected
44+
), undefined)
45+
}
46+
47+
export function comparisonSignalLabel(signal: Signal) {
48+
if (signal.type === 'attribute') {
49+
return attributes[signal.attribute][1]()
50+
}
51+
if (signal.type === 'mark') {
52+
return t('text', '{formatting} changed', { formatting: marks[signal.mark][1]() })
53+
}
54+
}
55+
56+
function signalPriority(signal: Signal) {
57+
if (signal.type === 'attribute') {
58+
return attributes[signal.attribute][0]
59+
}
60+
if (signal.type === 'mark') {
61+
return marks[signal.mark][0]
62+
}
63+
return 150
64+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { Node } from '@tiptap/pm/model'
7+
import type { ComparisonEdit } from './markdownComparisonTypes.ts'
8+
9+
import { increasingSubsequence } from './comparisonAlignment.ts'
10+
11+
export interface ComparisonHeading {
12+
from: number
13+
text: string
14+
}
15+
16+
export interface ComparisonSection {
17+
id: string
18+
title: string
19+
edits: readonly ComparisonEdit[]
20+
}
21+
type Heading = ComparisonHeading
22+
23+
export function headingLocations(doc: Node): readonly Heading[] {
24+
const headings: Heading[] = []
25+
doc.forEach((node, from) => {
26+
const text = node.textContent.trim()
27+
if (node.type.name === 'heading' && text) {
28+
headings.push({ from, text })
29+
}
30+
})
31+
return headings
32+
}
33+
34+
function nearestHeadingIndex(headings: readonly Heading[], position: number) {
35+
let lower = 0
36+
let upper = headings.length
37+
while (lower < upper) {
38+
const middle = Math.floor((lower + upper) / 2)
39+
if (headings[middle]!.from <= position) {
40+
lower = middle + 1
41+
} else {
42+
upper = middle
43+
}
44+
}
45+
return lower - 1
46+
}
47+
export function nearestHeading(headings: readonly Heading[], position: number) {
48+
return headings[nearestHeadingIndex(headings, position)]?.text ?? ''
49+
}
50+
51+
interface HeadingIndex {
52+
headings: readonly Heading[]
53+
keys: readonly string[]
54+
}
55+
56+
function indexByUniqueTitle(headings: readonly Heading[]) {
57+
const indexes = new Map<string, number>()
58+
const repeated = new Set<string>()
59+
headings.forEach(({ text }, index) => {
60+
if (indexes.has(text)) {
61+
repeated.add(text)
62+
} else {
63+
indexes.set(text, index)
64+
}
65+
})
66+
for (const text of repeated) {
67+
indexes.delete(text)
68+
}
69+
return indexes
70+
}
71+
72+
function headingAnchors(before: readonly Heading[], after: readonly Heading[]) {
73+
const beforeIndexes = indexByUniqueTitle(before)
74+
const afterIndexes = indexByUniqueTitle(after)
75+
const pairs: Array<readonly [number, number]> = []
76+
after.forEach(({ text }, afterIndex) => {
77+
const beforeIndex = beforeIndexes.get(text)
78+
if (beforeIndex !== undefined && afterIndexes.get(text) === afterIndex) {
79+
pairs.push([beforeIndex, afterIndex])
80+
}
81+
})
82+
return increasingSubsequence(pairs.map(([index]) => index)).indices.map((index) => pairs[index]!)
83+
}
84+
85+
function correlateHeadings(before: readonly Heading[], after: readonly Heading[]) {
86+
const beforeKeys: string[] = []
87+
const afterKeys: string[] = []
88+
let next = 0
89+
let row = 0
90+
let column = 0
91+
92+
function pairGap(rowEnd: number, columnEnd: number) {
93+
const rowCount = rowEnd - row
94+
const columnCount = columnEnd - column
95+
if (rowCount !== columnCount || rowCount > 1) {
96+
while (row < rowEnd) {
97+
beforeKeys[row++] = `#${next++}`
98+
}
99+
while (column < columnEnd) {
100+
afterKeys[column++] = `#${next++}`
101+
}
102+
return
103+
}
104+
while (row < rowEnd) {
105+
const key = `#${next++}`
106+
beforeKeys[row++] = key
107+
afterKeys[column++] = key
108+
}
109+
}
110+
111+
for (const [anchorRow, anchorColumn] of headingAnchors(before, after)) {
112+
pairGap(anchorRow, anchorColumn)
113+
const key = `#${next++}`
114+
beforeKeys[row++] = key
115+
afterKeys[column++] = key
116+
}
117+
pairGap(before.length, after.length)
118+
return { before: beforeKeys, after: afterKeys }
119+
}
120+
121+
function resolveSection(edit: ComparisonEdit, before: HeadingIndex, after: HeadingIndex) {
122+
const descriptor = edit.primary
123+
const deleted = descriptor.operation === 'delete'
124+
const side = deleted ? before : after
125+
const position = deleted
126+
? descriptor.context.before?.from ?? descriptor.before.from
127+
: descriptor.context.after?.from ?? descriptor.after.from
128+
return side.keys[nearestHeadingIndex(side.headings, position)] ?? ''
129+
}
130+
131+
export function buildComparisonSections(edits: readonly ComparisonEdit[], beforeDocument: Node, afterDocument: Node): readonly ComparisonSection[] {
132+
const beforeHeadings = headingLocations(beforeDocument)
133+
const afterHeadings = headingLocations(afterDocument)
134+
const correlation = correlateHeadings(beforeHeadings, afterHeadings)
135+
const before: HeadingIndex = { headings: beforeHeadings, keys: correlation.before }
136+
const after: HeadingIndex = { headings: afterHeadings, keys: correlation.after }
137+
const titleByKey = new Map<string, string>()
138+
beforeHeadings.forEach((heading, index) => titleByKey.set(correlation.before[index]!, heading.text))
139+
afterHeadings.forEach((heading, index) => titleByKey.set(correlation.after[index]!, heading.text))
140+
141+
const sections: Array<{ id: string, key: string, title: string, edits: ComparisonEdit[] }> = []
142+
for (const edit of edits) {
143+
const key = resolveSection(edit, before, after)
144+
const title = titleByKey.get(key) ?? ''
145+
const current = sections.at(-1)
146+
if (current?.key === key) {
147+
current.edits.push(edit)
148+
} else {
149+
sections.push({ id: edit.id, key, title, edits: [edit] })
150+
}
151+
}
152+
return sections.map(({ id, title, edits: sectionEdits }) => ({
153+
id,
154+
title,
155+
edits: sectionEdits,
156+
}))
157+
}

0 commit comments

Comments
 (0)