Skip to content

Commit 67ffd86

Browse files
fix(snippets): scope content mutations to snippets (#792) (#799)
1 parent 4658e06 commit 67ffd86

7 files changed

Lines changed: 157 additions & 63 deletions

File tree

src/main/api/routes/snippets.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ app
337337
({ params, status }) => {
338338
const storage = useStorage()
339339
const { deleted } = storage.snippets.deleteSnippetContent(
340+
Number(params.id),
340341
Number(params.contentId),
341342
)
342343

src/main/storage/contracts.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,10 @@ export interface SnippetsStorage {
170170
input: SnippetContentCreateInput,
171171
) => { id: number }
172172
deleteSnippet: (id: number) => { deleted: boolean }
173-
deleteSnippetContent: (contentId: number) => { deleted: boolean }
173+
deleteSnippetContent: (
174+
snippetId: number,
175+
contentId: number,
176+
) => { deleted: boolean }
174177
deleteTagFromSnippet: (
175178
snippetId: number,
176179
tagId: number,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,10 @@ export function findSnippetById(
532532
return snippet
533533
}
534534

535+
/**
536+
* Global content lookup is only safe for non-mutating fallback flows.
537+
* Mutation paths with a known owner must scope lookup by snippet id first.
538+
*/
535539
export function findSnippetByContentId(
536540
snippets: MarkdownSnippet[],
537541
contentId: number,

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

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -333,18 +333,7 @@ export function syncSnippetFileWithDisk(
333333
snippets[snippetIndexInRuntime] = syncedSnippet
334334
}
335335

336-
const maxSnippetContentId = syncedSnippet.contents.reduce(
337-
(maxId, content) => Math.max(maxId, content.id),
338-
0,
339-
)
340-
state.counters.snippetId = Math.max(
341-
state.counters.snippetId,
342-
syncedSnippet.id,
343-
)
344-
state.counters.contentId = Math.max(
345-
state.counters.contentId,
346-
maxSnippetContentId,
347-
)
336+
syncCounters(state, snippets)
348337

349338
saveState(paths, state)
350339

src/main/storage/providers/markdown/storages/__tests__/snippets.test.ts

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

6+
import { getRuntimeCache, writeSnippetToFile } from '../../runtime'
67
import { getPaths } from '../../runtime/paths'
78
import { ensureStateFile } from '../../runtime/state'
89
import { resetRuntimeCache } from '../../runtime/sync'
@@ -217,4 +218,81 @@ describe('code snippets storage validations', () => {
217218
expect(moved?.name.toLowerCase()).not.toBe('shared')
218219
expect(moved?.name.toLowerCase()).toContain('shared')
219220
})
221+
222+
it('updates content scoped to the requested snippet when content ids are duplicated', () => {
223+
const storage = createSnippetsStorage()
224+
const first = storage.createSnippet({ name: 'First' })
225+
const second = storage.createSnippet({ name: 'Second' })
226+
const firstContent = storage.createSnippetContent(first.id, {
227+
label: 'Fragment 1',
228+
language: 'plain_text',
229+
value: 'first value',
230+
})
231+
232+
storage.createSnippetContent(second.id, {
233+
label: 'Fragment 1',
234+
language: 'plain_text',
235+
value: 'second value',
236+
})
237+
238+
const paths = getPaths(tempVaultPath)
239+
const cache = getRuntimeCache(paths)
240+
const secondSnippet = cache.snippets.find(
241+
snippet => snippet.id === second.id,
242+
)
243+
244+
expect(secondSnippet).toBeDefined()
245+
secondSnippet!.contents[0]!.id = firstContent.id
246+
writeSnippetToFile(paths, secondSnippet!)
247+
248+
const result = storage.updateSnippetContent(second.id, firstContent.id, {
249+
value: 'second updated',
250+
})
251+
252+
expect(result).toEqual({
253+
invalidInput: false,
254+
notFound: false,
255+
parentNotFound: false,
256+
})
257+
expect(storage.getSnippetById(first.id)?.contents[0]?.value).toBe(
258+
'first value',
259+
)
260+
expect(storage.getSnippetById(second.id)?.contents[0]).toMatchObject({
261+
id: firstContent.id,
262+
value: 'second updated',
263+
})
264+
})
265+
266+
it('deletes content scoped to the requested snippet when content ids are duplicated', () => {
267+
const storage = createSnippetsStorage()
268+
const first = storage.createSnippet({ name: 'First' })
269+
const second = storage.createSnippet({ name: 'Second' })
270+
const firstContent = storage.createSnippetContent(first.id, {
271+
label: 'Fragment 1',
272+
language: 'plain_text',
273+
value: 'first value',
274+
})
275+
276+
storage.createSnippetContent(second.id, {
277+
label: 'Fragment 1',
278+
language: 'plain_text',
279+
value: 'second value',
280+
})
281+
282+
const paths = getPaths(tempVaultPath)
283+
const cache = getRuntimeCache(paths)
284+
const secondSnippet = cache.snippets.find(
285+
snippet => snippet.id === second.id,
286+
)
287+
288+
expect(secondSnippet).toBeDefined()
289+
secondSnippet!.contents[0]!.id = firstContent.id
290+
writeSnippetToFile(paths, secondSnippet!)
291+
292+
expect(storage.deleteSnippetContent(second.id, firstContent.id)).toEqual({
293+
deleted: true,
294+
})
295+
expect(storage.getSnippetById(first.id)?.contents).toHaveLength(1)
296+
expect(storage.getSnippetById(second.id)?.contents).toHaveLength(0)
297+
})
220298
})

src/main/storage/providers/markdown/storages/snippets.ts

Lines changed: 68 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
assertUniqueSiblingEntryName,
1414
createSnippetRecord,
1515
findFolderById,
16-
findSnippetByContentId,
1716
findSnippetById,
1817
getPaths,
1918
getRuntimeCache,
@@ -28,11 +27,7 @@ import {
2827
validateEntryName,
2928
writeSnippetToFile,
3029
} from '../runtime'
31-
import {
32-
createNestedContent,
33-
deleteNestedContent,
34-
updateNestedContent,
35-
} from '../runtime/shared/entityContent'
30+
import { createNestedContent } from '../runtime/shared/entityContent'
3631
import { filterAndSortByQuery } from '../runtime/shared/entityQuery'
3732
import {
3833
addTagToEntity,
@@ -44,6 +39,13 @@ import {
4439
getEntityDeleteCounts,
4540
} from '../runtime/shared/entityStorage'
4641

42+
function findContentIndexById(
43+
snippet: MarkdownSnippet,
44+
contentId: number,
45+
): number {
46+
return snippet.contents.findIndex(content => content.id === contentId)
47+
}
48+
4749
export function createSnippetsStorage(): SnippetsStorage {
4850
return {
4951
getSnippets: (query: SnippetsQueryInput) => {
@@ -229,39 +231,56 @@ export function createSnippetsStorage(): SnippetsStorage {
229231
): SnippetContentUpdateResult => {
230232
const paths = getPaths(getVaultPath())
231233
const { state, snippets } = getRuntimeCache(paths)
232-
const ownedContent = findSnippetByContentId(snippets, contentId)
233-
const result = updateNestedContent({
234-
applyPatch: (content, patch) => {
235-
if ('label' in patch) {
236-
content.label = patch.label || content.label
237-
}
238234

239-
if ('value' in patch) {
240-
content.value = patch.value ?? null
241-
}
235+
if (!('label' in input || 'value' in input || 'language' in input)) {
236+
return {
237+
invalidInput: true,
238+
notFound: false,
239+
parentNotFound: false,
240+
}
241+
}
242242

243-
if ('language' in patch) {
244-
content.language = patch.language || content.language
245-
}
246-
},
247-
findTargetOwnerById: id => findSnippetById(snippets, id),
248-
hasAnyField: patch =>
249-
'label' in patch || 'value' in patch || 'language' in patch,
250-
ownerId: snippetId,
251-
ownedContent: ownedContent
252-
? {
253-
contentIndex: ownedContent.contentIndex,
254-
owner: ownedContent.snippet,
255-
}
256-
: undefined,
257-
patch: input,
258-
persistOwner: snippet => writeSnippetToFile(paths, snippet),
259-
})
260-
if (!result.invalidInput && !result.notFound) {
261-
saveState(paths, state)
243+
const snippet = findSnippetById(snippets, snippetId)
244+
if (!snippet) {
245+
return {
246+
invalidInput: false,
247+
notFound: false,
248+
parentNotFound: true,
249+
}
262250
}
263251

264-
return result
252+
const contentIndex = findContentIndexById(snippet, contentId)
253+
if (contentIndex === -1) {
254+
return {
255+
invalidInput: false,
256+
notFound: true,
257+
parentNotFound: false,
258+
}
259+
}
260+
261+
const content = snippet.contents[contentIndex]
262+
263+
if ('label' in input) {
264+
content.label = input.label || content.label
265+
}
266+
267+
if ('value' in input) {
268+
content.value = input.value ?? null
269+
}
270+
271+
if ('language' in input) {
272+
content.language = input.language || content.language
273+
}
274+
275+
snippet.updatedAt = Date.now()
276+
writeSnippetToFile(paths, snippet)
277+
saveState(paths, state)
278+
279+
return {
280+
invalidInput: false,
281+
notFound: false,
282+
parentNotFound: false,
283+
}
265284
},
266285
addTagToSnippet: (snippetId, tagId): SnippetTagRelationResult => {
267286
const paths = getPaths(getVaultPath())
@@ -345,26 +364,26 @@ export function createSnippetsStorage(): SnippetsStorage {
345364

346365
return result
347366
},
348-
deleteSnippetContent: (contentId) => {
367+
deleteSnippetContent: (snippetId, contentId) => {
349368
const paths = getPaths(getVaultPath())
350369
const { state, snippets } = getRuntimeCache(paths)
351-
const ownedContent = findSnippetByContentId(snippets, contentId)
352-
const result = deleteNestedContent({
353-
ownedContent: ownedContent
354-
? {
355-
contentIndex: ownedContent.contentIndex,
356-
owner: ownedContent.snippet,
357-
}
358-
: undefined,
359-
persistOwner: snippet => writeSnippetToFile(paths, snippet),
360-
})
361-
if (!result.deleted) {
362-
return result
370+
const snippet = findSnippetById(snippets, snippetId)
371+
372+
if (!snippet) {
373+
return { deleted: false }
374+
}
375+
376+
const contentIndex = findContentIndexById(snippet, contentId)
377+
if (contentIndex === -1) {
378+
return { deleted: false }
363379
}
364380

381+
snippet.contents.splice(contentIndex, 1)
382+
snippet.updatedAt = Date.now()
383+
writeSnippetToFile(paths, snippet)
365384
saveState(paths, state)
366385

367-
return result
386+
return { deleted: true }
368387
},
369388
}
370389
}

src/renderer/services/api/generated/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1050,7 +1050,7 @@ export class HttpClient<SecurityDataType = unknown> {
10501050

10511051
/**
10521052
* @title massCode API
1053-
* @version 5.4.0
1053+
* @version 5.5.0
10541054
*
10551055
* Development documentation
10561056
*/

0 commit comments

Comments
 (0)