diff --git a/src/main/api/dto/folders.ts b/src/main/api/dto/folders.ts index 6ce8dade5..14715706e 100644 --- a/src/main/api/dto/folders.ts +++ b/src/main/api/dto/folders.ts @@ -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(), @@ -38,6 +44,7 @@ const foldersTreeResponse = t.Array(foldersItemWithChildren) export const foldersDTO = new Elysia().model({ foldersAdd, + foldersDefaultLanguageUpdate, foldersResponse, foldersUpdate, foldersTreeResponse, diff --git a/src/main/api/routes/folders.ts b/src/main/api/routes/folders.ts index 354cc4a66..bebd87242 100644 --- a/src/main/api/routes/folders.ts +++ b/src/main/api/routes/folders.ts @@ -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', diff --git a/src/main/i18n/locales/en_US/ui.json b/src/main/i18n/locales/en_US/ui.json index a842fea1d..c8940af86 100644 --- a/src/main/i18n/locales/en_US/ui.json +++ b/src/main/i18n/locales/en_US/ui.json @@ -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...", @@ -1242,6 +1247,7 @@ "back": "Back", "cancel": "Cancel", "confirm": "Confirm", + "yes": "Yes", "copy": "Copy", "fit": "Fit", "generate": "Generate", diff --git a/src/main/i18n/locales/ru_RU/ui.json b/src/main/i18n/locales/ru_RU/ui.json index 2f546d1b1..10bdfc54f 100644 --- a/src/main/i18n/locales/ru_RU/ui.json +++ b/src/main/i18n/locales/ru_RU/ui.json @@ -1200,6 +1200,11 @@ }, "folder": { "untitled": "Папка без названия", + "defaultLanguageDialog": { + "title": "Обновить вложенные файлы", + "descendantFolders": "Обновить язык по умолчанию для всех вложенных папок", + "snippetContents": "Обновить язык для всех вложенных сниппетов" + }, "iconPicker": { "searchPlaceholder": "Поиск иконок...", "emojiSearchPlaceholder": "Поиск эмодзи...", @@ -1245,6 +1250,7 @@ "back": "Назад", "cancel": "Отмена", "confirm": "Подтвердить", + "yes": "Да", "copy": "Копировать", "fit": "По размеру", "generate": "Сгенерировать", diff --git a/src/main/storage/contracts.ts b/src/main/storage/contracts.ts index 2a46f77c8..2abed23ad 100644 --- a/src/main/storage/contracts.ts +++ b/src/main/storage/contracts.ts @@ -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 } diff --git a/src/main/storage/providers/markdown/storages/__tests__/folders.test.ts b/src/main/storage/providers/markdown/storages/__tests__/folders.test.ts index ce248c601..883e89a9e 100644 --- a/src/main/storage/providers/markdown/storages/__tests__/folders.test.ts +++ b/src/main/storage/providers/markdown/storages/__tests__/folders.test.ts @@ -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 = '' @@ -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' }) diff --git a/src/main/storage/providers/markdown/storages/folders.ts b/src/main/storage/providers/markdown/storages/folders.ts index 298d277da..bfbb89d8e 100644 --- a/src/main/storage/providers/markdown/storages/folders.ts +++ b/src/main/storage/providers/markdown/storages/folders.ts @@ -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, @@ -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()) diff --git a/src/renderer/components/sidebar/folders/DefaultLanguageUpdateDialog.vue b/src/renderer/components/sidebar/folders/DefaultLanguageUpdateDialog.vue new file mode 100644 index 000000000..170c596cc --- /dev/null +++ b/src/renderer/components/sidebar/folders/DefaultLanguageUpdateDialog.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/renderer/components/sidebar/folders/Tree.vue b/src/renderer/components/sidebar/folders/Tree.vue index 590fb9f16..416fe02a2 100644 --- a/src/renderer/components/sidebar/folders/Tree.vue +++ b/src/renderer/components/sidebar/folders/Tree.vue @@ -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' @@ -45,6 +46,7 @@ const { deleteSelectedFolders, folders, updateFolder, + updateFolderDefaultLanguage, getFolderByIdFromTree, getFolders, selectedFolderIds, @@ -115,6 +117,10 @@ const highlightedIds = computed({ // --- Context menu state --- const contextNode = ref(null) +const isDefaultLanguageDialogOpen = ref(false) +const pendingDefaultLanguage = ref(null) +const hasDescendantFolders = ref(false) +const hasSnippetContents = ref(false) const isContextMultiSelection = computed(() => { if (!contextNode.value) @@ -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) { @@ -369,6 +412,12 @@ useDeleteShortcut({ data-code-folders-tree class="h-full min-h-0" > + {{ language.name }} diff --git a/src/renderer/composables/useFolders.ts b/src/renderer/composables/useFolders.ts index 89761a291..2a81acdef 100644 --- a/src/renderer/composables/useFolders.ts +++ b/src/renderer/composables/useFolders.ts @@ -391,6 +391,30 @@ async function updateFolder(folderId: number, data: FoldersUpdate) { } } +async function updateFolderDefaultLanguage( + folderId: number, + data: { + language: string + updateDescendantFolders: boolean + updateSnippetContents: boolean + }, +) { + try { + markPersistedStorageMutation() + await api.folders.patchFoldersByIdDefaultLanguage(String(folderId), data) + await getFolders(false) + + const { getSnippets, refreshSelectedSnippet } = useSnippets() + if (folderId === state.folderId) { + await getSnippets({ folderId }) + } + await refreshSelectedSnippet() + } + catch (error) { + console.error(error) + } +} + async function deleteFolder(folderId: number, shouldRefresh = true) { try { markPersistedStorageMutation() @@ -513,5 +537,6 @@ export function useFolders() { setFolderSelection, selectFolder, updateFolder, + updateFolderDefaultLanguage, } } diff --git a/src/renderer/services/api/generated/index.ts b/src/renderer/services/api/generated/index.ts index c1fcba36d..7f9875fe5 100644 --- a/src/renderer/services/api/generated/index.ts +++ b/src/renderer/services/api/generated/index.ts @@ -155,6 +155,12 @@ export interface FoldersAdd { parentId?: number | null; } +export interface FoldersDefaultLanguageUpdate { + language: string; + updateDescendantFolders: boolean; + updateSnippetContents: boolean; +} + export type FoldersResponse = { id: number; name: string; @@ -2165,6 +2171,26 @@ export class Api< ...params, }), + /** + * No description + * + * @tags Folders + * @name PatchFoldersByIdDefaultLanguage + * @request PATCH:/folders/{id}/default-language + */ + patchFoldersByIdDefaultLanguage: ( + id: string, + data: FoldersDefaultLanguageUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/folders/${id}/default-language`, + method: "PATCH", + body: data, + type: ContentType.Json, + ...params, + }), + /** * No description *