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 }} - + +