Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/main/i18n/locales/en_US/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@
"entryNameLeadingDot": "Name cannot start with a dot.",
"entryNameInvalidChars": "Name cannot contain these characters: {{- chars}}",
"entryNameTrailingDot": "Name cannot end with a dot.",
"entryNameWindowsReserved": "This name is reserved on Windows."
"entryNameWindowsReserved": "This name is reserved on Windows.",
"entryNameNoteConflict": "A note with this name already exists in this folder.",
"entryNameSnippetConflict": "A snippet with this name already exists in this folder.",
"entryNameFolderConflict": "A folder with this name already exists at this level."
},
"description": {
"storageVault": "Choose the vault directory. To sync between devices, select a folder in iCloud Drive, Google Drive or Dropbox.",
Expand Down
5 changes: 4 additions & 1 deletion src/main/i18n/locales/ru_RU/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@
"entryNameLeadingDot": "Имя не может начинаться с точки.",
"entryNameInvalidChars": "Имя не может содержать эти символы: {{- chars}}",
"entryNameTrailingDot": "Имя не может заканчиваться точкой.",
"entryNameWindowsReserved": "Это имя зарезервировано в Windows."
"entryNameWindowsReserved": "Это имя зарезервировано в Windows.",
"entryNameNoteConflict": "В этой папке уже есть заметка с таким именем.",
"entryNameSnippetConflict": "В этой папке уже есть сниппет с таким именем.",
"entryNameFolderConflict": "Папка с таким именем уже существует на этом уровне."
},
"description": {
"storageVault": "Выберите директорию для хранилища. Для синхронизации между устройствами выберите папку в iCloud Drive, Google Drive или Dropbox.",
Expand Down
3 changes: 3 additions & 0 deletions src/main/storage/providers/markdown/notes/runtime/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ export function persistNote(
let resolvedPath: string
if (options?.allowRenameOnConflict) {
resolvedPath = getUniqueNotePath(paths, state, targetPath, currentFilePath)
if (resolvedPath !== targetPath) {
note.name = path.posix.basename(resolvedPath, '.md')
}
}
else {
resolvedPath = targetPath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,20 @@ describe('folders storage validations', () => {
expect(result).toEqual({ invalidInput: true, notFound: false })
})

it('moving folder to sibling with same name throws NAME_CONFLICT', () => {
it('moving folder to sibling with same name auto-renames', () => {
const storage = createNotesFoldersStorage()

// Create a parent folder
const { id: parentId } = storage.createFolder({ name: 'Parent' })

// Create two folders: one at root named "Dupe", one inside Parent named "Dupe"
storage.createFolder({ name: 'Dupe', parentId })
const { id: rootDupeId } = storage.createFolder({ name: 'Dupe' })

// Move rootDupe into Parent — should conflict with existing "Dupe" child
expect(() => storage.updateFolder(rootDupeId, { parentId })).toThrow(
'NAME_CONFLICT',
)
storage.updateFolder(rootDupeId, { parentId })

const moved = storage.getFolders().find(f => f.id === rootDupeId)
expect(moved?.parentId).toBe(parentId)
expect(moved?.name.toLowerCase()).not.toBe('dupe')
expect(moved?.name.toLowerCase()).toContain('dupe')
})

it('rename to existing disk directory throws NAME_CONFLICT', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { ensureNotesStateFile } from '../../runtime/state'
import { resetNotesRuntimeCache } from '../../runtime/sync'
import { createNotesFoldersStorage } from '../folders'
import { createNotesNotesStorage } from '../notes'

let tempVaultPath = ''
Expand Down Expand Up @@ -132,6 +133,72 @@ describe('notes storage validations', () => {
expect(storage.getNoteById(99999)).toBeNull()
})

it('createNote throws NAME_CONFLICT for duplicate name in same folder', () => {
const storage = createNotesNotesStorage()
storage.createNote({ name: 'Duplicate' })

expect(() => storage.createNote({ name: 'Duplicate' })).toThrow(
'NAME_CONFLICT',
)
expect(() => storage.createNote({ name: 'duplicate' })).toThrow(
'NAME_CONFLICT',
)
})

it('createNote allows duplicate name in a different folder', () => {
const folders = createNotesFoldersStorage()
const storage = createNotesNotesStorage()
const folder = folders.createFolder({ name: 'Folder A', parentId: null })

storage.createNote({ name: 'Shared' })

expect(() =>
storage.createNote({ name: 'Shared', folderId: folder.id }),
).not.toThrow()
})

it('createNote allows reusing the name of a deleted note', () => {
const storage = createNotesNotesStorage()
const { id } = storage.createNote({ name: 'Reusable' })
storage.updateNote(id, { isDeleted: 1 })

expect(() => storage.createNote({ name: 'Reusable' })).not.toThrow()
})

it('updateNote rename to existing sibling name throws NAME_CONFLICT', () => {
const storage = createNotesNotesStorage()
storage.createNote({ name: 'Alpha' })
const { id: bravoId } = storage.createNote({ name: 'Bravo' })

expect(() => storage.updateNote(bravoId, { name: 'Alpha' })).toThrow(
'NAME_CONFLICT',
)
})

it('updateNote rename to same name (case-insensitive) is a no-op for uniqueness', () => {
const storage = createNotesNotesStorage()
const { id } = storage.createNote({ name: 'Stable' })

expect(() => storage.updateNote(id, { name: 'stable' })).not.toThrow()
expect(storage.getNoteById(id)?.name).toBe('stable')
})

it('updateNote move into folder with conflicting name auto-renames', () => {
const folders = createNotesFoldersStorage()
const storage = createNotesNotesStorage()
const target = folders.createFolder({ name: 'Target', parentId: null })

storage.createNote({ name: 'Shared', folderId: target.id })
const { id: movingId } = storage.createNote({ name: 'Shared' })

storage.updateNote(movingId, { folderId: target.id })

const moved = storage.getNoteById(movingId)
expect(moved?.folder?.id).toBe(target.id)
expect(moved?.name.toLowerCase()).not.toBe('shared')
expect(moved?.name.toLowerCase()).toContain('shared')
})

it('keeps newest created note first after content updates of older notes', () => {
vi.useFakeTimers()

Expand Down
45 changes: 24 additions & 21 deletions src/main/storage/providers/markdown/notes/storages/folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import {
assertDirectoryNameAvailableAtRoot,
assertUniqueSiblingFolderName,
resolveUniqueSiblingFolderName,
throwStorageError,
validateEntryName,
} from '../../runtime/validation'
Expand Down Expand Up @@ -156,22 +157,10 @@ export function createNotesFoldersStorage(): NotesFoldersStorage {
const oldFolderPathMap = buildNotesFolderPathMap(state)
const oldPath = oldFolderPathMap.get(id)

if (input.name !== undefined) {
const name = validateEntryName(input.name, 'folder')
const parentId
= input.parentId !== undefined
? (input.parentId ?? null)
: folder.parentId

assertNotReservedRootName(parentId, name)
assertUniqueSiblingFolderName(state, parentId, name, id)

if (name !== folder.name) {
pathChanged = true
}

folder.name = name
}
let targetName
= input.name !== undefined
? validateEntryName(input.name, 'folder')
: folder.name

const { targetOrderIndex, targetParentId } = resolveFolderUpdateTargets(
folder,
Expand All @@ -180,13 +169,27 @@ export function createNotesFoldersStorage(): NotesFoldersStorage {
)

if (input.parentId !== undefined) {
const newParentId = input.parentId ?? null
assertFolderMoveTargetValid(state.folders, id, targetParentId)
}

assertFolderMoveTargetValid(state.folders, id, newParentId)
assertNotReservedRootName(targetParentId, targetName)

if (newParentId !== folder.parentId && input.name === undefined) {
assertUniqueSiblingFolderName(state, newParentId, folder.name, id)
}
const isParentChanged = targetParentId !== folder.parentId
if (isParentChanged) {
targetName = resolveUniqueSiblingFolderName(
state,
targetParentId,
targetName,
id,
)
}
else if (targetName !== folder.name) {
assertUniqueSiblingFolderName(state, targetParentId, targetName, id)
}

if (targetName !== folder.name) {
folder.name = targetName
pathChanged = true
}

const { parentChanged } = applyFolderParentAndOrder(
Expand Down
29 changes: 26 additions & 3 deletions src/main/storage/providers/markdown/notes/storages/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import {
emptyEntityTrashFromStateAndDisk,
getEntityDeleteCounts,
} from '../../runtime/shared/entityStorage'
import { throwStorageError, validateEntryName } from '../../runtime/validation'
import {
assertUniqueSiblingEntryName,
throwStorageError,
validateEntryName,
} from '../../runtime/validation'
import { getNotesPaths } from '../runtime/constants'
import { findNoteById, persistNote, writeNoteToFile } from '../runtime/notes'
import { findNotesFolderById } from '../runtime/paths'
Expand Down Expand Up @@ -122,6 +126,7 @@ export function createNotesNotesStorage(): NotesStorage {

const name = validateEntryName(input.name, 'note')
const folderId = input.folderId ?? null
assertUniqueSiblingEntryName(notes, folderId, name, 'note')
const result = createEntityInStateAndDisk<MarkdownNote>({
createEntity: ({ folderId, id, name, now }) => ({
content: '',
Expand Down Expand Up @@ -167,6 +172,7 @@ export function createNotesNotesStorage(): NotesStorage {
}

const previousFilePath = note.filePath
const previousFolderId = note.folderId
const updateResult = applyEntityUpdateFields({
entity: note,
fieldPresence: 'defined',
Expand All @@ -175,8 +181,25 @@ export function createNotesNotesStorage(): NotesStorage {
normalizeFlag: value => normalizeFlag(value),
onMissingFolder: () =>
throwStorageError('FOLDER_NOT_FOUND', 'Folder not found'),
resolveName: (inputName, currentName) =>
validateEntryName(inputName ?? currentName, 'note'),
resolveName: (inputName, currentName) => {
const next = validateEntryName(inputName ?? currentName, 'note')
const isFolderChanging
= input.folderId !== undefined
&& (input.folderId ?? null) !== previousFolderId
if (
!isFolderChanging
&& next.toLowerCase() !== currentName.toLowerCase()
) {
assertUniqueSiblingEntryName(
notes,
previousFolderId,
next,
'note',
note.id,
)
}
return next
},
})
if (!updateResult.hasAnyField) {
return { invalidInput: true, notFound: false }
Expand Down
1 change: 1 addition & 0 deletions src/main/storage/providers/markdown/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export type {
export {
assertDirectoryNameAvailable,
assertNotReservedRootFolderName,
assertUniqueSiblingEntryName,
assertUniqueSiblingFolderName,
getMarkdownStorageErrorMessage,
resolveUniqueSiblingFolderName,
Expand Down
30 changes: 30 additions & 0 deletions src/main/storage/providers/markdown/runtime/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,36 @@ export function assertNotReservedRootFolderName(
}
}

export function assertUniqueSiblingEntryName(
entries: {
id: number
name: string
isDeleted: number
folderId: number | null
}[],
folderId: number | null,
name: string,
kind: 'note' | 'snippet',
excludeId?: number,
): void {
const normalizedName = name.toLowerCase()

const hasConflict = entries.some(
entry =>
entry.id !== excludeId
&& entry.isDeleted === 0
&& entry.folderId === folderId
&& entry.name.toLowerCase() === normalizedName,
)

if (hasConflict) {
throwStorageError(
'NAME_CONFLICT',
`${kind === 'note' ? 'Note' : 'Snippet'} with this name already exists in this folder`,
)
}
}

export function assertUniqueSiblingFolderName(
state: {
folders: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getPaths } from '../../runtime/paths'
import { ensureStateFile } from '../../runtime/state'
import { resetRuntimeCache } from '../../runtime/sync'
import { createFoldersStorage } from '../folders'
import { createSnippetsStorage } from '../snippets'

let tempVaultPath = ''
Expand Down Expand Up @@ -129,4 +130,70 @@ describe('code snippets storage validations', () => {

expect(storage.getSnippetById(99999)).toBeNull()
})

it('createSnippet throws NAME_CONFLICT for duplicate name in same folder', () => {
const storage = createSnippetsStorage()
storage.createSnippet({ name: 'Duplicate' })

expect(() => storage.createSnippet({ name: 'Duplicate' })).toThrow(
'NAME_CONFLICT',
)
expect(() => storage.createSnippet({ name: 'duplicate' })).toThrow(
'NAME_CONFLICT',
)
})

it('createSnippet allows duplicate name in a different folder', () => {
const folders = createFoldersStorage()
const storage = createSnippetsStorage()
const folder = folders.createFolder({ name: 'Folder A', parentId: null })

storage.createSnippet({ name: 'Shared' })

expect(() =>
storage.createSnippet({ name: 'Shared', folderId: folder.id }),
).not.toThrow()
})

it('createSnippet allows reusing the name of a deleted snippet', () => {
const storage = createSnippetsStorage()
const { id } = storage.createSnippet({ name: 'Reusable' })
storage.updateSnippet(id, { isDeleted: 1 })

expect(() => storage.createSnippet({ name: 'Reusable' })).not.toThrow()
})

it('updateSnippet rename to existing sibling name throws NAME_CONFLICT', () => {
const storage = createSnippetsStorage()
storage.createSnippet({ name: 'Alpha' })
const { id: bravoId } = storage.createSnippet({ name: 'Bravo' })

expect(() => storage.updateSnippet(bravoId, { name: 'Alpha' })).toThrow(
'NAME_CONFLICT',
)
})

it('updateSnippet rename to same name (case-insensitive) is a no-op for uniqueness', () => {
const storage = createSnippetsStorage()
const { id } = storage.createSnippet({ name: 'Stable' })

expect(() => storage.updateSnippet(id, { name: 'stable' })).not.toThrow()
expect(storage.getSnippetById(id)?.name).toBe('stable')
})

it('updateSnippet move into folder with conflicting name auto-renames', () => {
const folders = createFoldersStorage()
const storage = createSnippetsStorage()
const target = folders.createFolder({ name: 'Target', parentId: null })

storage.createSnippet({ name: 'Shared', folderId: target.id })
const { id: movingId } = storage.createSnippet({ name: 'Shared' })

storage.updateSnippet(movingId, { folderId: target.id })

const moved = storage.getSnippetById(movingId)
expect(moved?.folder?.id).toBe(target.id)
expect(moved?.name.toLowerCase()).not.toBe('shared')
expect(moved?.name.toLowerCase()).toContain('shared')
})
})
Loading