diff --git a/lib/menu.ts b/lib/menu.ts index d140ba2..344a46d 100644 --- a/lib/menu.ts +++ b/lib/menu.ts @@ -368,6 +368,20 @@ export async function buildMenu() { menuItems.push(copyMenuItem) } + if (!persistedStoreState.disabledActions?.includes('tray-move')) { + const moveMenuItem = await MenuItem.new({ + id: 'move', + text: 'Move', + action: async () => { + await openWindow({ + name: 'Move', + url: '/move', + }) + }, + }) + menuItems.push(moveMenuItem) + } + if (!persistedStoreState.disabledActions?.includes('tray-sync')) { const syncMenuItem = await MenuItem.new({ id: 'sync', diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index 8ddf6fc..8092b6b 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -423,6 +423,60 @@ export async function startCopy({ return r.jobid } +export async function startMove({ + srcFs, + dstFs, + createEmptySrcDirs, + deleteEmptyDstDirs, + _config, + _filter, +}: { + srcFs: string + dstFs: string + createEmptySrcDirs?: boolean // create empty src directories on destination if set + deleteEmptyDstDirs?: boolean // delete empty src directories if set + _config?: Record + _filter?: Record +}) { + console.log('[startMove]', srcFs, dstFs, createEmptySrcDirs, deleteEmptyDstDirs) + + const params = new URLSearchParams() + params.set('srcFs', srcFs) + params.set('dstFs', dstFs) + + if (createEmptySrcDirs) { + params.set('createEmptySrcDirs', 'true') + } + + if (deleteEmptyDstDirs) { + params.set('deleteEmptyDstDirs', 'true') + } + + // params.set('b2_disable_checksum', 'true') + params.set('_async', 'true') + + if (_config && Object.keys(_config).length > 0) { + params.set('_config', JSON.stringify(_config)) + } + + if (_filter && Object.keys(_filter).length > 0) { + params.set('_filter', JSON.stringify(_filter)) + } + + const r = await fetch(`http://localhost:5572/sync/move?${params.toString()}`, { + method: 'POST', + headers: getAuthHeader(), + }).then((res) => res.json() as Promise<{ jobid: string }>) + + console.log('[startMove] operation started:', r) + + if (!r.jobid) { + throw new Error('Failed to start move job') + } + + return r.jobid +} + export async function startSync({ srcFs, dstFs, diff --git a/lib/store.ts b/lib/store.ts index d3eebd4..24654dd 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -19,6 +19,7 @@ export interface RemoteConfig { vfsDefaults?: Record filterDefaults?: Record copyDefaults?: Record + moveDefaults?: Record syncDefaults?: Record } @@ -46,9 +47,21 @@ interface PersistedState { setRemoteConfig: (remote: string, config: RemoteConfig) => void mergeRemoteConfig: (remote: string, config: RemoteConfig) => void - disabledActions: ('tray-mount' | 'tray-sync' | 'tray-copy' | 'tray-serve')[] + disabledActions: ( + | 'tray-mount' + | 'tray-sync' + | 'tray-copy' + | 'tray-serve' + | 'tray-move' + )[] setDisabledActions: ( - actions: ('tray-mount' | 'tray-sync' | 'tray-copy' | 'tray-serve')[] + actions: ( + | 'tray-mount' + | 'tray-sync' + | 'tray-copy' + | 'tray-serve' + | 'tray-move' + )[] ) => void settingsPass: string | undefined @@ -143,7 +156,13 @@ export const usePersistedStore = create()( disabledActions: [], setDisabledActions: ( - actions: ('tray-mount' | 'tray-sync' | 'tray-copy' | 'tray-serve')[] + actions: ( + | 'tray-mount' + | 'tray-sync' + | 'tray-copy' + | 'tray-serve' + | 'tray-move' + )[] ) => set((_) => ({ disabledActions: actions })), settingsPass: undefined, diff --git a/main.ts b/main.ts index 39c1390..c3d660c 100644 --- a/main.ts +++ b/main.ts @@ -5,7 +5,14 @@ import { platform } from '@tauri-apps/plugin-os' import { exit } from '@tauri-apps/plugin-process' import { CronExpressionParser } from 'cron-parser' import { validateLicense } from './lib/license' -import { listRemotes, mountRemote, startCopy, startSync, unmountAllRemotes } from './lib/rclone/api' +import { + listRemotes, + mountRemote, + startCopy, + startMove, + startSync, + unmountAllRemotes, +} from './lib/rclone/api' import { initRclone } from './lib/rclone/init' import { usePersistedStore, useStore } from './lib/store' import { initLoadingTray, initTray, rebuildTrayMenu } from './lib/tray' @@ -308,7 +315,14 @@ async function handleTask(task: ScheduledTask) { } try { - const { srcFs, dstFs, _config, _filter } = task.args + const { + srcFs, + dstFs, + _config, + _filter, + createEmptySrcDirs, + deleteEmptyDstDirs, + } = task.args switch (task.type) { case 'delete': @@ -321,6 +335,16 @@ async function handleTask(task: ScheduledTask) { _filter, }) break + case 'move': + await startMove({ + srcFs, + dstFs, + createEmptySrcDirs, + deleteEmptyDstDirs, + _config, + _filter, + }) + break case 'sync': await startSync({ srcFs, diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8ea872c..3820d43 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": [ "main", "Copy", + "Move", "Sync", "Mount", "Settings", diff --git a/src/main.tsx b/src/main.tsx index b0f64e8..415b230 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,6 +9,7 @@ import Copy from './pages/Copy' import Cron from './pages/Cron' import Jobs from './pages/Jobs' import Mount from './pages/Mount' +import Move from './pages/Move' import Settings from './pages/Settings' import Sync from './pages/Sync' import Test from './pages/Test' @@ -49,6 +50,10 @@ const router = createBrowserRouter([ path: '/copy', element: , }, + { + path: '/move', + element: , + }, { path: '/mount', element: , diff --git a/src/pages/Move.tsx b/src/pages/Move.tsx new file mode 100644 index 0000000..d4bca74 --- /dev/null +++ b/src/pages/Move.tsx @@ -0,0 +1,463 @@ +import { Accordion, AccordionItem, Avatar, Button, Switch } from '@heroui/react' +import { message } from '@tauri-apps/plugin-dialog' +import { exists, readDir } from '@tauri-apps/plugin-fs' +import { fetch } from '@tauri-apps/plugin-http' +import cronstrue from 'cronstrue' +import { + AlertOctagonIcon, + ClockIcon, + FilterIcon, + FoldersIcon, + MoveIcon, + PlayIcon, +} from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { getRemoteName } from '../../lib/format' +import { isRemotePath } from '../../lib/fs' +import { getCopyFlags, getFilterFlags, getGlobalFlags, startMove } from '../../lib/rclone/api' +import { usePersistedStore } from '../../lib/store' +import { openWindow } from '../../lib/window' +import CronEditor from '../components/CronEditor' +import OptionsSection from '../components/OptionsSection' +import { MultiPathFinder } from '../components/PathFinder' + +export default function Move() { + const [searchParams] = useSearchParams() + + const [sources, setSources] = useState( + searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined + ) + const [dest, setDest] = useState(undefined) + const [createEmptySrcDirs, setCreateEmptySrcDirs] = useState(false) + const [deleteEmptyDstDirs, setDeleteEmptyDstDirs] = useState(false) + + const [isStarted, setIsStarted] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [jsonError, setJsonError] = useState<'move' | 'filter' | null>(null) + + const [moveOptionsLocked, setMoveOptionsLocked] = useState(false) + const [moveOptions, setMoveOptions] = useState>({}) + const [moveOptionsJson, setMoveOptionsJson] = useState('{}') + + const [filterOptionsLocked, setFilterOptionsLocked] = useState(false) + const [filterOptions, setFilterOptions] = useState>({}) + const [filterOptionsJson, setFilterOptionsJson] = useState('{}') + + const [cronExpression, setCronExpression] = useState(null) + + const [globalOptions, setGlobalOptions] = useState([]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: when unlocking, we don't want to re-run the effect + useEffect(() => { + const storeData = usePersistedStore.getState() + + const sourceRemote = getRemoteName(sources?.[0]) + const destRemote = getRemoteName(dest) + + let mergedMoveDefaults = {} + let mergedFilterDefaults = {} + + // Helper function to merge defaults from a remote + const mergeRemoteDefaults = (remote: string | null) => { + if (!remote || !(remote in storeData.remoteConfigList)) return + + const remoteConfig = storeData.remoteConfigList[remote] + + if (remoteConfig.moveDefaults) { + mergedMoveDefaults = { + ...mergedMoveDefaults, + ...remoteConfig.moveDefaults, + } + } + + if (remoteConfig.filterDefaults) { + mergedFilterDefaults = { + ...mergedFilterDefaults, + ...remoteConfig.filterDefaults, + } + } + } + + // Only merge defaults for remote paths + if (sourceRemote) mergeRemoteDefaults(sourceRemote) + if (destRemote) mergeRemoteDefaults(destRemote) + + if (Object.keys(mergedMoveDefaults).length > 0 && !moveOptionsLocked) { + setMoveOptionsJson(JSON.stringify(mergedMoveDefaults, null, 2)) + } + + if (Object.keys(mergedFilterDefaults).length > 0 && !filterOptionsLocked) { + setFilterOptionsJson(JSON.stringify(mergedFilterDefaults, null, 2)) + } + }, [sources, dest]) + + useEffect(() => { + getGlobalFlags().then((flags) => setGlobalOptions(flags)) + }, []) + + useEffect(() => { + let step: 'move' | 'filter' = 'move' + try { + setMoveOptions(JSON.parse(moveOptionsJson)) + + step = 'filter' + setFilterOptions(JSON.parse(filterOptionsJson)) + + setJsonError(null) + } catch (error) { + setJsonError(step) + console.error(`Error parsing ${step} options:`, error) + } + }, [moveOptionsJson, filterOptionsJson]) + + const handleStartMove = useCallback(async () => { + setIsLoading(true) + + if (!sources || sources.length === 0 || !dest) { + await message('Please select both a source and destination path', { + title: 'Error', + kind: 'error', + }) + return + } + + try { + // check local paths exists + for (const source of sources) { + if (!isRemotePath(source)) { + const sourceExists = await exists(source) + if (sourceExists) { + continue + } + await message(`Source path does not exist, ${source} is missing`, { + title: 'Error', + kind: 'error', + }) + setIsLoading(false) + return + } + } + } catch {} + + if (!isRemotePath(dest)) { + const destExists = await exists(dest) + if (!destExists) { + await message('Destination path does not exist', { + title: 'Error', + kind: 'error', + }) + setIsLoading(false) + return + } + } + + let isFolder = true + + try { + await readDir(sources[0]) + } catch { + console.log('not a folder') + isFolder = false + } + + if ( + !isFolder && + filterOptions && + ('IncludeRule' in filterOptions || 'IncludeFrom' in filterOptions) + ) { + throw new Error( + 'Include rules are not supported when the input is one or multiple files' + ) + } + + if (cronExpression) { + if (sources.length > 1) { + await message( + 'Cron is not supported for multiple sources, please use a single source', + { + title: 'Error', + kind: 'error', + } + ) + setIsLoading(false) + return + } + try { + cronstrue.toString(cronExpression) + } catch { + await message('Invalid cron expression', { + title: 'Error', + kind: 'error', + }) + setIsLoading(false) + return + } + usePersistedStore.getState().addScheduledTask({ + 'type': 'move', + 'cron': cronExpression, + 'args': { + 'srcFs': sources[0], + 'dstFs': dest, + 'createEmptySrcDirs': createEmptySrcDirs, + 'deleteEmptyDstDirs': deleteEmptyDstDirs, + '_config': moveOptions, + '_filter': filterOptions, + }, + }) + } + + const failedPaths: Record = {} + + for (const source of sources) { + try { + const customFilterOptions = isFolder + ? filterOptions + : { + ...filterOptions, + IncludeRule: [source.split('/').pop()!], + } + + // Use parent folder path if the input is a file + const customSource = isFolder ? source : source.split('/').slice(0, -1).join('/') + + const jobId = await startMove({ + srcFs: customSource, + dstFs: dest, + createEmptySrcDirs, + deleteEmptyDstDirs, + _config: moveOptions, + _filter: customFilterOptions, + }) + + await new Promise((resolve) => setTimeout(resolve, 500)) + + const statusRes = await fetch(`http://localhost:5572/job/status?jobid=${jobId}`, { + method: 'POST', + }) + .then((res) => { + return res.json() as Promise<{ + duration: number + endTime?: string + error?: string + finished: boolean + group: string + id: number + output?: Record + startTime: string + success: boolean + }> + }) + .catch(() => { + return { error: null } + }) + + console.log('statusRes', JSON.stringify(statusRes, null, 2)) + + if (statusRes.error) { + failedPaths[source] = statusRes.error + } + } catch (error) { + console.log('error', error) + console.error('Failed to start move for path:', source, error) + if (!failedPaths[source]) { + if (error instanceof Error) { + failedPaths[source] = error.message + } else { + failedPaths[source] = 'Unknown error' + } + } + } + } + + console.log('failedPaths', failedPaths) + + // dummy delay to avoid waiting when opening the Jobs page + await new Promise((resolve) => setTimeout(resolve, 1500)) + + if (sources.length !== Object.keys(failedPaths).length) { + setIsStarted(true) + } + + const failedPathsKeys = Object.keys(failedPaths) + + if (failedPathsKeys.length > 0) { + if (sources.length === failedPathsKeys.length) { + await message(`${failedPathsKeys[0]} ${failedPaths[failedPathsKeys[0]]}`, { + title: 'Failed to start move', + kind: 'error', + }) + } else { + await message( + `${failedPathsKeys.map((source) => `${source} ${failedPaths[source]}`).join(',')}`, + { + title: 'Failed to start move', + kind: 'error', + } + ) + } + } + + setIsLoading(false) + }, [ + sources, + dest, + moveOptions, + filterOptions, + cronExpression, + createEmptySrcDirs, + deleteEmptyDstDirs, + ]) + + const buttonText = useMemo(() => { + if (isLoading) return 'STARTING...' + if (!sources || sources.length === 0) return 'Please select a source path' + if (!dest) return 'Please select a destination path' + if (sources[0] === dest) return 'Source and destination cannot be the same' + if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' + return 'START MOVE' + }, [isLoading, jsonError, sources, dest]) + + const buttonIcon = useMemo(() => { + if (isLoading) return + if (!sources || sources.length === 0 || !dest || sources[0] === dest) + return + if (jsonError) return + return + }, [isLoading, jsonError, sources, dest]) + + return ( +
+ {/* Main Content */} +
+ {/* Paths Display */} + + +
+ + Create empty source directories on destination after move + + + + Delete empty source directories after move + +
+ + + } /> + } + indicator={} + subtitle="Tap to toggle move options for this operation" + title="Move" + > + + + } /> + } + indicator={} + subtitle="Tap to toggle filtering options for this operation" + title="Filters" + > + + + } /> + } + indicator={} + subtitle="Tap to toggle cron options for this operation" + title="Cron" + > + + + +
+ +
+ {isStarted ? ( + <> + + + + + ) : ( + + )} +
+
+ ) +} diff --git a/types/task.d.ts b/types/task.d.ts index f5ffe21..31b0563 100644 --- a/types/task.d.ts +++ b/types/task.d.ts @@ -1,6 +1,6 @@ export interface ScheduledTask { id: string - type: 'delete' | 'sync' | 'copy' + type: 'delete' | 'sync' | 'copy' | 'move' cron: string isRunning: boolean isEnabled: boolean