diff --git a/frontend/src/App.settings-center.test.ts b/frontend/src/App.settings-center.test.ts index f9203748..54235b59 100644 --- a/frontend/src/App.settings-center.test.ts +++ b/frontend/src/App.settings-center.test.ts @@ -128,7 +128,7 @@ describe('settings center layout', () => { expect(appSource).toContain('OpenLogDirectory'); expect(appSource).toContain('SelectLogDirectory'); expect(appSource).toContain("const [selectedLogDirectoryPath, setSelectedLogDirectoryPath] = useState('');"); - expect(appSource).toContain('const directorySettingsApplying = dataRootApplying || logDirectoryApplying;'); + expect(appSource).toContain('const directorySettingsApplying = dataRootApplying || logDirectoryApplying || savedQueryDirectoryApplying;'); expect(appSource).toContain('const handleSelectLogDirectory = useCallback(async () => {'); expect(appSource).toContain('const handleApplyLogDirectory = useCallback(async (useDefaultPath = false) => {'); expect(appSource).toContain('const handleOpenLogDirectory = useCallback(async () => {'); @@ -153,6 +153,50 @@ describe('settings center layout', () => { expect(appSource).not.toContain("handleOpenToolCenterPane('config', 'log-directory')"); }); + it('keeps configurable saved query file storage inside the data-root detail page', () => { + const selectHandlerStart = appSource.indexOf('const handleSelectSavedQueryDirectory = useCallback(async () => {'); + const selectHandlerEnd = appSource.indexOf('const handleApplySavedQueryDirectory = useCallback', selectHandlerStart); + const selectHandlerSource = appSource.slice(selectHandlerStart, selectHandlerEnd); + const rendererStart = appSource.indexOf('const renderSavedQueryDirectorySettings = (readOnly = false) => ('); + const rendererEnd = appSource.indexOf('const renderLogDirectorySettings = () => {', rendererStart); + const rendererSource = appSource.slice(rendererStart, rendererEnd); + const embeddedDataRootStart = appSource.indexOf("if (activeSettingsCenterPane.key === 'data-root')"); + const embeddedDataRootEnd = appSource.indexOf( + "if (activeSettingsCenterPane.key === 'security-update')", + embeddedDataRootStart, + ); + const embeddedDataRootSource = appSource.slice(embeddedDataRootStart, embeddedDataRootEnd); + const standaloneDataRootStart = appSource.indexOf('{isDataRootModalOpen && ('); + const standaloneDataRootEnd = appSource.indexOf( + ' {'); + expect(selectHandlerSource).toContain('if (data.cancelled === true) return;'); + expect(selectHandlerSource).not.toContain('已取消'); + expect(appSource).toContain('const handleApplySavedQueryDirectory = useCallback(async (useDefaultPath = false) => {'); + expect(appSource).toContain('const handleOpenSavedQueryDirectory = useCallback(async () => {'); + expect(rendererStart).toBeGreaterThan(-1); + expect(rendererEnd).toBeGreaterThan(rendererStart); + expect(rendererSource).toContain('data-saved-query-directory-settings="true"'); + expect(rendererSource).toContain("t('app.data_root.saved_query_directory.title')"); + expect(rendererSource).toContain('dataRootInfo?.defaultSavedQueryDirectory'); + expect(rendererSource).toContain('{!readOnly && ('); + expect(appSource).toContain('replaceSavedQueries(Array.isArray(queries) ? queries : []);'); + expect(appSource).toContain('await reloadSavedQueryGroups();'); + expect(embeddedDataRootSource).toContain('{renderSavedQueryDirectorySettings(true)}'); + expect(embeddedDataRootSource).toContain('{renderSavedQueryDirectorySettings()}'); + expect(standaloneDataRootSource).toContain('{renderSavedQueryDirectorySettings()}'); + }); + it('adds close and back-to-settings actions to settings center detail panes', () => { expect(appSource).toContain('const handleBackFromSettingsCenterPane = useCallback(() => {'); expect(appSource).toContain('const handleCancelSettingsCenterPane = useCallback(() => {'); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f1325f62..c343d139 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -222,7 +222,26 @@ import { useAppLogPanelResize } from './hooks/useAppLogPanelResize'; import { useAppSidebarResize } from './hooks/useAppSidebarResize'; import { useAppUtilityStyles } from './hooks/useAppUtilityStyles'; import { useWorkbenchTabs } from './hooks/useWorkbenchTabs'; -import { ApplyDataRootDirectory, ApplyLogDirectory, CancelApplicationQuit, ForceQuitApplication, GetDataRootDirectoryInfo, GetSavedConnections, ListInstalledFontFamilies, OpenDataRootDirectory, OpenLogDirectory, SelectDataRootDirectory, SelectLogDirectory, SetApplicationBrandIcon, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App'; +import { + ApplyDataRootDirectory, + ApplyLogDirectory, + ApplySavedQueryDirectory, + CancelApplicationQuit, + ForceQuitApplication, + GetDataRootDirectoryInfo, + GetSavedConnections, + GetSavedQueries, + ListInstalledFontFamilies, + OpenDataRootDirectory, + OpenLogDirectory, + OpenSavedQueryDirectory, + SelectDataRootDirectory, + SelectLogDirectory, + SelectSavedQueryDirectory, + SetApplicationBrandIcon, + SetMacNativeWindowControls, + SetWindowTranslucency, +} from '../wailsjs/go/app/App'; import { getAntdLocale } from './i18n/frameworkLocale'; import { useI18n } from './i18n/provider'; import './App.css'; @@ -3108,10 +3127,12 @@ function App() { const [dataRootInfo, setDataRootInfo] = useState(null); const [selectedDataRootPath, setSelectedDataRootPath] = useState(''); const [selectedLogDirectoryPath, setSelectedLogDirectoryPath] = useState(''); + const [selectedSavedQueryDirectoryPath, setSelectedSavedQueryDirectoryPath] = useState(''); const [dataRootLoading, setDataRootLoading] = useState(false); const [dataRootApplying, setDataRootApplying] = useState(false); const [logDirectoryApplying, setLogDirectoryApplying] = useState(false); - const directorySettingsApplying = dataRootApplying || logDirectoryApplying; + const [savedQueryDirectoryApplying, setSavedQueryDirectoryApplying] = useState(false); + const directorySettingsApplying = dataRootApplying || logDirectoryApplying || savedQueryDirectoryApplying; const aiEntryPlacement = resolveAIEntryPlacement(); const legacyAiEdgeHandleAttachment = resolveLegacyAIEdgeHandleAttachment(aiPanelVisible); @@ -3516,6 +3537,9 @@ function App() { setDataRootInfo(data); setSelectedDataRootPath(String(data.path || '')); setSelectedLogDirectoryPath(String(data.logDirectory || data.defaultLogDirectory || '')); + setSelectedSavedQueryDirectoryPath(String( + data.savedQueryDirectory || data.defaultSavedQueryDirectory || '', + )); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error || t('common.unknown')); void message.error(t('app.data_root.message.load_failed_with_error', { error: errMsg })); @@ -3641,6 +3665,144 @@ function App() { } }, [t]); + const handleSelectSavedQueryDirectory = useCallback(async () => { + try { + const res = await SelectSavedQueryDirectory( + selectedSavedQueryDirectoryPath + || dataRootInfo?.savedQueryDirectory + || dataRootInfo?.defaultSavedQueryDirectory + || '', + ); + if (!res?.success) { + const data = (res?.data || {}) as any; + if (data.cancelled === true) return; + throw new Error(res?.message || t('common.unknown')); + } + const data = (res?.data || {}) as any; + setSelectedSavedQueryDirectoryPath(String(data.directory || '')); + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error || t('common.unknown')); + void message.error(t('app.data_root.saved_query_directory.message.select_failed_with_error', { error: errMsg })); + } + }, [ + dataRootInfo?.defaultSavedQueryDirectory, + dataRootInfo?.savedQueryDirectory, + selectedSavedQueryDirectoryPath, + t, + ]); + + const handleApplySavedQueryDirectory = useCallback(async (useDefaultPath = false) => { + const nextPath = useDefaultPath + ? String(dataRootInfo?.defaultSavedQueryDirectory || '') + : String(selectedSavedQueryDirectoryPath || '').trim(); + if (!nextPath) { + void message.warning(t('app.data_root.saved_query_directory.message.select_valid_first')); + return; + } + setSavedQueryDirectoryApplying(true); + try { + const res = await ApplySavedQueryDirectory(nextPath); + if (!res?.success) { + throw new Error(res?.message || t('common.unknown')); + } + const data = (res?.data || {}) as any; + setDataRootInfo(data); + setSelectedSavedQueryDirectoryPath(String( + data.savedQueryDirectory || data.defaultSavedQueryDirectory || nextPath, + )); + try { + const queries = await GetSavedQueries(); + replaceSavedQueries(Array.isArray(queries) ? queries : []); + await reloadSavedQueryGroups(); + } catch (refreshError) { + console.warn('Failed to refresh saved queries after changing their directory', refreshError); + } + void message.success(res?.message || t('app.data_root.saved_query_directory.message.updated')); + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error || t('common.unknown')); + void message.error(t('app.data_root.saved_query_directory.message.apply_failed_with_error', { error: errMsg })); + } finally { + setSavedQueryDirectoryApplying(false); + } + }, [ + dataRootInfo?.defaultSavedQueryDirectory, + reloadSavedQueryGroups, + replaceSavedQueries, + selectedSavedQueryDirectoryPath, + t, + ]); + + const handleOpenSavedQueryDirectory = useCallback(async () => { + try { + const res = await OpenSavedQueryDirectory(); + if (!res?.success) { + throw new Error(res?.message || t('common.unknown')); + } + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error || t('common.unknown')); + void message.error(t('app.data_root.saved_query_directory.message.open_failed_with_error', { error: errMsg })); + } + }, [t]); + + const renderSavedQueryDirectorySettings = (readOnly = false) => ( +
+
{t('app.data_root.saved_query_directory.title')}
+
+ {t('app.data_root.saved_query_directory.description')} +
+
+
+
+ {t('app.data_root.saved_query_directory.current_directory')} +
+ +
+ {!readOnly && ( +
+ + + + +
+ )} +
+
+ {t('app.data_root.saved_query_directory.default_directory')} +
+
+ {dataRootInfo?.defaultSavedQueryDirectory || '-'} +
+
+
+
+ ); + const renderLogDirectorySettings = () => { const editable = dataRootInfo?.logDirectoryEditable !== false; const managedByEnvironment = dataRootInfo?.logDirectorySource === 'environment'; @@ -8044,6 +8206,7 @@ function App() { + {renderSavedQueryDirectorySettings(true)} ); } @@ -8142,6 +8305,7 @@ function App() { {t('app.data_root.restart_hint')} + {renderSavedQueryDirectorySettings()} {renderLogDirectorySettings()} )} @@ -8629,6 +8793,7 @@ function App() { {t('app.data_root.restart_hint')} + {renderSavedQueryDirectorySettings()} {renderLogDirectorySettings()} )} diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 74f3fe8e..06922a5e 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -1956,6 +1956,10 @@ describe('Sidebar locate toolbar', () => { expect(renameHandlerSource).toContain("message.error(t('query_editor.save_modal.name_required'))"); expect(renameHandlerSource).toContain("message.warning(t('sidebar.message.saved_query_name_unchanged'))"); expect(renameHandlerSource).toContain("message.success(t('sidebar.message.saved_query_renamed'))"); + expect(renameHandlerSource).toContain("typeof backendApp?.RenameSavedQuery === 'function'"); + expect(renameHandlerSource).toContain('backendApp.RenameSavedQuery(renameSavedQueryTarget.id, nextName)'); + expect(renameHandlerSource).toContain('latestState.replaceSavedQueries(latestState.savedQueries.map'); + expect(renameHandlerSource).toContain('persisted = await saveQuery({'); expect(savedQueryMenuSource).toContain("key: 'rename-query'"); expect(savedQueryMenuSource).toContain("label: t('sidebar.menu.rename_query')"); expect(savedQueryMenuSource).toContain("label: t('sidebar.menu.open_query')"); @@ -1984,6 +1988,25 @@ describe('Sidebar locate toolbar', () => { expect(renameModalSource).not.toContain("message: '请输入查询名称'"); }); + it('reveals a saved query file from its context menu', () => { + const source = readSidebarSource(); + const handlerStart = source.indexOf('const handleRevealSavedQueryInFolder = useCallback(async (query: SavedQuery) =>'); + const handlerEnd = source.indexOf('const isSavedQueryUnmatched', handlerStart); + const savedQueryMenuStart = source.indexOf('// \u5df2\u5b58\u67e5\u8be2\u8282\u70b9\u7684\u53f3\u952e\u83dc\u5355'); + const savedQueryMenuEnd = source.indexOf("if (node.type === 'external-sql-root') {", savedQueryMenuStart); + const handlerSource = source.slice(handlerStart, handlerEnd); + const savedQueryMenuSource = source.slice(savedQueryMenuStart, savedQueryMenuEnd); + + expect(handlerStart).toBeGreaterThanOrEqual(0); + expect(handlerEnd).toBeGreaterThan(handlerStart); + expect(handlerSource).toContain('await RevealSavedQueryInFolder(query.id)'); + expect(handlerSource).toContain("t('sidebar.message.saved_query_revealed')"); + expect(handlerSource).toContain("t('sidebar.message.saved_query_reveal_failed'"); + expect(savedQueryMenuSource).toContain("key: 'reveal-saved-query-in-folder'"); + expect(savedQueryMenuSource).toContain("label: t('sidebar.menu.reveal_saved_query_in_folder')"); + expect(savedQueryMenuSource).toContain('onClick: () => void handleRevealSavedQueryInFolder(q)'); + }); + it('renders the v2 table context menu with the redesigned table layout', () => { const markup = renderToStaticMarkup( , + onClick: () => void handleRevealSavedQueryInFolder(q), + }, ...rebindMenuItems, { key: 'move-saved-query-to-group', diff --git a/frontend/src/components/sidebar/useSidebarObjectActions.tsx b/frontend/src/components/sidebar/useSidebarObjectActions.tsx index db86a7bb..27af5424 100644 --- a/frontend/src/components/sidebar/useSidebarObjectActions.tsx +++ b/frontend/src/components/sidebar/useSidebarObjectActions.tsx @@ -41,6 +41,7 @@ import { RenameDatabase, RenameTable, RenameView, + RevealSavedQueryInFolder, } from '../../../wailsjs/go/app/App'; import { resolveSidebarNodeConnectionId, type SidebarTreeNode as TreeNode } from '../sidebarV2Utils'; @@ -1001,10 +1002,25 @@ export const useSidebarObjectActions = ({ return; } - const persisted = await saveQuery({ - ...renameSavedQueryTarget, - name: nextName, - }); + const backendApp = (window as any).go?.app?.App; + let persisted: SavedQuery; + if (typeof backendApp?.RenameSavedQuery === 'function') { + const renamed = await backendApp.RenameSavedQuery(renameSavedQueryTarget.id, nextName); + persisted = { + ...renameSavedQueryTarget, + ...(renamed || {}), + name: String(renamed?.name || nextName), + }; + const latestState = useStore.getState(); + latestState.replaceSavedQueries(latestState.savedQueries.map(query => ( + query.id === persisted.id ? { ...query, ...persisted } : query + ))); + } else { + persisted = await saveQuery({ + ...renameSavedQueryTarget, + name: nextName, + }); + } const updateSavedQueryNode = (list: TreeNode[]): TreeNode[] => list.map(node => { if (node.type === 'saved-query' && node.dataRef?.id === renameSavedQueryTarget.id) { @@ -1033,6 +1049,24 @@ export const useSidebarObjectActions = ({ } }; + const handleRevealSavedQueryInFolder = useCallback(async (query: SavedQuery) => { + if (!query?.id) return; + try { + const res = await RevealSavedQueryInFolder(query.id); + if (res?.success) { + message.success(res.message || t('sidebar.message.saved_query_revealed')); + return; + } + message.error(res?.message || t('sidebar.message.saved_query_reveal_failed', { + error: t('common.unknown'), + })); + } catch (error) { + message.error(t('sidebar.message.saved_query_reveal_failed', { + error: error instanceof Error ? error.message : String(error), + })); + } + }, []); + const isSavedQueryUnmatched = useCallback((query: SavedQuery): boolean => { return query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId); }, [connectionIdSet]); @@ -1401,6 +1435,7 @@ export const useSidebarObjectActions = ({ handleRenameView, openRenameSavedQueryModal, handleRenameSavedQuery, + handleRevealSavedQueryInFolder, isSavedQueryUnmatched, handleRebindSavedQuery, openRoutineDefinition, diff --git a/frontend/src/i18n/catalog.test.ts b/frontend/src/i18n/catalog.test.ts index c044fdf7..96ec045b 100644 --- a/frontend/src/i18n/catalog.test.ts +++ b/frontend/src/i18n/catalog.test.ts @@ -208,6 +208,50 @@ describe("i18n catalog", () => { expect(catalogs["en-US"]["app.data_root.log_directory.restart_hint"]).toContain("gonavi.log"); }); + it("keeps saved query directory copy complete across all catalogs", () => { + const savedQueryDirectoryKeys = [ + "app.data_root.saved_query_directory.backend.dialog.select_directory", + "app.data_root.saved_query_directory.backend.error.desktop_only", + "app.data_root.saved_query_directory.backend.error.directory_unavailable", + "app.data_root.saved_query_directory.backend.error.migrate_failed", + "app.data_root.saved_query_directory.backend.error.open_directory_failed", + "app.data_root.saved_query_directory.backend.error.open_directory_unsupported", + "app.data_root.saved_query_directory.backend.error.query_file_unavailable", + "app.data_root.saved_query_directory.backend.error.query_id_required", + "app.data_root.saved_query_directory.backend.error.query_not_found", + "app.data_root.saved_query_directory.backend.error.reveal_failed", + "app.data_root.saved_query_directory.backend.error.reveal_unsupported", + "app.data_root.saved_query_directory.backend.error.save_failed", + "app.data_root.saved_query_directory.backend.message.opened", + "app.data_root.saved_query_directory.backend.message.revealed", + "app.data_root.saved_query_directory.backend.message.unchanged", + "app.data_root.saved_query_directory.backend.message.updated", + "app.data_root.saved_query_directory.current_directory", + "app.data_root.saved_query_directory.default_directory", + "app.data_root.saved_query_directory.description", + "app.data_root.saved_query_directory.message.apply_failed_with_error", + "app.data_root.saved_query_directory.message.open_failed_with_error", + "app.data_root.saved_query_directory.message.select_failed_with_error", + "app.data_root.saved_query_directory.message.select_valid_first", + "app.data_root.saved_query_directory.message.updated", + "app.data_root.saved_query_directory.placeholder", + "app.data_root.saved_query_directory.title", + ] as const; + const base = catalogs["en-US"]; + + for (const language of SUPPORTED_LANGUAGES) { + for (const key of savedQueryDirectoryKeys) { + expect(catalogs[language]).toHaveProperty(key); + expect(catalogs[language][key]).toBeTruthy(); + expect(getPlaceholders(catalogs[language][key])).toEqual(getPlaceholders(base[key])); + } + } + + expect(catalogs["zh-CN"]["app.data_root.saved_query_directory.title"]).toBe("已存查询目录"); + expect(catalogs["zh-CN"]["app.data_root.saved_query_directory.description"]).toContain(".sql"); + expect(catalogs["en-US"]["app.data_root.saved_query_directory.description"]).toContain("independent .sql file"); + }); + it("includes App shell keys required by every supported language", () => { const appShellKeys = [ "app.tools.title", diff --git a/frontend/src/main.browserMock.test.ts b/frontend/src/main.browserMock.test.ts index 6e5c4c6b..c9f405a1 100644 --- a/frontend/src/main.browserMock.test.ts +++ b/frontend/src/main.browserMock.test.ts @@ -76,7 +76,10 @@ const importMain = async () => { ExportConnectionsPackage: (options?: { includeSecrets?: boolean; filePassword?: string }) => Promise<{ success: boolean; message?: string }>; ApplyDataRootDirectory: (path: string) => Promise<{ success: boolean; message?: string; data?: { path?: string } }>; ApplyLogDirectory: (path: string) => Promise<{ success: boolean; message?: string; data?: { logDirectory?: string; logDirectoryRestartRequired?: boolean } }>; + ApplySavedQueryDirectory: (path: string) => Promise<{ success: boolean; message?: string; data?: { savedQueryDirectory?: string; savedQueryDirectorySource?: string } }>; SaveQuery: (input: { id?: string; name?: string; sql?: string }) => Promise<{ name: string; sql: string }>; + RenameSavedQuery: (id: string, name: string) => Promise<{ id: string; name: string; sql: string }>; + RevealSavedQueryInFolder: (id: string) => Promise<{ success: boolean; message?: string; data?: { path?: string } }>; CheckForUpdates: () => Promise<{ success: boolean; data?: Record }>; CheckForUpdatesSilently: () => Promise<{ success: boolean; data?: Record }>; SetUpdateChannel: (channel: string) => Promise<{ success: boolean; data?: { channel?: string } }>; @@ -261,6 +264,56 @@ describe('main browser mock', () => { })); }); + it('keeps browser mock saved query directory state available for the data-root page', async () => { + vi.stubGlobal('navigator', { + languages: ['en-US'], + language: 'en-US', + }); + + const app = await importMain(); + const { t } = await import('./i18n'); + + await expect(app!.ApplySavedQueryDirectory('C:/mock/custom-saved-queries')).resolves.toEqual(expect.objectContaining({ + success: true, + message: t('app.data_root.saved_query_directory.message.updated'), + data: expect.objectContaining({ + savedQueryDirectory: 'C:/mock/custom-saved-queries', + savedQueryDirectorySource: 'custom', + }), + })); + }); + + it('renames browser mock saved queries without replacing their SQL', async () => { + const app = await importMain(); + const saved = await app!.SaveQuery({ + id: 'browser-mock-rename-query', + name: 'Before', + sql: 'select 42', + }); + + await expect(app!.RenameSavedQuery('browser-mock-rename-query', 'After')).resolves.toEqual(expect.objectContaining({ + id: 'browser-mock-rename-query', + name: 'After', + sql: saved.sql, + })); + }); + + it('reveals browser mock saved query files in their configured directory', async () => { + const app = await importMain(); + await app!.SaveQuery({ + id: 'browser-mock-reveal-query', + name: 'Reveal', + sql: 'select 7', + }); + + await expect(app!.RevealSavedQueryInFolder('browser-mock-reveal-query')).resolves.toEqual(expect.objectContaining({ + success: true, + data: expect.objectContaining({ + path: 'C:/mock/.gonavi/saved_queries/browser-mock-reveal-query.sql', + }), + })); + }); + it('localizes browser mock MCP server test messages', async () => { vi.stubGlobal('navigator', { languages: ['en-US'], diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c3f8d84b..e2e348f8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -149,6 +149,9 @@ if ( logDirectorySource: 'default', logDirectoryEditable: true, logDirectoryRestartRequired: false, + savedQueryDirectory: 'C:/mock/.gonavi/saved_queries', + defaultSavedQueryDirectory: 'C:/mock/.gonavi/saved_queries', + savedQueryDirectorySource: 'default', }; const upsertMockConnection = (view: any) => { @@ -466,6 +469,29 @@ if ( GetSavedQueries: async () => cloneBrowserMockValue(mockSavedQueries), GetSavedQueryGroups: async () => cloneBrowserMockValue(mockSavedQueryGroups), SaveQuery: async (input: any) => saveMockQuery(input), + RenameSavedQuery: async (id: string, name: string) => { + const existing = mockSavedQueries.find((item) => item.id === id); + if (!existing) throw new Error('saved query not found'); + return saveMockQuery({ + ...existing, + name: String(name || '').trim(), + }); + }, + RevealSavedQueryInFolder: async (id: string) => { + const existing = mockSavedQueries.find((item) => item.id === id); + if (!existing) { + return { + success: false, + message: t('app.data_root.saved_query_directory.backend.error.query_not_found', { id }), + }; + } + const path = `${mockDataRootInfo.savedQueryDirectory}/${id}.sql`; + return { + success: true, + message: t('app.data_root.saved_query_directory.backend.message.revealed', { path }), + data: { path }, + }; + }, SaveSavedQueryGroup: async (input: any) => saveMockSavedQueryGroup(input), ImportSavedQueries: async (payload: any) => { const items = Array.isArray(payload) ? payload : payload?.queries; @@ -551,6 +577,7 @@ if ( OpenDriverDownloadDirectory: async (path: string) => ({ success: true, data: { path } }), OpenDataRootDirectory: async () => ({ success: true }), OpenLogDirectory: async () => ({ success: true }), + OpenSavedQueryDirectory: async () => ({ success: true }), SelectSQLDirectory: async (currentPath: string) => ({ success: false, message: currentPath ? '已取消' : '已取消' }), ListSQLDirectory: async () => ({ success: true, data: [] }), ReadSQLFile: async () => ({ success: false, message: '已取消' }), @@ -674,6 +701,25 @@ if ( data: cloneBrowserMockValue(mockDataRootInfo), }; }, + SelectSavedQueryDirectory: async (currentPath: string) => ({ + success: true, + data: { directory: currentPath || mockDataRootInfo.defaultSavedQueryDirectory }, + }), + ApplySavedQueryDirectory: async (path: string) => { + const nextPath = String(path || mockDataRootInfo.defaultSavedQueryDirectory); + mockDataRootInfo = { + ...mockDataRootInfo, + savedQueryDirectory: nextPath, + savedQueryDirectorySource: nextPath === mockDataRootInfo.defaultSavedQueryDirectory + ? 'default' + : 'custom', + }; + return { + success: true, + message: t('app.data_root.saved_query_directory.message.updated'), + data: cloneBrowserMockValue(mockDataRootInfo), + }; + }, } }, aiservice: { diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index cdd7acda..87da284e 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -14,6 +14,8 @@ export function ApplyDataRootDirectory(arg1:string,arg2:boolean):Promise; +export function ApplySavedQueryDirectory(arg1:string):Promise; + export function BuildSQLAuditExport(arg1:sqlaudit.Filter,arg2:string):Promise; export function CancelApplicationQuit():Promise; @@ -290,6 +292,8 @@ export function OpenLogDirectory():Promise; export function OpenSQLFile():Promise; +export function OpenSavedQueryDirectory():Promise; + export function PreviewChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise; export function PreviewImportFile(arg1:string):Promise; @@ -364,6 +368,8 @@ export function RenameSQLDirectory(arg1:string,arg2:string):Promise; +export function RenameSavedQuery(arg1:string,arg2:string):Promise; + export function RenameSchema(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; @@ -392,6 +398,8 @@ export function ResultDiffUploadChunk(arg1:resultdiff.UploadChunkRequest):Promis export function RetrySecurityUpdateCurrentRound(arg1:app.RetrySecurityUpdateRequest):Promise; +export function RevealSavedQueryInFolder(arg1:string):Promise; + export function SaveConnection(arg1:connection.SavedConnectionInput):Promise; export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise; @@ -420,6 +428,8 @@ export function SelectSQLFileForExecution():Promise; export function SelectSSHKeyFile(arg1:string):Promise; +export function SelectSavedQueryDirectory(arg1:string):Promise; + export function SetApplicationBrandIcon(arg1:string):Promise; export function SetLanguage(arg1:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 4df4724a..5944bf2f 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -14,6 +14,10 @@ export function ApplyLogDirectory(arg1) { return window['go']['app']['App']['ApplyLogDirectory'](arg1); } +export function ApplySavedQueryDirectory(arg1) { + return window['go']['app']['App']['ApplySavedQueryDirectory'](arg1); +} + export function BuildSQLAuditExport(arg1, arg2) { return window['go']['app']['App']['BuildSQLAuditExport'](arg1, arg2); } @@ -566,6 +570,10 @@ export function OpenSQLFile() { return window['go']['app']['App']['OpenSQLFile'](); } +export function OpenSavedQueryDirectory() { + return window['go']['app']['App']['OpenSavedQueryDirectory'](); +} + export function PreviewChanges(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['PreviewChanges'](arg1, arg2, arg3, arg4); } @@ -714,6 +722,10 @@ export function RenameSQLFile(arg1, arg2) { return window['go']['app']['App']['RenameSQLFile'](arg1, arg2); } +export function RenameSavedQuery(arg1, arg2) { + return window['go']['app']['App']['RenameSavedQuery'](arg1, arg2); +} + export function RenameSchema(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['RenameSchema'](arg1, arg2, arg3, arg4); } @@ -770,6 +782,10 @@ export function RetrySecurityUpdateCurrentRound(arg1) { return window['go']['app']['App']['RetrySecurityUpdateCurrentRound'](arg1); } +export function RevealSavedQueryInFolder(arg1) { + return window['go']['app']['App']['RevealSavedQueryInFolder'](arg1); +} + export function SaveConnection(arg1) { return window['go']['app']['App']['SaveConnection'](arg1); } @@ -826,6 +842,10 @@ export function SelectSSHKeyFile(arg1) { return window['go']['app']['App']['SelectSSHKeyFile'](arg1); } +export function SelectSavedQueryDirectory(arg1) { + return window['go']['app']['App']['SelectSavedQueryDirectory'](arg1); +} + export function SetApplicationBrandIcon(arg1) { return window['go']['app']['App']['SetApplicationBrandIcon'](arg1); } diff --git a/internal/app/app.go b/internal/app/app.go index b319f0e1..e4023123 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -424,12 +424,24 @@ func dataRootInfoPayload(activeRoot string) map[string]interface{} { if currentRoot == "" { currentRoot = appdata.MustResolveActiveRoot() } + defaultSavedQueryDirectory := appdata.DefaultSavedQueryDirectory(currentRoot) + savedQueryDirectory, err := appdata.ResolveSavedQueryDirectory(currentRoot) + if err != nil || strings.TrimSpace(savedQueryDirectory) == "" { + savedQueryDirectory = defaultSavedQueryDirectory + } + savedQueryDirectorySource := "custom" + if directoriesEqual(savedQueryDirectory, defaultSavedQueryDirectory) { + savedQueryDirectorySource = "default" + } payload := map[string]interface{}{ - "path": currentRoot, - "defaultPath": defaultRoot, - "driverPath": appdata.DriverRoot(currentRoot), - "isDefaultPath": filepath.Clean(currentRoot) == filepath.Clean(defaultRoot), - "bootstrapPath": appdata.BootstrapPath(), + "path": currentRoot, + "defaultPath": defaultRoot, + "driverPath": appdata.DriverRoot(currentRoot), + "isDefaultPath": filepath.Clean(currentRoot) == filepath.Clean(defaultRoot), + "bootstrapPath": appdata.BootstrapPath(), + "savedQueryDirectory": savedQueryDirectory, + "defaultSavedQueryDirectory": defaultSavedQueryDirectory, + "savedQueryDirectorySource": savedQueryDirectorySource, } for key, value := range logDirectoryInfoPayload() { payload[key] = value diff --git a/internal/app/methods_saved_queries.go b/internal/app/methods_saved_queries.go index e606d1f7..1ad566d0 100644 --- a/internal/app/methods_saved_queries.go +++ b/internal/app/methods_saved_queries.go @@ -68,6 +68,10 @@ func (a *App) DeleteQuery(id string) error { return a.savedQueryRepository().Delete(id) } +func (a *App) RenameSavedQuery(id string, name string) (connection.SavedQuery, error) { + return a.savedQueryRepository().Rename(id, name) +} + // SaveSavedQueryGroup creates or fully replaces a saved SQL group. Callers // must submit the current parent, query IDs, and child order; query IDs in the // submitted group become owned by that direct group only. diff --git a/internal/app/methods_saved_query_directory.go b/internal/app/methods_saved_query_directory.go new file mode 100644 index 00000000..8cb5b13f --- /dev/null +++ b/internal/app/methods_saved_query_directory.go @@ -0,0 +1,303 @@ +package app + +import ( + "os" + "os/exec" + "path/filepath" + stdRuntime "runtime" + "strings" + + "GoNavi-Wails/internal/appdata" + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +func (a *App) SelectSavedQueryDirectory(currentDirectory string) connection.QueryResult { + if a.webRuntime { + return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.desktop_only", nil)} + } + + defaultDirectory := strings.TrimSpace(currentDirectory) + if defaultDirectory == "" { + resolved, err := appdata.ResolveSavedQueryDirectory(a.configDir) + if err == nil { + defaultDirectory = resolved + } + } + if defaultDirectory == "" { + defaultDirectory = appdata.DefaultSavedQueryDirectory(a.configDir) + } + if !filepath.IsAbs(defaultDirectory) { + if abs, err := filepath.Abs(defaultDirectory); err == nil { + defaultDirectory = abs + } + } + + selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ + Title: a.appText("app.data_root.saved_query_directory.backend.dialog.select_directory", nil), + DefaultDirectory: defaultDirectory, + CanCreateDirectories: true, + }) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if strings.TrimSpace(selection) == "" { + return connection.QueryResult{ + Success: false, + Data: map[string]interface{}{"cancelled": true}, + } + } + resolved, err := filepath.Abs(strings.TrimSpace(selection)) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{ + Success: true, + Data: map[string]interface{}{ + "directory": filepath.Clean(resolved), + }, + } +} + +func (a *App) ApplySavedQueryDirectory(directory string) connection.QueryResult { + a.dataRootApplyMu.Lock() + defer a.dataRootApplyMu.Unlock() + + if a.webRuntime { + return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.desktop_only", nil)} + } + + defaultDirectory := appdata.DefaultSavedQueryDirectory(a.configDir) + target := strings.TrimSpace(directory) + if target == "" { + target = defaultDirectory + } + abs, err := filepath.Abs(target) + if err != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.save_failed", map[string]any{ + "detail": err.Error(), + }), + } + } + target = filepath.Clean(abs) + + currentDirectory, err := appdata.ResolveSavedQueryDirectory(a.configDir) + if err != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.save_failed", map[string]any{ + "detail": err.Error(), + }), + } + } + changed := !directoriesEqual(currentDirectory, target) + configuredTarget := target + if directoriesEqual(target, defaultDirectory) { + configuredTarget = "" + } + var migrateErr error + var saveErr error + func() { + savedQueriesMu.Lock() + defer savedQueriesMu.Unlock() + if changed { + migrateErr = a.savedQueryRepository().migrateSQLDirectoryLocked(target) + if migrateErr != nil { + return + } + } + _, saveErr = appdata.SetConfiguredSavedQueryDirectory(configuredTarget) + }() + if migrateErr != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.migrate_failed", map[string]any{ + "detail": migrateErr.Error(), + }), + } + } + if saveErr != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.save_failed", map[string]any{ + "detail": saveErr.Error(), + }), + } + } + + messageKey := "app.data_root.saved_query_directory.backend.message.updated" + if !changed { + messageKey = "app.data_root.saved_query_directory.backend.message.unchanged" + } + return connection.QueryResult{ + Success: true, + Message: a.appText(messageKey, nil), + Data: dataRootInfoPayload(a.configDir), + } +} + +func (a *App) OpenSavedQueryDirectory() connection.QueryResult { + if a.webRuntime { + return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.desktop_only", nil)} + } + + directory, err := appdata.ResolveSavedQueryDirectory(a.configDir) + if err != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.open_directory_failed", map[string]any{ + "detail": err.Error(), + }), + } + } + if err := os.MkdirAll(directory, 0o755); err != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.open_directory_failed", map[string]any{ + "detail": err.Error(), + }), + } + } + if stat, err := os.Stat(directory); err != nil || !stat.IsDir() { + return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.directory_unavailable", nil)} + } + + var cmd *exec.Cmd + switch stdRuntime.GOOS { + case "darwin": + cmd = exec.Command("open", directory) + case "windows": + cmd = exec.Command("explorer", directory) + case "linux": + cmd = exec.Command("xdg-open", directory) + default: + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.open_directory_unsupported", map[string]any{ + "platform": stdRuntime.GOOS, + }), + } + } + if err := cmd.Start(); err != nil { + logger.Errorf("打开已存查询目录失败:%v", err) + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.open_directory_failed", map[string]any{ + "detail": err.Error(), + }), + } + } + return connection.QueryResult{ + Success: true, + Message: a.appText("app.data_root.saved_query_directory.backend.message.opened", nil), + Data: dataRootInfoPayload(a.configDir), + } +} + +func savedQueryRevealCommand(platform string, filePath string) *exec.Cmd { + switch platform { + case "darwin": + return exec.Command("open", "-R", filePath) + case "windows": + return exec.Command("explorer.exe", "/select,"+filePath) + case "linux": + return exec.Command("xdg-open", filepath.Dir(filePath)) + default: + return nil + } +} + +var startSavedQueryRevealCommand = func(cmd *exec.Cmd) error { + if err := cmd.Start(); err != nil { + return err + } + go func() { + _ = cmd.Wait() + }() + return nil +} + +func (a *App) RevealSavedQueryInFolder(id string) connection.QueryResult { + if a.webRuntime { + return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.desktop_only", nil)} + } + + targetID := strings.TrimSpace(id) + if targetID == "" { + return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.query_id_required", nil)} + } + + a.dataRootApplyMu.Lock() + defer a.dataRootApplyMu.Unlock() + + savedQueriesMu.Lock() + defer savedQueriesMu.Unlock() + + filePath, found, err := a.savedQueryRepository().findSQLPath(targetID) + if err != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.reveal_failed", map[string]any{ + "detail": err.Error(), + }), + } + } + if !found { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.query_not_found", map[string]any{ + "id": targetID, + }), + } + } + filePath, err = filepath.Abs(filePath) + if err != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.reveal_failed", map[string]any{ + "detail": err.Error(), + }), + } + } + if stat, statErr := os.Stat(filePath); statErr != nil || !stat.Mode().IsRegular() { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.query_file_unavailable", map[string]any{ + "path": filePath, + }), + } + } + + cmd := savedQueryRevealCommand(stdRuntime.GOOS, filePath) + if cmd == nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.reveal_unsupported", map[string]any{ + "platform": stdRuntime.GOOS, + }), + } + } + if err := startSavedQueryRevealCommand(cmd); err != nil { + logger.Errorf("在文件夹中显示已存查询失败:%v", err) + return connection.QueryResult{ + Success: false, + Message: a.appText("app.data_root.saved_query_directory.backend.error.reveal_failed", map[string]any{ + "detail": err.Error(), + }), + } + } + return connection.QueryResult{ + Success: true, + Message: a.appText("app.data_root.saved_query_directory.backend.message.revealed", map[string]any{ + "path": filePath, + }), + Data: map[string]any{ + "id": targetID, + "path": filePath, + }, + } +} diff --git a/internal/app/methods_saved_query_directory_test.go b/internal/app/methods_saved_query_directory_test.go new file mode 100644 index 00000000..3ee5b694 --- /dev/null +++ b/internal/app/methods_saved_query_directory_test.go @@ -0,0 +1,354 @@ +package app + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + stdRuntime "runtime" + "testing" + "time" + + "GoNavi-Wails/internal/appdata" + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/secretstore" +) + +func TestDataRootInfoPayloadIncludesSavedQueryDirectory(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + activeRoot := filepath.Join(t.TempDir(), "gonavi-data") + defaultDirectory := appdata.DefaultSavedQueryDirectory(activeRoot) + payload := dataRootInfoPayload(activeRoot) + if payload["savedQueryDirectory"] != defaultDirectory { + t.Fatalf("savedQueryDirectory = %#v, want %q", payload["savedQueryDirectory"], defaultDirectory) + } + if payload["defaultSavedQueryDirectory"] != defaultDirectory { + t.Fatalf("defaultSavedQueryDirectory = %#v, want %q", payload["defaultSavedQueryDirectory"], defaultDirectory) + } + if payload["savedQueryDirectorySource"] != "default" { + t.Fatalf("savedQueryDirectorySource = %#v, want default", payload["savedQueryDirectorySource"]) + } + + customDirectory := filepath.Join(t.TempDir(), "saved-queries") + if _, err := appdata.SetConfiguredSavedQueryDirectory(customDirectory); err != nil { + t.Fatalf("SetConfiguredSavedQueryDirectory returned error: %v", err) + } + payload = dataRootInfoPayload(activeRoot) + if payload["savedQueryDirectory"] != customDirectory { + t.Fatalf("custom savedQueryDirectory = %#v, want %q", payload["savedQueryDirectory"], customDirectory) + } + if payload["savedQueryDirectorySource"] != "custom" { + t.Fatalf("custom savedQueryDirectorySource = %#v, want custom", payload["savedQueryDirectorySource"]) + } +} + +func TestApplySavedQueryDirectoryMigratesBeforeSwitchingConfiguration(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + application := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + application.configDir = filepath.Join(t.TempDir(), "gonavi-data") + query := connection.SavedQuery{ + ID: "saved-directory-migration", + Name: "Directory migration", + SQL: "select 42;", + ConnectionID: "conn-1", + DBName: "app", + CreatedAt: 100, + } + if _, err := application.SaveQuery(query); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + + targetDirectory := filepath.Join(t.TempDir(), "custom-saved-queries") + result := application.ApplySavedQueryDirectory(targetDirectory) + if !result.Success { + t.Fatalf("ApplySavedQueryDirectory returned failure: %s", result.Message) + } + configuredDirectory, err := appdata.ResolveConfiguredSavedQueryDirectory() + if err != nil { + t.Fatalf("ResolveConfiguredSavedQueryDirectory returned error: %v", err) + } + if configuredDirectory != targetDirectory { + t.Fatalf("configured saved query directory = %q, want %q", configuredDirectory, targetDirectory) + } + payload, ok := result.Data.(map[string]interface{}) + if !ok { + t.Fatalf("ApplySavedQueryDirectory data = %#v, want data-root payload", result.Data) + } + for _, key := range []string{ + "path", "defaultPath", "driverPath", "isDefaultPath", "bootstrapPath", + "logDirectory", "activeLogDirectory", "logFilePath", "defaultLogDirectory", + "logDirectorySource", "logDirectoryEditable", "logDirectoryRestartRequired", + "savedQueryDirectory", "defaultSavedQueryDirectory", "savedQueryDirectorySource", + } { + if _, exists := payload[key]; !exists { + t.Fatalf("ApplySavedQueryDirectory payload missing key %q: %#v", key, payload) + } + } + if payload["savedQueryDirectory"] != targetDirectory || payload["savedQueryDirectorySource"] != "custom" { + t.Fatalf("ApplySavedQueryDirectory payload has unexpected saved query directory: %#v", payload) + } + + queries, err := application.GetSavedQueries() + if err != nil { + t.Fatalf("GetSavedQueries after directory migration returned error: %v", err) + } + if len(queries) != 1 || queries[0].ID != query.ID || queries[0].SQL != query.SQL { + t.Fatalf("migrated saved queries = %#v, want query %#v", queries, query) + } +} + +func TestApplySavedQueryDirectoryRestoresDefaultDirectory(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + application := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + application.configDir = filepath.Join(t.TempDir(), "gonavi-data") + customDirectory := filepath.Join(t.TempDir(), "custom-saved-queries") + if _, err := appdata.SetConfiguredSavedQueryDirectory(customDirectory); err != nil { + t.Fatalf("SetConfiguredSavedQueryDirectory returned error: %v", err) + } + query := connection.SavedQuery{ + ID: "saved-directory-restore", + Name: "Directory restore", + SQL: "select 84;", + ConnectionID: "conn-1", + DBName: "app", + CreatedAt: 100, + } + if _, err := application.SaveQuery(query); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + + defaultDirectory := appdata.DefaultSavedQueryDirectory(application.configDir) + result := application.ApplySavedQueryDirectory(defaultDirectory) + if !result.Success { + t.Fatalf("ApplySavedQueryDirectory default returned failure: %s", result.Message) + } + configuredDirectory, err := appdata.ResolveConfiguredSavedQueryDirectory() + if err != nil { + t.Fatalf("ResolveConfiguredSavedQueryDirectory returned error: %v", err) + } + if configuredDirectory != "" { + t.Fatalf("configured saved query directory = %q, want default override cleared", configuredDirectory) + } + queries, err := application.GetSavedQueries() + if err != nil { + t.Fatalf("GetSavedQueries after restoring default returned error: %v", err) + } + if len(queries) != 1 || queries[0].ID != query.ID || queries[0].SQL != query.SQL { + t.Fatalf("restored saved queries = %#v, want query %#v", queries, query) + } +} + +func TestApplySavedQueryDirectoryDoesNotSwitchConfigurationWhenMigrationFails(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + application := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + application.configDir = filepath.Join(t.TempDir(), "gonavi-data") + if _, err := application.SaveQuery(connection.SavedQuery{ + ID: "saved-directory-failed-migration", + Name: "Failed directory migration", + SQL: "select 126;", + ConnectionID: "conn-1", + DBName: "app", + CreatedAt: 100, + }); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + + blockingPath := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blockingPath, []byte("blocked"), 0o644); err != nil { + t.Fatalf("write blocking path: %v", err) + } + result := application.ApplySavedQueryDirectory(blockingPath) + if result.Success { + t.Fatalf("ApplySavedQueryDirectory should fail when target is a file: %+v", result) + } + configuredDirectory, err := appdata.ResolveConfiguredSavedQueryDirectory() + if err != nil { + t.Fatalf("ResolveConfiguredSavedQueryDirectory returned error: %v", err) + } + if configuredDirectory != "" { + t.Fatalf("failed migration changed configured directory to %q", configuredDirectory) + } +} + +func TestApplySavedQueryDirectoryRejectsWebRuntime(t *testing.T) { + application := NewApp() + application.webRuntime = true + result := application.ApplySavedQueryDirectory(filepath.Join(t.TempDir(), "saved-queries")) + if result.Success { + t.Fatalf("ApplySavedQueryDirectory should reject web runtime: %+v", result) + } +} + +func TestOpenSavedQueryDirectoryRejectsWebRuntime(t *testing.T) { + application := NewApp() + application.webRuntime = true + result := application.OpenSavedQueryDirectory() + if result.Success { + t.Fatalf("OpenSavedQueryDirectory should reject web runtime: %+v", result) + } +} + +func TestSelectSavedQueryDirectoryRejectsWebRuntime(t *testing.T) { + application := NewApp() + application.webRuntime = true + result := application.SelectSavedQueryDirectory("") + if result.Success { + t.Fatalf("SelectSavedQueryDirectory should reject web runtime: %+v", result) + } +} + +func TestRevealSavedQueryInFolderRejectsWebRuntime(t *testing.T) { + application := NewApp() + application.webRuntime = true + result := application.RevealSavedQueryInFolder("saved-query") + if result.Success { + t.Fatalf("RevealSavedQueryInFolder should reject web runtime: %+v", result) + } +} + +func TestRevealSavedQueryInFolderValidatesQueryID(t *testing.T) { + application := NewApp() + result := application.RevealSavedQueryInFolder(" ") + if result.Success { + t.Fatalf("RevealSavedQueryInFolder should reject an empty query id: %+v", result) + } +} + +func TestRevealSavedQueryInFolderRejectsMissingQuery(t *testing.T) { + application := newSavedQueryTestApp(t) + result := application.RevealSavedQueryInFolder("missing-query") + if result.Success { + t.Fatalf("RevealSavedQueryInFolder should reject a missing query: %+v", result) + } +} + +func TestRevealSavedQueryInFolderIgnoresUnrelatedMissingSQLFile(t *testing.T) { + application := newSavedQueryTestApp(t) + for _, query := range []connection.SavedQuery{ + {ID: "healthy-query", Name: "Healthy", SQL: "select 1;", ConnectionID: "conn-1", DBName: "app", CreatedAt: 100}, + {ID: "missing-file-query", Name: "Missing", SQL: "select 2;", ConnectionID: "conn-1", DBName: "app", CreatedAt: 101}, + } { + if _, err := application.SaveQuery(query); err != nil { + t.Fatalf("SaveQuery(%s) returned error: %v", query.ID, err) + } + } + repository := application.savedQueryRepository() + healthyPath, found, err := repository.findSQLPath("healthy-query") + if err != nil || !found { + t.Fatalf("findSQLPath(healthy-query) = %q, %v, %v", healthyPath, found, err) + } + missingPath, found, err := repository.findSQLPath("missing-file-query") + if err != nil || !found { + t.Fatalf("findSQLPath(missing-file-query) = %q, %v, %v", missingPath, found, err) + } + if err := os.Remove(missingPath); err != nil { + t.Fatalf("remove unrelated sql file: %v", err) + } + + previousStart := startSavedQueryRevealCommand + var startedArgs []string + startSavedQueryRevealCommand = func(command *exec.Cmd) error { + startedArgs = append([]string(nil), command.Args...) + return nil + } + t.Cleanup(func() { + startSavedQueryRevealCommand = previousStart + }) + + result := application.RevealSavedQueryInFolder("healthy-query") + if !result.Success { + t.Fatalf("RevealSavedQueryInFolder healthy query returned failure: %+v", result) + } + expectedCommand := savedQueryRevealCommand(stdRuntime.GOOS, healthyPath) + if expectedCommand == nil { + t.Fatalf("savedQueryRevealCommand does not support test platform %q", stdRuntime.GOOS) + } + if !reflect.DeepEqual(startedArgs, expectedCommand.Args) { + t.Fatalf("reveal command args = %#v, want %#v", startedArgs, expectedCommand.Args) + } + + startedArgs = nil + result = application.RevealSavedQueryInFolder("missing-file-query") + if result.Success { + t.Fatalf("RevealSavedQueryInFolder should reject the missing target file: %+v", result) + } + if len(startedArgs) != 0 { + t.Fatalf("missing target unexpectedly started file manager: %#v", startedArgs) + } +} + +func TestRevealSavedQueryInFolderSerializesWithDataRootRequests(t *testing.T) { + application := newSavedQueryTestApp(t) + if _, err := application.SaveQuery(connection.SavedQuery{ + ID: "serialized-reveal", Name: "Serialized", SQL: "select 1;", ConnectionID: "conn-1", DBName: "app", CreatedAt: 100, + }); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + + previousStart := startSavedQueryRevealCommand + startSavedQueryRevealCommand = func(*exec.Cmd) error { return nil } + t.Cleanup(func() { + startSavedQueryRevealCommand = previousStart + }) + + application.dataRootApplyMu.Lock() + done := make(chan connection.QueryResult, 1) + go func() { + done <- application.RevealSavedQueryInFolder("serialized-reveal") + }() + + select { + case <-done: + application.dataRootApplyMu.Unlock() + t.Fatal("RevealSavedQueryInFolder bypassed the shared data-root serialization lock") + case <-time.After(50 * time.Millisecond): + } + application.dataRootApplyMu.Unlock() + + select { + case result := <-done: + if !result.Success { + t.Fatalf("serialized RevealSavedQueryInFolder returned failure: %+v", result) + } + case <-time.After(5 * time.Second): + t.Fatal("serialized RevealSavedQueryInFolder did not resume") + } +} + +func TestSavedQueryRevealCommandUsesPlatformFileManager(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "saved query.sql") + tests := []struct { + platform string + want []string + }{ + {platform: "darwin", want: []string{"open", "-R", filePath}}, + {platform: "windows", want: []string{"explorer.exe", "/select," + filePath}}, + {platform: "linux", want: []string{"xdg-open", filepath.Dir(filePath)}}, + } + for _, test := range tests { + t.Run(test.platform, func(t *testing.T) { + command := savedQueryRevealCommand(test.platform, filePath) + if command == nil { + t.Fatalf("savedQueryRevealCommand(%q) returned nil", test.platform) + } + if !reflect.DeepEqual(command.Args, test.want) { + t.Fatalf("savedQueryRevealCommand(%q) args = %#v, want %#v", test.platform, command.Args, test.want) + } + }) + } + if command := savedQueryRevealCommand("plan9", filePath); command != nil { + t.Fatalf("unsupported platform command = %#v, want nil", command.Args) + } +} diff --git a/internal/app/saved_queries.go b/internal/app/saved_queries.go index d06ce23e..2f3ad40b 100644 --- a/internal/app/saved_queries.go +++ b/internal/app/saved_queries.go @@ -2,18 +2,24 @@ package app import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" "strings" "sync" "time" + "unicode/utf8" + "GoNavi-Wails/internal/appdata" "GoNavi-Wails/internal/connection" "github.com/google/uuid" ) -const savedQueriesFileName = "saved_queries.json" +const ( + savedQueriesFileName = "saved_queries.json" + savedQueriesFormatVersion = 3 +) const ( savedQueryGroupTokenPrefix = "group:" @@ -22,8 +28,34 @@ const ( var savedQueriesMu sync.Mutex +var writeSavedQueriesMetadataAtomic = writeSavedQueriesFileAtomic + type savedQueriesFile struct { - Queries []connection.SavedQuery `json:"queries"` + Queries []connection.SavedQuery + Groups []connection.SavedQueryGroup + FileNames map[string]string +} + +// savedQueryDiskRecord deliberately excludes SQL from the current on-disk +// format. LegacySQL is read only so older saved_queries.json files can be +// migrated without losing content. +type savedQueryDiskRecord struct { + ID string `json:"id"` + Name string `json:"name"` + FileName string `json:"fileName,omitempty"` + LegacySQL string `json:"sql,omitempty"` + ConnectionID string `json:"connectionId"` + DBName string `json:"dbName"` + CreatedAt int64 `json:"createdAt"` + ConnectionFingerprint string `json:"connectionFingerprint,omitempty"` + FingerprintVersion string `json:"fingerprintVersion,omitempty"` + BindingStatus string `json:"bindingStatus,omitempty"` + OriginalConnectionID string `json:"originalConnectionId,omitempty"` +} + +type savedQueriesDiskFile struct { + Version int `json:"version,omitempty"` + Queries []savedQueryDiskRecord `json:"queries"` Groups []connection.SavedQueryGroup `json:"groups,omitempty"` } @@ -42,20 +74,24 @@ func (r *savedQueryRepository) queriesPath() string { return filepath.Join(r.configDir, savedQueriesFileName) } +func (r *savedQueryRepository) sqlDirectory() (string, error) { + return appdata.ResolveSavedQueryDirectory(r.configDir) +} + func (r *savedQueryRepository) loadFile() (savedQueriesFile, error) { data, err := os.ReadFile(r.queriesPath()) if err != nil { if os.IsNotExist(err) { - return savedQueriesFile{Queries: []connection.SavedQuery{}, Groups: []connection.SavedQueryGroup{}}, nil + return emptySavedQueriesFile(), nil } return savedQueriesFile{}, err } - var file savedQueriesFile - if err := json.Unmarshal(data, &file); err != nil { + var diskFile savedQueriesDiskFile + if err := json.Unmarshal(data, &diskFile); err != nil { return savedQueriesFile{}, err } - return normalizeSavedQueriesFile(file), nil + return r.hydrateDiskFile(diskFile) } func (r *savedQueryRepository) load() ([]connection.SavedQuery, error) { @@ -74,27 +110,559 @@ func (r *savedQueryRepository) loadGroups() ([]connection.SavedQueryGroup, error return file.Groups, nil } -func (r *savedQueryRepository) saveFile(file savedQueriesFile) error { +func (r *savedQueryRepository) findSQLPath(id string) (string, bool, error) { + targetID := strings.TrimSpace(id) + if targetID == "" { + return "", false, nil + } + payload, err := os.ReadFile(r.queriesPath()) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, err + } + var diskFile savedQueriesDiskFile + if err := json.Unmarshal(payload, &diskFile); err != nil { + return "", false, err + } + + fileName := "" + for _, record := range diskFile.Queries { + if strings.TrimSpace(record.ID) != targetID { + continue + } + fileName = strings.TrimSpace(record.FileName) + if diskFile.Version < savedQueriesFormatVersion || fileName == "" || record.LegacySQL != "" { + hydrated, err := r.hydrateDiskFile(diskFile) + if err != nil { + return "", false, err + } + fileName = hydrated.FileNames[targetID] + } + break + } + if fileName == "" { + return "", false, nil + } + fileName, err = normalizeSavedQueryDiskFileName(fileName) + if err != nil { + return "", false, err + } + directory, err := r.sqlDirectory() + if err != nil { + return "", false, err + } + return filepath.Join(directory, fileName), true, nil +} + +func emptySavedQueriesFile() savedQueriesFile { + return savedQueriesFile{ + Queries: []connection.SavedQuery{}, + Groups: []connection.SavedQueryGroup{}, + FileNames: map[string]string{}, + } +} + +func savedQueryFromDiskRecord(record savedQueryDiskRecord, sqlText string) connection.SavedQuery { + return connection.SavedQuery{ + ID: record.ID, + Name: record.Name, + SQL: sqlText, + ConnectionID: record.ConnectionID, + DBName: record.DBName, + CreatedAt: record.CreatedAt, + ConnectionFingerprint: record.ConnectionFingerprint, + FingerprintVersion: record.FingerprintVersion, + BindingStatus: record.BindingStatus, + OriginalConnectionID: record.OriginalConnectionID, + } +} + +func savedQueryToDiskRecord(query connection.SavedQuery, fileName string) savedQueryDiskRecord { + return savedQueryDiskRecord{ + ID: query.ID, + Name: query.Name, + FileName: fileName, + ConnectionID: query.ConnectionID, + DBName: query.DBName, + CreatedAt: query.CreatedAt, + ConnectionFingerprint: query.ConnectionFingerprint, + FingerprintVersion: query.FingerprintVersion, + BindingStatus: query.BindingStatus, + OriginalConnectionID: query.OriginalConnectionID, + } +} + +func normalizeSavedQueryDiskFileName(fileName string) (string, error) { + name := strings.TrimSpace(fileName) + if name == "" || filepath.Base(name) != name || strings.ContainsAny(name, `/\`) { + return "", fmt.Errorf("saved query has an invalid sql file name: %q", fileName) + } + if !strings.EqualFold(filepath.Ext(name), ".sql") { + return "", fmt.Errorf("saved query sql file must use the .sql extension: %q", fileName) + } + return name, nil +} + +func trimSavedQueryFileBase(value string, maxBytes int) string { + if len(value) <= maxBytes { + return value + } + end := 0 + for index := range value { + if index > maxBytes { + break + } + end = index + } + if end == 0 { + _, size := utf8.DecodeRuneInString(value) + if size <= maxBytes { + end = size + } + } + return strings.TrimSpace(value[:end]) +} + +func buildSavedQuerySQLFileBase(name string) string { + base := strings.TrimSpace(name) + if strings.EqualFold(filepath.Ext(base), ".sql") { + base = strings.TrimSpace(base[:len(base)-len(filepath.Ext(base))]) + } + base = strings.Map(func(value rune) rune { + if value < 0x20 || strings.ContainsRune(`<>:"/\|?*`, value) { + return '_' + } + return value + }, base) + base = strings.Trim(base, " .") + base = trimSavedQueryFileBase(base, 120) + if base == "" { + base = "query" + } + return base +} + +func savedQuerySQLFileNameKey(fileName string) string { + return strings.ToLower(strings.TrimSpace(fileName)) +} + +func allocateSavedQuerySQLFileName(name string, unavailable map[string]struct{}) string { + base := buildSavedQuerySQLFileBase(name) + for suffix := 1; ; suffix++ { + candidate := base + ".sql" + if suffix > 1 { + candidate = fmt.Sprintf("%s (%d).sql", base, suffix) + } + key := savedQuerySQLFileNameKey(candidate) + if _, exists := unavailable[key]; exists { + continue + } + unavailable[key] = struct{}{} + return candidate + } +} + +func listSavedQueryDirectoryFileNames(directory string) (map[string]struct{}, error) { + fileNames := make(map[string]struct{}) + entries, err := os.ReadDir(directory) + if err != nil { + if os.IsNotExist(err) { + return fileNames, nil + } + return nil, err + } + for _, entry := range entries { + fileNames[savedQuerySQLFileNameKey(entry.Name())] = struct{}{} + } + return fileNames, nil +} + +func (r *savedQueryRepository) hydrateDiskFile(diskFile savedQueriesDiskFile) (savedQueriesFile, error) { + directory, err := r.sqlDirectory() + if err != nil { + return savedQueriesFile{}, err + } + file := emptySavedQueriesFile() + file.Groups = diskFile.Groups + migrated := diskFile.Version < savedQueriesFormatVersion + unavailable, err := listSavedQueryDirectoryFileNames(directory) + if err != nil { + return savedQueriesFile{}, err + } + mutations := make([]savedQuerySQLMutation, 0) + oldPaths := make([]string, 0, len(diskFile.Queries)) + seenQueryIDs := make(map[string]struct{}, len(diskFile.Queries)) + rollback := func(cause error) (savedQueriesFile, error) { + return savedQueriesFile{}, errors.Join(cause, rollbackSavedQuerySQLMutations(mutations)) + } + + for index, record := range diskFile.Queries { + recordID := strings.TrimSpace(record.ID) + if recordID == "" { + migrated = true + continue + } + if _, exists := seenQueryIDs[recordID]; exists { + migrated = true + continue + } + seenQueryIDs[recordID] = struct{}{} + if record.LegacySQL != "" { + migrated = true + } + oldFileName := strings.TrimSpace(record.FileName) + content := []byte(record.LegacySQL) + if oldFileName != "" { + oldFileName, err = normalizeSavedQueryDiskFileName(oldFileName) + if err != nil { + return rollback(err) + } + oldPath := filepath.Join(directory, oldFileName) + content, err = os.ReadFile(oldPath) + if err != nil { + return rollback(fmt.Errorf("read saved query sql file %s: %w", oldFileName, err)) + } + oldPaths = append(oldPaths, oldPath) + } + query, ok := sanitizeSavedQuery(savedQueryFromDiskRecord(record, string(content)), index, false) + if !ok { + if oldFileName == "" { + migrated = true + continue + } + return rollback(fmt.Errorf("saved query is invalid: %s", strings.TrimSpace(record.ID))) + } + + fileName := oldFileName + if diskFile.Version < savedQueriesFormatVersion || fileName == "" { + oldKey := savedQuerySQLFileNameKey(oldFileName) + if oldKey != "" { + delete(unavailable, oldKey) + } + fileName = allocateSavedQuerySQLFileName(query.Name, unavailable) + if oldKey != "" && savedQuerySQLFileNameKey(fileName) != oldKey { + unavailable[oldKey] = struct{}{} + } + migrated = true + } else { + fileName, err = normalizeSavedQueryDiskFileName(fileName) + if err != nil { + return rollback(err) + } + } + + targetPath := filepath.Join(directory, fileName) + if oldFileName == "" || !savedQueryPathsReferToSameFile(filepath.Join(directory, oldFileName), targetPath) { + previous, readErr := os.ReadFile(targetPath) + if readErr != nil && !os.IsNotExist(readErr) { + return rollback(readErr) + } + if readErr == nil && string(previous) != string(content) { + return rollback(fmt.Errorf("saved query migration target already exists: %s", targetPath)) + } + if os.IsNotExist(readErr) { + mutation := savedQuerySQLMutation{path: targetPath} + if err := writeSavedQuerySQLFileAtomic(targetPath, content); err != nil { + return rollback(err) + } + mutations = append(mutations, mutation) + } + } + record.FileName = fileName + record.LegacySQL = "" + diskFile.Queries[index] = record + file.Queries = append(file.Queries, query) + file.FileNames[query.ID] = fileName + } + + file = normalizeSavedQueriesFile(file) + if migrated { + diskFile = buildSavedQueriesDiskFile(file) + if err := r.saveDiskFile(diskFile); err != nil { + return rollback(err) + } + referencedPaths := make([]string, 0, len(file.FileNames)) + for _, fileName := range file.FileNames { + referencedPaths = append(referencedPaths, filepath.Join(directory, fileName)) + } + for _, oldPath := range oldPaths { + referenced := false + for _, targetPath := range referencedPaths { + if savedQueryPathsReferToSameFile(oldPath, targetPath) { + referenced = true + break + } + } + if !referenced { + _ = os.Remove(oldPath) + } + } + } + return file, nil +} + +func buildSavedQueriesDiskFile(file savedQueriesFile) savedQueriesDiskFile { + file = normalizeSavedQueriesFile(file) + records := make([]savedQueryDiskRecord, 0, len(file.Queries)) + for _, query := range file.Queries { + records = append(records, savedQueryToDiskRecord(query, file.FileNames[query.ID])) + } + return savedQueriesDiskFile{ + Version: savedQueriesFormatVersion, + Queries: records, + Groups: file.Groups, + } +} + +func (r *savedQueryRepository) saveDiskFile(file savedQueriesDiskFile) error { if err := os.MkdirAll(r.configDir, 0o755); err != nil { return err } - payload, err := json.MarshalIndent(normalizeSavedQueriesFile(file), "", " ") + payload, err := json.MarshalIndent(file, "", " ") if err != nil { return err } - return writeSavedQueriesFileAtomic(r.queriesPath(), payload) + return writeSavedQueriesMetadataAtomic(r.queriesPath(), payload) +} + +func (r *savedQueryRepository) saveMetadataFile(file savedQueriesFile) error { + return r.saveDiskFile(buildSavedQueriesDiskFile(file)) +} + +type savedQuerySQLMutation struct { + path string + previous []byte + existed bool +} + +func rollbackSavedQuerySQLMutations(mutations []savedQuerySQLMutation) error { + var rollbackErr error + for index := len(mutations) - 1; index >= 0; index-- { + mutation := mutations[index] + if mutation.existed { + rollbackErr = errors.Join(rollbackErr, writeSavedQuerySQLFileAtomic(mutation.path, mutation.previous)) + continue + } + if err := os.Remove(mutation.path); err != nil && !os.IsNotExist(err) { + rollbackErr = errors.Join(rollbackErr, err) + } + } + return rollbackErr +} + +func savedQueryPathsReferToSameFile(left string, right string) bool { + if filepath.Clean(left) == filepath.Clean(right) { + return true + } + leftInfo, leftErr := os.Stat(left) + rightInfo, rightErr := os.Stat(right) + return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) +} + +func (r *savedQueryRepository) replaceQueries(current savedQueriesFile, queries []connection.SavedQuery) error { + directory, err := r.sqlDirectory() + if err != nil { + return err + } + if err := os.MkdirAll(directory, 0o755); err != nil { + return err + } + + next := savedQueriesFile{ + Queries: sanitizeSavedQueries(queries), + Groups: append([]connection.SavedQueryGroup(nil), current.Groups...), + FileNames: make(map[string]string, len(queries)), + } + currentByID := make(map[string]connection.SavedQuery, len(current.Queries)) + for _, query := range current.Queries { + currentByID[query.ID] = query + } + unavailable, err := listSavedQueryDirectoryFileNames(directory) + if err != nil { + return err + } + for _, query := range next.Queries { + previous, existed := currentByID[query.ID] + if existed && previous.Name == query.Name { + fileName, normalizeErr := normalizeSavedQueryDiskFileName(current.FileNames[query.ID]) + if normalizeErr != nil { + return normalizeErr + } + next.FileNames[query.ID] = fileName + continue + } + + oldFileName := "" + if existed { + oldFileName = current.FileNames[query.ID] + delete(unavailable, savedQuerySQLFileNameKey(oldFileName)) + } + fileName := allocateSavedQuerySQLFileName(query.Name, unavailable) + next.FileNames[query.ID] = fileName + if oldFileName != "" && savedQuerySQLFileNameKey(fileName) != savedQuerySQLFileNameKey(oldFileName) { + unavailable[savedQuerySQLFileNameKey(oldFileName)] = struct{}{} + } + } + + mutations := make([]savedQuerySQLMutation, 0, len(next.Queries)) + rollback := func(cause error) error { + return errors.Join(cause, rollbackSavedQuerySQLMutations(mutations)) + } + for _, query := range next.Queries { + previous, existed := currentByID[query.ID] + fileName := next.FileNames[query.ID] + fileName, err = normalizeSavedQueryDiskFileName(fileName) + if err != nil { + return rollback(err) + } + next.FileNames[query.ID] = fileName + targetPath := filepath.Join(directory, fileName) + previousPath := "" + if existed { + previousPath = filepath.Join(directory, current.FileNames[query.ID]) + } + + if existed && savedQueryPathsReferToSameFile(previousPath, targetPath) && previous.SQL == query.SQL { + continue + } + priorContent, readErr := os.ReadFile(targetPath) + if readErr != nil && !os.IsNotExist(readErr) { + return rollback(readErr) + } + if !existed || !savedQueryPathsReferToSameFile(previousPath, targetPath) { + if readErr == nil && string(priorContent) != query.SQL { + return rollback(fmt.Errorf("saved query sql target already exists: %s", targetPath)) + } + } + if readErr == nil && string(priorContent) == query.SQL { + continue + } + mutation := savedQuerySQLMutation{path: targetPath} + if readErr == nil { + mutation.existed = true + mutation.previous = priorContent + } + if err := writeSavedQuerySQLFileAtomic(targetPath, []byte(query.SQL)); err != nil { + return rollback(err) + } + mutations = append(mutations, mutation) + } + + next = normalizeSavedQueriesFile(next) + if err := r.saveMetadataFile(next); err != nil { + return rollback(err) + } + + referencedPaths := make(map[string]struct{}, len(next.FileNames)) + for _, fileName := range next.FileNames { + referencedPaths[filepath.Clean(filepath.Join(directory, fileName))] = struct{}{} + } + for queryID, fileName := range current.FileNames { + oldPath := filepath.Clean(filepath.Join(directory, fileName)) + if _, stillReferenced := referencedPaths[oldPath]; stillReferenced { + continue + } + if nextName, exists := next.FileNames[queryID]; exists { + newPath := filepath.Join(directory, nextName) + if savedQueryPathsReferToSameFile(oldPath, newPath) { + continue + } + } + _ = os.Remove(oldPath) + } + return nil } // saveAll remains available to callers that only replace query content. It // loads and carries forward saved-query groups instead of silently dropping // the new metadata. func (r *savedQueryRepository) saveAll(queries []connection.SavedQuery) error { + savedQueriesMu.Lock() + defer savedQueriesMu.Unlock() + file, err := r.loadFile() if err != nil { return err } - file.Queries = queries - return r.saveFile(file) + return r.replaceQueries(file, queries) +} + +func writeSavedQuerySQLFileAtomic(targetPath string, payload []byte) error { + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + mode := os.FileMode(0o644) + if info, err := os.Stat(targetPath); err == nil { + if info.IsDir() { + return fmt.Errorf("saved query sql path is a directory: %s", targetPath) + } + mode = info.Mode().Perm() + } else if !os.IsNotExist(err) { + return err + } + temp, err := os.CreateTemp(filepath.Dir(targetPath), ".saved_query_*.tmp") + if err != nil { + return err + } + tempPath := temp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempPath) + } + }() + if err := temp.Chmod(mode); err != nil { + _ = temp.Close() + return err + } + if _, err := temp.Write(payload); err != nil { + _ = temp.Close() + return err + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := replaceSavedQueryTempFile(tempPath, targetPath); err != nil { + return err + } + cleanup = false + return nil +} + +func replaceSavedQueryTempFile(tempPath string, targetPath string) error { + renameErr := os.Rename(tempPath, targetPath) + if renameErr == nil { + return nil + } + if _, err := os.Stat(targetPath); err != nil { + return renameErr + } + backup, err := os.CreateTemp(filepath.Dir(targetPath), ".saved_query_backup_*.tmp") + if err != nil { + return errors.Join(renameErr, err) + } + backupPath := backup.Name() + if err := backup.Close(); err != nil { + _ = os.Remove(backupPath) + return errors.Join(renameErr, err) + } + if err := os.Remove(backupPath); err != nil { + return errors.Join(renameErr, err) + } + if err := os.Rename(targetPath, backupPath); err != nil { + return errors.Join(renameErr, err) + } + if err := os.Rename(tempPath, targetPath); err != nil { + return errors.Join(err, os.Rename(backupPath, targetPath)) + } + _ = os.Remove(backupPath) + return nil } func writeSavedQueriesFileAtomic(targetPath string, payload []byte) error { @@ -125,13 +693,8 @@ func writeSavedQueriesFileAtomic(targetPath string, payload []byte) error { if err := os.Chmod(tempPath, 0o644); err != nil { return err } - if err := os.Rename(tempPath, targetPath); err != nil { - if removeErr := os.Remove(targetPath); removeErr != nil && !os.IsNotExist(removeErr) { - return err - } - if retryErr := os.Rename(tempPath, targetPath); retryErr != nil { - return retryErr - } + if err := replaceSavedQueryTempFile(tempPath, targetPath); err != nil { + return err } cleanup = false return nil @@ -150,7 +713,7 @@ func (r *savedQueryRepository) Save(input connection.SavedQuery) (connection.Sav if err != nil { return connection.SavedQuery{}, err } - queries := file.Queries + queries := append([]connection.SavedQuery(nil), file.Queries...) replaced := false for index, item := range queries { @@ -163,8 +726,7 @@ func (r *savedQueryRepository) Save(input connection.SavedQuery) (connection.Sav if !replaced { queries = append(queries, query) } - file.Queries = queries - if err := r.saveFile(file); err != nil { + if err := r.replaceQueries(file, queries); err != nil { return connection.SavedQuery{}, err } return query, nil @@ -199,17 +761,16 @@ func (r *savedQueryRepository) Import(payload connection.SavedQueryImportPayload existing = append(existing, query) } - file.Queries = existing if payload.Groups != nil { if err := validateSavedQueryGroupsQueryIDs(payload.Groups, existing); err != nil { return nil, err } file.Groups = mergeSavedQueryGroups(file.Groups, payload.Groups) } - if err := r.saveFile(file); err != nil { + if err := r.replaceQueries(file, existing); err != nil { return nil, err } - return existing, nil + return sanitizeSavedQueries(existing), nil } func (r *savedQueryRepository) Rebind(id string, target connection.SavedConnectionView) (connection.SavedQuery, error) { @@ -242,7 +803,7 @@ func (r *savedQueryRepository) Rebind(id string, target connection.SavedConnecti } queries[index] = query file.Queries = queries - if err := r.saveFile(file); err != nil { + if err := r.saveMetadataFile(file); err != nil { return connection.SavedQuery{}, err } return query, nil @@ -271,8 +832,106 @@ func (r *savedQueryRepository) Delete(id string) error { filtered = append(filtered, item) } } - file.Queries = filtered - return r.saveFile(file) + return r.replaceQueries(file, filtered) +} + +func (r *savedQueryRepository) Rename(id string, name string) (connection.SavedQuery, error) { + savedQueriesMu.Lock() + defer savedQueriesMu.Unlock() + + targetID := strings.TrimSpace(id) + nextName := strings.TrimSpace(name) + if targetID == "" || nextName == "" { + return connection.SavedQuery{}, fmt.Errorf("saved query and name are required") + } + file, err := r.loadFile() + if err != nil { + return connection.SavedQuery{}, err + } + for index, query := range file.Queries { + if query.ID != targetID { + continue + } + if query.Name == nextName { + return query, nil + } + queries := append([]connection.SavedQuery(nil), file.Queries...) + query.Name = nextName + queries[index] = query + if err := r.replaceQueries(file, queries); err != nil { + return connection.SavedQuery{}, err + } + return query, nil + } + + return connection.SavedQuery{}, fmt.Errorf("saved query not found: %s", targetID) +} + +// migrateSQLDirectory copies all managed SQL files to target atomically as a +// batch. Source files remain in place until the directory setting is switched, +// so a later configuration write failure cannot disconnect saved queries from +// their content. +func (r *savedQueryRepository) migrateSQLDirectory(target string) error { + savedQueriesMu.Lock() + defer savedQueriesMu.Unlock() + return r.migrateSQLDirectoryLocked(target) +} + +func (r *savedQueryRepository) migrateSQLDirectoryLocked(target string) error { + targetDirectory := strings.TrimSpace(target) + if targetDirectory == "" { + targetDirectory = appdata.DefaultSavedQueryDirectory(r.configDir) + } + absTarget, err := filepath.Abs(targetDirectory) + if err != nil { + return err + } + targetDirectory = filepath.Clean(absTarget) + if err := os.MkdirAll(targetDirectory, 0o755); err != nil { + return err + } + currentDirectory, err := r.sqlDirectory() + if err != nil { + return err + } + if savedQueryPathsReferToSameFile(currentDirectory, targetDirectory) { + return nil + } + file, err := r.loadFile() + if err != nil { + return err + } + + mutations := make([]savedQuerySQLMutation, 0, len(file.Queries)) + rollback := func(cause error) error { + return errors.Join(cause, rollbackSavedQuerySQLMutations(mutations)) + } + for _, query := range file.Queries { + fileName := file.FileNames[query.ID] + sourcePath := filepath.Join(currentDirectory, fileName) + content, err := os.ReadFile(sourcePath) + if err != nil { + return rollback(err) + } + targetPath := filepath.Join(targetDirectory, fileName) + previous, readErr := os.ReadFile(targetPath) + if readErr == nil && string(previous) == string(content) { + continue + } + if readErr != nil && !os.IsNotExist(readErr) { + return rollback(readErr) + } + mutation := savedQuerySQLMutation{path: targetPath} + if readErr == nil { + mutation.existed = true + mutation.previous = previous + } + if err := writeSavedQuerySQLFileAtomic(targetPath, content); err != nil { + return rollback(err) + } + mutations = append(mutations, mutation) + } + return nil } func (r *savedQueryRepository) SaveGroup(input connection.SavedQueryGroup) (connection.SavedQueryGroup, error) { @@ -317,7 +976,7 @@ func (r *savedQueryRepository) SaveGroup(input connection.SavedQueryGroup) (conn nextGroups = removeSavedQueryIDsFromOtherGroups(nextGroups, groupID, group.QueryIDs) file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries) - if err := r.saveFile(file); err != nil { + if err := r.saveMetadataFile(file); err != nil { return connection.SavedQueryGroup{}, err } @@ -370,7 +1029,7 @@ func (r *savedQueryRepository) DeleteGroup(id string) error { } file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries) - return r.saveFile(file) + return r.saveMetadataFile(file) } func (r *savedQueryRepository) MoveQueryToGroup(queryID string, groupID string) error { @@ -414,7 +1073,7 @@ func (r *savedQueryRepository) MoveQueryToGroup(queryID string, groupID string) } file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries) - return r.saveFile(file) + return r.saveMetadataFile(file) } func (r *savedQueryRepository) MoveGroup(groupID string, parentGroupID string) error { @@ -454,14 +1113,21 @@ func (r *savedQueryRepository) MoveGroup(groupID string, parentGroupID string) e } file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries) - return r.saveFile(file) + return r.saveMetadataFile(file) } func normalizeSavedQueriesFile(file savedQueriesFile) savedQueriesFile { queries := sanitizeSavedQueries(file.Queries) + fileNames := make(map[string]string, len(queries)) + for _, query := range queries { + if fileName := strings.TrimSpace(file.FileNames[query.ID]); fileName != "" { + fileNames[query.ID] = fileName + } + } return savedQueriesFile{ - Queries: queries, - Groups: normalizeSavedQueryGroups(file.Groups, queries), + Queries: queries, + Groups: normalizeSavedQueryGroups(file.Groups, queries), + FileNames: fileNames, } } diff --git a/internal/app/saved_queries_test.go b/internal/app/saved_queries_test.go index b5c8f27b..a5fa8e23 100644 --- a/internal/app/saved_queries_test.go +++ b/internal/app/saved_queries_test.go @@ -1,6 +1,8 @@ package app import ( + "bytes" + "encoding/json" "fmt" "os" "path/filepath" @@ -12,6 +14,455 @@ import ( "GoNavi-Wails/internal/secretstore" ) +func TestSavedQueryRepositoryStoresSQLInIndependentFile(t *testing.T) { + app := newSavedQueryTestApp(t) + query := connection.SavedQuery{ + ID: "saved-file-backed", + Name: "Orders / latest", + SQL: "\n select * from orders;\n", + ConnectionID: "conn-1", + DBName: "app", + CreatedAt: 100, + } + + if _, err := app.SaveQuery(query); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + diskFile, payload := readSavedQueriesDiskFile(t, app) + if bytes.Contains(payload, []byte(`"sql"`)) { + t.Fatalf("saved query metadata still contains inline sql: %s", payload) + } + if diskFile.Version != savedQueriesFormatVersion || len(diskFile.Queries) != 1 { + t.Fatalf("unexpected saved query metadata: %#v", diskFile) + } + record := diskFile.Queries[0] + if record.FileName != "Orders _ latest.sql" { + t.Fatalf("managed sql file name = %q, want %q", record.FileName, "Orders _ latest.sql") + } + if strings.ContainsAny(record.FileName, `/\`) { + t.Fatalf("managed sql file name contains a path separator: %q", record.FileName) + } + content, err := os.ReadFile(savedQuerySQLPath(t, app, record.FileName)) + if err != nil { + t.Fatalf("ReadFile saved query sql: %v", err) + } + if string(content) != query.SQL { + t.Fatalf("saved query sql = %q, want %q", content, query.SQL) + } +} + +func TestSavedQueryRepositoryAllocatesReadableDuplicateFileNames(t *testing.T) { + app := newSavedQueryTestApp(t) + for index, id := range []string{"orders-1", "orders-2", "orders-3"} { + if _, err := app.SaveQuery(connection.SavedQuery{ + ID: id, Name: "Orders", SQL: fmt.Sprintf("select %d", index+1), ConnectionID: "conn-1", DBName: "app", CreatedAt: int64(index + 1), + }); err != nil { + t.Fatalf("SaveQuery(%s) returned error: %v", id, err) + } + } + + diskFile, _ := readSavedQueriesDiskFile(t, app) + got := make([]string, 0, len(diskFile.Queries)) + for _, record := range diskFile.Queries { + got = append(got, record.FileName) + } + want := []string{"Orders.sql", "Orders (2).sql", "Orders (3).sql"} + if !sameStringSlice(got, want) { + t.Fatalf("managed sql file names = %#v, want %#v", got, want) + } +} + +func TestSavedQueryRepositoryAvoidsCaseInsensitiveExternalFileCollision(t *testing.T) { + app := newSavedQueryTestApp(t) + directory, err := app.savedQueryRepository().sqlDirectory() + if err != nil { + t.Fatalf("resolve sql directory: %v", err) + } + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatalf("MkdirAll sql directory: %v", err) + } + externalPath := filepath.Join(directory, "orders.SQL") + if err := os.WriteFile(externalPath, []byte("external sql"), 0o644); err != nil { + t.Fatalf("WriteFile external sql: %v", err) + } + + if _, err := app.SaveQuery(connection.SavedQuery{ + ID: "orders-managed", Name: "Orders", SQL: "select 1", ConnectionID: "conn-1", DBName: "app", CreatedAt: 1, + }); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + diskFile, _ := readSavedQueriesDiskFile(t, app) + if got := diskFile.Queries[0].FileName; got != "Orders (2).sql" { + t.Fatalf("managed sql file name = %q, want %q", got, "Orders (2).sql") + } + content, err := os.ReadFile(externalPath) + if err != nil || string(content) != "external sql" { + t.Fatalf("external sql content = %q, %v", content, err) + } +} + +func TestRenameSavedQueryAllocatesNextReadableFileName(t *testing.T) { + app := newSavedQueryTestApp(t) + for _, query := range []connection.SavedQuery{ + {ID: "rename-source", Name: "Source", SQL: "select 1", ConnectionID: "conn-1", DBName: "app", CreatedAt: 1}, + {ID: "rename-target", Name: "Target", SQL: "select 2", ConnectionID: "conn-1", DBName: "app", CreatedAt: 2}, + } { + if _, err := app.SaveQuery(query); err != nil { + t.Fatalf("SaveQuery(%s) returned error: %v", query.ID, err) + } + } + directory, err := app.savedQueryRepository().sqlDirectory() + if err != nil { + t.Fatalf("resolve sql directory: %v", err) + } + if err := os.WriteFile(filepath.Join(directory, "target (2).SQL"), []byte("external sql"), 0o644); err != nil { + t.Fatalf("WriteFile external sql: %v", err) + } + beforeDisk, _ := readSavedQueriesDiskFile(t, app) + oldPath := savedQuerySQLPath(t, app, beforeDisk.Queries[0].FileName) + latestSQL := "select 3 -- edited outside GoNavi" + if err := os.WriteFile(oldPath, []byte(latestSQL), 0o644); err != nil { + t.Fatalf("WriteFile external edit: %v", err) + } + + renamed, err := app.RenameSavedQuery("rename-source", "Target") + if err != nil { + t.Fatalf("RenameSavedQuery returned error: %v", err) + } + if renamed.SQL != latestSQL { + t.Fatalf("renamed query sql = %q, want latest disk content %q", renamed.SQL, latestSQL) + } + afterDisk, _ := readSavedQueriesDiskFile(t, app) + if got := afterDisk.Queries[0].FileName; got != "Target (3).sql" { + t.Fatalf("renamed sql file name = %q, want %q", got, "Target (3).sql") + } + content, err := os.ReadFile(savedQuerySQLPath(t, app, afterDisk.Queries[0].FileName)) + if err != nil || string(content) != latestSQL { + t.Fatalf("renamed sql content = %q, %v", content, err) + } +} + +func TestSavedQueryRepositoryMigratesVersionTwoHashedFileNames(t *testing.T) { + app := newSavedQueryTestApp(t) + directory, err := app.savedQueryRepository().sqlDirectory() + if err != nil { + t.Fatalf("resolve sql directory: %v", err) + } + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatalf("MkdirAll sql directory: %v", err) + } + externalPath := filepath.Join(directory, "orders.SQL") + if err := os.WriteFile(externalPath, []byte("external sql"), 0o644); err != nil { + t.Fatalf("WriteFile external sql: %v", err) + } + oldFileNames := []string{ + "Orders--11111111111111111111111111111111.sql", + "Orders--22222222222222222222222222222222.sql", + } + for index, fileName := range oldFileNames { + if err := os.WriteFile(filepath.Join(directory, fileName), []byte(fmt.Sprintf("select %d", index+1)), 0o644); err != nil { + t.Fatalf("WriteFile old managed sql: %v", err) + } + } + diskFile := savedQueriesDiskFile{ + Version: 2, + Queries: []savedQueryDiskRecord{ + {ID: "migrate-v2-1", Name: "Orders", FileName: oldFileNames[0], ConnectionID: "conn-1", DBName: "app", CreatedAt: 1}, + {ID: "migrate-v2-2", Name: "Orders", FileName: oldFileNames[1], ConnectionID: "conn-1", DBName: "app", CreatedAt: 2}, + }, + } + payload, err := json.Marshal(diskFile) + if err != nil { + t.Fatalf("Marshal v2 metadata: %v", err) + } + if err := os.WriteFile(filepath.Join(app.configDir, savedQueriesFileName), payload, 0o644); err != nil { + t.Fatalf("WriteFile v2 metadata: %v", err) + } + + queries, err := app.GetSavedQueries() + if err != nil { + t.Fatalf("GetSavedQueries returned error: %v", err) + } + if len(queries) != 2 || queries[0].SQL != "select 1" || queries[1].SQL != "select 2" { + t.Fatalf("migrated queries = %#v", queries) + } + migratedDisk, _ := readSavedQueriesDiskFile(t, app) + if migratedDisk.Version != savedQueriesFormatVersion { + t.Fatalf("migrated metadata version = %d, want %d", migratedDisk.Version, savedQueriesFormatVersion) + } + wantFileNames := []string{"Orders (2).sql", "Orders (3).sql"} + for index, record := range migratedDisk.Queries { + if record.FileName != wantFileNames[index] { + t.Fatalf("migrated file name %d = %q, want %q", index, record.FileName, wantFileNames[index]) + } + content, readErr := os.ReadFile(savedQuerySQLPath(t, app, record.FileName)) + if readErr != nil || string(content) != fmt.Sprintf("select %d", index+1) { + t.Fatalf("migrated sql %d = %q, %v", index, content, readErr) + } + if _, statErr := os.Stat(filepath.Join(directory, oldFileNames[index])); !os.IsNotExist(statErr) { + t.Fatalf("old hashed sql file still exists: %s, err=%v", oldFileNames[index], statErr) + } + } + content, err := os.ReadFile(externalPath) + if err != nil || string(content) != "external sql" { + t.Fatalf("external sql content = %q, %v", content, err) + } + path, found, err := app.savedQueryRepository().findSQLPath("migrate-v2-2") + if err != nil || !found || filepath.Base(path) != "Orders (3).sql" { + t.Fatalf("findSQLPath after migration = %q, %v, %v", path, found, err) + } +} + +func TestSavedQueryRepositoryVersionTwoMigrationRollsBackWhenMetadataWriteFails(t *testing.T) { + app := newSavedQueryTestApp(t) + directory, err := app.savedQueryRepository().sqlDirectory() + if err != nil { + t.Fatalf("resolve sql directory: %v", err) + } + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatalf("MkdirAll sql directory: %v", err) + } + oldFileName := "Rollback--11111111111111111111111111111111.sql" + oldPath := filepath.Join(directory, oldFileName) + if err := os.WriteFile(oldPath, []byte("select 1"), 0o644); err != nil { + t.Fatalf("WriteFile old managed sql: %v", err) + } + diskFile := savedQueriesDiskFile{ + Version: 2, + Queries: []savedQueryDiskRecord{{ + ID: "migrate-rollback", Name: "Rollback", FileName: oldFileName, ConnectionID: "conn-1", DBName: "app", CreatedAt: 1, + }}, + } + payload, err := json.Marshal(diskFile) + if err != nil { + t.Fatalf("Marshal v2 metadata: %v", err) + } + metadataPath := filepath.Join(app.configDir, savedQueriesFileName) + if err := os.WriteFile(metadataPath, payload, 0o644); err != nil { + t.Fatalf("WriteFile v2 metadata: %v", err) + } + + previousWrite := writeSavedQueriesMetadataAtomic + writeSavedQueriesMetadataAtomic = func(string, []byte) error { + return fmt.Errorf("forced metadata write failure") + } + t.Cleanup(func() { + writeSavedQueriesMetadataAtomic = previousWrite + }) + + if _, err := app.GetSavedQueries(); err == nil { + t.Fatal("expected migration to fail when metadata cannot be written") + } + afterMetadata, err := os.ReadFile(metadataPath) + if err != nil || !bytes.Equal(afterMetadata, payload) { + t.Fatalf("metadata after failed migration = %q, %v", afterMetadata, err) + } + oldContent, err := os.ReadFile(oldPath) + if err != nil || string(oldContent) != "select 1" { + t.Fatalf("old managed sql after failed migration = %q, %v", oldContent, err) + } + if _, err := os.Stat(filepath.Join(directory, "Rollback.sql")); !os.IsNotExist(err) { + t.Fatalf("new readable sql should be rolled back, err=%v", err) + } +} + +func TestSavedQueryRepositoryFindSQLPathTracksManagedFileName(t *testing.T) { + app := newSavedQueryTestApp(t) + query := connection.SavedQuery{ + ID: "saved-reveal-path", + Name: "Before reveal", + SQL: "select 1;", + ConnectionID: "conn-1", + DBName: "app", + CreatedAt: 100, + } + if _, err := app.SaveQuery(query); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + + repository := app.savedQueryRepository() + beforePath, found, err := repository.findSQLPath(query.ID) + if err != nil || !found { + t.Fatalf("findSQLPath before rename = %q, %v, %v", beforePath, found, err) + } + if _, err := os.Stat(beforePath); err != nil { + t.Fatalf("managed sql path before rename is unavailable: %v", err) + } + + if _, err := app.RenameSavedQuery(query.ID, "After reveal"); err != nil { + t.Fatalf("RenameSavedQuery returned error: %v", err) + } + afterPath, found, err := repository.findSQLPath(query.ID) + if err != nil || !found { + t.Fatalf("findSQLPath after rename = %q, %v, %v", afterPath, found, err) + } + if afterPath == beforePath { + t.Fatalf("findSQLPath still returns the pre-rename path: %s", afterPath) + } + if _, err := os.Stat(afterPath); err != nil { + t.Fatalf("managed sql path after rename is unavailable: %v", err) + } + if _, found, err := repository.findSQLPath("missing-query"); err != nil || found { + t.Fatalf("findSQLPath for a missing query returned found=%v, err=%v", found, err) + } +} + +func TestSavedQueryRepositoryMigratesLegacyInlineSQLIdempotently(t *testing.T) { + app := newSavedQueryTestApp(t) + legacyPayload := []byte(`{ + "queries": [{ + "id": "saved-legacy-file", + "name": "Legacy", + "sql": "select 1;\n", + "connectionId": "conn-1", + "dbName": "app", + "createdAt": 100 + }] +}`) + if err := os.WriteFile(filepath.Join(app.configDir, savedQueriesFileName), legacyPayload, 0o644); err != nil { + t.Fatalf("WriteFile legacy metadata: %v", err) + } + + for attempt := 0; attempt < 2; attempt++ { + queries, err := app.GetSavedQueries() + if err != nil { + t.Fatalf("GetSavedQueries attempt %d returned error: %v", attempt+1, err) + } + if len(queries) != 1 || queries[0].SQL != "select 1;\n" { + t.Fatalf("unexpected migrated query on attempt %d: %#v", attempt+1, queries) + } + } + diskFile, payload := readSavedQueriesDiskFile(t, app) + if bytes.Contains(payload, []byte(`"sql"`)) { + t.Fatalf("migrated metadata still contains inline sql: %s", payload) + } + if len(diskFile.Queries) != 1 { + t.Fatalf("unexpected migrated metadata: %#v", diskFile) + } + content, err := os.ReadFile(savedQuerySQLPath(t, app, diskFile.Queries[0].FileName)) + if err != nil || string(content) != "select 1;\n" { + t.Fatalf("migrated sql content = %q, %v", content, err) + } +} + +func TestSavedQueryRepositoryLegacyMigrationFailureLeavesJSONUntouched(t *testing.T) { + app := newSavedQueryTestApp(t) + legacyPayload := []byte(`{"queries":[{"id":"saved-blocked","name":"Blocked","sql":"select 1","connectionId":"conn-1","dbName":"app","createdAt":100}]}`) + metadataPath := filepath.Join(app.configDir, savedQueriesFileName) + if err := os.WriteFile(metadataPath, legacyPayload, 0o644); err != nil { + t.Fatalf("WriteFile legacy metadata: %v", err) + } + directory, err := app.savedQueryRepository().sqlDirectory() + if err != nil { + t.Fatalf("resolve sql directory: %v", err) + } + if err := os.WriteFile(directory, []byte("blocks directory creation"), 0o644); err != nil { + t.Fatalf("WriteFile blocking path: %v", err) + } + + if _, err := app.GetSavedQueries(); err == nil { + t.Fatal("expected legacy migration to fail when the sql directory is blocked") + } + after, err := os.ReadFile(metadataPath) + if err != nil { + t.Fatalf("ReadFile metadata after failure: %v", err) + } + if !bytes.Equal(after, legacyPayload) { + t.Fatalf("legacy metadata changed after failed migration:\n%s", after) + } +} + +func TestSavedQueryRepositoryReloadsExternallyModifiedSQL(t *testing.T) { + app := newSavedQueryTestApp(t) + if _, err := app.SaveQuery(connection.SavedQuery{ + ID: "saved-external-edit", Name: "External", SQL: "select 1", ConnectionID: "conn-1", DBName: "app", CreatedAt: 100, + }); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + diskFile, _ := readSavedQueriesDiskFile(t, app) + path := savedQuerySQLPath(t, app, diskFile.Queries[0].FileName) + if err := os.WriteFile(path, []byte("select 2 -- edited externally"), 0o644); err != nil { + t.Fatalf("WriteFile external edit: %v", err) + } + + queries, err := app.GetSavedQueries() + if err != nil { + t.Fatalf("GetSavedQueries returned error: %v", err) + } + if len(queries) != 1 || queries[0].SQL != "select 2 -- edited externally" { + t.Fatalf("expected latest disk content, got %#v", queries) + } +} + +func TestRenameSavedQueryPreservesLatestDiskContent(t *testing.T) { + app := newSavedQueryTestApp(t) + if _, err := app.SaveQuery(connection.SavedQuery{ + ID: "saved-rename-file", Name: "Before", SQL: "select 1", ConnectionID: "conn-1", DBName: "app", CreatedAt: 100, + }); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + beforeDisk, _ := readSavedQueriesDiskFile(t, app) + oldPath := savedQuerySQLPath(t, app, beforeDisk.Queries[0].FileName) + latestSQL := "select 2 -- edited outside GoNavi" + if err := os.WriteFile(oldPath, []byte(latestSQL), 0o644); err != nil { + t.Fatalf("WriteFile external edit: %v", err) + } + + renamed, err := app.RenameSavedQuery("saved-rename-file", "After") + if err != nil { + t.Fatalf("RenameSavedQuery returned error: %v", err) + } + if renamed.Name != "After" || renamed.SQL != latestSQL { + t.Fatalf("unexpected renamed query: %#v", renamed) + } + afterDisk, payload := readSavedQueriesDiskFile(t, app) + if bytes.Contains(payload, []byte(`"sql"`)) { + t.Fatalf("renamed metadata contains inline sql: %s", payload) + } + newPath := savedQuerySQLPath(t, app, afterDisk.Queries[0].FileName) + if newPath == oldPath { + t.Fatalf("expected query rename to rename the managed sql file: %s", newPath) + } + if _, err := os.Stat(oldPath); !os.IsNotExist(err) { + t.Fatalf("old sql path should be gone, got err=%v", err) + } + content, err := os.ReadFile(newPath) + if err != nil || string(content) != latestSQL { + t.Fatalf("renamed sql content = %q, %v", content, err) + } +} + +func TestDeleteSavedQueryRemovesSQLFileAndGroupMembership(t *testing.T) { + app := newSavedQueryTestApp(t) + if _, err := app.SaveQuery(connection.SavedQuery{ + ID: "saved-delete-file", Name: "Delete", SQL: "select 1", ConnectionID: "conn-1", DBName: "app", CreatedAt: 100, + }); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{ + ID: "group-delete-file", Name: "Delete", QueryIDs: []string{"saved-delete-file"}, + }); err != nil { + t.Fatalf("SaveSavedQueryGroup returned error: %v", err) + } + diskFile, _ := readSavedQueriesDiskFile(t, app) + queryPath := savedQuerySQLPath(t, app, diskFile.Queries[0].FileName) + + if err := app.DeleteQuery("saved-delete-file"); err != nil { + t.Fatalf("DeleteQuery returned error: %v", err) + } + if _, err := os.Stat(queryPath); !os.IsNotExist(err) { + t.Fatalf("deleted query sql file still exists, err=%v", err) + } + groups, err := app.GetSavedQueryGroups() + if err != nil { + t.Fatalf("GetSavedQueryGroups returned error: %v", err) + } + group := findSavedQueryGroup(groups, "group-delete-file") + if group == nil || len(group.QueryIDs) != 0 || len(group.ChildOrder) != 0 { + t.Fatalf("deleted query still belongs to a group: %#v", group) + } +} + func TestSavedQueryRepositorySaveUpdateAndDelete(t *testing.T) { app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) app.configDir = t.TempDir() @@ -865,3 +1316,35 @@ func containsString(values []string, target string) bool { } return false } + +func newSavedQueryTestApp(t *testing.T) *App { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + application := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + application.configDir = t.TempDir() + return application +} + +func readSavedQueriesDiskFile(t *testing.T, application *App) (savedQueriesDiskFile, []byte) { + t.Helper() + payload, err := os.ReadFile(filepath.Join(application.configDir, savedQueriesFileName)) + if err != nil { + t.Fatalf("ReadFile saved query metadata: %v", err) + } + var file savedQueriesDiskFile + if err := json.Unmarshal(payload, &file); err != nil { + t.Fatalf("Unmarshal saved query metadata: %v", err) + } + return file, payload +} + +func savedQuerySQLPath(t *testing.T, application *App, fileName string) string { + t.Helper() + directory, err := application.savedQueryRepository().sqlDirectory() + if err != nil { + t.Fatalf("resolve saved query sql directory: %v", err) + } + return filepath.Join(directory, fileName) +} diff --git a/internal/appdata/root.go b/internal/appdata/root.go index a8b4cc7f..b175ee81 100644 --- a/internal/appdata/root.go +++ b/internal/appdata/root.go @@ -10,9 +10,11 @@ import ( ) const ( - bootstrapFileName = "storage_root.json" - bootstrapLockFileName = bootstrapFileName + ".lock" - configuredLogFileName = "gonavi.log" + bootstrapFileName = "storage_root.json" + bootstrapLockFileName = bootstrapFileName + ".lock" + configuredLogFileName = "gonavi.log" + savedQueryDirectoryName = "saved_queries" + savedQueryDirectoryProbePrefix = ".gonavi-saved-query-" ) const dataRootEnvName = "GONAVI_DATA_ROOT" @@ -63,8 +65,9 @@ func SetActiveRootErrorDetail(err error) error { } type bootstrapConfig struct { - DataRoot string `json:"dataRoot,omitempty"` - LogDirectory string `json:"logDirectory,omitempty"` + DataRoot string `json:"dataRoot,omitempty"` + LogDirectory string `json:"logDirectory,omitempty"` + SavedQueryDirectory string `json:"savedQueryDirectory,omitempty"` } func readBootstrapConfig() (bootstrapConfig, error) { @@ -85,7 +88,8 @@ func readBootstrapConfig() (bootstrapConfig, error) { func writeBootstrapConfig(cfg bootstrapConfig) error { cfg.DataRoot = strings.TrimSpace(cfg.DataRoot) cfg.LogDirectory = strings.TrimSpace(cfg.LogDirectory) - if cfg.DataRoot == "" && cfg.LogDirectory == "" { + cfg.SavedQueryDirectory = strings.TrimSpace(cfg.SavedQueryDirectory) + if cfg.DataRoot == "" && cfg.LogDirectory == "" && cfg.SavedQueryDirectory == "" { if err := os.Remove(BootstrapPath()); err != nil && !os.IsNotExist(err) { return err } @@ -228,6 +232,17 @@ func DriverRoot(activeRoot string) string { return filepath.Join(root, "drivers") } +func DefaultSavedQueryDirectory(activeRoot string) string { + root := strings.TrimSpace(activeRoot) + if root == "" { + root = MustResolveActiveRoot() + } + if abs, err := filepath.Abs(root); err == nil { + root = abs + } + return filepath.Join(filepath.Clean(root), savedQueryDirectoryName) +} + func SetActiveRoot(root string) (string, error) { targetRoot, err := normalizeRoot(root) if err != nil { @@ -303,3 +318,66 @@ func SetConfiguredLogDirectory(directory string) (string, error) { } return target, nil } + +func ResolveConfiguredSavedQueryDirectory() (string, error) { + bootstrapConfigMu.Lock() + cfg, err := readBootstrapConfig() + bootstrapConfigMu.Unlock() + if err != nil { + return "", err + } + directory := strings.TrimSpace(cfg.SavedQueryDirectory) + if directory == "" { + return "", nil + } + abs, err := filepath.Abs(directory) + if err != nil { + return "", err + } + return filepath.Clean(abs), nil +} + +func ResolveSavedQueryDirectory(activeRoot string) (string, error) { + directory, err := ResolveConfiguredSavedQueryDirectory() + if err != nil { + return "", err + } + if directory != "" { + return directory, nil + } + return DefaultSavedQueryDirectory(activeRoot), nil +} + +func SetConfiguredSavedQueryDirectory(directory string) (string, error) { + target := strings.TrimSpace(directory) + if target != "" { + abs, resolveErr := filepath.Abs(target) + if resolveErr != nil { + return "", resolveErr + } + target = filepath.Clean(abs) + if mkdirErr := os.MkdirAll(target, 0o755); mkdirErr != nil { + return "", mkdirErr + } + + probe, createErr := os.CreateTemp(target, savedQueryDirectoryProbePrefix) + if createErr != nil { + return "", createErr + } + probePath := probe.Name() + if closeErr := probe.Close(); closeErr != nil { + _ = os.Remove(probePath) + return "", closeErr + } + if removeErr := os.Remove(probePath); removeErr != nil { + return "", removeErr + } + } + + if err := updateBootstrapConfig(func(cfg *bootstrapConfig) { + cfg.SavedQueryDirectory = target + }); err != nil { + return "", err + } + return target, nil +} diff --git a/internal/appdata/root_test.go b/internal/appdata/root_test.go index 93bb7c8b..86061062 100644 --- a/internal/appdata/root_test.go +++ b/internal/appdata/root_test.go @@ -146,6 +146,120 @@ func TestDataRootAndLogDirectoryPreserveEachOtherInBootstrap(t *testing.T) { } } +func TestDefaultSavedQueryDirectoryUsesActiveRoot(t *testing.T) { + activeRoot := filepath.Join(t.TempDir(), "gonavi-data") + want := filepath.Join(activeRoot, savedQueryDirectoryName) + if got := DefaultSavedQueryDirectory(activeRoot); got != want { + t.Fatalf("DefaultSavedQueryDirectory = %q, want %q", got, want) + } +} + +func TestSavedQueryDirectoryFollowsDataRootOnlyWhileUsingDefault(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + firstRoot := filepath.Join(t.TempDir(), "first-root") + secondRoot := filepath.Join(t.TempDir(), "second-root") + if _, err := SetActiveRoot(firstRoot); err != nil { + t.Fatalf("SetActiveRoot first returned error: %v", err) + } + resolved, err := ResolveSavedQueryDirectory(firstRoot) + if err != nil || resolved != filepath.Join(firstRoot, savedQueryDirectoryName) { + t.Fatalf("first default saved query directory = %q, %v", resolved, err) + } + if _, err := SetActiveRoot(secondRoot); err != nil { + t.Fatalf("SetActiveRoot second returned error: %v", err) + } + resolved, err = ResolveSavedQueryDirectory(secondRoot) + if err != nil || resolved != filepath.Join(secondRoot, savedQueryDirectoryName) { + t.Fatalf("second default saved query directory = %q, %v", resolved, err) + } + + customDirectory := filepath.Join(t.TempDir(), "custom-saved-queries") + if _, err := SetConfiguredSavedQueryDirectory(customDirectory); err != nil { + t.Fatalf("SetConfiguredSavedQueryDirectory returned error: %v", err) + } + if _, err := SetActiveRoot(firstRoot); err != nil { + t.Fatalf("restore SetActiveRoot first returned error: %v", err) + } + resolved, err = ResolveSavedQueryDirectory(firstRoot) + if err != nil || resolved != customDirectory { + t.Fatalf("custom saved query directory after data-root switch = %q, %v; want %q", resolved, err, customDirectory) + } +} + +func TestSavedQueryDirectoryAndOtherSettingsPreserveEachOtherInBootstrap(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + customDataRoot := filepath.Join(t.TempDir(), "gonavi-data") + customLogDirectory := filepath.Join(t.TempDir(), "gonavi-logs") + customSavedQueryDirectory := filepath.Join(t.TempDir(), "saved-queries") + if _, err := SetActiveRoot(customDataRoot); err != nil { + t.Fatalf("SetActiveRoot returned error: %v", err) + } + if _, err := SetConfiguredLogDirectory(customLogDirectory); err != nil { + t.Fatalf("SetConfiguredLogDirectory returned error: %v", err) + } + if _, err := SetConfiguredSavedQueryDirectory(customSavedQueryDirectory); err != nil { + t.Fatalf("SetConfiguredSavedQueryDirectory returned error: %v", err) + } + + resolvedSavedQueryDirectory, err := ResolveSavedQueryDirectory(customDataRoot) + if err != nil || resolvedSavedQueryDirectory != customSavedQueryDirectory { + t.Fatalf("saved query directory = %q, %v; want %q", resolvedSavedQueryDirectory, err, customSavedQueryDirectory) + } + + if _, err := SetActiveRoot(""); err != nil { + t.Fatalf("reset SetActiveRoot returned error: %v", err) + } + if _, err := SetConfiguredLogDirectory(""); err != nil { + t.Fatalf("reset SetConfiguredLogDirectory returned error: %v", err) + } + if _, err := os.Stat(BootstrapPath()); err != nil { + t.Fatalf("bootstrap should remain while saved query directory is customized: %v", err) + } + resolvedSavedQueryDirectory, err = ResolveConfiguredSavedQueryDirectory() + if err != nil || resolvedSavedQueryDirectory != customSavedQueryDirectory { + t.Fatalf("configured saved query directory = %q, %v; want %q", resolvedSavedQueryDirectory, err, customSavedQueryDirectory) + } + + if _, err := SetConfiguredSavedQueryDirectory(""); err != nil { + t.Fatalf("reset SetConfiguredSavedQueryDirectory returned error: %v", err) + } + if _, err := os.Stat(BootstrapPath()); !os.IsNotExist(err) { + t.Fatalf("bootstrap should be removed when all settings use defaults, got err=%v", err) + } + defaultSavedQueryDirectory := filepath.Join(homeDir, ".gonavi", savedQueryDirectoryName) + resolvedSavedQueryDirectory, err = ResolveSavedQueryDirectory("") + if err != nil || resolvedSavedQueryDirectory != defaultSavedQueryDirectory { + t.Fatalf("default saved query directory = %q, %v; want %q", resolvedSavedQueryDirectory, err, defaultSavedQueryDirectory) + } +} + +func TestSetConfiguredSavedQueryDirectoryRejectsFilePathWithoutChangingConfig(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + blockingPath := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blockingPath, []byte("blocked"), 0o644); err != nil { + t.Fatalf("write blocking file: %v", err) + } + if _, err := SetConfiguredSavedQueryDirectory(blockingPath); err == nil { + t.Fatal("expected file path to be rejected as a saved query directory") + } + configured, err := ResolveConfiguredSavedQueryDirectory() + if err != nil { + t.Fatalf("ResolveConfiguredSavedQueryDirectory returned error: %v", err) + } + if configured != "" { + t.Fatalf("failed update changed configured saved query directory to %q", configured) + } +} + func TestSetConfiguredLogDirectoryRejectsFilePathWithoutChangingConfig(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) diff --git a/internal/webserver/server.go b/internal/webserver/server.go index 325e9bd0..c2db6f52 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -46,6 +46,10 @@ var desktopOnlyAppMethods = map[string]struct{}{ "SelectLogDirectory": {}, "ApplyLogDirectory": {}, "OpenLogDirectory": {}, + "SelectSavedQueryDirectory": {}, + "ApplySavedQueryDirectory": {}, + "OpenSavedQueryDirectory": {}, + "RevealSavedQueryInFolder": {}, "SelectDriverDownloadDirectory": {}, "SelectDriverPackageFile": {}, "SelectDriverPackageDirectory": {}, diff --git a/internal/webserver/server_test.go b/internal/webserver/server_test.go index 613b8d0d..9b11b92d 100644 --- a/internal/webserver/server_test.go +++ b/internal/webserver/server_test.go @@ -106,7 +106,8 @@ func TestMethodInvokerRejectsDesktopOnlyAppMethodsBeforeReflection(t *testing.T) "Shutdown", "ExportSQLAuditFile", "OpenSQLFile", "ExecuteSQLFile", "ReadSQLFile", "PreviewImportFile", "ImportDatabaseSQL", "ImportDataWithProgress", "ImportDataWithProgressOptions", "GetDataRootDirectoryInfo", "ExportDatabaseSQLWithOptions", "ExportSchemaSQLWithOptions", - "ApplyDataRootDirectory", "OpenDataRootDirectory", "SelectLogDirectory", "ApplyLogDirectory", "OpenLogDirectory", "SetApplicationBrandIcon", + "ApplyDataRootDirectory", "OpenDataRootDirectory", "SelectLogDirectory", "ApplyLogDirectory", "OpenLogDirectory", + "SelectSavedQueryDirectory", "ApplySavedQueryDirectory", "OpenSavedQueryDirectory", "RevealSavedQueryInFolder", "SetApplicationBrandIcon", } { _, err := invoker.Invoke(invokeRequest{Namespace: "app", Receiver: "app", Method: method}) if err == nil || !strings.Contains(err.Error(), "unavailable in web runtime") { diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index eed1c726..156eaec5 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2634,6 +2634,32 @@ "app.data_root.log_directory.placeholder": "Neues Protokollverzeichnis auswählen", "app.data_root.log_directory.restart_hint": "Der Dateiname bleibt gonavi.log. Starten Sie die App nach einer Verzeichnisänderung neu.", "app.data_root.log_directory.title": "Protokollverzeichnis", + "app.data_root.saved_query_directory.backend.dialog.select_directory": "SQL-Verzeichnis für gespeicherte Abfragen auswählen", + "app.data_root.saved_query_directory.backend.error.desktop_only": "Das Verzeichnis für gespeicherte Abfragen kann nur in der Desktop-App konfiguriert werden", + "app.data_root.saved_query_directory.backend.error.directory_unavailable": "Das aktuelle Verzeichnis für gespeicherte Abfragen ist nicht vorhanden oder nicht zugänglich", + "app.data_root.saved_query_directory.backend.error.migrate_failed": "SQL-Dateien gespeicherter Abfragen konnten nicht migriert werden: {{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_failed": "Verzeichnis für gespeicherte Abfragen konnte nicht geöffnet werden: {{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_unsupported": "Das Öffnen des Verzeichnisses für gespeicherte Abfragen wird auf dieser Plattform nicht unterstützt: {{platform}}", + "app.data_root.saved_query_directory.backend.error.query_file_unavailable": "Die Datei der gespeicherten Abfrage ist nicht vorhanden oder nicht zugänglich: {{path}}", + "app.data_root.saved_query_directory.backend.error.query_id_required": "Die ID der gespeicherten Abfrage ist erforderlich", + "app.data_root.saved_query_directory.backend.error.query_not_found": "Gespeicherte Abfrage nicht gefunden: {{id}}", + "app.data_root.saved_query_directory.backend.error.reveal_failed": "Gespeicherte Abfragedatei konnte nicht im Ordner angezeigt werden: {{detail}}", + "app.data_root.saved_query_directory.backend.error.reveal_unsupported": "Das Anzeigen gespeicherter Abfragedateien im Ordner wird auf dieser Plattform nicht unterstützt: {{platform}}", + "app.data_root.saved_query_directory.backend.error.save_failed": "Verzeichnis für gespeicherte Abfragen konnte nicht gespeichert werden: {{detail}}", + "app.data_root.saved_query_directory.backend.message.opened": "Verzeichnis für gespeicherte Abfragen geöffnet", + "app.data_root.saved_query_directory.backend.message.revealed": "Gespeicherte Abfragedatei im Ordner angezeigt: {{path}}", + "app.data_root.saved_query_directory.backend.message.unchanged": "Das Verzeichnis für gespeicherte Abfragen wurde nicht geändert", + "app.data_root.saved_query_directory.backend.message.updated": "Verzeichnis für gespeicherte Abfragen aktualisiert", + "app.data_root.saved_query_directory.current_directory": "Aktuelles Verzeichnis für gespeicherte Abfragen", + "app.data_root.saved_query_directory.default_directory": "Standardverzeichnis für gespeicherte Abfragen", + "app.data_root.saved_query_directory.description": "Jede gespeicherte Abfrage wird als eigenständige .sql-Datei abgelegt. Wählen Sie den Speicherort dieser Dateien aus.", + "app.data_root.saved_query_directory.message.apply_failed_with_error": "Verzeichnis für gespeicherte Abfragen konnte nicht aktualisiert werden: {{error}}", + "app.data_root.saved_query_directory.message.open_failed_with_error": "Verzeichnis für gespeicherte Abfragen konnte nicht geöffnet werden: {{error}}", + "app.data_root.saved_query_directory.message.select_failed_with_error": "Verzeichnis für gespeicherte Abfragen konnte nicht ausgewählt werden: {{error}}", + "app.data_root.saved_query_directory.message.select_valid_first": "Wählen Sie zuerst ein gültiges Verzeichnis für gespeicherte Abfragen aus", + "app.data_root.saved_query_directory.message.updated": "Verzeichnis für gespeicherte Abfragen aktualisiert", + "app.data_root.saved_query_directory.placeholder": "Neues Verzeichnis für gespeicherte Abfragen auswählen", + "app.data_root.saved_query_directory.title": "Verzeichnis für gespeicherte Abfragen", "app.linux_cjk_font_banner.action.open_font_settings": "Schrifteinstellungen", "app.linux_cjk_font_banner.description": "Chinesischer Text kann als Kästchen für fehlende Glyphen erscheinen. Installieren Sie die Schriften und starten Sie GoNavi danach neu: ", "app.linux_cjk_font_banner.title": "Linux-CJK-Schriften fehlen", @@ -7275,6 +7301,7 @@ "sidebar.menu.rename_sql_file": "SQL-Datei umbenennen", "sidebar.menu.rename_table": "Tabelle umbenennen", "sidebar.menu.rename_view": "Ansicht umbenennen", + "sidebar.menu.reveal_saved_query_in_folder": "Im Ordner anzeigen", "sidebar.menu.sort_by_frequency": "Sortieren nach Häufigkeit", "sidebar.schema_visibility.action.show_all": "Alle Schemas anzeigen", "sidebar.schema_visibility.description": "Wirkt nur auf die Anzeige im Navigationsbaum; SQL-Berechtigungen und Datenbankzugriff bleiben unverändert.", @@ -7408,6 +7435,8 @@ "sidebar.message.saved_query_rebind_success": "Abfrage an {{name}} gebunden", "sidebar.message.saved_query_rename_failed": "Abfrage konnte nicht umbenannt werden: {{error}}", "sidebar.message.saved_query_renamed": "Abfrage umbenannt.", + "sidebar.message.saved_query_reveal_failed": "Gespeicherte Abfragedatei konnte nicht im Ordner angezeigt werden: {{error}}", + "sidebar.message.saved_query_revealed": "Gespeicherte Abfragedatei im Ordner angezeigt.", "sidebar.message.schema_create_unsupported": "Diese Datenbank unterstützt das Erstellen von schema nicht.", "sidebar.message.schema_created": "schema wurde erstellt.", "sidebar.message.schema_deleted": "schema gelöscht.", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 597fec36..c012cbff 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2634,6 +2634,32 @@ "app.data_root.log_directory.placeholder": "Choose a new log directory", "app.data_root.log_directory.restart_hint": "The log file remains named gonavi.log. Restart the app after changing its directory.", "app.data_root.log_directory.title": "Log Directory", + "app.data_root.saved_query_directory.backend.dialog.select_directory": "Choose saved query SQL directory", + "app.data_root.saved_query_directory.backend.error.desktop_only": "Saved query directory settings are only available in the desktop app", + "app.data_root.saved_query_directory.backend.error.directory_unavailable": "The current saved query directory does not exist or cannot be accessed", + "app.data_root.saved_query_directory.backend.error.migrate_failed": "Failed to migrate saved query SQL files: {{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_failed": "Failed to open the saved query directory: {{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_unsupported": "Opening the saved query directory is not supported on this platform: {{platform}}", + "app.data_root.saved_query_directory.backend.error.query_file_unavailable": "The saved query file does not exist or cannot be accessed: {{path}}", + "app.data_root.saved_query_directory.backend.error.query_id_required": "Saved query ID is required", + "app.data_root.saved_query_directory.backend.error.query_not_found": "Saved query not found: {{id}}", + "app.data_root.saved_query_directory.backend.error.reveal_failed": "Failed to show the saved query file in its folder: {{detail}}", + "app.data_root.saved_query_directory.backend.error.reveal_unsupported": "Showing saved query files in a folder is not supported on this platform: {{platform}}", + "app.data_root.saved_query_directory.backend.error.save_failed": "Failed to save the saved query directory: {{detail}}", + "app.data_root.saved_query_directory.backend.message.opened": "Saved query directory opened", + "app.data_root.saved_query_directory.backend.message.revealed": "Saved query file shown in folder: {{path}}", + "app.data_root.saved_query_directory.backend.message.unchanged": "The saved query directory did not change", + "app.data_root.saved_query_directory.backend.message.updated": "Saved query directory updated", + "app.data_root.saved_query_directory.current_directory": "Current saved query directory", + "app.data_root.saved_query_directory.default_directory": "Default saved query directory", + "app.data_root.saved_query_directory.description": "Store each saved query as an independent .sql file and choose where those files are kept.", + "app.data_root.saved_query_directory.message.apply_failed_with_error": "Failed to update the saved query directory: {{error}}", + "app.data_root.saved_query_directory.message.open_failed_with_error": "Failed to open the saved query directory: {{error}}", + "app.data_root.saved_query_directory.message.select_failed_with_error": "Failed to choose the saved query directory: {{error}}", + "app.data_root.saved_query_directory.message.select_valid_first": "Choose a valid saved query directory first", + "app.data_root.saved_query_directory.message.updated": "Saved query directory updated", + "app.data_root.saved_query_directory.placeholder": "Choose a new saved query directory", + "app.data_root.saved_query_directory.title": "Saved Query Directory", "app.linux_cjk_font_banner.action.open_font_settings": "Font Settings", "app.linux_cjk_font_banner.description": "Chinese text may render as missing glyph boxes. Install fonts, then restart GoNavi: ", "app.linux_cjk_font_banner.title": "Linux CJK fonts missing", @@ -7275,6 +7301,7 @@ "sidebar.menu.rename_sql_file": "Rename SQL file", "sidebar.menu.rename_table": "Rename table", "sidebar.menu.rename_view": "Rename view", + "sidebar.menu.reveal_saved_query_in_folder": "Show in folder", "sidebar.menu.sort_by_frequency": "Sort by usage frequency", "sidebar.schema_visibility.action.show_all": "Show all schemas", "sidebar.schema_visibility.description": "Only affects navigation tree visibility; SQL permissions and database access are unchanged.", @@ -7408,6 +7435,8 @@ "sidebar.message.saved_query_rebind_success": "Query bound to {{name}}", "sidebar.message.saved_query_rename_failed": "Failed to rename query: {{error}}", "sidebar.message.saved_query_renamed": "Query renamed.", + "sidebar.message.saved_query_reveal_failed": "Failed to show the saved query file in its folder: {{error}}", + "sidebar.message.saved_query_revealed": "Saved query file shown in folder.", "sidebar.message.schema_create_unsupported": "Schema creation is not supported for this database.", "sidebar.message.schema_created": "Schema created.", "sidebar.message.schema_deleted": "Schema deleted.", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index cd5c5bcf..82d9d8ec 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2634,6 +2634,32 @@ "app.data_root.log_directory.placeholder": "新しいログディレクトリを選択", "app.data_root.log_directory.restart_hint": "ログファイル名は gonavi.log のままです。ディレクトリ変更後はアプリを再起動してください。", "app.data_root.log_directory.title": "ログディレクトリ", + "app.data_root.saved_query_directory.backend.dialog.select_directory": "保存済みクエリの SQL ディレクトリを選択", + "app.data_root.saved_query_directory.backend.error.desktop_only": "保存済みクエリのディレクトリ設定はデスクトップアプリでのみ利用できます", + "app.data_root.saved_query_directory.backend.error.directory_unavailable": "現在の保存済みクエリディレクトリが存在しないか、アクセスできません", + "app.data_root.saved_query_directory.backend.error.migrate_failed": "保存済みクエリの SQL ファイルを移行できませんでした: {{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_failed": "保存済みクエリディレクトリを開けませんでした: {{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_unsupported": "このプラットフォームでは保存済みクエリディレクトリを開けません: {{platform}}", + "app.data_root.saved_query_directory.backend.error.query_file_unavailable": "保存済みクエリファイルが存在しないか、アクセスできません: {{path}}", + "app.data_root.saved_query_directory.backend.error.query_id_required": "保存済みクエリ ID が必要です", + "app.data_root.saved_query_directory.backend.error.query_not_found": "保存済みクエリが見つかりません: {{id}}", + "app.data_root.saved_query_directory.backend.error.reveal_failed": "保存済みクエリファイルをフォルダーに表示できませんでした: {{detail}}", + "app.data_root.saved_query_directory.backend.error.reveal_unsupported": "このプラットフォームでは保存済みクエリファイルをフォルダーに表示できません: {{platform}}", + "app.data_root.saved_query_directory.backend.error.save_failed": "保存済みクエリディレクトリを保存できませんでした: {{detail}}", + "app.data_root.saved_query_directory.backend.message.opened": "保存済みクエリディレクトリを開きました", + "app.data_root.saved_query_directory.backend.message.revealed": "保存済みクエリファイルをフォルダーに表示しました: {{path}}", + "app.data_root.saved_query_directory.backend.message.unchanged": "保存済みクエリディレクトリは変更されていません", + "app.data_root.saved_query_directory.backend.message.updated": "保存済みクエリディレクトリを更新しました", + "app.data_root.saved_query_directory.current_directory": "現在の保存済みクエリディレクトリ", + "app.data_root.saved_query_directory.default_directory": "既定の保存済みクエリディレクトリ", + "app.data_root.saved_query_directory.description": "保存済みクエリをそれぞれ独立した .sql ファイルとして保存し、その保存先を選択します。", + "app.data_root.saved_query_directory.message.apply_failed_with_error": "保存済みクエリディレクトリを更新できませんでした: {{error}}", + "app.data_root.saved_query_directory.message.open_failed_with_error": "保存済みクエリディレクトリを開けませんでした: {{error}}", + "app.data_root.saved_query_directory.message.select_failed_with_error": "保存済みクエリディレクトリを選択できませんでした: {{error}}", + "app.data_root.saved_query_directory.message.select_valid_first": "有効な保存済みクエリディレクトリを選択してください", + "app.data_root.saved_query_directory.message.updated": "保存済みクエリディレクトリを更新しました", + "app.data_root.saved_query_directory.placeholder": "新しい保存済みクエリディレクトリを選択", + "app.data_root.saved_query_directory.title": "保存済みクエリディレクトリ", "app.linux_cjk_font_banner.action.open_font_settings": "フォント設定", "app.linux_cjk_font_banner.description": "中国語テキストが欠落グリフの四角として表示される可能性があります。フォントをインストールしてから GoNavi を再起動してください: ", "app.linux_cjk_font_banner.title": "Linux CJK フォントが不足しています", @@ -7275,6 +7301,7 @@ "sidebar.menu.rename_sql_file": "SQL ファイル名を変更", "sidebar.menu.rename_table": "テーブル名を変更", "sidebar.menu.rename_view": "ビュー名を変更", + "sidebar.menu.reveal_saved_query_in_folder": "フォルダーに表示", "sidebar.menu.sort_by_frequency": "使用頻度順に並べ替え", "sidebar.schema_visibility.action.show_all": "すべてのスキーマを表示", "sidebar.schema_visibility.description": "ナビゲーションツリーの表示だけに影響し、SQL 権限やデータベースアクセスは変更しません。", @@ -7408,6 +7435,8 @@ "sidebar.message.saved_query_rebind_success": "クエリを {{name}} にバインドしました", "sidebar.message.saved_query_rename_failed": "クエリ名の変更に失敗しました: {{error}}", "sidebar.message.saved_query_renamed": "クエリ名を変更しました。", + "sidebar.message.saved_query_reveal_failed": "保存済みクエリファイルをフォルダーに表示できませんでした: {{error}}", + "sidebar.message.saved_query_revealed": "保存済みクエリファイルをフォルダーに表示しました。", "sidebar.message.schema_create_unsupported": "このデータベースでは schema を作成できません。", "sidebar.message.schema_created": "schema を作成しました。", "sidebar.message.schema_deleted": "schema を削除しました。", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index c631f6bb..08d48d94 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2634,6 +2634,32 @@ "app.data_root.log_directory.placeholder": "Выберите новый каталог журналов", "app.data_root.log_directory.restart_hint": "Имя файла остаётся gonavi.log. После изменения каталога перезапустите приложение.", "app.data_root.log_directory.title": "Каталог журналов", + "app.data_root.saved_query_directory.backend.dialog.select_directory": "Выберите каталог SQL для сохранённых запросов", + "app.data_root.saved_query_directory.backend.error.desktop_only": "Настройка каталога сохранённых запросов доступна только в настольном приложении", + "app.data_root.saved_query_directory.backend.error.directory_unavailable": "Текущий каталог сохранённых запросов не существует или недоступен", + "app.data_root.saved_query_directory.backend.error.migrate_failed": "Не удалось перенести SQL-файлы сохранённых запросов: {{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_failed": "Не удалось открыть каталог сохранённых запросов: {{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_unsupported": "Открытие каталога сохранённых запросов не поддерживается на этой платформе: {{platform}}", + "app.data_root.saved_query_directory.backend.error.query_file_unavailable": "Файл сохранённого запроса не существует или недоступен: {{path}}", + "app.data_root.saved_query_directory.backend.error.query_id_required": "Требуется идентификатор сохранённого запроса", + "app.data_root.saved_query_directory.backend.error.query_not_found": "Сохранённый запрос не найден: {{id}}", + "app.data_root.saved_query_directory.backend.error.reveal_failed": "Не удалось показать файл сохранённого запроса в папке: {{detail}}", + "app.data_root.saved_query_directory.backend.error.reveal_unsupported": "Показ файлов сохранённых запросов в папке не поддерживается на этой платформе: {{platform}}", + "app.data_root.saved_query_directory.backend.error.save_failed": "Не удалось сохранить каталог сохранённых запросов: {{detail}}", + "app.data_root.saved_query_directory.backend.message.opened": "Каталог сохранённых запросов открыт", + "app.data_root.saved_query_directory.backend.message.revealed": "Файл сохранённого запроса показан в папке: {{path}}", + "app.data_root.saved_query_directory.backend.message.unchanged": "Каталог сохранённых запросов не изменился", + "app.data_root.saved_query_directory.backend.message.updated": "Каталог сохранённых запросов обновлён", + "app.data_root.saved_query_directory.current_directory": "Текущий каталог сохранённых запросов", + "app.data_root.saved_query_directory.default_directory": "Каталог сохранённых запросов по умолчанию", + "app.data_root.saved_query_directory.description": "Каждый сохранённый запрос хранится в отдельном файле .sql; можно выбрать каталог для этих файлов.", + "app.data_root.saved_query_directory.message.apply_failed_with_error": "Не удалось обновить каталог сохранённых запросов: {{error}}", + "app.data_root.saved_query_directory.message.open_failed_with_error": "Не удалось открыть каталог сохранённых запросов: {{error}}", + "app.data_root.saved_query_directory.message.select_failed_with_error": "Не удалось выбрать каталог сохранённых запросов: {{error}}", + "app.data_root.saved_query_directory.message.select_valid_first": "Сначала выберите допустимый каталог сохранённых запросов", + "app.data_root.saved_query_directory.message.updated": "Каталог сохранённых запросов обновлён", + "app.data_root.saved_query_directory.placeholder": "Выберите новый каталог сохранённых запросов", + "app.data_root.saved_query_directory.title": "Каталог сохранённых запросов", "app.linux_cjk_font_banner.action.open_font_settings": "Настройки шрифтов", "app.linux_cjk_font_banner.description": "Китайский текст может отображаться квадратами вместо символов. Установите шрифты, затем перезапустите GoNavi: ", "app.linux_cjk_font_banner.title": "Отсутствуют Linux CJK-шрифты", @@ -7275,6 +7301,7 @@ "sidebar.menu.rename_sql_file": "Переименовать SQL-файл", "sidebar.menu.rename_table": "Переименовать таблицу", "sidebar.menu.rename_view": "Переименовать представление", + "sidebar.menu.reveal_saved_query_in_folder": "Показать в папке", "sidebar.menu.sort_by_frequency": "Сортировать по частоте использования", "sidebar.schema_visibility.action.show_all": "Показать все схемы", "sidebar.schema_visibility.description": "Влияет только на дерево навигации; права SQL и доступ к базе данных не меняются.", @@ -7408,6 +7435,8 @@ "sidebar.message.saved_query_rebind_success": "Запрос привязан к {{name}}", "sidebar.message.saved_query_rename_failed": "Не удалось переименовать запрос: {{error}}", "sidebar.message.saved_query_renamed": "Запрос переименован.", + "sidebar.message.saved_query_reveal_failed": "Не удалось показать файл сохранённого запроса в папке: {{error}}", + "sidebar.message.saved_query_revealed": "Файл сохранённого запроса показан в папке.", "sidebar.message.schema_create_unsupported": "Эта база данных не поддерживает создание schema.", "sidebar.message.schema_created": "schema создана.", "sidebar.message.schema_deleted": "schema удалена.", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 50802966..3e34397b 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2634,6 +2634,32 @@ "app.data_root.log_directory.placeholder": "选择新的日志目录", "app.data_root.log_directory.restart_hint": "日志文件名保持为 gonavi.log,目录修改后需重启应用。", "app.data_root.log_directory.title": "日志目录", + "app.data_root.saved_query_directory.backend.dialog.select_directory": "选择已存查询 SQL 目录", + "app.data_root.saved_query_directory.backend.error.desktop_only": "已存查询目录设置仅可在桌面应用中使用", + "app.data_root.saved_query_directory.backend.error.directory_unavailable": "当前已存查询目录不存在或不可访问", + "app.data_root.saved_query_directory.backend.error.migrate_failed": "迁移已存查询 SQL 文件失败:{{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_failed": "打开已存查询目录失败:{{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_unsupported": "当前平台暂不支持打开已存查询目录:{{platform}}", + "app.data_root.saved_query_directory.backend.error.query_file_unavailable": "已存查询文件不存在或无法访问:{{path}}", + "app.data_root.saved_query_directory.backend.error.query_id_required": "缺少已存查询 ID", + "app.data_root.saved_query_directory.backend.error.query_not_found": "未找到已存查询:{{id}}", + "app.data_root.saved_query_directory.backend.error.reveal_failed": "在文件夹中显示已存查询文件失败:{{detail}}", + "app.data_root.saved_query_directory.backend.error.reveal_unsupported": "当前平台暂不支持在文件夹中显示已存查询文件:{{platform}}", + "app.data_root.saved_query_directory.backend.error.save_failed": "保存已存查询目录失败:{{detail}}", + "app.data_root.saved_query_directory.backend.message.opened": "已打开已存查询目录", + "app.data_root.saved_query_directory.backend.message.revealed": "已在文件夹中显示已存查询文件:{{path}}", + "app.data_root.saved_query_directory.backend.message.unchanged": "已存查询目录未发生变化", + "app.data_root.saved_query_directory.backend.message.updated": "已存查询目录已更新", + "app.data_root.saved_query_directory.current_directory": "当前已存查询目录", + "app.data_root.saved_query_directory.default_directory": "默认已存查询目录", + "app.data_root.saved_query_directory.description": "每条已存查询以独立 .sql 文件保存,并可自定义这些文件的存储目录。", + "app.data_root.saved_query_directory.message.apply_failed_with_error": "更新已存查询目录失败:{{error}}", + "app.data_root.saved_query_directory.message.open_failed_with_error": "打开已存查询目录失败:{{error}}", + "app.data_root.saved_query_directory.message.select_failed_with_error": "选择已存查询目录失败:{{error}}", + "app.data_root.saved_query_directory.message.select_valid_first": "请先选择有效的已存查询目录", + "app.data_root.saved_query_directory.message.updated": "已存查询目录已更新", + "app.data_root.saved_query_directory.placeholder": "选择新的已存查询目录", + "app.data_root.saved_query_directory.title": "已存查询目录", "app.linux_cjk_font_banner.action.open_font_settings": "字体设置", "app.linux_cjk_font_banner.description": "中文可能显示为方框。请安装字体后重启 GoNavi:", "app.linux_cjk_font_banner.title": "Linux CJK 字体缺失", @@ -7275,6 +7301,7 @@ "sidebar.menu.rename_sql_file": "重命名 SQL 文件", "sidebar.menu.rename_table": "重命名表", "sidebar.menu.rename_view": "重命名视图", + "sidebar.menu.reveal_saved_query_in_folder": "在文件夹中打开", "sidebar.menu.sort_by_frequency": "按使用频率排序", "sidebar.schema_visibility.action.show_all": "显示全部 Schema", "sidebar.schema_visibility.description": "仅影响导航树显示,不限制 SQL 权限或数据库访问。", @@ -7408,6 +7435,8 @@ "sidebar.message.saved_query_rebind_success": "查询已绑定到 {{name}}", "sidebar.message.saved_query_rename_failed": "重命名查询失败:{{error}}", "sidebar.message.saved_query_renamed": "查询已重命名。", + "sidebar.message.saved_query_reveal_failed": "在文件夹中显示已存查询文件失败:{{error}}", + "sidebar.message.saved_query_revealed": "已在文件夹中显示已存查询文件。", "sidebar.message.schema_create_unsupported": "当前数据库不支持创建 schema。", "sidebar.message.schema_created": "schema 创建成功。", "sidebar.message.schema_deleted": "模式已删除。", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 37c0c75d..40f0ef54 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2634,6 +2634,32 @@ "app.data_root.log_directory.placeholder": "選擇新的日誌目錄", "app.data_root.log_directory.restart_hint": "日誌檔名維持 gonavi.log,修改目錄後需要重新啟動應用程式。", "app.data_root.log_directory.title": "日誌目錄", + "app.data_root.saved_query_directory.backend.dialog.select_directory": "選擇已存查詢 SQL 目錄", + "app.data_root.saved_query_directory.backend.error.desktop_only": "已存查詢目錄設定僅可在桌面應用程式中使用", + "app.data_root.saved_query_directory.backend.error.directory_unavailable": "目前已存查詢目錄不存在或無法存取", + "app.data_root.saved_query_directory.backend.error.migrate_failed": "遷移已存查詢 SQL 檔案失敗:{{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_failed": "開啟已存查詢目錄失敗:{{detail}}", + "app.data_root.saved_query_directory.backend.error.open_directory_unsupported": "目前平台暫不支援開啟已存查詢目錄:{{platform}}", + "app.data_root.saved_query_directory.backend.error.query_file_unavailable": "已存查詢檔案不存在或無法存取:{{path}}", + "app.data_root.saved_query_directory.backend.error.query_id_required": "缺少已存查詢 ID", + "app.data_root.saved_query_directory.backend.error.query_not_found": "找不到已存查詢:{{id}}", + "app.data_root.saved_query_directory.backend.error.reveal_failed": "在資料夾中顯示已存查詢檔案失敗:{{detail}}", + "app.data_root.saved_query_directory.backend.error.reveal_unsupported": "目前平台暫不支援在資料夾中顯示已存查詢檔案:{{platform}}", + "app.data_root.saved_query_directory.backend.error.save_failed": "儲存已存查詢目錄失敗:{{detail}}", + "app.data_root.saved_query_directory.backend.message.opened": "已開啟已存查詢目錄", + "app.data_root.saved_query_directory.backend.message.revealed": "已在資料夾中顯示已存查詢檔案:{{path}}", + "app.data_root.saved_query_directory.backend.message.unchanged": "已存查詢目錄未發生變更", + "app.data_root.saved_query_directory.backend.message.updated": "已存查詢目錄已更新", + "app.data_root.saved_query_directory.current_directory": "目前已存查詢目錄", + "app.data_root.saved_query_directory.default_directory": "預設已存查詢目錄", + "app.data_root.saved_query_directory.description": "每筆已存查詢以獨立 .sql 檔案儲存,並可自訂這些檔案的儲存目錄。", + "app.data_root.saved_query_directory.message.apply_failed_with_error": "更新已存查詢目錄失敗:{{error}}", + "app.data_root.saved_query_directory.message.open_failed_with_error": "開啟已存查詢目錄失敗:{{error}}", + "app.data_root.saved_query_directory.message.select_failed_with_error": "選擇已存查詢目錄失敗:{{error}}", + "app.data_root.saved_query_directory.message.select_valid_first": "請先選擇有效的已存查詢目錄", + "app.data_root.saved_query_directory.message.updated": "已存查詢目錄已更新", + "app.data_root.saved_query_directory.placeholder": "選擇新的已存查詢目錄", + "app.data_root.saved_query_directory.title": "已存查詢目錄", "app.linux_cjk_font_banner.action.open_font_settings": "字體設定", "app.linux_cjk_font_banner.description": "中文可能顯示為方框。請安裝字體後重新啟動 GoNavi:", "app.linux_cjk_font_banner.title": "Linux CJK 字體缺失", @@ -7275,6 +7301,7 @@ "sidebar.menu.rename_sql_file": "重新命名 SQL 檔案", "sidebar.menu.rename_table": "重新命名資料表", "sidebar.menu.rename_view": "重新命名檢視", + "sidebar.menu.reveal_saved_query_in_folder": "在資料夾中開啟", "sidebar.menu.sort_by_frequency": "依使用頻率排序", "sidebar.schema_visibility.action.show_all": "顯示全部 Schema", "sidebar.schema_visibility.description": "僅影響導覽樹顯示,不限制 SQL 權限或資料庫存取。", @@ -7408,6 +7435,8 @@ "sidebar.message.saved_query_rebind_success": "查詢已綁定到 {{name}}", "sidebar.message.saved_query_rename_failed": "重新命名查詢失敗:{{error}}", "sidebar.message.saved_query_renamed": "查詢已重新命名。", + "sidebar.message.saved_query_reveal_failed": "在資料夾中顯示已存查詢檔案失敗:{{error}}", + "sidebar.message.saved_query_revealed": "已在資料夾中顯示已存查詢檔案。", "sidebar.message.schema_create_unsupported": "目前資料庫不支援建立 schema。", "sidebar.message.schema_created": "schema 已建立。", "sidebar.message.schema_deleted": "模式已刪除。",