Skip to content
Open
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: 7 additions & 0 deletions src/main/api/dto/folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ const foldersUpdate = t.Object({
orderIndex: t.Optional(t.Number()),
})

const foldersDefaultLanguageUpdate = t.Object({
language: t.String(),
updateDescendantFolders: t.Boolean(),
updateSnippetContents: t.Boolean(),
})

const foldersItem = t.Object({
id: t.Number(),
name: t.String(),
Expand All @@ -38,6 +44,7 @@ const foldersTreeResponse = t.Array(foldersItemWithChildren)

export const foldersDTO = new Elysia().model({
foldersAdd,
foldersDefaultLanguageUpdate,
foldersResponse,
foldersUpdate,
foldersTreeResponse,
Expand Down
27 changes: 27 additions & 0 deletions src/main/api/routes/folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,33 @@ app
},
},
)
.patch(
'/:id/default-language',
({ params, body, status }) => {
const storage = useStorage()
try {
const { notFound } = storage.folders.updateFolderDefaultLanguage(
Number(params.id),
body,
)

if (notFound) {
return status(404, { message: 'Folder not found' })
}

return { message: 'Folder language updated' }
}
catch (error) {
return mapStorageError(status, error)
}
},
{
body: 'foldersDefaultLanguageUpdate',
detail: {
tags: ['Folders'],
},
},
)
// Удаление папки
.delete(
'/:id',
Expand Down
6 changes: 6 additions & 0 deletions src/main/i18n/locales/en_US/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,11 @@
},
"folder": {
"untitled": "Untitled folder",
"defaultLanguageDialog": {
"title": "Update nested content",
"descendantFolders": "Update the default language for all nested folders",
"snippetContents": "Update the language for all nested snippet contents"
},
"iconPicker": {
"searchPlaceholder": "Search icons...",
"emojiSearchPlaceholder": "Search emoji...",
Expand Down Expand Up @@ -1242,6 +1247,7 @@
"back": "Back",
"cancel": "Cancel",
"confirm": "Confirm",
"yes": "Yes",
"copy": "Copy",
"fit": "Fit",
"generate": "Generate",
Expand Down
6 changes: 6 additions & 0 deletions src/main/i18n/locales/ru_RU/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,11 @@
},
"folder": {
"untitled": "Папка без названия",
"defaultLanguageDialog": {
"title": "Обновить вложенные файлы",
"descendantFolders": "Обновить язык по умолчанию для всех вложенных папок",
"snippetContents": "Обновить язык для всех вложенных сниппетов"
},
"iconPicker": {
"searchPlaceholder": "Поиск иконок...",
"emojiSearchPlaceholder": "Поиск эмодзи...",
Expand Down Expand Up @@ -1245,6 +1250,7 @@
"back": "Назад",
"cancel": "Отмена",
"confirm": "Подтвердить",
"yes": "Да",
"copy": "Копировать",
"fit": "По размеру",
"generate": "Сгенерировать",
Expand Down
10 changes: 10 additions & 0 deletions src/main/storage/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,21 @@ export interface FolderUpdateResult {
notFound: boolean
}

export interface FolderDefaultLanguageUpdateInput {
language: string
updateDescendantFolders: boolean
updateSnippetContents: boolean
}

export interface FoldersStorage {
createFolder: (input: FolderCreateInput) => { id: number }
deleteFolder: (id: number) => { deleted: boolean }
getFolders: () => FolderRecord[]
getFoldersTree: () => FolderTreeRecord[]
updateFolderDefaultLanguage: (
id: number,
input: FolderDefaultLanguageUpdateInput,
) => FolderUpdateResult
updateFolder: (id: number, input: FolderUpdateInput) => FolderUpdateResult
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { ensureStateFile } from '../../runtime/state'
import { resetRuntimeCache } from '../../runtime/sync'
import { createFoldersStorage } from '../folders'
import { createSnippetsStorage } from '../snippets'

let tempVaultPath = ''

Expand Down Expand Up @@ -138,6 +139,39 @@ describe('code folders storage validations', () => {
expect(result).toEqual({ invalidInput: true, notFound: false })
})

it('updates the selected folder and opted-in nested content language', () => {
const folders = createFoldersStorage()
const snippets = createSnippetsStorage()
const { id: parentId } = folders.createFolder({ name: 'Parent' })
const { id: childId } = folders.createFolder({ name: 'Child', parentId })
const parentSnippet = snippets.createSnippet({
name: 'Parent snippet',
folderId: parentId,
})
const childSnippet = snippets.createSnippet({
name: 'Child snippet',
folderId: childId,
})
snippets.createSnippetContent(parentSnippet.id, {
label: 'Fragment 1', language: 'plain_text', value: '',
})
snippets.createSnippetContent(childSnippet.id, {
label: 'Fragment 1', language: 'plain_text', value: '',
})

const result = folders.updateFolderDefaultLanguage(parentId, {
language: 'typescript',
updateDescendantFolders: true,
updateSnippetContents: true,
})

expect(result).toEqual({ invalidInput: false, notFound: false })
expect(folders.getFolders().find(folder => folder.id === parentId)?.defaultLanguage).toBe('typescript')
expect(folders.getFolders().find(folder => folder.id === childId)?.defaultLanguage).toBe('typescript')
expect(snippets.getSnippetById(parentSnippet.id)?.contents[0]?.language).toBe('typescript')
expect(snippets.getSnippetById(childSnippet.id)?.contents[0]?.language).toBe('typescript')
})

it('moving folder into sibling level with same name auto-resolves unique name', () => {
const storage = createFoldersStorage()
const { id: parentId } = storage.createFolder({ name: 'Parent' })
Expand Down
59 changes: 59 additions & 0 deletions src/main/storage/providers/markdown/storages/folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ import {
syncFolderMetadataFiles,
throwStorageError,
validateEntryName,
writeSnippetToFile,
} from '../runtime'
import {
getFileAvailability,
markAppWrittenFileAsLocal,
} from '../runtime/shared/cloudFiles'
import { assertEntityFileWritable } from '../runtime/shared/cloudGuards'
import {
applyFolderParentAndOrder,
assertFolderMoveTargetValid,
Expand Down Expand Up @@ -102,6 +104,63 @@ export function createFoldersStorage(): FoldersStorage {

return { id }
},
updateFolderDefaultLanguage: (id, input): FolderUpdateResult => {
const paths = getPaths(getVaultPath())
const { state, snippets } = getRuntimeCache(paths)
const folder = findFolderById(state, id)

if (!folder) {
return { invalidInput: false, notFound: true }
}

const descendantFolderIds = collectDescendantIds(state.folders, id)
const contentFolderIds = new Set(descendantFolderIds)
contentFolderIds.add(id)
const snippetsToUpdate = input.updateSnippetContents
? snippets.filter(
snippet =>
snippet.isDeleted !== 1
&& snippet.folderId !== null
&& contentFolderIds.has(snippet.folderId),
)
: []

snippetsToUpdate.forEach((snippet) => {
assertEntityFileWritable(
path.join(paths.vaultPath, snippet.filePath),
snippet,
)
})

const now = Date.now()
folder.defaultLanguage = input.language || folder.defaultLanguage
folder.updatedAt = now

if (input.updateDescendantFolders) {
state.folders.forEach((childFolder) => {
if (!descendantFolderIds.has(childFolder.id)) {
return
}

childFolder.defaultLanguage
= input.language || childFolder.defaultLanguage
childFolder.updatedAt = now
})
}

snippetsToUpdate.forEach((snippet) => {
snippet.contents.forEach((content) => {
content.language = input.language || content.language
})
snippet.updatedAt = now
writeSnippetToFile(paths, snippet)
})

syncFolderMetadataFiles(paths, state)
saveState(paths, state)

return { invalidInput: false, notFound: false }
},
updateFolder: (id, input): FolderUpdateResult => {
const paths = getPaths(getVaultPath())

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { Checkbox } from '@/components/ui/shadcn/checkbox'
import * as Dialog from '@/components/ui/shadcn/dialog'
import { i18n } from '@/electron'

interface Props {
hasDescendantFolders: boolean
hasSnippetContents: boolean
}

const props = defineProps<Props>()
const open = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{
confirm: [options: { updateDescendantFolders: boolean, updateSnippetContents: boolean }]
}>()
const updateDescendantFolders = ref(false)
const updateSnippetContents = ref(false)

function confirm() {
emit('confirm', {
updateDescendantFolders: updateDescendantFolders.value,
updateSnippetContents: updateSnippetContents.value,
})
}

watch(open, (isOpen) => {
if (!isOpen) {
updateDescendantFolders.value = false
updateSnippetContents.value = false
}
})
</script>

<template>
<Dialog.Dialog v-model:open="open">
<Dialog.DialogContent class="cursor-default sm:max-w-[425px]">
<Dialog.DialogHeader>
<Dialog.DialogTitle>{{ i18n.t('folder.defaultLanguageDialog.title') }}</Dialog.DialogTitle>
</Dialog.DialogHeader>
<div class="space-y-3">
<label v-if="props.hasDescendantFolders" class="flex cursor-pointer items-center gap-2">
<Checkbox v-model="updateDescendantFolders" />
<UiText as="span">{{ i18n.t('folder.defaultLanguageDialog.descendantFolders') }}</UiText>
</label>
<label v-if="props.hasSnippetContents" class="flex cursor-pointer items-center gap-2">
<Checkbox v-model="updateSnippetContents" />
<UiText as="span">{{ i18n.t('folder.defaultLanguageDialog.snippetContents') }}</UiText>
</label>
</div>
<Dialog.DialogFooter>
<UiButton class="cursor-pointer" variant="outline" @click="open = false">{{ i18n.t('button.cancel') }}</UiButton>
<UiButton class="cursor-pointer" @click="confirm">{{ i18n.t('button.yes') }}</UiButton>
</Dialog.DialogFooter>
</Dialog.DialogContent>
</Dialog.Dialog>
</template>
55 changes: 52 additions & 3 deletions src/renderer/components/sidebar/folders/Tree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getEntryNameConflictMessage,
getEntryNameValidationMessage,
} from '@/utils'
import { api } from '@/services/api'
import { Folder } from 'lucide-vue-next'
import CustomIcons from './custom-icons/CustomIcons.vue'

Expand All @@ -45,6 +46,7 @@ const {
deleteSelectedFolders,
folders,
updateFolder,
updateFolderDefaultLanguage,
getFolderByIdFromTree,
getFolders,
selectedFolderIds,
Expand Down Expand Up @@ -115,6 +117,10 @@ const highlightedIds = computed({
// --- Context menu state ---

const contextNode = ref<Node | null>(null)
const isDefaultLanguageDialogOpen = ref(false)
const pendingDefaultLanguage = ref<string | null>(null)
const hasDescendantFolders = ref(false)
const hasSnippetContents = ref(false)

const isContextMultiSelection = computed(() => {
if (!contextNode.value)
Expand Down Expand Up @@ -304,10 +310,47 @@ function onRenameFolder() {
}, 100)
}

function onSelectLanguage(language: string) {
async function onSelectLanguage(language: string) {
if (!contextNode.value)
return
updateFolder(contextNode.value.id, { defaultLanguage: language })

const folderIds = flattenFolders([contextNode.value]).map(folder => folder.id)
hasDescendantFolders.value = folderIds.length > 1
const { data: snippets } = await api.snippets.getSnippets({})
hasSnippetContents.value = snippets.some(
snippet =>
!snippet.isDeleted
&& snippet.folder
&& folderIds.includes(snippet.folder.id)
&& snippet.contents.length > 0,
)

if (!hasDescendantFolders.value && !hasSnippetContents.value) {
await updateFolderDefaultLanguage(contextNode.value.id, {
language,
updateDescendantFolders: false,
updateSnippetContents: false,
})
return
}

pendingDefaultLanguage.value = language
isDefaultLanguageDialogOpen.value = true
}

async function onConfirmDefaultLanguageUpdate(options: {
updateDescendantFolders: boolean
updateSnippetContents: boolean
}) {
if (!contextNode.value || !pendingDefaultLanguage.value)
return

await updateFolderDefaultLanguage(contextNode.value.id, {
language: pendingDefaultLanguage.value,
...options,
})
isDefaultLanguageDialogOpen.value = false
pendingDefaultLanguage.value = null
}

function scrollToSelectedLanguage(el: any, isSelected: boolean) {
Expand Down Expand Up @@ -369,6 +412,12 @@ useDeleteShortcut({
data-code-folders-tree
class="h-full min-h-0"
>
<SidebarFoldersDefaultLanguageUpdateDialog
v-model:open="isDefaultLanguageDialogOpen"
:has-descendant-folders="hasDescendantFolders"
:has-snippet-contents="hasSnippetContents"
@confirm="onConfirmDefaultLanguageUpdate"
/>
<ContextMenu.ContextMenu>
<ContextMenu.ContextMenuTrigger as-child>
<UiTree
Expand Down Expand Up @@ -455,7 +504,7 @@ useDeleteShortcut({
contextNodeDefaultLanguage === language.value,
)
"
:checked="contextNodeDefaultLanguage === language.value"
:model-value="contextNodeDefaultLanguage === language.value"
@click="onSelectLanguage(language.value)"
>
{{ language.name }}
Expand Down
Loading