Skip to content

Commit 1fd76d9

Browse files
committed
fix(document): restore arbitrary metadata when reverting versions
Move restore metadata diffing into document change generation so nested fields, integer values, and removed attributes are restored from the selected version.
1 parent 2281e25 commit 1fd76d9

3 files changed

Lines changed: 120 additions & 133 deletions

File tree

frontend/packages/shared/src/utils/document-changes.ts

Lines changed: 80 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {Empty} from '@bufbuild/protobuf'
12
import {EditorBlock} from '@seed-hypermedia/client/editor-types'
23
import {editorBlockToHMBlock} from '@seed-hypermedia/client/editorblock-to-hmblock'
34
import {HMBlock, HMBlockNode, HMMetadata, HMQuery} from '@seed-hypermedia/client/hm-types'
@@ -16,35 +17,57 @@ export type BlocksMapItem = {
1617
block: HMBlock
1718
}
1819

19-
export function getDocAttributeChanges(metadata: HMMetadata) {
20-
const changes = []
21-
if (metadata.name !== undefined) changes.push(docAttributeChangeString(['name'], metadata.name))
22-
if (metadata.summary !== undefined) changes.push(docAttributeChangeString(['summary'], metadata.summary))
23-
if (metadata.icon !== undefined) changes.push(docAttributeChangeString(['icon'], metadata.icon))
24-
if (metadata.thumbnail !== undefined) changes.push(docAttributeChangeString(['thumbnail'], metadata.thumbnail))
25-
if (metadata.cover !== undefined) changes.push(docAttributeChangeString(['cover'], metadata.cover))
26-
if (metadata.siteUrl !== undefined) changes.push(docAttributeChangeString(['siteUrl'], metadata.siteUrl))
27-
if (metadata.layout !== undefined) changes.push(docAttributeChangeString(['layout'], metadata.layout))
28-
if (metadata.displayAuthor !== undefined)
29-
changes.push(docAttributeChangeString(['displayAuthor'], metadata.displayAuthor))
30-
if (metadata.displayPublishTime !== undefined)
31-
changes.push(docAttributeChangeString(['displayPublishTime'], metadata.displayPublishTime))
32-
if (metadata.seedExperimentalLogo !== undefined)
33-
changes.push(docAttributeChangeString(['seedExperimentalLogo'], metadata.seedExperimentalLogo))
34-
if (metadata.seedExperimentalHomeOrder !== undefined)
35-
changes.push(docAttributeChangeString(['seedExperimentalHomeOrder'], metadata.seedExperimentalHomeOrder))
36-
if (metadata.showOutline !== undefined) changes.push(docAttributeChangeBool(['showOutline'], metadata.showOutline))
37-
if (metadata.theme !== undefined) {
38-
if (metadata.theme.headerLayout !== undefined)
39-
changes.push(docAttributeChangeString(['theme', 'headerLayout'], metadata.theme.headerLayout))
20+
export function getDocAttributeChanges(metadata: HMMetadata, baseMetadata?: HMMetadata) {
21+
return getAttributeChangesForObject(
22+
metadata as Record<string, unknown>,
23+
baseMetadata as Record<string, unknown> | undefined,
24+
)
25+
}
26+
27+
function getAttributeChangesForObject(
28+
metadata: Record<string, unknown>,
29+
baseMetadata: Record<string, unknown> | undefined,
30+
) {
31+
const changes: DocumentChange[] = []
32+
const keys = new Set([...Object.keys(baseMetadata ?? {}), ...Object.keys(metadata ?? {})])
33+
for (const key of Array.from(keys)) {
34+
pushAttributeChanges(changes, [key], metadata?.[key], baseMetadata?.[key])
35+
}
36+
return changes
37+
}
38+
39+
function pushAttributeChanges(changes: DocumentChange[], key: string[], value: unknown, baseValue: unknown) {
40+
if (isPlainObject(value) || isPlainObject(baseValue)) {
41+
const valueObj = isPlainObject(value) ? value : undefined
42+
const baseObj = isPlainObject(baseValue) ? baseValue : undefined
43+
const keys = new Set([...Object.keys(baseObj ?? {}), ...Object.keys(valueObj ?? {})])
44+
for (const childKey of Array.from(keys)) {
45+
pushAttributeChanges(changes, [...key, childKey], valueObj?.[childKey], baseObj?.[childKey])
46+
}
47+
return
4048
}
41-
if (metadata.contentWidth !== undefined) {
42-
changes.push(docAttributeChangeString(['contentWidth'], metadata.contentWidth))
49+
50+
if (baseValue !== undefined && value === undefined) {
51+
changes.push(docAttributeChangeNull(key))
52+
return
4353
}
44-
if (metadata.showActivity !== undefined) {
45-
changes.push(docAttributeChangeBool(['showActivity'], metadata.showActivity))
54+
if (baseValue !== undefined && value === baseValue) return
55+
56+
if (typeof value === 'string') {
57+
changes.push(docAttributeChangeString(key, value))
58+
} else if (typeof value === 'boolean') {
59+
changes.push(docAttributeChangeBool(key, value))
60+
} else if (typeof value === 'number' && Number.isInteger(value)) {
61+
changes.push(docAttributeChangeInt(key, value))
62+
} else if (typeof value === 'bigint') {
63+
changes.push(docAttributeChangeInt(key, value))
64+
} else if (value === null) {
65+
changes.push(docAttributeChangeNull(key))
4666
}
47-
return changes
67+
}
68+
69+
function isPlainObject(value: unknown): value is Record<string, unknown> {
70+
return !!value && typeof value === 'object' && !Array.isArray(value)
4871
}
4972

5073
type PrimitiveValue = string | number | boolean | null | undefined
@@ -75,21 +98,37 @@ function docAttributeChangeString(key: string[], value: string) {
7598
},
7699
})
77100
}
78-
// function docAttributeChangeInt(key: string[], value: number) {
79-
// return new DocumentChange({
80-
// op: {
81-
// case: 'setAttribute',
82-
// value: new DocumentChange_SetAttribute({
83-
// blockId: '',
84-
// key,
85-
// value: {
86-
// case: 'intValue',
87-
// value: BigInt(value),
88-
// },
89-
// }),
90-
// },
91-
// })
92-
// }
101+
function docAttributeChangeInt(key: string[], value: number | bigint) {
102+
return new DocumentChange({
103+
op: {
104+
case: 'setAttribute',
105+
value: new DocumentChange_SetAttribute({
106+
blockId: '',
107+
key,
108+
value: {
109+
case: 'intValue',
110+
value: BigInt(value),
111+
},
112+
}),
113+
},
114+
})
115+
}
116+
117+
function docAttributeChangeNull(key: string[]) {
118+
return new DocumentChange({
119+
op: {
120+
case: 'setAttribute',
121+
value: new DocumentChange_SetAttribute({
122+
blockId: '',
123+
key,
124+
value: {
125+
case: 'nullValue',
126+
value: new Empty(),
127+
},
128+
}),
129+
},
130+
})
131+
}
93132
function docAttributeChangeBool(key: string[], value: boolean) {
94133
return new DocumentChange({
95134
op: {

frontend/packages/shared/src/utils/restore-document-version.test.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type {HMBlockNode, HMDocument} from '@seed-hypermedia/client/hm-types'
22
import {describe, expect, it} from 'vitest'
3-
import {buildRestoreMetadataChanges, buildRestoreVersionChanges} from './restore-document-version'
3+
import {buildRestoreVersionChanges} from './restore-document-version'
44

55
function node(id: string, text: string): HMBlockNode {
66
return {
@@ -43,14 +43,44 @@ describe('buildRestoreVersionChanges', () => {
4343
expect(changes[1]?.op.case === 'deleteBlock' ? changes[1].op.value : null).toBe('b')
4444
})
4545

46-
it('restores metadata values and removes fields missing from selected version', () => {
47-
const changes = buildRestoreMetadataChanges(
48-
{name: 'Latest', summary: 'remove me', showOutline: true, theme: {headerLayout: 'Center'}},
49-
{name: 'Old', showOutline: false},
46+
it('restores arbitrary metadata values and removes fields missing from selected version', () => {
47+
const changes = buildRestoreVersionChanges(
48+
doc({
49+
metadata: {
50+
name: 'Latest',
51+
summary: 'remove me',
52+
showOutline: true,
53+
theme: {headerLayout: 'Center'},
54+
custom: {count: 2, stale: 'yes'},
55+
} as any,
56+
}),
57+
doc({
58+
metadata: {
59+
name: 'Old',
60+
showOutline: false,
61+
custom: {count: 3, label: 'restored'},
62+
} as any,
63+
}),
5064
)
5165

5266
const attrs = changes.map((change) => (change.op.case === 'setAttribute' ? change.op.value : null))
53-
expect(attrs.map((attr) => attr?.key.join('.'))).toEqual(['name', 'summary', 'showOutline', 'theme.headerLayout'])
54-
expect(attrs.map((attr) => attr?.value.case)).toEqual(['stringValue', 'nullValue', 'boolValue', 'nullValue'])
67+
expect(attrs.map((attr) => attr?.key.join('.'))).toEqual([
68+
'name',
69+
'summary',
70+
'showOutline',
71+
'theme.headerLayout',
72+
'custom.count',
73+
'custom.stale',
74+
'custom.label',
75+
])
76+
expect(attrs.map((attr) => attr?.value.case)).toEqual([
77+
'stringValue',
78+
'nullValue',
79+
'boolValue',
80+
'nullValue',
81+
'intValue',
82+
'nullValue',
83+
'stringValue',
84+
])
5585
})
5686
})
Lines changed: 3 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,7 @@
11
import {hmBlocksToEditorContent} from '@seed-hypermedia/client/hmblock-to-editorblock'
2-
import type {HMDocument, HMMetadata} from '@seed-hypermedia/client/hm-types'
3-
import {Empty} from '@bufbuild/protobuf'
4-
import {DocumentChange_SetAttribute} from '../client'
2+
import type {HMDocument} from '@seed-hypermedia/client/hm-types'
53
import {DocumentChange} from '../client/.generated/documents/v3alpha/documents_pb'
6-
import {compareBlocksWithMap, createBlocksMap, extractDeletes} from './document-changes'
7-
8-
const metadataStringKeys = [
9-
'name',
10-
'summary',
11-
'icon',
12-
'thumbnail',
13-
'cover',
14-
'siteUrl',
15-
'layout',
16-
'displayAuthor',
17-
'displayPublishTime',
18-
'seedExperimentalLogo',
19-
'seedExperimentalHomeOrder',
20-
'contentWidth',
21-
'importCategories',
22-
'importTags',
23-
] as const
24-
25-
const metadataBoolKeys = ['showOutline', 'showActivity'] as const
4+
import {compareBlocksWithMap, createBlocksMap, extractDeletes, getDocAttributeChanges} from './document-changes'
265

276
/** Build document changes that restore selected version content and metadata on top of the latest document. */
287
export function buildRestoreVersionChanges(latestDocument: HMDocument, selectedVersion: HMDocument): DocumentChange[] {
@@ -31,69 +10,8 @@ export function buildRestoreVersionChanges(latestDocument: HMDocument, selectedV
3110
const blockDiff = compareBlocksWithMap(latestBlocksMap, selectedEditorBlocks, '')
3211
const deleteChanges = extractDeletes(latestBlocksMap, blockDiff.touchedBlocks)
3312
return [
34-
...buildRestoreMetadataChanges(latestDocument.metadata ?? {}, selectedVersion.metadata ?? {}),
13+
...getDocAttributeChanges(selectedVersion.metadata ?? {}, latestDocument.metadata ?? {}),
3514
...blockDiff.changes,
3615
...deleteChanges,
3716
]
3817
}
39-
40-
/** Build attribute changes that make latest metadata match selected metadata, including removals. */
41-
export function buildRestoreMetadataChanges(
42-
latestMetadata: HMMetadata,
43-
selectedMetadata: HMMetadata,
44-
): DocumentChange[] {
45-
const changes: DocumentChange[] = []
46-
47-
for (const key of metadataStringKeys) {
48-
pushMetadataValueChange(changes, [key], latestMetadata[key], selectedMetadata[key])
49-
}
50-
for (const key of metadataBoolKeys) {
51-
pushMetadataValueChange(changes, [key], latestMetadata[key], selectedMetadata[key])
52-
}
53-
pushMetadataValueChange(
54-
changes,
55-
['theme', 'headerLayout'],
56-
latestMetadata.theme?.headerLayout,
57-
selectedMetadata.theme?.headerLayout,
58-
)
59-
60-
return changes
61-
}
62-
63-
function pushMetadataValueChange(
64-
changes: DocumentChange[],
65-
key: string[],
66-
latestValue: unknown,
67-
selectedValue: unknown,
68-
) {
69-
if (latestValue === selectedValue) return
70-
if (typeof selectedValue === 'string') {
71-
changes.push(attributeChange(key, {case: 'stringValue', value: selectedValue}))
72-
return
73-
}
74-
if (typeof selectedValue === 'boolean') {
75-
changes.push(attributeChange(key, {case: 'boolValue', value: selectedValue}))
76-
return
77-
}
78-
if (selectedValue === undefined || selectedValue === null) {
79-
if (latestValue !== undefined && latestValue !== null) {
80-
changes.push(attributeChange(key, {case: 'nullValue', value: new Empty()}))
81-
}
82-
}
83-
}
84-
85-
function attributeChange(
86-
key: string[],
87-
value: {case: 'stringValue'; value: string} | {case: 'boolValue'; value: boolean} | {case: 'nullValue'; value: Empty},
88-
) {
89-
return new DocumentChange({
90-
op: {
91-
case: 'setAttribute',
92-
value: new DocumentChange_SetAttribute({
93-
blockId: '',
94-
key,
95-
value,
96-
}),
97-
},
98-
})
99-
}

0 commit comments

Comments
 (0)