diff --git a/lib/menu.ts b/lib/menu.ts index 344a46d..1b723ee 100644 --- a/lib/menu.ts +++ b/lib/menu.ts @@ -396,6 +396,20 @@ export async function buildMenu() { menuItems.push(syncMenuItem) } + if (!persistedStoreState.disabledActions?.includes('tray-delete')) { + const deleteMenuItem = await MenuItem.new({ + id: 'delete', + text: 'Delete', + action: async () => { + await openWindow({ + name: 'Delete', + url: '/delete', + }) + }, + }) + menuItems.push(deleteMenuItem) + } + await PredefinedMenuItem.new({ item: 'Separator', }).then((item) => { diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index 8092b6b..feb3eff 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -516,6 +516,42 @@ export async function startSync({ } } +export async function startDelete({ + fs, + rmDirs, + _filter, +}: { + fs: string + rmDirs?: boolean // delete empty src directories if set + _filter?: Record +}) { + console.log('[startDelete]', fs, rmDirs) + + const params = new URLSearchParams() + params.set('fs', fs) + + if (rmDirs) { + params.set('rmDirs', 'true') + } + + params.set('_async', 'true') + + if (_filter && Object.keys(_filter).length > 0) { + params.set('_filter', JSON.stringify(_filter)) + } + + const r = await fetch(`http://localhost:5572/operations/delete?${params.toString()}`, { + method: 'POST', + headers: getAuthHeader(), + }) + + console.log('[startDelete] operation started:', r) + + if (!r.ok) { + throw new Error('Failed to start delete job') + } +} + /* FLAGS */ export async function getGlobalFlags() { diff --git a/lib/store.ts b/lib/store.ts index 24654dd..67f2879 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -53,6 +53,7 @@ interface PersistedState { | 'tray-copy' | 'tray-serve' | 'tray-move' + | 'tray-delete' )[] setDisabledActions: ( actions: ( @@ -61,6 +62,7 @@ interface PersistedState { | 'tray-copy' | 'tray-serve' | 'tray-move' + | 'tray-delete' )[] ) => void @@ -162,6 +164,7 @@ export const usePersistedStore = create()( | 'tray-copy' | 'tray-serve' | 'tray-move' + | 'tray-delete' )[] ) => set((_) => ({ disabledActions: actions })), diff --git a/main.ts b/main.ts index c3d660c..54e927f 100644 --- a/main.ts +++ b/main.ts @@ -9,6 +9,7 @@ import { listRemotes, mountRemote, startCopy, + startDelete, startMove, startSync, unmountAllRemotes, @@ -322,10 +323,17 @@ async function handleTask(task: ScheduledTask) { _filter, createEmptySrcDirs, deleteEmptyDstDirs, + fs, + rmDirs, } = task.args switch (task.type) { case 'delete': + await startDelete({ + fs, + rmDirs, + _filter, + }) break case 'copy': await startCopy({ diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3820d43..ce8f976 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "Move", "Sync", "Mount", + "Delete", "Settings", "Jobs", "Cron", diff --git a/src/main.tsx b/src/main.tsx index 415b230..a611c54 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import { HeroUIProvider } from '@heroui/react' import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log' import Copy from './pages/Copy' import Cron from './pages/Cron' +import Delete from './pages/Delete' import Jobs from './pages/Jobs' import Mount from './pages/Mount' import Move from './pages/Move' @@ -54,6 +55,10 @@ const router = createBrowserRouter([ path: '/move', element: , }, + { + path: '/delete', + element: , + }, { path: '/mount', element: , diff --git a/src/pages/Delete.tsx b/src/pages/Delete.tsx new file mode 100644 index 0000000..cd9f39d --- /dev/null +++ b/src/pages/Delete.tsx @@ -0,0 +1,254 @@ +import { Accordion, AccordionItem, Avatar, Button } from '@heroui/react' +import { message } from '@tauri-apps/plugin-dialog' +import {} from '@tauri-apps/plugin-fs' +import cronstrue from 'cronstrue' +import { AlertOctagonIcon, ClockIcon, FilterIcon, FoldersIcon, PlayIcon } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { getRemoteName } from '../../lib/format' +import { getFilterFlags, getGlobalFlags, startDelete } from '../../lib/rclone/api' +import { usePersistedStore } from '../../lib/store' +import CronEditor from '../components/CronEditor' +import OptionsSection from '../components/OptionsSection' +import { PathField } from '../components/PathFinder' + +export default function Delete() { + const [searchParams] = useSearchParams() + + const [sourceFs, setSourceFs] = useState( + searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined + ) + const [rmDirs, setRmDirs] = useState(false) + + const [cronExpression, setCronExpression] = useState(null) + + const [isStarted, setIsStarted] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [jsonError, setJsonError] = useState<'filter' | null>(null) + + const [filterOptionsLocked, setFilterOptionsLocked] = useState(false) + const [filterOptions, setFilterOptions] = useState>({}) + const [filterOptionsJson, setFilterOptionsJson] = useState('{}') + + 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(sourceFs?.[0]) + + 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.filterDefaults) { + mergedFilterDefaults = { + ...mergedFilterDefaults, + ...remoteConfig.filterDefaults, + } + } + } + + // Only merge defaults for remote paths + if (sourceRemote) mergeRemoteDefaults(sourceRemote) + + if (Object.keys(mergedFilterDefaults).length > 0 && !filterOptionsLocked) { + setFilterOptionsJson(JSON.stringify(mergedFilterDefaults, null, 2)) + } + }, [sourceFs]) + + useEffect(() => { + getGlobalFlags().then((flags) => setGlobalOptions(flags)) + }, []) + + useEffect(() => { + try { + setFilterOptions(JSON.parse(filterOptionsJson)) + + setJsonError(null) + } catch (error) { + setJsonError('filter') + console.error('Error parsing filter options:', error) + } + }, [filterOptionsJson]) + + const handleStartDelete = useCallback(async () => { + setIsLoading(true) + + if (!sourceFs) { + await message('Please select a source path', { + title: 'Error', + kind: 'error', + }) + return + } + + if (cronExpression) { + try { + cronstrue.toString(cronExpression) + } catch { + await message('Invalid cron expression', { + title: 'Error', + kind: 'error', + }) + setIsLoading(false) + return + } + usePersistedStore.getState().addScheduledTask({ + 'type': 'delete', + 'cron': cronExpression, + 'args': { + 'fs': sourceFs, + 'rmDirs': rmDirs, + '_filter': filterOptions, + }, + }) + } + + try { + await startDelete({ + fs: sourceFs, + rmDirs, + _filter: filterOptions, + }) + + setIsStarted(true) + + await message('Delete job started', { + title: 'Success', + okLabel: 'OK', + }) + } catch (error) { + await message(`Failed to start delete job, ${error}`, { + title: 'Error', + kind: 'error', + okLabel: 'OK', + }) + } finally { + setIsLoading(false) + } + }, [sourceFs, filterOptions, rmDirs, cronExpression]) + + const buttonText = useMemo(() => { + if (isLoading) return 'STARTING...' + if (!sourceFs || sourceFs.length === 0) return 'Please select a source path' + if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' + return 'START DELETE' + }, [isLoading, jsonError, sourceFs]) + + const buttonIcon = useMemo(() => { + if (isLoading) return + if (!sourceFs || sourceFs.length === 0) return + if (jsonError) return + return + }, [isLoading, jsonError, sourceFs]) + + return ( +
+ {/* Main Content */} +
+ {/* Path Display */} + + + {/*
+ + Delete empty source directories after delete + +
*/} + + + } /> + } + 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/src/pages/Settings.tsx b/src/pages/Settings.tsx index ec2a43b..28f66eb 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -401,6 +401,36 @@ function GeneralSection() { > Show Copy option + { + if (value) { + setDisabledActions( + disabledActions?.filter((action) => action !== 'tray-move') || + [] + ) + } else { + setDisabledActions([...(disabledActions || []), 'tray-move']) + } + }} + > + Show Move option + + { + if (value) { + setDisabledActions( + disabledActions?.filter((action) => action !== 'tray-delete') || + [] + ) + } else { + setDisabledActions([...(disabledActions || []), 'tray-delete']) + } + }} + > + Show Delete option + {/*