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
7 changes: 6 additions & 1 deletion src/main/i18n/locales/en_US/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
7 changes: 6 additions & 1 deletion src/main/i18n/locales/ru_RU/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
)
})
})
4 changes: 4 additions & 0 deletions src/main/storage/providers/markdown/runtime/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
'#',
'<',
'>',
':',
'"',
'/',
'\\',
'[',
']',
'^',
'|',
'?',
'*',
Expand Down
4 changes: 4 additions & 0 deletions src/main/storage/providers/markdown/runtime/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
77 changes: 68 additions & 9 deletions src/renderer/components/editor/header/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from '@/composables'
import { i18n } from '@/electron'
import { navigateBack, navigateForward } from '@/ipc/listeners/deepLinks'

import {
ChevronLeft,
ChevronRight,
Expand All @@ -20,6 +19,10 @@ import {
Plus,
Type,
} from 'lucide-vue-next'
import {
formatEntryNameValidationChars,
getEntryNameValidationIssue,
} from '~/shared/entryNameValidation'

const {
selectedSnippet,
Expand All @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -129,14 +183,19 @@ function onJsonVisualizerToggle() {
</UiActionButton>
</div>
<div class="min-w-0 flex-1">
<UiInput
v-model="name"
variant="ghost"
class="w-full truncate px-0"
:select="isFocusedSnippetName"
@focus="onNameFocus"
@blur="onNameBlur"
/>
<UiInputValidationTooltip
:open="isNameValidationTooltipOpen"
:message="nameValidationMessage"
>
<UiInput
v-model="name"
variant="ghost"
class="w-full truncate px-0"
:select="isFocusedSnippetName"
@focus="onSnippetNameFocus"
@blur="onNameBlur"
/>
</UiInputValidationTooltip>
</div>
</div>
<div class="ml-2 flex">
Expand Down
76 changes: 68 additions & 8 deletions src/renderer/components/notes/NotesEditorPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -170,14 +225,19 @@ const textStats = computed(() => getTextStats(content.value))
</UiActionButton>
</div>
<div class="min-w-0 flex-1">
<UiInput
v-model="name"
variant="ghost"
class="w-full truncate px-0"
:select="isFocusedNoteName"
@focus="onNameFocus"
@blur="onNameBlur"
/>
<UiInputValidationTooltip
:open="isNameValidationTooltipOpen"
:message="nameValidationMessage"
>
<UiInput
v-model="name"
variant="ghost"
class="w-full truncate px-0"
:select="isFocusedNoteName"
@focus="onNoteNameFocus"
@blur="onNameBlur"
/>
</UiInputValidationTooltip>
</div>
</div>
<div class="ml-2 flex h-7 items-center">
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/components/notes/NotesSidebarFolders.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -114,6 +115,10 @@ const highlightedIds = computed({

const contextNode = ref<any>(null)

function getFolderValidationMessage(_node: TreeNodeType, value: string) {
return getEntryNameValidationMessage(value, i18n.t.bind(i18n))
}

// --- Event handlers ---

async function onClickNode({
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/components/sidebar/folders/Tree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -111,6 +111,10 @@ const contextNodeDefaultLanguage = computed(() => {
)
})

function getFolderValidationMessage(_node: TreeNodeType, value: string) {
return getEntryNameValidationMessage(value, i18n.t.bind(i18n))
}

// --- Event handlers ---

function onClickNode({
Expand Down Expand Up @@ -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"
Expand Down
Loading