From 341e2aa86ceecd16cf17b98a571c880cf4a99ab3 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Fri, 24 Apr 2026 10:00:23 +0300 Subject: [PATCH 1/3] feat: add inline entry name validation for notes and snippets --- src/main/i18n/locales/en_US/messages.json | 7 +- src/main/i18n/locales/ru_RU/messages.json | 7 +- .../components/editor/header/Header.vue | 77 +++++++++++++-- .../components/notes/NotesEditorPane.vue | 76 +++++++++++++-- .../components/ui/input/ValidationTooltip.vue | 36 +++++++ src/renderer/composables/useEditableField.ts | 6 +- .../__tests__/entryNameValidation.test.ts | 57 ++++++++++++ src/shared/entryNameValidation.ts | 93 +++++++++++++++++++ 8 files changed, 339 insertions(+), 20 deletions(-) create mode 100644 src/renderer/components/ui/input/ValidationTooltip.vue create mode 100644 src/shared/__tests__/entryNameValidation.test.ts create mode 100644 src/shared/entryNameValidation.ts diff --git a/src/main/i18n/locales/en_US/messages.json b/src/main/i18n/locales/en_US/messages.json index 110ee554..2f716b2a 100644 --- a/src/main/i18n/locales/en_US/messages.json +++ b/src/main/i18n/locales/en_US/messages.json @@ -31,7 +31,12 @@ ] }, "error": { - "migration": "Auto-migration from SQLite failed: {{error}}" + "migration": "Auto-migration from SQLite failed: {{error}}", + "entryNameEmpty": "Name cannot be empty.", + "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." }, "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 74bc9b60..e1904068 100644 --- a/src/main/i18n/locales/ru_RU/messages.json +++ b/src/main/i18n/locales/ru_RU/messages.json @@ -26,7 +26,12 @@ ] }, "error": { - "migration": "Автомиграция из SQLite завершилась ошибкой: {{error}}" + "migration": "Автомиграция из SQLite завершилась ошибкой: {{error}}", + "entryNameEmpty": "Имя не может быть пустым.", + "entryNameLeadingDot": "Имя не может начинаться с точки.", + "entryNameInvalidChars": "Имя не может содержать эти символы: {{- chars}}", + "entryNameTrailingDot": "Имя не может заканчиваться точкой.", + "entryNameWindowsReserved": "Это имя зарезервировано в Windows." }, "description": { "storageVault": "Выберите директорию для хранилища. Для синхронизации между устройствами выберите папку в iCloud Drive, Google Drive или Dropbox.", diff --git a/src/renderer/components/editor/header/Header.vue b/src/renderer/components/editor/header/Header.vue index 354ea5d4..2580b514 100644 --- a/src/renderer/components/editor/header/Header.vue +++ b/src/renderer/components/editor/header/Header.vue @@ -8,7 +8,6 @@ import { } from '@/composables' import { i18n } from '@/electron' import { navigateBack, navigateForward } from '@/ipc/listeners/deepLinks' - import { ChevronLeft, ChevronRight, @@ -20,6 +19,10 @@ import { Plus, Type, } from 'lucide-vue-next' +import { + formatEntryNameValidationChars, + getEntryNameValidationIssue, +} from '~/shared/entryNameValidation' const { selectedSnippet, @@ -39,14 +42,20 @@ const { const { addToUpdateQueue } = useSnippetUpdate() const isShowDescription = ref(false) +const isNameFocused = ref(false) const { model: name, onFocus: onNameFocus, onBlur, + reset: resetName, } = useEditableField( () => selectedSnippet?.value?.name, (v) => { + if (getEntryNameValidationIssue(v)) { + return + } + addToUpdateQueue(selectedSnippet.value!.id, { name: v, description: selectedSnippet.value!.description, @@ -57,7 +66,52 @@ const { }, ) +const nameValidationIssue = computed(() => + getEntryNameValidationIssue(name.value), +) +const nameValidationMessage = computed(() => { + const issue = nameValidationIssue.value + + if (!issue) { + return '' + } + + 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 === 'trailingDot') { + return i18n.t('messages:error.entryNameTrailingDot') + } + + if (issue.code === 'windowsReserved') { + return i18n.t('messages:error.entryNameWindowsReserved') + } + + return i18n.t('messages:error.entryNameEmpty') +}) + +const isNameValidationTooltipOpen = computed(() => { + return isNameFocused.value && Boolean(nameValidationMessage.value) +}) + +function onSnippetNameFocus() { + isNameFocused.value = true + onNameFocus() +} + function onNameBlur() { + if (nameValidationIssue.value) { + resetName() + } + + isNameFocused.value = false onBlur() isFocusedSnippetName.value = false } @@ -129,14 +183,19 @@ function onJsonVisualizerToggle() {
- + + +
diff --git a/src/renderer/components/notes/NotesEditorPane.vue b/src/renderer/components/notes/NotesEditorPane.vue index 641838d1..dbc1b581 100644 --- a/src/renderer/components/notes/NotesEditorPane.vue +++ b/src/renderer/components/notes/NotesEditorPane.vue @@ -22,6 +22,10 @@ import { Pencil, Presentation, } from 'lucide-vue-next' +import { + formatEntryNameValidationChars, + getEntryNameValidationIssue, +} from '~/shared/entryNameValidation' import { shouldSyncSelectedNoteContent } from './editorSync' import { getTextStats } from './textStats' @@ -61,6 +65,7 @@ const presentationActionTooltip = computed(() => : i18n.t('menu:markdown.presentationMode'), ) const isHistoryVisible = computed(() => canGoBack.value || canGoForward.value) +const isNameFocused = ref(false) function onSidebarToggle() { toggleNotesSidebar() @@ -98,16 +103,66 @@ const { model: name, onFocus: onNameFocus, onBlur, + reset: resetName, } = useEditableField( () => selectedNote.value?.name, (v) => { + if (getEntryNameValidationIssue(v)) { + return + } + if (selectedNote.value) { addToUpdateQueue(selectedNote.value.id, { name: v }) } }, ) +const nameValidationIssue = computed(() => + getEntryNameValidationIssue(name.value), +) +const nameValidationMessage = computed(() => { + const issue = nameValidationIssue.value + + if (!issue) { + return '' + } + + 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 === 'trailingDot') { + return i18n.t('messages:error.entryNameTrailingDot') + } + + if (issue.code === 'windowsReserved') { + return i18n.t('messages:error.entryNameWindowsReserved') + } + + return i18n.t('messages:error.entryNameEmpty') +}) + +const isNameValidationTooltipOpen = computed(() => { + return isNameFocused.value && Boolean(nameValidationMessage.value) +}) + +function onNoteNameFocus() { + isNameFocused.value = true + onNameFocus() +} + function onNameBlur() { + if (nameValidationIssue.value) { + resetName() + } + + isNameFocused.value = false onBlur() isFocusedNoteName.value = false } @@ -170,14 +225,19 @@ const textStats = computed(() => getTextStats(content.value))
- + + +
diff --git a/src/renderer/components/ui/input/ValidationTooltip.vue b/src/renderer/components/ui/input/ValidationTooltip.vue new file mode 100644 index 00000000..b65e9e05 --- /dev/null +++ b/src/renderer/components/ui/input/ValidationTooltip.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/renderer/composables/useEditableField.ts b/src/renderer/composables/useEditableField.ts index 822f555a..8b61017f 100644 --- a/src/renderer/composables/useEditableField.ts +++ b/src/renderer/composables/useEditableField.ts @@ -33,5 +33,9 @@ export function useEditableField( isFocused.value = false } - return { model, onFocus, onBlur } + function reset() { + localValue.value = source() ?? '' + } + + return { model, onFocus, onBlur, reset } } diff --git a/src/shared/__tests__/entryNameValidation.test.ts b/src/shared/__tests__/entryNameValidation.test.ts new file mode 100644 index 00000000..8a1d57c1 --- /dev/null +++ b/src/shared/__tests__/entryNameValidation.test.ts @@ -0,0 +1,57 @@ +import { + findInvalidEntryNameChars, + formatEntryNameValidationChars, + getEntryNameValidationIssue, +} from '../entryNameValidation' + +describe('entryNameValidation', () => { + it('collects unique invalid characters in input order', () => { + expect(findInvalidEntryNameChars('bad:/name:*')).toEqual([':', '/', '*']) + }) + + it('returns invalid chars issue with collected characters', () => { + expect(getEntryNameValidationIssue('bad:/name')).toEqual({ + code: 'invalidChars', + chars: [':', '/'], + }) + expect(getEntryNameValidationIssue('bad#[name]^')).toEqual({ + code: 'invalidChars', + chars: ['#', '[', ']', '^'], + }) + }) + + it('returns empty issue for blank names', () => { + expect(getEntryNameValidationIssue(' ')).toEqual({ + code: 'empty', + }) + }) + + it('returns trailing issue for names ending with dot', () => { + expect(getEntryNameValidationIssue('note.')).toEqual({ + code: 'trailingDot', + }) + }) + + it('returns leading dot issue for names starting with dot', () => { + expect(getEntryNameValidationIssue('.note')).toEqual({ + code: 'leadingDot', + }) + }) + + it('returns reserved issue for Windows reserved names', () => { + expect(getEntryNameValidationIssue('con')).toEqual({ + code: 'windowsReserved', + }) + expect(getEntryNameValidationIssue('LPT1.txt')).toEqual({ + code: 'windowsReserved', + }) + }) + + it('returns null for valid names', () => { + expect(getEntryNameValidationIssue('Valid note name')).toBeNull() + }) + + it('formats control characters for tooltip display', () => { + expect(formatEntryNameValidationChars([':', '\n'])).toBe(': U+000A') + }) +}) diff --git a/src/shared/entryNameValidation.ts b/src/shared/entryNameValidation.ts new file mode 100644 index 00000000..26f44eed --- /dev/null +++ b/src/shared/entryNameValidation.ts @@ -0,0 +1,93 @@ +export type EntryNameValidationIssue = + | { code: 'empty' } + | { code: 'leadingDot' } + | { code: 'invalidChars', chars: string[] } + | { code: 'trailingDot' } + | { code: 'windowsReserved' } + +const INVALID_ENTRY_NAME_CHARS = new Set([ + '#', + '<', + '>', + ':', + '"', + '/', + '\\', + '[', + ']', + '^', + '|', + '?', + '*', +]) + +const WINDOWS_RESERVED_NAME_RE + = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i + +function normalizeEntryName(name: string): string { + return name.trim() +} + +export function findInvalidEntryNameChars(name: string): string[] { + const chars: string[] = [] + const seen = new Set() + + for (const char of name) { + const isInvalid + = INVALID_ENTRY_NAME_CHARS.has(char) || char.charCodeAt(0) <= 0x1F + + if (!isInvalid || seen.has(char)) { + continue + } + + seen.add(char) + chars.push(char) + } + + return chars +} + +export function formatEntryNameValidationChars(chars: string[]): string { + return chars + .map((char) => { + if (char.charCodeAt(0) <= 0x1F) { + return `U+${char.charCodeAt(0).toString(16).padStart(4, '0').toUpperCase()}` + } + + return char + }) + .join(' ') +} + +export function getEntryNameValidationIssue( + name: string, +): EntryNameValidationIssue | null { + const normalizedName = normalizeEntryName(name) + + if (!normalizedName || normalizedName === '.' || normalizedName === '..') { + return { code: 'empty' } + } + + if (normalizedName.startsWith('.')) { + return { code: 'leadingDot' } + } + + const invalidChars = findInvalidEntryNameChars(normalizedName) + + if (invalidChars.length) { + return { + code: 'invalidChars', + chars: invalidChars, + } + } + + if (normalizedName.endsWith('.')) { + return { code: 'trailingDot' } + } + + if (WINDOWS_RESERVED_NAME_RE.test(normalizedName)) { + return { code: 'windowsReserved' } + } + + return null +} From 382597bb7769528d1527fb83bf4dce18b02bf209 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Fri, 24 Apr 2026 10:00:50 +0300 Subject: [PATCH 2/3] fix: reject invalid markdown storage entry names --- .../runtime/__tests__/validation.test.ts | 24 +++++++++++++++++++ .../providers/markdown/runtime/constants.ts | 4 ++++ .../providers/markdown/runtime/validation.ts | 4 ++++ 3 files changed, 32 insertions(+) create mode 100644 src/main/storage/providers/markdown/runtime/__tests__/validation.test.ts diff --git a/src/main/storage/providers/markdown/runtime/__tests__/validation.test.ts b/src/main/storage/providers/markdown/runtime/__tests__/validation.test.ts new file mode 100644 index 00000000..d5ad7989 --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/__tests__/validation.test.ts @@ -0,0 +1,24 @@ +import { validateEntryName } from '../validation' + +describe('validateEntryName', () => { + it('rejects names starting with dot', () => { + expect(() => validateEntryName('.note', 'note')).toThrow( + 'INVALID_NAME:note name cannot start with a dot', + ) + expect(() => validateEntryName('.snippet', 'snippet')).toThrow( + 'INVALID_NAME:snippet name cannot start with a dot', + ) + }) + + it('rejects names with Obsidian-reserved characters', () => { + expect(() => validateEntryName('note#heading', 'note')).toThrow( + 'INVALID_NAME:note name contains invalid characters', + ) + expect(() => validateEntryName('folder[name]', 'folder')).toThrow( + 'INVALID_NAME:folder name contains invalid characters', + ) + expect(() => validateEntryName('snippet^block', 'snippet')).toThrow( + 'INVALID_NAME:snippet name contains invalid characters', + ) + }) +}) diff --git a/src/main/storage/providers/markdown/runtime/constants.ts b/src/main/storage/providers/markdown/runtime/constants.ts index e44344b0..2c32a354 100644 --- a/src/main/storage/providers/markdown/runtime/constants.ts +++ b/src/main/storage/providers/markdown/runtime/constants.ts @@ -33,12 +33,16 @@ export const SEARCH_WORD_RE = /[\p{L}\p{N}_]+/gu export const STATE_WRITE_DEBOUNCE_MS = 100 export const INVALID_NAME_CHARS_RE = /[<>:"/\\|?*]/g export const INVALID_NAME_CHARS = new Set([ + '#', '<', '>', ':', '"', '/', '\\', + '[', + ']', + '^', '|', '?', '*', diff --git a/src/main/storage/providers/markdown/runtime/validation.ts b/src/main/storage/providers/markdown/runtime/validation.ts index 3aa72e47..7d85c28b 100644 --- a/src/main/storage/providers/markdown/runtime/validation.ts +++ b/src/main/storage/providers/markdown/runtime/validation.ts @@ -49,6 +49,10 @@ export function validateEntryName( throwStorageError('INVALID_NAME', `${kind} name is empty or invalid`) } + if (normalized.startsWith('.')) { + throwStorageError('INVALID_NAME', `${kind} name cannot start with a dot`) + } + if (hasInvalidNameChars(normalized)) { throwStorageError( 'INVALID_NAME', From 627e8b33290d81fdbd782de15b47d4f4971510cc Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Fri, 24 Apr 2026 10:01:06 +0300 Subject: [PATCH 3/3] feat: show inline validation when renaming folders --- .../components/notes/NotesSidebarFolders.vue | 6 +++ .../components/sidebar/folders/Tree.vue | 7 +++- src/renderer/components/ui/tree/Tree.vue | 2 + src/renderer/components/ui/tree/TreeNode.vue | 40 ++++++++++++++----- src/renderer/components/ui/tree/keys.ts | 1 + .../entryNameValidationMessage.test.ts | 35 ++++++++++++++++ .../utils/entryNameValidationMessage.ts | 37 +++++++++++++++++ src/renderer/utils/index.ts | 2 + 8 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 src/renderer/utils/__tests__/entryNameValidationMessage.test.ts create mode 100644 src/renderer/utils/entryNameValidationMessage.ts diff --git a/src/renderer/components/notes/NotesSidebarFolders.vue b/src/renderer/components/notes/NotesSidebarFolders.vue index d8bfaed2..1334cce0 100644 --- a/src/renderer/components/notes/NotesSidebarFolders.vue +++ b/src/renderer/components/notes/NotesSidebarFolders.vue @@ -12,6 +12,7 @@ import { } from '@/composables' import { i18n, store } from '@/electron' import { router, RouterName } from '@/router' +import { getEntryNameValidationMessage } from '@/utils' import { Folder, Plus } from 'lucide-vue-next' import { useRoute } from 'vue-router' import { LAYOUT_DEFAULTS } from '~/main/store/constants' @@ -114,6 +115,10 @@ const highlightedIds = computed({ const contextNode = ref(null) +function getFolderValidationMessage(_node: TreeNodeType, value: string) { + return getEntryNameValidationMessage(value, i18n.t.bind(i18n)) +} + // --- Event handlers --- async function onClickNode({ @@ -220,6 +225,7 @@ function onCancelEdit() { :editable-id="editableId" :focused-id="focusedId" :highlighted-ids="highlightedIds" + :get-validation-message="getFolderValidationMessage" class="h-full px-0.5 pb-1" @click-node="onClickNode" @dblclick-node="onDblclickNode" diff --git a/src/renderer/components/sidebar/folders/Tree.vue b/src/renderer/components/sidebar/folders/Tree.vue index 4a3e1263..294b65db 100644 --- a/src/renderer/components/sidebar/folders/Tree.vue +++ b/src/renderer/components/sidebar/folders/Tree.vue @@ -6,7 +6,7 @@ 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 { scrollToElement } from '@/utils' +import { getEntryNameValidationMessage, scrollToElement } from '@/utils' import { Folder } from 'lucide-vue-next' import CustomIcons from './custom-icons/CustomIcons.vue' @@ -111,6 +111,10 @@ const contextNodeDefaultLanguage = computed(() => { ) }) +function getFolderValidationMessage(_node: TreeNodeType, value: string) { + return getEntryNameValidationMessage(value, i18n.t.bind(i18n)) +} + // --- Event handlers --- function onClickNode({ @@ -324,6 +328,7 @@ async function onRemoveCustomIcon() { :editable-id="editableId" :focused-id="focusedId" :highlighted-ids="highlightedIds" + :get-validation-message="getFolderValidationMessage" @click-node="onClickNode" @dblclick-node="onDblclickNode" @toggle-node="onToggleNode" diff --git a/src/renderer/components/ui/tree/Tree.vue b/src/renderer/components/ui/tree/Tree.vue index 4f3022ec..d229d645 100644 --- a/src/renderer/components/ui/tree/Tree.vue +++ b/src/renderer/components/ui/tree/Tree.vue @@ -10,6 +10,7 @@ interface Props { focusedId?: string | number | undefined highlightedIds?: Set indent?: number + getValidationMessage?: (node: TreeNode, value: string) => string } interface Emits { @@ -142,6 +143,7 @@ provide(treeInjectionKey, { contextMenu, updateLabel: updateLabelHandler, cancelEdit: cancelEditHandler, + getValidationMessage: props.getValidationMessage, isHoveredByIdDisabled, editableId: internalEditableId, selectedIds: internalSelectedIds, diff --git a/src/renderer/components/ui/tree/TreeNode.vue b/src/renderer/components/ui/tree/TreeNode.vue index 8a1e55eb..5ce632f4 100644 --- a/src/renderer/components/ui/tree/TreeNode.vue +++ b/src/renderer/components/ui/tree/TreeNode.vue @@ -37,6 +37,7 @@ const { contextMenu, updateLabel, cancelEdit, + getValidationMessage, editableId, selectedIds, focusedId, @@ -78,6 +79,9 @@ const isFocused = computed(() => focusedId.value === props.node.id) const isHighlighted = computed(() => highlightedIds.value.has(props.node.id)) const isEditing = computed(() => editableId.value === props.node.id) +const validationMessage = computed(() => { + return getValidationMessage?.(props.node, editValue.value) || '' +}) const isDragActive = computed(() => Boolean(dragStore.dragNode)) @@ -357,9 +361,13 @@ function onDrop(e: DragEvent) { // --- Inline Edit --- -function onUpdateLabel() { +function submitEdit() { const trimmed = editValue.value.trim() + if (validationMessage.value) { + return + } + if (trimmed === '' || editValue.value === props.node.label) { editValue.value = props.node.label cancelEdit(props.node) @@ -369,6 +377,15 @@ function onUpdateLabel() { updateLabel(props.node, editValue.value) } +function onBlurEdit() { + if (validationMessage.value) { + onCancelEdit() + return + } + + submitEdit() +} + function onCancelEdit() { editValue.value = props.node.label cancelEdit(props.node) @@ -438,16 +455,21 @@ function onCancelEdit() { {{ node.label }} - + +