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/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',
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/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/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/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 }}
-
+
+
diff --git a/src/renderer/components/ui/tree/keys.ts b/src/renderer/components/ui/tree/keys.ts
index 48140c54..d6742eb5 100644
--- a/src/renderer/components/ui/tree/keys.ts
+++ b/src/renderer/components/ui/tree/keys.ts
@@ -18,6 +18,7 @@ export interface TreeInjection {
contextMenu: (node: TreeNode) => void
updateLabel: (node: TreeNode, value: string) => void
cancelEdit: (node: TreeNode) => void
+ getValidationMessage?: (node: TreeNode, value: string) => string
isHoveredByIdDisabled: Ref
editableId: Ref
selectedIds: Ref<(string | number)[]>
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/renderer/utils/__tests__/entryNameValidationMessage.test.ts b/src/renderer/utils/__tests__/entryNameValidationMessage.test.ts
new file mode 100644
index 00000000..a2c7dcb4
--- /dev/null
+++ b/src/renderer/utils/__tests__/entryNameValidationMessage.test.ts
@@ -0,0 +1,35 @@
+import { getEntryNameValidationMessage } from '../entryNameValidationMessage'
+
+describe('getEntryNameValidationMessage', () => {
+ const t = (key: string, params?: Record) => {
+ if (key === 'messages:error.entryNameLeadingDot') {
+ return 'leading dot'
+ }
+
+ if (key === 'messages:error.entryNameTrailingDot') {
+ return 'trailing dot'
+ }
+
+ if (key === 'messages:error.entryNameWindowsReserved') {
+ return 'windows reserved'
+ }
+
+ if (key === 'messages:error.entryNameEmpty') {
+ return 'empty'
+ }
+
+ if (key === 'messages:error.entryNameInvalidChars') {
+ return `invalid ${(params?.chars as string) || ''}`.trim()
+ }
+
+ return key
+ }
+
+ it('returns leading dot message', () => {
+ expect(getEntryNameValidationMessage('.folder', t)).toBe('leading dot')
+ })
+
+ it('returns invalid chars message with raw characters', () => {
+ expect(getEntryNameValidationMessage('bad) => string
+
+export function getEntryNameValidationMessage(
+ name: string,
+ translate: TranslateFn,
+): string {
+ const issue = getEntryNameValidationIssue(name)
+
+ if (!issue) {
+ return ''
+ }
+
+ if (issue.code === 'invalidChars') {
+ return translate('messages:error.entryNameInvalidChars', {
+ chars: formatEntryNameValidationChars(issue.chars),
+ })
+ }
+
+ if (issue.code === 'leadingDot') {
+ return translate('messages:error.entryNameLeadingDot')
+ }
+
+ if (issue.code === 'trailingDot') {
+ return translate('messages:error.entryNameTrailingDot')
+ }
+
+ if (issue.code === 'windowsReserved') {
+ return translate('messages:error.entryNameWindowsReserved')
+ }
+
+ return translate('messages:error.entryNameEmpty')
+}
diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts
index e623fd04..9b1d2c17 100644
--- a/src/renderer/utils/index.ts
+++ b/src/renderer/utils/index.ts
@@ -40,3 +40,5 @@ export function getContiguousSelection(
return orderedIds.slice(startIndex, endIndex + 1)
}
+
+export * from './entryNameValidationMessage'
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
+}