diff --git a/src/main/i18n/locales/en_US/messages.json b/src/main/i18n/locales/en_US/messages.json index 2f716b2a..a7102e07 100644 --- a/src/main/i18n/locales/en_US/messages.json +++ b/src/main/i18n/locales/en_US/messages.json @@ -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.", diff --git a/src/main/i18n/locales/ru_RU/messages.json b/src/main/i18n/locales/ru_RU/messages.json index e1904068..2cab5ced 100644 --- a/src/main/i18n/locales/ru_RU/messages.json +++ b/src/main/i18n/locales/ru_RU/messages.json @@ -31,7 +31,10 @@ "entryNameLeadingDot": "Имя не может начинаться с точки.", "entryNameInvalidChars": "Имя не может содержать эти символы: {{- chars}}", "entryNameTrailingDot": "Имя не может заканчиваться точкой.", - "entryNameWindowsReserved": "Это имя зарезервировано в Windows." + "entryNameWindowsReserved": "Это имя зарезервировано в Windows.", + "entryNameNoteConflict": "В этой папке уже есть заметка с таким именем.", + "entryNameSnippetConflict": "В этой папке уже есть сниппет с таким именем.", + "entryNameFolderConflict": "Папка с таким именем уже существует на этом уровне." }, "description": { "storageVault": "Выберите директорию для хранилища. Для синхронизации между устройствами выберите папку в iCloud Drive, Google Drive или Dropbox.", diff --git a/src/main/storage/providers/markdown/notes/runtime/notes.ts b/src/main/storage/providers/markdown/notes/runtime/notes.ts index 97c2b43f..be3f4494 100644 --- a/src/main/storage/providers/markdown/notes/runtime/notes.ts +++ b/src/main/storage/providers/markdown/notes/runtime/notes.ts @@ -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 diff --git a/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts b/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts index 3e7c5137..28bd5aae 100644 --- a/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts +++ b/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts @@ -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', () => { diff --git a/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts b/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts index ddda81ba..89f5e2da 100644 --- a/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts +++ b/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts @@ -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 = '' @@ -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() diff --git a/src/main/storage/providers/markdown/notes/storages/folders.ts b/src/main/storage/providers/markdown/notes/storages/folders.ts index 6301a737..8e5c8e06 100644 --- a/src/main/storage/providers/markdown/notes/storages/folders.ts +++ b/src/main/storage/providers/markdown/notes/storages/folders.ts @@ -24,6 +24,7 @@ import { import { assertDirectoryNameAvailableAtRoot, assertUniqueSiblingFolderName, + resolveUniqueSiblingFolderName, throwStorageError, validateEntryName, } from '../../runtime/validation' @@ -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, @@ -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( diff --git a/src/main/storage/providers/markdown/notes/storages/notes.ts b/src/main/storage/providers/markdown/notes/storages/notes.ts index 73c1adfd..028b742a 100644 --- a/src/main/storage/providers/markdown/notes/storages/notes.ts +++ b/src/main/storage/providers/markdown/notes/storages/notes.ts @@ -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' @@ -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({ createEntity: ({ folderId, id, name, now }) => ({ content: '', @@ -167,6 +172,7 @@ export function createNotesNotesStorage(): NotesStorage { } const previousFilePath = note.filePath + const previousFolderId = note.folderId const updateResult = applyEntityUpdateFields({ entity: note, fieldPresence: 'defined', @@ -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 } diff --git a/src/main/storage/providers/markdown/runtime/index.ts b/src/main/storage/providers/markdown/runtime/index.ts index 88d5459d..ba77f7d0 100644 --- a/src/main/storage/providers/markdown/runtime/index.ts +++ b/src/main/storage/providers/markdown/runtime/index.ts @@ -126,6 +126,7 @@ export type { export { assertDirectoryNameAvailable, assertNotReservedRootFolderName, + assertUniqueSiblingEntryName, assertUniqueSiblingFolderName, getMarkdownStorageErrorMessage, resolveUniqueSiblingFolderName, diff --git a/src/main/storage/providers/markdown/runtime/validation.ts b/src/main/storage/providers/markdown/runtime/validation.ts index 7d85c28b..dd38f902 100644 --- a/src/main/storage/providers/markdown/runtime/validation.ts +++ b/src/main/storage/providers/markdown/runtime/validation.ts @@ -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: { diff --git a/src/main/storage/providers/markdown/storages/__tests__/snippets.test.ts b/src/main/storage/providers/markdown/storages/__tests__/snippets.test.ts index a3270665..d10314ca 100644 --- a/src/main/storage/providers/markdown/storages/__tests__/snippets.test.ts +++ b/src/main/storage/providers/markdown/storages/__tests__/snippets.test.ts @@ -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 = '' @@ -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') + }) }) diff --git a/src/main/storage/providers/markdown/storages/snippets.ts b/src/main/storage/providers/markdown/storages/snippets.ts index f1ed1ddd..499be57e 100644 --- a/src/main/storage/providers/markdown/storages/snippets.ts +++ b/src/main/storage/providers/markdown/storages/snippets.ts @@ -10,6 +10,7 @@ import type { } from '../../../contracts' import path from 'node:path' import { + assertUniqueSiblingEntryName, createSnippetRecord, findFolderById, findSnippetByContentId, @@ -90,6 +91,7 @@ export function createSnippetsStorage(): SnippetsStorage { const name = validateEntryName(input.name, 'snippet') const folderId = input.folderId ?? null + assertUniqueSiblingEntryName(snippets, folderId, name, 'snippet') const result = createEntityInStateAndDisk({ createEntity: ({ folderId, id, name, now }) => ({ contents: [], @@ -159,6 +161,7 @@ export function createSnippetsStorage(): SnippetsStorage { } const previousPath = snippet.filePath + const previousFolderId = snippet.folderId const updateResult = applyEntityUpdateFields({ entity: snippet, fieldPresence: 'in', @@ -167,8 +170,25 @@ export function createSnippetsStorage(): SnippetsStorage { normalizeFlag: value => value || 0, onMissingFolder: () => throwStorageError('FOLDER_NOT_FOUND', 'Folder not found'), - resolveName: (inputName, currentName) => - validateEntryName(inputName || currentName, 'snippet'), + resolveName: (inputName, currentName) => { + const next = validateEntryName(inputName || currentName, 'snippet') + const isFolderChanging + = 'folderId' in input + && (input.folderId ?? null) !== previousFolderId + if ( + !isFolderChanging + && next.toLowerCase() !== currentName.toLowerCase() + ) { + assertUniqueSiblingEntryName( + snippets, + previousFolderId, + next, + 'snippet', + snippet.id, + ) + } + return next + }, }) if (!updateResult.hasAnyField) { return { diff --git a/src/renderer/components/editor/header/Header.vue b/src/renderer/components/editor/header/Header.vue index 2580b514..89b37620 100644 --- a/src/renderer/components/editor/header/Header.vue +++ b/src/renderer/components/editor/header/Header.vue @@ -8,6 +8,7 @@ import { } from '@/composables' import { i18n } from '@/electron' import { navigateBack, navigateForward } from '@/ipc/listeners/deepLinks' +import { getEntryNameConflictMessage } from '@/utils' import { ChevronLeft, ChevronRight, @@ -25,6 +26,7 @@ import { } from '~/shared/entryNameValidation' const { + displayedSnippets, selectedSnippet, selectedSnippetContent, addFragment, @@ -41,6 +43,20 @@ const { } = useApp() const { addToUpdateQueue } = useSnippetUpdate() +function hasSiblingSnippetNameConflict(value: string, excludeId: number) { + const normalized = value.trim().toLowerCase() + if (!normalized || !selectedSnippet.value) { + return false + } + const folderId = selectedSnippet.value.folder?.id ?? null + return (displayedSnippets.value ?? []).some( + snippet => + snippet.id !== excludeId + && (snippet.folder?.id ?? null) === folderId + && snippet.name.toLowerCase() === normalized, + ) +} + const isShowDescription = ref(false) const isNameFocused = ref(false) @@ -56,12 +72,20 @@ const { return } - addToUpdateQueue(selectedSnippet.value!.id, { + if (!selectedSnippet.value) { + return + } + + if (hasSiblingSnippetNameConflict(v, selectedSnippet.value.id)) { + return + } + + addToUpdateQueue(selectedSnippet.value.id, { name: v, - description: selectedSnippet.value!.description, - folderId: selectedSnippet.value!.folder?.id || null, - isDeleted: selectedSnippet.value!.isDeleted, - isFavorites: selectedSnippet.value!.isFavorites, + description: selectedSnippet.value.description, + folderId: selectedSnippet.value.folder?.id || null, + isDeleted: selectedSnippet.value.isDeleted, + isFavorites: selectedSnippet.value.isFavorites, }) }, ) @@ -69,32 +93,49 @@ const { const nameValidationIssue = computed(() => getEntryNameValidationIssue(name.value), ) +const hasNameConflict = computed(() => { + if (nameValidationIssue.value || !selectedSnippet.value) { + return false + } + + if ( + name.value.trim().toLowerCase() === selectedSnippet.value.name.toLowerCase() + ) { + return false + } + + return hasSiblingSnippetNameConflict(name.value, selectedSnippet.value.id) +}) const nameValidationMessage = computed(() => { const issue = nameValidationIssue.value - if (!issue) { - return '' - } + if (issue) { + if (issue.code === 'invalidChars') { + return i18n.t('messages:error.entryNameInvalidChars', { + chars: formatEntryNameValidationChars(issue.chars), + }) + } - if (issue.code === 'invalidChars') { - return i18n.t('messages:error.entryNameInvalidChars', { - chars: formatEntryNameValidationChars(issue.chars), - }) - } + if (issue.code === 'leadingDot') { + return i18n.t('messages:error.entryNameLeadingDot') + } - if (issue.code === 'leadingDot') { - return i18n.t('messages:error.entryNameLeadingDot') - } + if (issue.code === 'trailingDot') { + return i18n.t('messages:error.entryNameTrailingDot') + } + + if (issue.code === 'windowsReserved') { + return i18n.t('messages:error.entryNameWindowsReserved') + } - if (issue.code === 'trailingDot') { - return i18n.t('messages:error.entryNameTrailingDot') + return i18n.t('messages:error.entryNameEmpty') } - if (issue.code === 'windowsReserved') { - return i18n.t('messages:error.entryNameWindowsReserved') + if (hasNameConflict.value) { + return getEntryNameConflictMessage('snippet', i18n.t.bind(i18n)) } - return i18n.t('messages:error.entryNameEmpty') + return '' }) const isNameValidationTooltipOpen = computed(() => { @@ -107,7 +148,7 @@ function onSnippetNameFocus() { } function onNameBlur() { - if (nameValidationIssue.value) { + if (nameValidationIssue.value || hasNameConflict.value) { resetName() } diff --git a/src/renderer/components/notes/NotesEditorPane.vue b/src/renderer/components/notes/NotesEditorPane.vue index dbc1b581..bf374fda 100644 --- a/src/renderer/components/notes/NotesEditorPane.vue +++ b/src/renderer/components/notes/NotesEditorPane.vue @@ -10,6 +10,7 @@ import { import { i18n } from '@/electron' import { navigateBack, navigateForward } from '@/ipc/listeners/deepLinks' import { router, RouterName } from '@/router' +import { getEntryNameConflictMessage } from '@/utils' import { BookOpen, ChevronLeft, @@ -30,6 +31,7 @@ import { shouldSyncSelectedNoteContent } from './editorSync' import { getTextStats } from './textStats' const { + notes, selectedNote, updateNoteContent, isNotesLoading, @@ -37,6 +39,20 @@ const { } = useNotes() const { canGoBack, canGoForward } = useNavigationHistory() const { addToUpdateQueue } = useNoteUpdate() + +function hasSiblingNoteNameConflict(value: string, excludeId: number) { + const normalized = value.trim().toLowerCase() + if (!normalized || !selectedNote.value) { + return false + } + const folderId = selectedNote.value.folder?.id ?? null + return (notes.value ?? []).some( + note => + note.id !== excludeId + && (note.folder?.id ?? null) === folderId + && note.name.toLowerCase() === normalized, + ) +} const { isFocusedNoteName, isNotesMindmapShown, @@ -111,41 +127,64 @@ const { return } - if (selectedNote.value) { - addToUpdateQueue(selectedNote.value.id, { name: v }) + if (!selectedNote.value) { + return } + + if (hasSiblingNoteNameConflict(v, selectedNote.value.id)) { + return + } + + addToUpdateQueue(selectedNote.value.id, { name: v }) }, ) const nameValidationIssue = computed(() => getEntryNameValidationIssue(name.value), ) +const hasNameConflict = computed(() => { + if (nameValidationIssue.value || !selectedNote.value) { + return false + } + + if ( + name.value.trim().toLowerCase() === selectedNote.value.name.toLowerCase() + ) { + return false + } + + return hasSiblingNoteNameConflict(name.value, selectedNote.value.id) +}) const nameValidationMessage = computed(() => { const issue = nameValidationIssue.value - if (!issue) { - return '' - } + if (issue) { + if (issue.code === 'invalidChars') { + return i18n.t('messages:error.entryNameInvalidChars', { + chars: formatEntryNameValidationChars(issue.chars), + }) + } - if (issue.code === 'invalidChars') { - return i18n.t('messages:error.entryNameInvalidChars', { - chars: formatEntryNameValidationChars(issue.chars), - }) - } + if (issue.code === 'leadingDot') { + return i18n.t('messages:error.entryNameLeadingDot') + } - if (issue.code === 'leadingDot') { - return i18n.t('messages:error.entryNameLeadingDot') - } + if (issue.code === 'trailingDot') { + return i18n.t('messages:error.entryNameTrailingDot') + } + + if (issue.code === 'windowsReserved') { + return i18n.t('messages:error.entryNameWindowsReserved') + } - if (issue.code === 'trailingDot') { - return i18n.t('messages:error.entryNameTrailingDot') + return i18n.t('messages:error.entryNameEmpty') } - if (issue.code === 'windowsReserved') { - return i18n.t('messages:error.entryNameWindowsReserved') + if (hasNameConflict.value) { + return getEntryNameConflictMessage('note', i18n.t.bind(i18n)) } - return i18n.t('messages:error.entryNameEmpty') + return '' }) const isNameValidationTooltipOpen = computed(() => { @@ -158,7 +197,7 @@ function onNoteNameFocus() { } function onNameBlur() { - if (nameValidationIssue.value) { + if (nameValidationIssue.value || hasNameConflict.value) { resetName() } diff --git a/src/renderer/components/notes/NotesSidebarFolders.vue b/src/renderer/components/notes/NotesSidebarFolders.vue index 1334cce0..f45c01d9 100644 --- a/src/renderer/components/notes/NotesSidebarFolders.vue +++ b/src/renderer/components/notes/NotesSidebarFolders.vue @@ -12,7 +12,10 @@ import { } from '@/composables' import { i18n, store } from '@/electron' import { router, RouterName } from '@/router' -import { getEntryNameValidationMessage } from '@/utils' +import { + getEntryNameConflictMessage, + getEntryNameValidationMessage, +} from '@/utils' import { Folder, Plus } from 'lucide-vue-next' import { useRoute } from 'vue-router' import { LAYOUT_DEFAULTS } from '~/main/store/constants' @@ -115,8 +118,49 @@ const highlightedIds = computed({ const contextNode = ref(null) -function getFolderValidationMessage(_node: TreeNodeType, value: string) { - return getEntryNameValidationMessage(value, i18n.t.bind(i18n)) +function flattenFolders(nodes: any[], acc: any[] = []): any[] { + for (const folder of nodes) { + acc.push(folder) + if (folder.children?.length) { + flattenFolders(folder.children, acc) + } + } + + return acc +} + +function hasSiblingFolderConflict(node: TreeNodeType, value: string): boolean { + const folderId = Number(node.id) + const folder = getFolderByIdFromTree(folders.value, folderId) + if (!folder) { + return false + } + + const normalized = value.trim().toLowerCase() + if (!normalized || normalized === folder.name.toLowerCase()) { + return false + } + + const parentId = folder.parentId ?? null + return flattenFolders(folders.value || []).some( + sibling => + sibling.id !== folderId + && (sibling.parentId ?? null) === parentId + && sibling.name.toLowerCase() === normalized, + ) +} + +function getFolderValidationMessage(node: TreeNodeType, value: string) { + const message = getEntryNameValidationMessage(value, i18n.t.bind(i18n)) + if (message) { + return message + } + + if (hasSiblingFolderConflict(node, value)) { + return getEntryNameConflictMessage('folder', i18n.t.bind(i18n)) + } + + return '' } // --- Event handlers --- diff --git a/src/renderer/components/sidebar/folders/Tree.vue b/src/renderer/components/sidebar/folders/Tree.vue index 294b65db..add73101 100644 --- a/src/renderer/components/sidebar/folders/Tree.vue +++ b/src/renderer/components/sidebar/folders/Tree.vue @@ -6,7 +6,11 @@ import * as ContextMenu from '@/components/ui/shadcn/context-menu' import { Tree as UiTree } from '@/components/ui/tree' import { useApp, useDialog, useFolders, useSnippets } from '@/composables' import { i18n } from '@/electron' -import { getEntryNameValidationMessage, scrollToElement } from '@/utils' +import { + getEntryNameConflictMessage, + getEntryNameValidationMessage, + scrollToElement, +} from '@/utils' import { Folder } from 'lucide-vue-next' import CustomIcons from './custom-icons/CustomIcons.vue' @@ -111,8 +115,49 @@ const contextNodeDefaultLanguage = computed(() => { ) }) -function getFolderValidationMessage(_node: TreeNodeType, value: string) { - return getEntryNameValidationMessage(value, i18n.t.bind(i18n)) +function flattenFolders(nodes: Node[], acc: Node[] = []): Node[] { + for (const folder of nodes) { + acc.push(folder) + if (folder.children?.length) { + flattenFolders(folder.children, acc) + } + } + + return acc +} + +function hasSiblingFolderConflict(node: TreeNodeType, value: string): boolean { + const folderId = Number(node.id) + const folder = getFolderByIdFromTree(folders.value, folderId) + if (!folder) { + return false + } + + const normalized = value.trim().toLowerCase() + if (!normalized || normalized === folder.name.toLowerCase()) { + return false + } + + const parentId = folder.parentId ?? null + return flattenFolders((folders.value ?? []) as Node[]).some( + sibling => + sibling.id !== folderId + && (sibling.parentId ?? null) === parentId + && sibling.name.toLowerCase() === normalized, + ) +} + +function getFolderValidationMessage(node: TreeNodeType, value: string) { + const message = getEntryNameValidationMessage(value, i18n.t.bind(i18n)) + if (message) { + return message + } + + if (hasSiblingFolderConflict(node, value)) { + return getEntryNameConflictMessage('folder', i18n.t.bind(i18n)) + } + + return '' } // --- Event handlers --- diff --git a/src/renderer/composables/spaces/notes/useNotes.ts b/src/renderer/composables/spaces/notes/useNotes.ts index 7f492b9d..6faafb2f 100644 --- a/src/renderer/composables/spaces/notes/useNotes.ts +++ b/src/renderer/composables/spaces/notes/useNotes.ts @@ -196,13 +196,18 @@ function getNextIndexedName(baseName: string, existingNames: string[]): string { async function getNoteNamesForCreate( folderId: number | null, ): Promise { - const query: NotesQuery - = folderId !== null - ? { folderId, isDeleted: 0 } - : { isInbox: 1, isDeleted: 0 } + const query: NotesQuery = { isDeleted: 0 } + if (folderId !== null) { + query.folderId = folderId + } + else { + query.isInbox = 1 + } const { data } = await api.notes.getNotes(query) - return data.map((note: NoteRecord) => note.name) + return data + .filter((note: NoteRecord) => (note.folder?.id ?? null) === folderId) + .map((note: NoteRecord) => note.name) } // --- CRUD --- diff --git a/src/renderer/composables/useSnippets.ts b/src/renderer/composables/useSnippets.ts index b4ccf2ec..301a3d85 100644 --- a/src/renderer/composables/useSnippets.ts +++ b/src/renderer/composables/useSnippets.ts @@ -60,13 +60,18 @@ function getNextIndexedName(baseName: string, existingNames: string[]): string { async function getSnippetNamesForCreate( folderId: number | null, ): Promise { - const query: SnippetsQuery - = folderId !== null - ? { folderId, isDeleted: 0 } - : { isInbox: 1, isDeleted: 0 } + const query: SnippetsQuery = { isDeleted: 0 } + if (folderId !== null) { + query.folderId = folderId + } + else { + query.isInbox = 1 + } const { data } = await api.snippets.getSnippets(query) - return data.map(snippet => snippet.name) + return data + .filter(snippet => (snippet.folder?.id ?? null) === folderId) + .map(snippet => snippet.name) } const displayedSnippets = computed(() => { diff --git a/src/renderer/utils/entryNameValidationMessage.ts b/src/renderer/utils/entryNameValidationMessage.ts index 307d66d1..229866eb 100644 --- a/src/renderer/utils/entryNameValidationMessage.ts +++ b/src/renderer/utils/entryNameValidationMessage.ts @@ -5,6 +5,14 @@ import { type TranslateFn = (key: string, params?: Record) => string +export type EntryNameConflictKind = 'note' | 'snippet' | 'folder' + +const CONFLICT_MESSAGE_KEYS: Record = { + folder: 'messages:error.entryNameFolderConflict', + note: 'messages:error.entryNameNoteConflict', + snippet: 'messages:error.entryNameSnippetConflict', +} + export function getEntryNameValidationMessage( name: string, translate: TranslateFn, @@ -35,3 +43,10 @@ export function getEntryNameValidationMessage( return translate('messages:error.entryNameEmpty') } + +export function getEntryNameConflictMessage( + kind: EntryNameConflictKind, + translate: TranslateFn, +): string { + return translate(CONFLICT_MESSAGE_KEYS[kind]) +}