From d31687cc01cf5af1621562d6fbad3ffe556a832b Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Wed, 13 Aug 2025 01:30:36 +0200 Subject: [PATCH] can now sync with config files that are updated externally (not imported) Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com> --- lib/rclone/api.ts | 21 +- lib/rclone/init.ts | 28 ++- src/components/ConfigCreateDrawer.tsx | 29 ++- src/components/ConfigEditDrawer.tsx | 11 +- src/components/ConfigSyncDrawer.tsx | 338 ++++++++++++++++++++++++++ src/pages/Settings.tsx | 116 +++++---- types/config.d.ts | 1 + 7 files changed, 479 insertions(+), 65 deletions(-) create mode 100644 src/components/ConfigSyncDrawer.tsx diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index 095bad0..26cdb45 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -718,7 +718,16 @@ export async function getConfigPath({ id, validate = true }: { id: string; valid const appLocalDataDirPath = await appLocalDataDir() console.log('[getConfigPath] appLocalDataDirPath', appLocalDataDirPath) - let configPath = `${appLocalDataDirPath}/configs/${id}/rclone.conf` + const slashSymbol = platform() === 'windows' ? '\\' : '/' + + let configPath = + appLocalDataDirPath + + slashSymbol + + 'configs' + + slashSymbol + + id + + slashSymbol + + 'rclone.conf' if (id == 'default') { const defaultPaths = await getDefaultPaths() @@ -731,10 +740,12 @@ export async function getConfigPath({ id, validate = true }: { id: string; valid configPath = defaultPaths.config } - const configExists = await exists(configPath) - if (validate && !configExists) { - console.error('[getConfigPath] config file does not exist') - throw new Error('Config file does not exist') + if (validate) { + const configExists = await exists(configPath) + if (!configExists) { + console.error('[getConfigPath] config file does not exist') + throw new Error('Config file does not exist') + } } return configPath diff --git a/lib/rclone/init.ts b/lib/rclone/init.ts index fd2abf5..f49e9b5 100644 --- a/lib/rclone/init.ts +++ b/lib/rclone/init.ts @@ -77,6 +77,7 @@ export async function initRclone(args: string[]) { sync: undefined, isEncrypted: false, pass: undefined, + passCommand: undefined, }) usePersistedStore.setState({ configFiles }) @@ -89,6 +90,29 @@ export async function initRclone(args: string[]) { usePersistedStore.setState({ activeConfigFile }) } + let configFolderPath = activeConfigFile.sync + ? activeConfigFile.sync + : (await getConfigPath({ id: activeConfigFile.id!, validate: true })).replace( + /\/rclone\.conf$/, + '' + ) + + if (activeConfigFile.sync) { + const slashSymbol = platform() === 'windows' ? '\\' : '/' + if (!(await exists(configFolderPath + slashSymbol + 'rclone.conf'))) { + await message('The config file could not be found. Switching to the default config.', { + title: 'Invalid synced config', + kind: 'error', + okLabel: 'OK', + }) + configFolderPath = (await getConfigPath({ id: 'default', validate: true })).replace( + /\/rclone\.conf$/, + '' + ) + usePersistedStore.setState({ activeConfigFile: configFiles[0] }) + } + } + const extraParams = activeConfigFile.id === 'default' ? undefined @@ -97,9 +121,7 @@ export async function initRclone(args: string[]) { ...(activeConfigFile.isEncrypted ? { RCLONE_CONFIG_PASS: activeConfigFile.pass } : {}), - RCLONE_CONFIG_DIR: ( - await getConfigPath({ id: activeConfigFile.id!, validate: true }) - ).replace(/\/rclone\.conf$/, ''), + RCLONE_CONFIG_DIR: configFolderPath, }, } diff --git a/src/components/ConfigCreateDrawer.tsx b/src/components/ConfigCreateDrawer.tsx index 0011508..0da6c32 100644 --- a/src/components/ConfigCreateDrawer.tsx +++ b/src/components/ConfigCreateDrawer.tsx @@ -8,8 +8,9 @@ import { Textarea, } from '@heroui/react' import { Button } from '@heroui/react' -import { ask, open } from '@tauri-apps/plugin-dialog' +import { message, open } from '@tauri-apps/plugin-dialog' import { mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs' +import { platform } from '@tauri-apps/plugin-os' import { UploadIcon } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import { getConfigPath } from '../../lib/rclone/api' @@ -58,7 +59,11 @@ export default function ConfigCreateDrawer({ const configPath = await getConfigPath({ id: generatedId, validate: false }) - await mkdir(configPath.replace('/rclone.conf', ''), { recursive: true }) + const slashSymbol = platform() === 'windows' ? '\\' : '/' + + await mkdir(configPath.replace(slashSymbol + 'rclone.conf', ''), { + recursive: true, + }) await writeTextFile(configPath, content) console.log('[handleCreate] saved config to', configPath) @@ -68,17 +73,20 @@ export default function ConfigCreateDrawer({ pass, isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'), sync: undefined, + passCommand: undefined, }) onClose() } catch (error) { console.error('[handleCreate] failed to save config', error) - await ask('Failed to save config', { - title: 'Error', - kind: 'error', - okLabel: 'OK', - cancelLabel: '', - }) + await message( + error instanceof Error ? error.message : 'An unknown error occurred', + { + title: 'Failed to save config', + kind: 'error', + okLabel: 'OK', + } + ) } finally { setIsSaving(false) } @@ -194,12 +202,15 @@ export default function ConfigCreateDrawer({ }, '') } + const slashSymbol = + platform() === 'windows' ? '\\' : '/' + setConfigContent(content) setConfig({ ...config, label: config.label || - selectedFile.split('/').pop() || + selectedFile.split(slashSymbol).pop() || 'New Config', isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'), diff --git a/src/components/ConfigEditDrawer.tsx b/src/components/ConfigEditDrawer.tsx index 728259f..32c1e5b 100644 --- a/src/components/ConfigEditDrawer.tsx +++ b/src/components/ConfigEditDrawer.tsx @@ -8,6 +8,7 @@ import { Textarea, } from '@heroui/react' import { Button } from '@heroui/react' +import { message } from '@tauri-apps/plugin-dialog' import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs' import { useCallback, useEffect, useMemo, useState } from 'react' import { getConfigPath } from '../../lib/rclone/api' @@ -65,7 +66,15 @@ export default function ConfigEditDrawer({ onClose() } catch (error) { - console.error(error) + console.error('[handleUpdate] failed to save config', error) + await message( + error instanceof Error ? error.message : 'An unknown error occurred', + { + title: 'Failed to save config', + kind: 'error', + okLabel: 'OK', + } + ) } finally { setIsSaving(false) } diff --git a/src/components/ConfigSyncDrawer.tsx b/src/components/ConfigSyncDrawer.tsx new file mode 100644 index 0000000..9fbcacc --- /dev/null +++ b/src/components/ConfigSyncDrawer.tsx @@ -0,0 +1,338 @@ +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerFooter, + DrawerHeader, + Input, + Switch, +} from '@heroui/react' +import { Button } from '@heroui/react' +import { message, open } from '@tauri-apps/plugin-dialog' +import { exists, readTextFile } from '@tauri-apps/plugin-fs' +import { platform } from '@tauri-apps/plugin-os' +import { UploadIcon } from 'lucide-react' +import { useCallback, useState } from 'react' +import { usePersistedStore } from '../../lib/store' +import type { ConfigFile } from '../../types/config' + +export default function ConfigSyncDrawer({ + onClose, + isOpen, +}: { + onClose: () => void + isOpen: boolean +}) { + const [config, setConfig] = useState>({ + label: 'New Config', + }) + + const [isPasswordCommand, setIsPasswordCommand] = useState(false) + + const [isSaving, setIsSaving] = useState(false) + + const handleCreate = useCallback( + async ({ + label, + pass, + isEncrypted, + passCommand, + sync, + }: { + label?: string + sync?: string + isEncrypted?: boolean + pass?: string + passCommand?: string + }) => { + try { + if (!label) { + throw new Error('Label is required') + } + + if (!sync) { + throw new Error('Path is required') + } + + if (isEncrypted) { + if (!pass && !passCommand) { + throw new Error('Password is required for encrypted configs') + } + } + + setIsSaving(true) + + const generatedId = crypto.randomUUID() + + usePersistedStore.getState().addConfigFile({ + id: generatedId, + label, + isEncrypted: isEncrypted || false, + pass, + passCommand, + sync, + }) + + onClose() + } catch (error) { + console.error('[handleCreate] failed to save config', error) + await message( + error instanceof Error ? error.message : 'An unknown error occurred', + { + title: 'Failed to save config', + kind: 'error', + okLabel: 'OK', + } + ) + } finally { + setIsSaving(false) + } + }, + [onClose] + ) + + return ( + + + {(close) => ( + <> + Sync Config + +
{ + e.preventDefault() + handleCreate({ + label: config.label, + pass: isPasswordCommand ? undefined : config.pass, + isEncrypted: config.isEncrypted, + passCommand: isPasswordCommand + ? config.passCommand + : undefined, + sync: config.sync, + }) + }} + > + + setConfig({ ...config, isEncrypted: !config.isEncrypted }) + } + color="primary" + > + Encrypted + + + { + setConfig({ ...config, label: value }) + }} + isClearable={true} + onClear={() => { + setConfig({ ...config, label: '' }) + }} + size="lg" + /> + + {config.isEncrypted && ( + + //

Password

+ // + // setIsPasswordCommand(!isPasswordCommand) + // } + // color="primary" + // > + // Command + // + // + // } + label="Password" + labelPlacement="outside" + placeholder={ + isPasswordCommand + ? 'Enter the password command for your config file' + : 'Enter the password for your config file' + } + type={isPasswordCommand ? 'text' : 'password'} + value={isPasswordCommand ? config.passCommand : config.pass} + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + spellCheck="false" + onValueChange={(value) => { + setConfig({ + ...config, + ...(isPasswordCommand + ? { passCommand: value } + : { pass: value }), + }) + }} + isClearable={true} + onClear={() => { + setConfig({ + ...config, + ...(isPasswordCommand + ? { passCommand: '' } + : { pass: '' }), + }) + }} + size="lg" + /> + )} + + +

Config

+ + + } + labelPlacement="outside" + placeholder="Select the folder containing your rclone.conf file" + value={config.sync || ''} + onValueChange={(value) => { + console.log(value) + setConfig({ ...config, sync: value }) + }} + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + spellCheck="false" + type="textarea" + size="lg" + isClearable={true} + onClear={() => { + setConfig({ ...config, sync: '' }) + }} + data-focus-visible="false" + /> +
+
+ + + + + + )} +
+
+ ) +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7d7d2c9..095c03c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,4 +1,17 @@ -import { Button, Card, CardBody, Checkbox, Chip, Input, Tab, Tabs } from '@heroui/react' +import { + Button, + Card, + CardBody, + Checkbox, + Chip, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownTrigger, + Input, + Tab, + Tabs, +} from '@heroui/react' import { getVersion as getUiVersion } from '@tauri-apps/api/app' import { ask, message } from '@tauri-apps/plugin-dialog' import { remove } from '@tauri-apps/plugin-fs' @@ -10,6 +23,7 @@ import { CodeIcon, CogIcon, EyeIcon, + ImportIcon, MedalIcon, PencilIcon, PlusIcon, @@ -23,6 +37,7 @@ import { usePersistedStore, useStore } from '../../lib/store' import { triggerTrayRebuild } from '../../lib/tray' import ConfigCreateDrawer from '../components/ConfigCreateDrawer' import ConfigEditDrawer from '../components/ConfigEditDrawer' +import ConfigSyncDrawer from '../components/ConfigSyncDrawer' import RemoteCreateDrawer from '../components/RemoteCreateDrawer' import RemoteDefaultsDrawer from '../components/RemoteDefaultsDrawer' import RemoteEditDrawer from '../components/RemoteEditDrawer' @@ -870,6 +885,7 @@ function ConfigSection() { const activeConfigFile = usePersistedStore((state) => state.activeConfigFile) const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false) + const [isSyncDrawerOpen, setIsSyncDrawerOpen] = useState(false) const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false) const [focusedConfigId, setFocusedConfigId] = useState(null) @@ -878,44 +894,40 @@ function ConfigSection() { { - // setFocusedConfig({ - // id: undefined, - // label: filePath.split('/').pop() || 'New Config', - // isEncrypted: configText.includes('RCLONE_ENCRYPT_V0:'), - // pass: undefined, - // sync: undefined, - // }) - setIsCreateDrawerOpen(true) - - // await ask( - // 'Config loaded successfully, you can now remove the file.', - // { - // title: 'Success', - // kind: 'info', - // okLabel: 'OK', - // cancelLabel: '', - // } - // ) - // } catch (error) { - // console.error(error) - // await ask('Failed to load config file', { - // title: 'Error', - // kind: 'error', - // okLabel: 'OK', - // cancelLabel: '', - // }) - // } - }} - isIconOnly={true} - variant="faded" - color="primary" - data-focus-visible="false" - size="sm" - > - - + + + + + { + setTimeout(() => { + if (key === 'import') { + setIsCreateDrawerOpen(true) + } else { + setIsSyncDrawerOpen(true) + } + }, 100) + }} + variant="faded" + > + } + > + Import Config + + } + > + Sync Config + + + } />
@@ -964,7 +976,8 @@ function ConfigSection() { }} isDisabled={ configFile.id === 'default' || - configFile.id === activeConfigFile?.id + configFile.id === activeConfigFile?.id || + Boolean(configFile.sync) } > @@ -999,14 +1012,16 @@ function ConfigSection() { return } - const path = await getConfigPath({ - id: configFile.id!, - validate: true, - }) + if (!configFile.sync) { + const path = await getConfigPath({ + id: configFile.id!, + validate: true, + }) - await remove(path.replace('rclone.conf', ''), { - recursive: true, - }) + await remove(path.replace('rclone.conf', ''), { + recursive: true, + }) + } if (activeConfigFile?.id === configFile.id) { usePersistedStore @@ -1043,6 +1058,13 @@ function ConfigSection() { }} id={focusedConfigId} /> + + { + setIsSyncDrawerOpen(false) + }} + />
) } diff --git a/types/config.d.ts b/types/config.d.ts index 22579cc..be60ed2 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -4,4 +4,5 @@ export interface ConfigFile { sync: string | undefined isEncrypted: boolean pass: string | undefined + passCommand: string | undefined }