Skip to content

Commit 5f091fa

Browse files
fix(storage): preserve local zero-block folder metadata (#887)
1 parent c83f41e commit 5f091fa

7 files changed

Lines changed: 391 additions & 6 deletions

File tree

src/main/storage/providers/markdown/notes/runtime/parser.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
prioritizeCloudDownload,
1212
} from '../../cloudDownloads'
1313
import { rememberAppFileChange } from '../../runtime/shared/appChanges'
14-
import { getFileAvailability } from '../../runtime/shared/cloudFiles'
14+
import {
15+
getFileAvailability,
16+
markAppWrittenFileAsLocal,
17+
} from '../../runtime/shared/cloudFiles'
1518
import {
1619
isYamlFileCloudUnavailable,
1720
readYamlObjectFile,
@@ -90,5 +93,6 @@ export function writeNotesFolderMetadataFile(
9093

9194
fs.ensureDirSync(folderAbsPath)
9295
fs.writeFileSync(metaPath, nextContent, 'utf8')
96+
markAppWrittenFileAsLocal(metaPath)
9397
rememberAppFileChange(metaPath)
9498
}

src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,45 @@ import path from 'node:path'
33
import fs from 'fs-extra'
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55

6+
import { enqueueCloudDownload } from '../../../cloudDownloads'
7+
import {
8+
getFileAvailability,
9+
markAppWrittenFileAsLocal,
10+
resetCloudFileExemptions,
11+
setDatalessProbeForTests,
12+
} from '../../../runtime/shared/cloudFiles'
613
import { ensureNotesStateFile } from '../../runtime/state'
714
import { resetNotesRuntimeCache } from '../../runtime/sync'
815
import { createNotesFoldersStorage } from '../folders'
916
import { createNotesNotesStorage } from '../notes'
1017

1118
let tempVaultPath = ''
1219

20+
function makeSparsePlaceholder(absolutePath: string, size = 4096): void {
21+
fs.removeSync(absolutePath)
22+
const fd = fs.openSync(absolutePath, 'w')
23+
fs.ftruncateSync(fd, size)
24+
fs.closeSync(fd)
25+
}
26+
27+
function mockFolderMetadataAsZeroBlocks() {
28+
const statSync = fs.statSync.bind(fs)
29+
30+
return vi.spyOn(fs, 'statSync').mockImplementation((filePath) => {
31+
const stats = statSync(filePath)
32+
33+
if (
34+
typeof filePath === 'string'
35+
&& filePath.endsWith('.meta.yaml')
36+
&& stats.size > 0
37+
) {
38+
return Object.assign(stats, { blocks: 0 })
39+
}
40+
41+
return stats
42+
})
43+
}
44+
1345
vi.mock('electron-store', () => {
1446
class MockStore {
1547
private state: Record<string, unknown>
@@ -60,6 +92,11 @@ vi.mock('electron', () => ({
6092
},
6193
}))
6294

95+
vi.mock('../../../cloudDownloads', () => ({
96+
enqueueCloudDownload: vi.fn(),
97+
prioritizeCloudDownload: vi.fn(),
98+
}))
99+
63100
vi.mock('../../../../../../store', () => ({
64101
store: {
65102
preferences: {
@@ -94,6 +131,10 @@ describe('folders storage validations', () => {
94131
})
95132

96133
afterEach(() => {
134+
setDatalessProbeForTests(null)
135+
resetCloudFileExemptions()
136+
resetNotesRuntimeCache()
137+
97138
if (tempVaultPath) {
98139
fs.removeSync(tempVaultPath)
99140
}
@@ -215,4 +256,101 @@ describe('folders storage validations', () => {
215256

216257
expect(notes.getNoteById(linker.id)?.content).toBe('See [[Foo]] here')
217258
})
259+
260+
it('keeps resident zero-block subtree metadata local after parent move', () => {
261+
const notesRoot = path.join(tempVaultPath, 'notes')
262+
setDatalessProbeForTests(() => true)
263+
const statSpy = mockFolderMetadataAsZeroBlocks()
264+
265+
try {
266+
const storage = createNotesFoldersStorage()
267+
const destination = storage.createFolder({ name: 'Destination' })
268+
const parent = storage.createFolder({ name: 'Parent' })
269+
storage.createFolder({ name: 'Child', parentId: parent.id })
270+
const beforeMetadata = fs.readFileSync(
271+
path.join(notesRoot, 'Parent', '.meta.yaml'),
272+
'utf8',
273+
)
274+
const previousUpdatedAt = storage
275+
.getFolders()
276+
.find(folder => folder.id === parent.id)!.updatedAt
277+
const nowSpy = vi
278+
.spyOn(Date, 'now')
279+
.mockReturnValue(previousUpdatedAt + 1)
280+
281+
try {
282+
storage.updateFolder(parent.id, { parentId: destination.id })
283+
}
284+
finally {
285+
nowSpy.mockRestore()
286+
}
287+
288+
const parentMetaPath = path.join(
289+
notesRoot,
290+
'Destination',
291+
'Parent',
292+
'.meta.yaml',
293+
)
294+
const childMetaPath = path.join(
295+
notesRoot,
296+
'Destination',
297+
'Parent',
298+
'Child',
299+
'.meta.yaml',
300+
)
301+
302+
expect(getFileAvailability(parentMetaPath).isCloudPlaceholder).toBe(
303+
false,
304+
)
305+
expect(getFileAvailability(childMetaPath).isCloudPlaceholder).toBe(false)
306+
expect(fs.readFileSync(parentMetaPath, 'utf8')).not.toBe(beforeMetadata)
307+
}
308+
finally {
309+
statSpy.mockRestore()
310+
}
311+
})
312+
313+
it('does not exempt or overwrite descendant metadata placeholder after parent move', () => {
314+
const notesRoot = path.join(tempVaultPath, 'notes')
315+
setDatalessProbeForTests(() => true)
316+
const statSpy = mockFolderMetadataAsZeroBlocks()
317+
318+
try {
319+
const storage = createNotesFoldersStorage()
320+
const destination = storage.createFolder({ name: 'Destination' })
321+
const parent = storage.createFolder({ name: 'Parent' })
322+
storage.createFolder({ name: 'Child', parentId: parent.id })
323+
const parentMetaPath = path.join(notesRoot, 'Parent', '.meta.yaml')
324+
const childSourcePath = path.join(
325+
notesRoot,
326+
'Parent',
327+
'Child',
328+
'.meta.yaml',
329+
)
330+
331+
makeSparsePlaceholder(childSourcePath)
332+
resetCloudFileExemptions()
333+
markAppWrittenFileAsLocal(parentMetaPath)
334+
const placeholderBefore = fs.readFileSync(childSourcePath)
335+
const childTargetPath = path.join(
336+
notesRoot,
337+
'Destination',
338+
'Parent',
339+
'Child',
340+
'.meta.yaml',
341+
)
342+
vi.mocked(enqueueCloudDownload).mockClear()
343+
344+
storage.updateFolder(parent.id, { parentId: destination.id })
345+
346+
expect(fs.readFileSync(childTargetPath)).toEqual(placeholderBefore)
347+
expect(getFileAvailability(childTargetPath).isCloudPlaceholder).toBe(
348+
true,
349+
)
350+
expect(enqueueCloudDownload).toHaveBeenCalledWith(childTargetPath)
351+
}
352+
finally {
353+
statSpy.mockRestore()
354+
}
355+
})
218356
})

src/main/storage/providers/markdown/notes/storages/folders.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import path from 'node:path'
88
import { scheduleDockBadgeRefresh } from '../../../../../dockBadge'
99
import { normalizeFlag, normalizeNumber } from '../../runtime/normalizers'
1010
import { getVaultPath } from '../../runtime/paths'
11+
import {
12+
getFileAvailability,
13+
markAppWrittenFileAsLocal,
14+
} from '../../runtime/shared/cloudFiles'
1115
import {
1216
assertEntityFileWritable,
1317
throwCloudContentUnavailable,
@@ -38,6 +42,7 @@ import { rewriteBacklinksAfterFolderUpdate } from '../runtime/backlinks'
3842
import {
3943
getNotesPaths,
4044
META_DIR_NAME,
45+
META_FILE_NAME,
4146
NOTES_RESERVED_ROOT_NAMES,
4247
} from '../runtime/constants'
4348
import { ensureNoteContentLoaded, persistNote } from '../runtime/notes'
@@ -248,8 +253,41 @@ export function createNotesFoldersStorage(): NotesFoldersStorage {
248253
oldPath,
249254
)
250255

256+
const affectedFolderIds = collectDescendantIds(state.folders, id)
257+
affectedFolderIds.add(id)
258+
const localMetadataTargetPaths: string[] = []
259+
260+
for (const folderId of affectedFolderIds) {
261+
const oldFolderPath = oldFolderPathMap.get(folderId)
262+
const newFolderPath = newFolderPathMap.get(folderId)
263+
if (
264+
!oldFolderPath
265+
|| !newFolderPath
266+
|| oldFolderPath === newFolderPath
267+
) {
268+
continue
269+
}
270+
271+
const sourceMetadataPath = path.join(
272+
paths.notesRoot,
273+
oldFolderPath,
274+
META_FILE_NAME,
275+
)
276+
const sourceAvailability = getFileAvailability(sourceMetadataPath)
277+
if (
278+
sourceAvailability.exists
279+
&& !sourceAvailability.isCloudPlaceholder
280+
) {
281+
localMetadataTargetPaths.push(
282+
path.join(paths.notesRoot, newFolderPath, META_FILE_NAME),
283+
)
284+
}
285+
}
286+
251287
moveFolderDirectoryOnDisk(paths.notesRoot, oldPath, newPath)
252288

289+
localMetadataTargetPaths.forEach(markAppWrittenFileAsLocal)
290+
253291
updateChildEntityPaths({
254292
entries: notes,
255293
getNextPath: (_, previousPath) =>

src/main/storage/providers/markdown/runtime/__tests__/parser.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
11
import type { MarkdownSnippet } from '../types'
2-
import { describe, expect, it } from 'vitest'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import fs from 'fs-extra'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
36
import {
47
parseBodyFragments,
58
parseBodyFragmentsWithMetadata,
9+
readFolderMetadata,
610
serializeSnippet,
711
splitFrontmatter,
812
} from '../parser'
13+
import {
14+
getFileAvailability,
15+
resetCloudFileExemptions,
16+
setDatalessProbeForTests,
17+
} from '../shared/cloudFiles'
18+
19+
afterEach(() => {
20+
setDatalessProbeForTests(null)
21+
resetCloudFileExemptions()
22+
})
923

1024
function createSnippet(value: string): MarkdownSnippet {
1125
return {
@@ -118,3 +132,47 @@ describe('legacy snippet fence recovery', () => {
118132
expect(result.fragments[1].value).toBe('console.log("second")')
119133
})
120134
})
135+
136+
describe('legacy folder metadata migration', () => {
137+
it('marks a zero-block migrated metadata file as local', () => {
138+
const vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'parser-legacy-'))
139+
const folderPath = path.join(vaultPath, 'Legacy')
140+
const legacyPath = path.join(folderPath, '.masscode-folder.yml')
141+
const metaPath = path.join(folderPath, '.meta.yaml')
142+
fs.ensureDirSync(folderPath)
143+
fs.writeFileSync(legacyPath, 'masscode_id: 7\nname: Legacy\n', 'utf8')
144+
145+
const statSync = fs.statSync.bind(fs)
146+
const statSpy = vi.spyOn(fs, 'statSync').mockImplementation((filePath) => {
147+
const stats = statSync(filePath)
148+
149+
if (filePath === metaPath && stats.size > 0) {
150+
return Object.assign(stats, { blocks: 0 })
151+
}
152+
153+
return stats
154+
})
155+
setDatalessProbeForTests(() => true)
156+
157+
try {
158+
expect(
159+
readFolderMetadata(
160+
{
161+
inboxDirPath: path.join(vaultPath, '.masscode', 'inbox'),
162+
metaDirPath: path.join(vaultPath, '.masscode'),
163+
statePath: path.join(vaultPath, '.masscode', 'state.json'),
164+
trashDirPath: path.join(vaultPath, '.masscode', 'trash'),
165+
vaultPath,
166+
},
167+
'Legacy',
168+
),
169+
).toMatchObject({ id: 7, name: 'Legacy' })
170+
expect(fs.pathExistsSync(legacyPath)).toBe(false)
171+
expect(getFileAvailability(metaPath).isCloudPlaceholder).toBe(false)
172+
}
173+
finally {
174+
statSpy.mockRestore()
175+
fs.removeSync(vaultPath)
176+
}
177+
})
178+
})

src/main/storage/providers/markdown/runtime/parser.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
NEW_LINE_SPLIT_RE,
2121
} from './constants'
2222
import { rememberAppFileChange } from './shared/appChanges'
23-
import { getFileAvailability } from './shared/cloudFiles'
23+
import {
24+
getFileAvailability,
25+
markAppWrittenFileAsLocal,
26+
} from './shared/cloudFiles'
2427
import {
2528
isYamlFileCloudUnavailable,
2629
readYamlObjectFile,
@@ -69,6 +72,7 @@ export function readFolderMetadata(
6972

7073
try {
7174
writeYamlObjectFile(metaPath, migrated as Record<string, unknown>)
75+
markAppWrittenFileAsLocal(metaPath)
7276
fs.removeSync(legacyPath)
7377
rememberAppFileChange(metaPath)
7478
rememberAppFileChange(legacyPath)
@@ -145,6 +149,7 @@ export function writeFolderMetadataFile(
145149

146150
fs.ensureDirSync(folderAbsPath)
147151
fs.writeFileSync(metaPath, nextContent, 'utf8')
152+
markAppWrittenFileAsLocal(metaPath)
148153
rememberAppFileChange(metaPath)
149154

150155
// Clean up legacy file if it exists

0 commit comments

Comments
 (0)