Skip to content

Commit a4614b7

Browse files
committed
feat(comparison): present a semantic changelog
Expose the Text comparison factory and render grouped changes by document section. Preserve every changed-only preview in multi-edit blocks with semantic deletion and insertion markup. Assisted-by: Codex:gpt-5.6-sol Signed-off-by: Hoang Pham <hoangmaths96@gmail.com>
1 parent 802f3e4 commit a4614b7

11 files changed

Lines changed: 2141 additions & 1 deletion
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { ComparisonDescriptor } from './markdownComparisonTypes.ts'
7+
8+
export interface ComparisonDescriptorGroup {
9+
id: string
10+
descriptors: readonly ComparisonDescriptor[]
11+
}
12+
13+
/**
14+
* Reader-facing change kinds.
15+
*
16+
* `facets` overlap by design, so they cannot be presented as filters directly.
17+
* These four kinds are assigned by `comparisonChangeKind`, which is total and
18+
* disjoint: every descriptor resolves to exactly one kind, so kind counts
19+
* always sum to the descriptor count.
20+
*/
21+
export type ComparisonChangeKind = 'content' | 'formatting' | 'move' | 'other'
22+
23+
/** Stable presentation order for change kinds. */
24+
export const COMPARISON_CHANGE_KINDS: readonly ComparisonChangeKind[] = ['content', 'formatting', 'move', 'other']
25+
26+
/**
27+
* Check whether a descriptor contains only formatting changes.
28+
*
29+
* @param descriptor Semantic comparison descriptor
30+
*/
31+
export function isPureFormatting(descriptor: ComparisonDescriptor) {
32+
return descriptor.facets.length === 1 && descriptor.facets[0] === 'formatting'
33+
}
34+
35+
/**
36+
* Resolve the single reader-facing kind of one descriptor.
37+
*
38+
* Ordered so that the strongest claim wins: a relocation is a move even when
39+
* its text also changed, and anything touching words or document structure is
40+
* content even when it also changed formatting. Everything the reader cannot
41+
* act on as text — attribute-only edits and unclassified changes — is `other`.
42+
*
43+
* @param descriptor Semantic change
44+
*/
45+
export function comparisonChangeKind(descriptor: ComparisonDescriptor): ComparisonChangeKind {
46+
if (descriptor.operation === 'move') {
47+
return 'move'
48+
}
49+
if (isPureFormatting(descriptor)) {
50+
return 'formatting'
51+
}
52+
if (descriptor.facets.includes('text') || descriptor.facets.includes('structure')) {
53+
return 'content'
54+
}
55+
return 'other'
56+
}
57+
58+
/**
59+
* Select visible descriptor IDs for the active filter.
60+
*
61+
* @param descriptors Semantic comparison descriptors in source order
62+
* @param hidePureFormatting Whether to omit formatting-only descriptors
63+
*/
64+
export function visibleDescriptorIds(
65+
descriptors: readonly ComparisonDescriptor[],
66+
hidePureFormatting: boolean,
67+
) {
68+
return descriptors
69+
.filter((descriptor) => !hidePureFormatting || !isPureFormatting(descriptor))
70+
.map(({ id }) => id)
71+
}
72+
73+
/**
74+
* Present multiple algorithm ranges in one semantic block as one human edit.
75+
* Exact moves remain standalone because one move can span several blocks.
76+
*
77+
* @param descriptors Visible descriptors in source order
78+
*/
79+
export function groupComparisonDescriptors(descriptors: readonly ComparisonDescriptor[]) {
80+
const groups: ComparisonDescriptorGroup[] = []
81+
const groupIndexByContext = new Map<string, number>()
82+
for (const descriptor of descriptors) {
83+
const key = descriptor.operation === 'move' ? descriptor.id : descriptorContextKey(descriptor)
84+
const existingIndex = groupIndexByContext.get(key)
85+
if (existingIndex !== undefined) {
86+
const existing = groups[existingIndex]!
87+
groups[existingIndex] = { ...existing, descriptors: [...existing.descriptors, descriptor] }
88+
} else {
89+
const group = { id: descriptor.id, descriptors: [descriptor] }
90+
groups.push(group)
91+
groupIndexByContext.set(key, groups.length - 1)
92+
}
93+
}
94+
return groups
95+
}
96+
97+
/** @param descriptor Semantic descriptor */
98+
function descriptorContextKey(descriptor: ComparisonDescriptor) {
99+
const path = (side: 'before' | 'after') => descriptor.context[side]?.path.join('.') ?? '-'
100+
return `${path('before')}|${path('after')}`
101+
}
102+
103+
/**
104+
* Preserve current ID or choose the next, then previous, descriptor in full-model order.
105+
*
106+
* @param descriptors Semantic comparison descriptors in source order
107+
* @param activeIds Visible descriptor IDs
108+
* @param currentId Selected descriptor ID
109+
*/
110+
export function currentIdAfterFilter(
111+
descriptors: readonly ComparisonDescriptor[],
112+
activeIds: readonly string[],
113+
currentId: string | null,
114+
) {
115+
const active = new Set(activeIds)
116+
if (currentId && active.has(currentId)) {
117+
return currentId
118+
}
119+
if (active.size === 0) {
120+
return null
121+
}
122+
const currentIndex = descriptors.findIndex(({ id }) => id === currentId)
123+
if (currentIndex >= 0) {
124+
for (let index = currentIndex + 1; index < descriptors.length; index++) {
125+
if (active.has(descriptors[index]!.id)) {
126+
return descriptors[index]!.id
127+
}
128+
}
129+
for (let index = currentIndex - 1; index >= 0; index--) {
130+
if (active.has(descriptors[index]!.id)) {
131+
return descriptors[index]!.id
132+
}
133+
}
134+
}
135+
return descriptors.find(({ id }) => active.has(id))?.id ?? null
136+
}
137+
138+
/**
139+
* Move the current selection through visible descriptor IDs.
140+
*
141+
* @param activeIds Visible descriptor IDs
142+
* @param currentId Selected descriptor ID
143+
* @param offset Signed navigation offset
144+
*/
145+
export function moveCurrentId(activeIds: readonly string[], currentId: string | null, offset: number) {
146+
if (activeIds.length === 0) {
147+
return null
148+
}
149+
const current = Math.max(0, activeIds.indexOf(currentId ?? ''))
150+
const next = ((current + offset) % activeIds.length + activeIds.length) % activeIds.length
151+
return activeIds[next]!
152+
}
153+
154+
/**
155+
* Resolve the one-based ordinal of the current descriptor.
156+
*
157+
* @param activeIds Visible descriptor IDs
158+
* @param currentId Selected descriptor ID
159+
*/
160+
export function currentOrdinal(activeIds: readonly string[], currentId: string | null) {
161+
const index = currentId ? activeIds.indexOf(currentId) : -1
162+
return index < 0 ? 0 : index + 1
163+
}
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 {
7+
ComparisonAttributeCode,
8+
ComparisonMarkCode,
9+
ComparisonSignal,
10+
} from './markdownComparisonTypes.ts'
11+
12+
const attributePriority: Record<ComparisonAttributeCode, number> = {
13+
'image-target': 219,
14+
'image-alt': 218,
15+
'link-target': 217,
16+
link: 216,
17+
'mention-identity': 215,
18+
mathematics: 214,
19+
'preview-target': 213,
20+
'footnote-reference': 212,
21+
'task-state': 211,
22+
'heading-level': 210,
23+
'list-start': 209,
24+
'code-language': 208,
25+
'text-direction': 207,
26+
'table-span': 206,
27+
'table-alignment': 205,
28+
'callout-type': 204,
29+
'details-state': 203,
30+
'unknown-attribute': 202,
31+
}
32+
33+
const markPriority: Record<ComparisonMarkCode, number> = {
34+
bold: 106,
35+
italic: 105,
36+
strike: 104,
37+
highlight: 103,
38+
underline: 102,
39+
'inline-code': 101,
40+
}
41+
42+
/**
43+
* Select the user-facing semantic signal independently of storage order.
44+
*
45+
* @param signals Canonically stored descriptor signals
46+
*/
47+
export function selectComparisonSignal(signals: readonly ComparisonSignal[]): ComparisonSignal | undefined {
48+
return signals.reduce<ComparisonSignal | undefined>((selected, signal) => {
49+
return !selected || signalPriority(signal) > signalPriority(selected) ? signal : selected
50+
}, undefined)
51+
}
52+
53+
/**
54+
* @param signal Comparison signal
55+
*/
56+
function signalPriority(signal: ComparisonSignal) {
57+
if (signal.type === 'attribute') {
58+
return attributePriority[signal.attribute]
59+
}
60+
if (signal.type === 'mark') {
61+
return markPriority[signal.mark]
62+
}
63+
return 150
64+
}

0 commit comments

Comments
 (0)