diff --git a/lib/menu.ts b/lib/menu.ts index 16820e1..c5a8130 100644 --- a/lib/menu.ts +++ b/lib/menu.ts @@ -518,6 +518,7 @@ export async function buildMenu() { const allowsAdditional = !persistedStoreState.disabledActions?.includes('tray-sync') || !persistedStoreState.disabledActions?.includes('tray-move') || + !persistedStoreState.disabledActions?.includes('tray-bisync') || !persistedStoreState.disabledActions?.includes('tray-serve') || !persistedStoreState.disabledActions?.includes('tray-purge') || !persistedStoreState.disabledActions?.includes('tray-delete') @@ -553,6 +554,20 @@ export async function buildMenu() { commandsSubmenuItems.push(moveMenuItem) } + if (!persistedStoreState.disabledActions?.includes('tray-bisync')) { + const bisyncMenuItem = await MenuItem.new({ + id: 'bisync', + text: 'Bisync', + action: async () => { + await openWindow({ + name: 'Bisync', + url: '/bisync', + }) + }, + }) + commandsSubmenuItems.push(bisyncMenuItem) + } + const rcloneVersion = await getRcloneVersion() if ( diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index 8366ce8..fc71d12 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -586,6 +586,62 @@ export async function startMove({ return r.jobid } +export async function startBisync({ + path1, + path2, + _config, + _filter, + remoteOptions, + outerOptions, +}: { + path1: string + path2: string + _config?: Record + _filter?: Record + remoteOptions?: Record + outerOptions?: Record +}) { + console.log('[startBisync]', path1, path2) + + const params = new URLSearchParams() + params.set('path1', path1) + params.set('path2', path2) + params.set('_async', 'true') + + if (_config && Object.keys(_config).length > 0) { + params.set('_config', JSON.stringify(parseRcloneOptions(_config))) + } + + if (_filter && Object.keys(_filter).length > 0) { + params.set('_filter', JSON.stringify(parseRcloneOptions(_filter))) + } + + if (remoteOptions && Object.keys(remoteOptions).length > 0) { + for (const [key, value] of Object.entries(remoteOptions)) { + params.set(key, value.toString()) + } + } + + if (outerOptions && Object.keys(outerOptions).length > 0) { + for (const [key, value] of Object.entries(outerOptions)) { + params.set(key, value.toString()) + } + } + + const r = await fetch(`http://localhost:5572/sync/bisync?${params.toString()}`, { + method: 'POST', + headers: getAuthHeader(), + }).then((res) => res.json() as Promise<{ jobid: string }>) + + console.log('[startBisync] operation started:', r) + + if (!r.jobid) { + throw new Error('Failed to start bisync job') + } + + return r.jobid +} + export async function startSync({ srcFs, dstFs, diff --git a/lib/store.ts b/lib/store.ts index acdc394..45a31ef 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -25,6 +25,7 @@ export interface RemoteConfig { syncDefaults?: Record configDefaults?: Record serveDefaults?: Record<(typeof SERVE_TYPES)[number], Record> + bisyncDefaults?: Record remoteDefaults?: Record } @@ -58,6 +59,7 @@ type SupportedAction = | 'tray-copy' | 'tray-serve' | 'tray-move' + | 'tray-bisync' | 'tray-delete' | 'tray-purge' | 'tray-download' diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 656fdc1..51e7f18 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,7 @@ "Jobs", "Cron", "Serve", + "Bisync", "Download", "Browse", "Startup", diff --git a/src/main.tsx b/src/main.tsx index 6019dfb..6f9a855 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import Home from './pages/Home' import './global.css' import { HeroUIProvider } from '@heroui/react' import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log' +import Bisync from './pages/Bisync' import Copy from './pages/Copy' import Cron from './pages/Cron' import Delete from './pages/Delete' @@ -79,6 +80,10 @@ const router = createBrowserRouter([ path: '/serve', element: , }, + { + path: '/bisync', + element: , + }, { path: '/mount', element: , diff --git a/src/pages/Bisync.tsx b/src/pages/Bisync.tsx new file mode 100644 index 0000000..91dbf7f --- /dev/null +++ b/src/pages/Bisync.tsx @@ -0,0 +1,728 @@ +import { Accordion, AccordionItem, Avatar, Button, Switch } from '@heroui/react' +import { getCurrentWindow } from '@tauri-apps/api/window' +import { message } from '@tauri-apps/plugin-dialog' +import { exists } from '@tauri-apps/plugin-fs' +import { fetch } from '@tauri-apps/plugin-http' +import { + AlertOctagonIcon, + DiamondPercentIcon, + FilterIcon, + FoldersIcon, + PlayIcon, + ServerIcon, + WrenchIcon, + XIcon, +} from 'lucide-react' +import { startTransition, useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { getRemoteName } from '../../lib/format' +import { isRemotePath } from '../../lib/fs' +import { + getConfigFlags, + getCopyFlags, + getCurrentGlobalFlags, + getFilterFlags, + startBisync, +} from '../../lib/rclone/api' +import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' +import { usePersistedStore } from '../../lib/store' +import { openWindow } from '../../lib/window' +import type { FlagValue } from '../../types/rclone' +import CommandInfo from '../components/CommandInfo' +import OptionsSection from '../components/OptionsSection' +import { MultiPathFinder } from '../components/PathFinder' +import RemoteOptionsSection from '../components/RemoteOptionsSection' + +export default function Bisync() { + const [searchParams] = useSearchParams() + + const [sources, setSources] = useState( + searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined + ) + const [dest, setDest] = useState(undefined) + + const [isStarted, setIsStarted] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [jsonError, setJsonError] = useState<'bisync' | 'filter' | 'config' | 'remote' | null>( + null + ) + + const [bisyncOptionsLocked, setBisyncOptionsLocked] = useState(false) + const [bisyncOptions, setBisyncOptions] = useState>({}) + const [bisyncOptionsJson, setBisyncOptionsJson] = useState('{}') + const [outerBisyncOptions, setOuterBisyncOptions] = useState>({}) + + const [filterOptionsLocked, setFilterOptionsLocked] = useState(false) + const [filterOptions, setFilterOptions] = useState>({}) + const [filterOptionsJson, setFilterOptionsJson] = useState('{}') + + const [configOptionsLocked, setConfigOptionsLocked] = useState(false) + const [configOptions, setConfigOptions] = useState>({}) + const [configOptionsJson, setConfigOptionsJson] = useState('{}') + + const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false) + const [remoteOptions, setRemoteOptions] = useState>({}) + const [remoteOptionsJson, setRemoteOptionsJson] = useState('{}') + + const [currentGlobalOptions, setCurrentGlobalOptions] = useState([]) + + const selectedRemotes = (() => { + return [...(sources || []), dest].filter(Boolean) as string[] + })() + + useEffect(() => { + getCurrentGlobalFlags().then((flags) => { + startTransition(() => { + setCurrentGlobalOptions(flags) + }) + }) + }, []) + + useEffect(() => { + getCurrentGlobalFlags().then((flags) => + startTransition(() => setCurrentGlobalOptions(flags)) + ) + }, []) + + // 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 mergedBisyncDefaults = {} + let mergedFilterDefaults = {} + let mergedConfigDefaults = {} + + // Helper function to merge defaults from a remote + const mergeRemoteDefaults = (remote: string | null) => { + if (!remote) return + + const remoteConfig = storeData.remoteConfigList?.[remote] || {} + + if (remoteConfig.bisyncDefaults) { + mergedBisyncDefaults = { + ...mergedBisyncDefaults, + ...remoteConfig.bisyncDefaults, + } + } + + if (remoteConfig.filterDefaults) { + mergedFilterDefaults = { + ...mergedFilterDefaults, + ...remoteConfig.filterDefaults, + } + } + + if (remoteConfig.configDefaults) { + mergedConfigDefaults = { + ...mergedConfigDefaults, + ...remoteConfig.configDefaults, + } + } else { + mergedConfigDefaults = { + ...mergedConfigDefaults, + ...RCLONE_CONFIG_DEFAULTS, + } + } + } + + // Only merge defaults for remote paths + if (sourceRemote) mergeRemoteDefaults(sourceRemote) + if (destRemote) mergeRemoteDefaults(destRemote) + + if (Object.keys(mergedBisyncDefaults).length > 0 && !bisyncOptionsLocked) { + setBisyncOptionsJson(JSON.stringify(mergedBisyncDefaults, null, 2)) + } + + if (Object.keys(mergedFilterDefaults).length > 0 && !filterOptionsLocked) { + setFilterOptionsJson(JSON.stringify(mergedFilterDefaults, null, 2)) + } + + if (Object.keys(mergedConfigDefaults).length > 0 && !configOptionsLocked) { + setConfigOptionsJson(JSON.stringify(mergedConfigDefaults, null, 2)) + } + }, [sources, dest]) + + useEffect(() => { + let step: 'bisync' | 'filter' | 'config' | 'remote' = 'bisync' + try { + setBisyncOptions(JSON.parse(bisyncOptionsJson)) + + step = 'filter' + setFilterOptions(JSON.parse(filterOptionsJson)) + + step = 'config' + setConfigOptions(JSON.parse(configOptionsJson)) + + step = 'remote' + setRemoteOptions(JSON.parse(remoteOptionsJson)) + + setJsonError(null) + } catch (error) { + setJsonError(step) + console.error(`Error parsing ${step} options:`, error) + } + }, [bisyncOptionsJson, filterOptionsJson, configOptionsJson, remoteOptionsJson]) + + async function handleStartBisync() { + setIsLoading(true) + + if (!sources || sources.length === 0 || !dest) { + await message('Please select both a source and destination path', { + title: 'Error', + kind: 'error', + }) + setIsLoading(false) + return + } + + // check local paths exists + for (const source of sources) { + try { + 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 + } + } + + if ( + sources.length > 1 && + filterOptions && + ('IncludeRule' in filterOptions || 'IncludeFrom' in filterOptions) + ) { + await message('Include rules are not supported with multiple sources', { + title: 'Error', + kind: 'error', + }) + setIsLoading(false) + return + } + + const mergedConfig = { + ...configOptions, + ...bisyncOptions, + } + + const failedPaths: Record = {} + + // Group files by their parent folder to build a single IncludeRule per group + const folderSources = sources.filter((path) => path.endsWith('/')) + const fileSources = sources.filter((path) => !path.endsWith('/')) + + const parentToFilesMap: Record = {} + for (const fileSource of fileSources) { + const parentFolder = fileSource.split('/').slice(0, -1).join('/') + const fileName = fileSource.split('/').pop()! + if (!parentToFilesMap[parentFolder]) parentToFilesMap[parentFolder] = [] + parentToFilesMap[parentFolder].push(fileName) + } + + const fileGroups = Object.entries(parentToFilesMap) + + // Start move for each file group (per parent folder) + for (const [parentFolder, fileNames] of fileGroups) { + const customFilterOptions = { + ...filterOptions, + IncludeRule: fileNames, + } + console.log('[Bisync] customFilterOptions for group', parentFolder, customFilterOptions) + + const customSource = parentFolder + console.log('[Bisync] customSource for group', parentFolder, customSource) + + const destination = dest + console.log('[Bisync] destination for group', parentFolder, destination) + + try { + const jobId = await startBisync({ + path1: customSource, + path2: destination, + _config: mergedConfig, + _filter: customFilterOptions, + remoteOptions, + outerOptions: outerBisyncOptions, + }) + + 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[`[group] ${parentFolder}`] = statusRes.error + } + } catch (error) { + console.log('error', error) + console.error('Failed to start move for group:', parentFolder, error) + if (!failedPaths[`[group] ${parentFolder}`]) { + if (error instanceof Error) { + failedPaths[`[group] ${parentFolder}`] = error.message + } else { + failedPaths[`[group] ${parentFolder}`] = 'Unknown error' + } + } + } + } + + // Start move for each full folder source (preserve existing behavior) + for (const source of folderSources) { + const isFolder = true + const customFilterOptions = filterOptions + console.log('[Bisync] customFilterOptions for', source, customFilterOptions) + const customSource = source + console.log('[Bisync] customSource for', source, customSource) + + const destination = + isFolder && sources.length > 1 + ? `${dest}/${source.split('/').filter(Boolean).pop()!}` + : dest + console.log('[Bisync] destination for', source, destination) + + try { + const jobId = await startBisync({ + path1: customSource, + path2: destination, + _config: mergedConfig, + _filter: customFilterOptions, + remoteOptions, + outerOptions: outerBisyncOptions, + }) + + 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('[Bisync] failedPaths', failedPaths) + + // dummy delay to avoid waiting when opening the Jobs page + await new Promise((resolve) => setTimeout(resolve, 1000)) + + const failedPathsKeys = Object.keys(failedPaths) + console.log('[Bisync] failedPathsKeys', failedPathsKeys) + + const expectedJobsFinal = (() => { + const folderSourcesFinal = (sources || []).filter((path) => path.endsWith('/')) + const fileSourcesFinal = (sources || []).filter((path) => !path.endsWith('/')) + const parentToFilesMapFinal: Record = {} + for (const fileSource of fileSourcesFinal) { + const parentFolder = fileSource.split('/').slice(0, -1).join('/') + parentToFilesMapFinal[parentFolder] = true + } + return folderSourcesFinal.length + Object.keys(parentToFilesMapFinal).length + })() + + if (expectedJobsFinal !== failedPathsKeys.length) { + setIsStarted(true) + } + + if (failedPathsKeys.length > 0) { + if (expectedJobsFinal === 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) + } + + const buttonText = (() => { + 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 BISYNC' + })() + + const buttonIcon = (() => { + if (isLoading) return + if (!sources || sources.length === 0 || !dest || sources[0] === dest) + return + if (jsonError) return + return + })() + + return ( +
+ + + {/* Main Content */} +
+ {/* Paths Display */} + + + + + } + /> + } + indicator={} + subtitle="Tap to toggle bisync options for this operation" + title="Bisync" + > +
+ + setOuterBisyncOptions({ + ...outerBisyncOptions, + resync: value, + }) + } + size="sm" + > + resync + + + + setOuterBisyncOptions({ + ...outerBisyncOptions, + checkAccess: value, + }) + } + size="sm" + > + checkAccess + + + + setOuterBisyncOptions({ + ...outerBisyncOptions, + force: value, + }) + } + size="sm" + > + force + + + + setOuterBisyncOptions({ + ...outerBisyncOptions, + createEmptySrcDirs: value, + }) + } + size="sm" + > + createEmptySrcDirs + + + + setOuterBisyncOptions({ + ...outerBisyncOptions, + removeEmptyDirs: value, + }) + } + size="sm" + > + removeEmptyDirs + + + + setOuterBisyncOptions({ + ...outerBisyncOptions, + ignoreListingChecksum: value, + }) + } + size="sm" + > + ignoreListingChecksum + + + + setOuterBisyncOptions({ + ...outerBisyncOptions, + resilient: value, + }) + } + size="sm" + > + resilient + + + + setOuterBisyncOptions({ + ...outerBisyncOptions, + noCleanup: value, + }) + } + size="sm" + > + noCleanup + +
+ +
+ } /> + } + indicator={} + subtitle="Tap to toggle filtering options for this operation" + title="Filters" + > + + + + } /> + } + indicator={} + subtitle="Tap to toggle config options for this operation" + title="Config" + > + + + + {selectedRemotes.length > 0 ? ( + } + /> + } + indicator={} + subtitle="Tap to toggle remote options for this operation" + title={'Remotes'} + > + + + ) : null} +
+
+ +
+ {isStarted ? ( + <> + + + + + + + ) : ( + + )} +
+
+ ) +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 2d880e1..45f3be0 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -559,6 +559,14 @@ function GeneralSection() { > Show Copy option + + updateDisabledActions({ name: 'tray-bisync', value }) + } + > + Show Bisync option +