import * as Sentry from '@sentry/browser' import { message } from '@tauri-apps/plugin-dialog' import { platform } from '@tauri-apps/plugin-os' import pRetry from 'p-retry' import { useHostStore } from '../../store/host' import type { JobItem } from '../../types/jobs' import type { FlagValue } from '../../types/rclone' import { getFsInfo } from '../format' import { restartActiveRclone, runRcloneCli } from './cli' import rclone from './client' import { parseRcloneOptions } from './common' const RE_BACKSLASH = /\\/g const RE_PATH_SEPARATOR = /[/\\]/ const RE_WINDOWS_EXTENDED_PATH = /(\/\/\?\/|\\\\\?\\)/ const RE_WINDOWS_DRIVE_ROOT = /^:local:[a-zA-Z]:\/$/ const RE_WINDOWS_DRIVE_LETTER = /^[a-zA-Z]:$/ export async function startDryRun(operation: () => Promise): Promise { await rclone('/options/set', { params: { query: { main: { DryRun: true }, }, }, }) try { return await operation() } finally { await rclone('/options/set', { params: { query: { main: { DryRun: false }, }, }, }) } } function serializeOptions( remotePath: string, options: { remote?: Record global?: Record } ) { console.log('[serializeRemoteOptions] ', remotePath) const { remoteName, filePath, dirPath, type, root } = getFsInfo(remotePath) console.log('[serializeRemoteOptions] ', remotePath, 'remoteName', remoteName) console.log('[serializeRemoteOptions] ', remotePath, 'filePath', filePath) console.log('[serializeRemoteOptions] ', remotePath, 'dirPath', dirPath) console.log('[serializeRemoteOptions] ', remotePath, 'type', type) console.log('[serializeRemoteOptions] ', remotePath, 'root', root) let serialized = `${remoteName}` if ( Object.keys(options.remote || {}).length > 0 || Object.keys(options.global || {}).length > 0 ) { serialized += ',' } if (options.remote && Object.keys(options.remote).length > 0) { serialized += Object.entries(options.remote) .map(([key, value]) => `${key}="${value}"`) .join(',') } if (options.global && Object.keys(options.global).length > 0) { serialized += Object.entries(options.global) .map(([key, value]) => `global.${key}="${value}"`) .join(',') } serialized += ':' if (remoteName === ':local') { if (RE_WINDOWS_DRIVE_ROOT.test(root)) { const driveLetter = root.slice(7) console.log( '[serializeRemoteOptions] ', remotePath, 'adding Windows drive', driveLetter ) serialized += driveLetter } else { console.log('[serializeRemoteOptions] ', remotePath, 'adding / for Unix local') serialized += '/' } } if (type === 'folder') { serialized += dirPath } else { serialized += filePath } console.log('[serializeRemoteOptions] ', remotePath, 'serialized', serialized) return serialized } async function hasStat(path: string) { try { const { root, filePath } = getFsInfo(path) const r = await rclone('/operations/stat', { params: { query: { fs: root === ':local:' ? ':local:/' : root, remote: filePath, }, }, }) if (!r || !r.item) { return false } return true } catch { return false } } export async function startCopy({ sources, destination, options, }: { sources: string[] destination: string options: { copy?: Record config?: Record filter?: Record remotes?: Record> } }) { console.log('[startCopy] starting', { sources, destination, optionKeys: Object.keys(options), }) for (const source of sources) { const sourceExists = await hasStat(source) if (!sourceExists) { throw new Error(`Source does not exist, ${source} is missing`) } } if ( sources.length > 1 && options.filter && ('include' in options.filter || 'include_from' in options.filter) ) { throw new Error('Include rules are not supported with multiple sources') } const mergedOptions = { ...(options.config || {}), ...(options.copy || {}), ...(options.filter || {}), } const pendingJobs: Parameters[0] = [] const handledSourcePaths: Record = {} const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\')) console.log('[Copy] ======DST INFO====== ', destination, ' ====================') const { root: dstRoot, dirPath: dstDirPath, fullDirPath: dstFullDirPath, remoteName: dstRemoteName, } = getFsInfo(destination) console.log('[Copy] ======DST INFO====== ', destination, ' ====================') const dstOptions = options.remotes && dstRemoteName && dstRemoteName in options.remotes ? (JSON.parse(options.remotes[dstRemoteName] as unknown as string) as any) : undefined for (const source of sources) { console.log('[Copy] ======START====== ', source, ' ====================') if (handledSourcePaths[source]) { console.log('[Copy] skipping because source is already handled', source) continue } handledSourcePaths[source] = true console.log('[Copy] ======SRC INFO====== ', source, ' ====================') const { root: srcRoot, filePath: srcFilePath, fullDirPath: srcFullDirPath, type: srcType, name: srcName, remoteName: srcRemoteName, } = getFsInfo(source) console.log('[Copy] ======SRC INFO====== ', source, ' ====================') const srcOptions = options.remotes && srcRemoteName && srcRemoteName in options.remotes ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) : undefined if (srcType === 'folder') { const jobParams: Parameters[0][number] = { _path: 'sync/copy', srcFs: serializeOptions(srcFullDirPath, { remote: srcOptions, global: mergedOptions, }), dstFs: serializeOptions(`${dstFullDirPath}${srcName}`, { remote: dstOptions, }), createEmptySrcDirs: true, } pendingJobs.push(jobParams) continue } if (folderSources.some((folder) => source.startsWith(folder))) { console.log( '[Copy] skipping because source or parent folder is already handled', source ) continue } console.log('[Copy] ', source, 'srcRoot', srcRoot, srcFilePath) console.log('[Copy] ', destination, 'dstRoot', dstRoot, dstDirPath) const jobParams: Parameters[0][number] = { _path: 'operations/copyfile', srcFs: serializeOptions(srcRoot, { remote: srcOptions, global: mergedOptions, }), srcRemote: srcFilePath, dstFs: serializeOptions(dstRoot, { remote: dstOptions, }), dstRemote: `${dstDirPath === '/' ? '' : dstDirPath}${srcName}`, } pendingJobs.push(jobParams) } console.log('[startCopy] submitting batch', { jobCount: pendingJobs.length }) return startBatch(pendingJobs) } export async function startMove({ sources, destination, options, }: { sources: string[] destination: string options: { move?: Record config?: Record filter?: Record remotes?: Record> } }) { console.log('[startMove] starting', { sources, destination, optionKeys: Object.keys(options), }) for (const source of sources) { const sourceExists = await hasStat(source) if (!sourceExists) { throw new Error(`Source does not exist, ${source} is missing`) } } if ( sources.length > 1 && options.filter && ('include' in options.filter || 'include_from' in options.filter) ) { throw new Error('Include rules are not supported with multiple sources') } const mergedOptions = { ...(options.config || {}), ...(options.move || {}), ...(options.filter || {}), } const pendingJobs: Parameters[0] = [] const handledSourcePaths: Record = {} const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\')) const { root: dstRoot, dirPath: dstDirPath, fullDirPath: dstFullDirPath, remoteName: dstRemoteName, } = getFsInfo(destination) const dstOptions = options.remotes && dstRemoteName && dstRemoteName in options.remotes ? (JSON.parse(options.remotes[dstRemoteName] as unknown as string) as any) : undefined for (const source of sources) { if (handledSourcePaths[source]) { console.log('[Move] skipping because source is already handled', source) continue } handledSourcePaths[source] = true const { root: srcRoot, filePath: srcFilePath, fullDirPath: srcFullDirPath, type: srcType, name: srcName, remoteName: srcRemoteName, } = getFsInfo(source) const srcOptions = options.remotes && srcRemoteName && srcRemoteName in options.remotes ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) : undefined if (srcType === 'folder') { const jobParams: Parameters[0][number] = { _path: 'sync/move', srcFs: serializeOptions(srcFullDirPath, { remote: srcOptions, global: mergedOptions, }), dstFs: serializeOptions(`${dstFullDirPath}${srcName}`, { remote: dstOptions, }), createEmptySrcDirs: true, } pendingJobs.push(jobParams) continue } if (folderSources.some((folder) => source.startsWith(folder))) { console.log( '[Move] skipping because source or parent folder is already handled', source ) continue } const jobParams: Parameters[0][number] = { _path: 'operations/movefile', srcFs: serializeOptions(srcRoot, { remote: srcOptions, global: mergedOptions, }), srcRemote: srcFilePath, dstFs: serializeOptions(dstRoot, { remote: dstOptions, }), dstRemote: `${dstDirPath === '/' ? '' : dstDirPath}${srcName}`, } pendingJobs.push(jobParams) } console.log('[startMove] submitting batch', { jobCount: pendingJobs.length }) return startBatch(pendingJobs) } /* JOBS */ async function fetchTransferred() { const transferredStats = await rclone('/core/transferred') const transferred = transferredStats?.transferred return transferred } async function fetchJob( jobId: number, transferred: Awaited>, checkingItems: { group?: string; name?: string; size?: number }[] ) { console.log('[fetchJob] fetching job', jobId) const job = await rclone('/core/stats', { params: { query: { group: `job/${jobId}`, }, }, }) console.log('[fetchJob] job stats', jobId, JSON.stringify(job, null, 2)) const jobStatus = await rclone('/job/status', { params: { query: { jobid: jobId, }, }, }) console.log('[fetchJob] job status', jobId, JSON.stringify(jobStatus, null, 2)) let hasError = !!jobStatus?.error let isDryRun = false if ( jobStatus.output && typeof jobStatus.output === 'object' && 'results' in jobStatus.output && Array.isArray(jobStatus.output.results) ) { if (!hasError) { hasError = jobStatus.output.results.some((result: any) => !!result?.error) } isDryRun = jobStatus.output.results.some((result: any) => { const srcFs = result.input?.srcFs || '' return srcFs.includes('dry_run') || srcFs.includes('dry-run') }) } const jobCheckingItems = checkingItems.filter((c) => c.group === `job/${jobId}`) const isChecking = jobCheckingItems.length > 0 const checkingCount = jobCheckingItems.length console.log('[fetchJob] checking state', jobId, { isChecking, checkingCount }) const relatedItems = transferred.filter((t) => t.group === `job/${jobId}`) if (relatedItems.length === 0 && !isChecking) { console.log('[fetchJob] no relatedItems and not checking', jobId) return null } console.log('[fetchJob] relatedItems', JSON.stringify(relatedItems, null, 2)) const sources = new Set() if (relatedItems.length === 1) { if (relatedItems[0].srcFs) { const combinedSource = `${relatedItems[0].srcFs}${relatedItems[0].name}` sources.add( platform() === 'windows' ? combinedSource.replace(RE_WINDOWS_EXTENDED_PATH, '') : combinedSource ) } } else { for (const item of relatedItems) { if (item.srcFs) { sources.add( platform() === 'windows' ? item.srcFs.replace(RE_WINDOWS_EXTENDED_PATH, '') : item.srcFs ) } } } if (isChecking && sources.size === 0) { for (const checkItem of jobCheckingItems) { if (checkItem.name) { sources.add(checkItem.name) } } } if (sources.size === 0 && !hasError) { console.log('[fetchJob] source or hasError not found', jobId) return null } return { id: jobId, bytes: job.bytes, totalBytes: job.totalBytes, speed: job.speed, done: job.bytes === job.totalBytes, progress: job.totalBytes > 0 ? Math.round((job.bytes / job.totalBytes) * 100) : 0, hasError: hasError, sources: Array.from(sources), isChecking, checkingCount, isDryRun, } } export async function listTransfers() { console.log('[listTransfers] starting') const allStats = await rclone('/core/stats') console.log('[listTransfers] allStats', JSON.stringify(allStats, null, 2)) const transferring = allStats?.transferring || [] const checking = allStats?.checking || [] console.log('[listTransfers] transferring count:', transferring.length) console.log('[listTransfers] checking count:', checking.length) const transferred = await fetchTransferred() console.log('[listTransfers] transferred count:', transferred?.length || 0) const jobs = { active: [] as JobItem[], inactive: [] as JobItem[], } const transferringJobIds = new Set( transferring .filter((t) => t.group?.startsWith('job/')) .map((t) => Number(t.group!.split('/')[1])) ) const checkingJobIds = new Set( checking .filter((c) => c.group?.startsWith('job/')) .map((c) => Number(c.group!.split('/')[1])) ) const activeJobIds = new Set([...transferringJobIds, ...checkingJobIds]) const sortedActiveJobIds = Array.from(activeJobIds).sort((a, b) => a - b) console.log('[listTransfers] transferring job IDs:', Array.from(transferringJobIds)) console.log('[listTransfers] checking job IDs:', Array.from(checkingJobIds)) console.log('[listTransfers] combined active job IDs:', sortedActiveJobIds) const isWindows = platform() === 'windows' console.log('[listTransfers] isWindows', isWindows) for (const jobId of sortedActiveJobIds) { const job = await fetchJob(jobId, transferred, checking) if (job) { jobs.active.push({ ...job, type: 'active', }) } } const inactiveJobIds = new Set( transferred ?.filter((t) => t.group?.startsWith('job/')) .map((t) => Number(t.group!.split('/')[1])) .filter((id) => !activeJobIds.has(id)) .sort((a, b) => a - b) ) console.log('[listTransfers] inactive job IDs:', Array.from(inactiveJobIds)) for (const jobId of inactiveJobIds) { const job = await fetchJob(jobId, transferred, checking) if (job) { jobs.inactive.push({ ...job, speed: 0, type: 'inactive', isChecking: false, checkingCount: 0, }) } } console.log( '[listTransfers] final result - active:', jobs.active.length, 'inactive:', jobs.inactive.length ) return jobs } /* OPERATIONS */ export async function startMount({ source, destination, options, }: { source: string destination: string options: { mount?: Record vfs?: Record filter?: Record config?: Record remotes?: Record> } }) { const currentPlatform = platform() let needsVolumeName = currentPlatform === 'macos' if ( currentPlatform === 'windows' && destination !== '*' && !RE_WINDOWS_DRIVE_LETTER.test(destination) ) { needsVolumeName = true } const mountOptions = { ...(options.mount || {}) } const hasVolumeName = 'volname' in mountOptions && mountOptions.volname if (!hasVolumeName && needsVolumeName) { const segments = source.split(RE_PATH_SEPARATOR).filter(Boolean) console.log('[Mount] segments', segments) const sourcePath = segments.length === 1 ? segments[0].replace(/:/g, '') : segments.pop() console.log('[Mount] sourcePath', sourcePath) mountOptions.volname = `${sourcePath}-${Math.random().toString(36).substring(2, 3).toUpperCase()}` } const mergedOptions = { ...mountOptions, ...(options.config || {}), ...(options.vfs || {}), ...(options.filter || {}), } const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source) const srcOptions = options.remotes && srcRemoteName && srcRemoteName in options.remotes ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) : undefined if (destination === '*' && currentPlatform === 'windows') { await pRetry( async () => await rclone('/mount/mount', { params: { query: { fs: serializeOptions(srcFullDirPath, { global: mergedOptions, remote: srcOptions, }), mountPoint: '*', mount: 'nfsmount', }, }, }), { retries: 3, } ) return } const { root: dstRoot, filePath: dstFilePath, fullDirPath: dstFullDirPath, } = getFsInfo(destination) const dstFs = dstRoot === ':local:' ? ':local:/' : dstRoot const dstFilePathNormalized = dstFilePath.replace(RE_BACKSLASH, '/') let directoryExists: boolean | undefined try { const r = await pRetry( async () => await rclone('/operations/stat', { params: { query: { fs: dstFs, remote: dstFilePathNormalized, }, }, }), { retries: 3, } ) if (!r || !r.item) { directoryExists = false } else { if (!r.item.IsDir) { throw new Error('The selected directory is not a directory') } directoryExists = true } } catch (err) { console.error('[Mount] Error checking if directory exists:', err) } console.log('[Mount] directoryExists', directoryExists) const isPlatformWindows = platform() === 'windows' if (directoryExists) { let isEmpty = false try { const { list } = await pRetry( async () => await rclone('/operations/list', { params: { query: { fs: dstRoot === ':local:' ? ':local:/' : dstRoot, remote: dstFilePath, }, }, }), { retries: 3, } ) isEmpty = !list || list.length === 0 } catch (err) { console.error('[Mount] Error checking if directory is empty:', err) } if (!isEmpty) { throw new Error('The selected directory must be empty to mount a remote.') } if (isPlatformWindows) { try { await pRetry( async () => await rclone('/operations/rmdir', { params: { query: { fs: dstRoot === ':local:' ? ':local:/' : dstRoot, remote: dstFilePath, }, }, }), { retries: 3, } ) } catch (err) { console.error('[Mount] Error removing directory:', err) } } } else if (!isPlatformWindows) { try { await pRetry( async () => await rclone('/operations/mkdir', { params: { query: { fs: dstRoot === ':local:' ? ':local:/' : dstRoot, remote: dstFilePath, }, }, }), { retries: 3, } ) } catch (error) { console.error('[Mount] Error creating directory:', error) throw new Error('Failed to create mount directory. Try creating it manually first.') } } await pRetry( async () => await rclone('/mount/mount', { params: { query: { fs: serializeOptions(srcFullDirPath, { global: mergedOptions, remote: srcOptions, }), mountPoint: (() => { if (platform() !== 'windows') { return dstFullDirPath.replace(':local:', '/') } const mp = dstFullDirPath .replace(':local:', '') .replace(RE_BACKSLASH, '/') if (/^[a-zA-Z]:\/$/.test(mp)) { return mp.slice(0, -1) } return mp })(), mount: 'nfsmount', }, }, }), { retries: 3, } ) } export async function startBisync({ source, destination, options, }: { source: string destination: string options: { config?: Record bisync?: Record filter?: Record remotes?: Record> outer?: Record } }) { const sourceExists = await hasStat(source) if (!sourceExists) { throw new Error(`Source does not exist, ${source} is missing`) } const mergedOptions = { ...(options.config || {}), ...(options.bisync || {}), ...(options.filter || {}), } const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source) const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination) const srcOptions = options.remotes && srcRemoteName && srcRemoteName in options.remotes ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) : undefined const dstOptions = options.remotes && dstRemoteName && dstRemoteName in options.remotes ? (JSON.parse(options.remotes[dstRemoteName] as unknown as string) as any) : undefined const r = await pRetry( async () => await rclone('/sync/bisync', { params: { query: { path1: serializeOptions(srcFullDirPath, { global: mergedOptions, remote: srcOptions, }), path2: serializeOptions(dstFullDirPath, { remote: dstOptions, }), _async: true, ...(options.outer && Object.keys(options.outer).length > 0 ? Object.fromEntries( Object.entries(options.outer).map(([key, value]) => [ key, Array.isArray(value) ? value.join(',') : value, ]) ) : {}), }, }, }), { retries: 3, } ) if (!r?.jobid) { console.error('Failed to start job: missing jobid', r) throw new Error('Failed to start operation') } await new Promise((resolve) => setTimeout(resolve, 1000)) const jobStatus = await pRetry( async () => await rclone('/job/status', { params: { query: { jobid: r.jobid!, }, }, }), { retries: 3, } ).catch(null) console.log('jobStatus', JSON.stringify(jobStatus, null, 2)) if (!jobStatus) { console.error('Failed to start job:', r.jobid) throw new Error('Failed to start operation') } if (jobStatus.error) { console.error('Failed to start job:', r.jobid, jobStatus.error) throw new Error(jobStatus.error) } return r.jobid } export async function startSync({ source, destination, options, }: { source: string destination: string options: { config?: Record sync?: Record filter?: Record remotes?: Record> } }) { const sourceExists = await hasStat(source) if (!sourceExists) { throw new Error(`Source does not exist, ${source} is missing`) } const mergedOptions = { ...(options.config || {}), ...(options.sync || {}), ...(options.filter || {}), } const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source) const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination) const srcOptions = options.remotes && srcRemoteName && srcRemoteName in options.remotes ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) : undefined const dstOptions = options.remotes && dstRemoteName && dstRemoteName in options.remotes ? (JSON.parse(options.remotes[dstRemoteName] as unknown as string) as any) : undefined const r = await pRetry( async () => await rclone('/sync/sync', { params: { query: { srcFs: serializeOptions(srcFullDirPath, { global: mergedOptions, remote: srcOptions, }), dstFs: serializeOptions(dstFullDirPath, { remote: dstOptions, }), createEmptySrcDirs: true, _async: true, }, }, }), { retries: 3, } ) if (!r?.jobid) { console.error('Failed to start job: missing jobid', r) throw new Error('Failed to start operation') } await new Promise((resolve) => setTimeout(resolve, 1000)) const jobStatus = await pRetry( async () => await rclone('/job/status', { params: { query: { jobid: r.jobid!, }, }, }), { retries: 3, } ).catch(null) console.log('jobStatus', JSON.stringify(jobStatus, null, 2)) if (!jobStatus) { console.error('Failed to start job:', r.jobid) throw new Error('Failed to start operation') } if (jobStatus.error) { console.error('Failed to start job:', r.jobid, jobStatus.error) throw new Error(jobStatus.error) } return r.jobid } export async function startDelete({ sources, options, }: { sources: string[] options: { filter?: Record config?: Record remotes?: Record> } }) { for (const source of sources) { const sourceExists = await hasStat(source) if (!sourceExists) { throw new Error(`Source does not exist, ${source} is missing`) } } if ( sources.length > 1 && options.filter && ('include' in options.filter || 'include_from' in options.filter) ) { throw new Error('Include rules are not supported with multiple sources') } const mergedOptions = { ...(options.config || {}), ...(options.filter || {}), } const pendingJobs: Parameters[0] = [] const handledSourcePaths: Record = {} const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\')) for (const source of sources) { if (handledSourcePaths[source]) { console.log('[Delete] skipping because source is already handled', source) continue } handledSourcePaths[source] = true const { root: srcRoot, filePath: srcFilePath, type: srcType, remoteName: srcRemoteName, } = getFsInfo(source) const srcOptions = options.remotes && srcRemoteName && srcRemoteName in options.remotes ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) : undefined if (srcType === 'folder') { const jobParams: Parameters[0][number] = { _path: 'operations/delete', fs: serializeOptions(source, { global: mergedOptions, remote: srcOptions, }), } pendingJobs.push(jobParams) continue } if (folderSources.some((folder) => source.startsWith(folder))) { console.log( '[Delete] skipping because source or parent folder is already handled', source ) continue } const jobParams: Parameters[0][number] = { _path: 'operations/deletefile', fs: serializeOptions(srcRoot, { global: mergedOptions, remote: srcOptions, }), remote: srcFilePath, } pendingJobs.push(jobParams) } return startBatch(pendingJobs) } export async function startPurge({ sources, options, }: { sources: string[] options: { config?: Record remotes?: Record> } }) { for (const source of sources) { const sourceExists = await hasStat(source) if (!sourceExists) { throw new Error(`Source does not exist, ${source} is missing`) } } const pendingJobs: Parameters[0] = [] const handledSourcePaths: Record = {} for (const source of sources) { if (handledSourcePaths[source]) { console.log('[Purge] skipping because source is already handled', source) continue } handledSourcePaths[source] = true const { root: srcRoot, dirPath: srcDirPath, type: srcType, remoteName: srcRemoteName, } = getFsInfo(source) if (srcType !== 'folder') { throw new Error('Only folders can be purged') } const srcOptions = options.remotes && srcRemoteName && srcRemoteName in options.remotes ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) : undefined const jobParams: Parameters[0][number] = { _path: 'operations/purge', fs: serializeOptions(srcRoot, { global: options.config, remote: srcOptions, }), remote: srcDirPath, } pendingJobs.push(jobParams) } return startBatch(pendingJobs) } export async function startServe({ type, fs, addr, _filter, _config, ...props }: { type: string fs: string addr: string _filter?: Record _config?: Record } & Record) { return rclone('/serve/start', { params: { query: { type, fs, addr, _filter: _filter && Object.keys(_filter).length > 0 ? JSON.stringify(parseRcloneOptions(_filter)) : undefined, _config: _config && Object.keys(_config).length > 0 ? JSON.stringify(parseRcloneOptions(_config)) : undefined, ...(props && Object.keys(props).length > 0 ? Object.fromEntries( Object.entries(props).map(([key, value]) => [ key, Array.isArray(value) ? value.join(',') : value, ]) ) : {}), }, }, }) } export async function startBatch(inputs: ({ _path: string } & Record)[]) { console.log('[startBatch] starting batch operation', { inputCount: inputs.length, paths: inputs.map((i) => i._path), }) console.log('[startBatch] inputs', JSON.stringify(inputs, null, 2)) const r = await pRetry( async () => await rclone('/job/batch', { body: { inputs, _async: true, }, }), { retries: 3, } ) console.log('[startBatch] job created', { jobid: r.jobid }) await new Promise((resolve) => setTimeout(resolve, 1000)) const jobStatus = await pRetry( async () => await rclone('/job/status', { params: { query: { jobid: r.jobid, }, }, }), { retries: 3, } ).catch(null) console.log('[startBatch] jobStatus', { jobid: r.jobid, finished: jobStatus?.finished, success: jobStatus?.success, error: jobStatus?.error, }) console.log('[startBatch] jobStatus full', JSON.stringify(jobStatus, null, 2)) if (!jobStatus) { console.error('[startBatch] ERROR: job status is null', { jobid: r.jobid }) throw new Error('Failed to start operation') } const output = jobStatus.output as any if ( output?.results && Array.isArray(output.results) && output.results.length === inputs.length ) { const results = output.results const allFailed = results.every((res: any) => res.error) if (allFailed) { const errorMessages = results .map((res: any) => { const path = res.input?.srcRemote || res.input?.dstRemote || 'unknown' return `${path}: ${res.error}` }) .join('\n') console.error('[startBatch] ERROR: all batch operations failed', { jobid: r.jobid, errorMessages, }) throw new Error(errorMessages) } } if (jobStatus.error) { console.error('[startBatch] ERROR: job failed', { jobid: r.jobid, error: jobStatus.error }) throw new Error(jobStatus.error) } console.log('[startBatch] SUCCESS', { jobid: r.jobid }) return r.jobid } /* PASSWORD */ export async function removeConfigPassword() { console.log('[removeConfigPassword]') const state = useHostStore.getState() const activeConfig = state.activeConfigFile if (!activeConfig || !activeConfig.id) { throw new Error('No active configuration selected.') } if (!activeConfig.isEncrypted) { throw new Error('Configuration is not encrypted.') } try { await runRcloneCli(['config', 'encryption', 'remove']) state.updateConfigFile(activeConfig.id, { isEncrypted: false, pass: undefined, passCommand: undefined, }) console.log('[removeConfigPassword] restarting rclone') await restartActiveRclone() } catch (error) { Sentry.captureException(error) await message(error instanceof Error ? error.message : 'Failed to disable encryption.', { title: 'Config Encryption', kind: 'error', okLabel: 'OK', }) throw error } } export async function setConfigPassword(options: { password: string persist?: boolean }) { console.log('[setConfigPassword]') const state = useHostStore.getState() const activeConfig = state.activeConfigFile if (!activeConfig || !activeConfig.id) { throw new Error('No active configuration selected.') } // if (!activeConfig.isEncrypted) { // throw new Error('Configuration is not encrypted.') // } const password = options.password if (!password) { throw new Error('Password is required to update encryption.') } try { await runRcloneCli(['config', 'encryption', 'set'], [password, password]) state.updateConfigFile(activeConfig.id, { isEncrypted: true, pass: options.persist ? password : undefined, passCommand: undefined, }) console.log('[setConfigPassword] restarting rclone') await restartActiveRclone() } catch (error) { Sentry.captureException(error) await message( error instanceof Error ? error.message : 'Failed to update encryption password.', { title: 'Config Encryption', kind: 'error', okLabel: 'OK', } ) throw error } } /* OTHERS */ export async function fetchServeList() { try { const response = await rclone('/serve/list') return response.list } catch (error) { console.error('[fetchServeList] failed to fetch active serves', error) return [] } } export async function fetchMountList() { try { const response = await rclone('/mount/listmounts') return response.mountPoints } catch (error) { console.error('[fetchMountList] failed to fetch active mounts', error) return [] } }