params fixing

This commit is contained in:
FTCHD
2026-07-12 18:01:52 +03:00
parent d96cfdee00
commit 5ba82e91da
14 changed files with 544 additions and 231 deletions
+29 -2
View File
@@ -1,6 +1,8 @@
import type { FlagValue } from '../types/rclone' import type { FlagValue } from '../types/rclone'
import { SERVE_TYPES } from './rclone/constants' import { SERVE_TYPES } from './rclone/constants'
const RE_DASH = /-/g
export const FLAG_CATEGORIES = [ export const FLAG_CATEGORIES = [
'copy', 'copy',
'sync', 'sync',
@@ -26,7 +28,7 @@ export function getFlagCategory(
flags: Record<string, { Name: string; Groups?: string }[]> flags: Record<string, { Name: string; Groups?: string }[]>
) { ) {
console.log('[getFlagCategory] flag', flag) console.log('[getFlagCategory] flag', flag)
const normalizedFlag = (flag.startsWith('--') ? flag.slice(2) : flag).replace(/-/g, '_') const normalizedFlag = (flag.startsWith('--') ? flag.slice(2) : flag).replace(RE_DASH, '_')
console.log('[getFlagCategory] normalized flag', normalizedFlag) console.log('[getFlagCategory] normalized flag', normalizedFlag)
let foundFlag = null let foundFlag = null
@@ -68,6 +70,31 @@ export function getFlagCategory(
return null return null
} }
/**
* Resolves a flag's option definition using the SAME blocks and priority order as getFlagCategory
* (main, vfs, filter, mount, then the serve backends), so a flag is typed from the exact block it
* will later be grouped into. Blocks getFlagCategory never routes to (rc, log, proxy) are excluded
* so a flag that also lives there can't be mis-typed or spuriously matched.
*/
export function findFlagOption(
flag: string,
allFlags: Record<string, { Name: string; Type?: string; Groups?: string }[]>
) {
const normalizedFlag = (flag.startsWith('--') ? flag.slice(2) : flag).replace(RE_DASH, '_')
const blocks = [
allFlags.main,
allFlags.vfs,
allFlags.filter,
allFlags.mount,
...SERVE_TYPES.map((type) => allFlags[type]),
]
for (const block of blocks) {
const found = block?.find((f) => f.Name === normalizedFlag)
if (found) return found
}
return undefined
}
export function sortByName(flag1: { Name: string }, flag2: { Name: string }) { export function sortByName(flag1: { Name: string }, flag2: { Name: string }) {
return flag1.Name.localeCompare(flag2.Name) return flag1.Name.localeCompare(flag2.Name)
} }
@@ -107,7 +134,7 @@ export function groupByCategory(
} }
for (const [k, v] of Object.entries(flags)) { for (const [k, v] of Object.entries(flags)) {
const normalizedKey = k.replace(/-/g, '_') const normalizedKey = k.replace(RE_DASH, '_')
const category = getFlagCategory(k, allFlags) const category = getFlagCategory(k, allFlags)
if (!category) continue if (!category) continue
if (category.category.startsWith('serve.')) { if (category.category.startsWith('serve.')) {
+2 -1
View File
@@ -428,12 +428,13 @@ export function initJobWatcher() {
/** /**
* Forget all watched jobs — jobids do not survive a daemon restart or crash. The dedupe sets * Forget all watched jobs — jobids do not survive a daemon restart or crash. The dedupe sets
* must go too: a fresh daemon issues jobids from 1 again, guaranteed to collide with old ones. * must go too: a fresh daemon issues jobids from 1 again, guaranteed to collide with old ones.
* Dry-run IDs use the same daemon-local namespace and must be cleared with them.
*/ */
export function clearWatchedJobs() { export function clearWatchedJobs() {
statusFailures.clear() statusFailures.clear()
seenJobIds.clear() seenJobIds.clear()
handledJobIds.clear() handledJobIds.clear()
useStore.setState({ watchedJobs: {} }) useStore.setState({ watchedJobs: {}, dryRunJobIds: [] })
} }
function onWatchedJobsChange(watchedJobs: Record<number, WatchedJob>) { function onWatchedJobsChange(watchedJobs: Record<number, WatchedJob>) {
+145 -93
View File
@@ -11,7 +11,6 @@ import { getFsInfo } from '../format'
import { dispatchNotification } from '../notifications' import { dispatchNotification } from '../notifications'
import { restartActiveRclone, runRcloneCli } from './cli' import { restartActiveRclone, runRcloneCli } from './cli'
import rclone, { rcloneAsync } from './client' import rclone, { rcloneAsync } from './client'
import { parseRcloneOptions } from './common'
import { import {
type BisyncArgs, type BisyncArgs,
type CopyArgs, type CopyArgs,
@@ -26,6 +25,8 @@ import {
buildPurgeRequests, buildPurgeRequests,
buildSyncRequests, buildSyncRequests,
serializeOptions, serializeOptions,
toConfigParam,
toFilterParam,
} from './requests' } from './requests'
const RE_BACKSLASH = /\\/g const RE_BACKSLASH = /\\/g
@@ -39,34 +40,10 @@ const RETRY_OPTIONS = {
shouldRetry: ({ error }: { error: unknown }) => !(error instanceof UserCancelledError), shouldRetry: ({ error }: { error: unknown }) => !(error instanceof UserCancelledError),
} }
// Non-zero while a dry run is in flight. The start* functions capture this at submission time // Dry-run state travels with each submission so a preview never changes daemon-global options or
// so a dry-run job is never registered with the watcher — checking it at registration time // suppresses a real job that overlaps it.
// instead would wrongly suppress a real job that overlaps a concurrent dry run. export function startDryRun<T>(operation: (isDryRun: true) => Promise<T>): Promise<T> {
let dryRunDepth = 0 return operation(true)
export async function startDryRun<T>(operation: () => Promise<T>): Promise<T> {
await rclone('/options/set', {
body: {
main: { DryRun: true },
},
})
dryRunDepth++
try {
const result = await operation()
if (typeof result === 'number') {
useStore.setState((state) => ({
dryRunJobIds: [...state.dryRunJobIds, result],
}))
}
return result
} finally {
dryRunDepth--
await rclone('/options/set', {
body: {
main: { DryRun: false },
},
})
}
} }
// Makes a freshly submitted job visible to the main window's job watcher (via the shared // Makes a freshly submitted job visible to the main window's job watcher (via the shared
@@ -89,66 +66,112 @@ function registerWatchedJob(
})) }))
} }
async function hasStat(path: string) { function registerSubmittedJob(
jobid: number,
job: Pick<WatchedJob, 'operation' | 'sources' | 'destination'>,
isDryRun: boolean
) {
if (isDryRun) {
useStore.setState((state) => ({
dryRunJobIds: state.dryRunJobIds.includes(jobid)
? state.dryRunJobIds
: [...state.dryRunJobIds, jobid],
}))
return
}
registerWatchedJob(jobid, job)
}
async function hasStat(
path: string,
options?: {
configParam?: string
remotes?: Record<string, Record<string, FlagValue>>
}
) {
// No try/catch: a transport failure must propagate as the real error instead of being // No try/catch: a transport failure must propagate as the real error instead of being
// masked as "Source does not exist". A genuinely missing path returns a response with no // masked as "Source does not exist". A genuinely missing path returns a response with no
// item, which still yields false. // item, which still yields false.
const { root, filePath } = getFsInfo(path) const { root, filePath, remoteName } = getFsInfo(path)
const remoteOptions = options?.remotes?.[remoteName]
let fs = root === ':local:' ? ':local:/' : root
if (remoteOptions && Object.keys(remoteOptions).length > 0) {
fs = serializeOptions(root.endsWith('/') ? root.slice(0, -1) : root, {
remote: remoteOptions,
})
}
const r = await rclone('/operations/stat', { const r = await rclone('/operations/stat', {
params: { params: {
query: { query: {
fs: root === ':local:' ? ':local:/' : root, fs,
remote: filePath, remote: filePath,
...(options?.configParam ? { _config: options.configParam } : {}),
}, },
}, },
}) })
return !!r?.item return !!r?.item
} }
export async function startCopy(args: CopyArgs) { export async function startCopy(args: CopyArgs, isDryRun = false) {
console.log('[startCopy] starting', { console.log('[startCopy] starting', {
sources: args.sources, sources: args.sources,
destination: args.destination, destination: args.destination,
optionKeys: Object.keys(args.options), optionKeys: Object.keys(args.options),
}) })
const [request] = buildCopyRequests(args)
for (const source of args.sources) { for (const source of args.sources) {
const sourceExists = await hasStat(source) const sourceExists = await hasStat(source, {
configParam: request.body._config,
remotes: args.options.remotes,
})
if (!sourceExists) { if (!sourceExists) {
throw new Error(`Source does not exist, ${source} is missing`) throw new Error(`Source does not exist, ${source} is missing`)
} }
} }
const [request] = buildCopyRequests(args)
console.log('[startCopy] submitting batch', { jobCount: request.body.inputs.length }) console.log('[startCopy] submitting batch', { jobCount: request.body.inputs.length })
return startBatch(request.body.inputs, { return startBatch(
request.body.inputs,
{
operation: 'copy', operation: 'copy',
sources: args.sources, sources: args.sources,
destination: args.destination, destination: args.destination,
}) },
{ isDryRun, configParam: request.body._config }
)
} }
export async function startMove(args: MoveArgs) { export async function startMove(args: MoveArgs, isDryRun = false) {
console.log('[startMove] starting', { console.log('[startMove] starting', {
sources: args.sources, sources: args.sources,
destination: args.destination, destination: args.destination,
optionKeys: Object.keys(args.options), optionKeys: Object.keys(args.options),
}) })
const [request] = buildMoveRequests(args)
for (const source of args.sources) { for (const source of args.sources) {
const sourceExists = await hasStat(source) const sourceExists = await hasStat(source, {
configParam: request.body._config,
remotes: args.options.remotes,
})
if (!sourceExists) { if (!sourceExists) {
throw new Error(`Source does not exist, ${source} is missing`) throw new Error(`Source does not exist, ${source} is missing`)
} }
} }
const [request] = buildMoveRequests(args)
console.log('[startMove] submitting batch', { jobCount: request.body.inputs.length }) console.log('[startMove] submitting batch', { jobCount: request.body.inputs.length })
return startBatch(request.body.inputs, { return startBatch(
request.body.inputs,
{
operation: 'move', operation: 'move',
sources: args.sources, sources: args.sources,
destination: args.destination, destination: args.destination,
}) },
{ isDryRun, configParam: request.body._config }
)
} }
/* JOBS */ /* JOBS */
@@ -411,14 +434,14 @@ async function startMountInner({
mountOptions.volname = `${sourcePath}-${Math.random().toString(36).substring(2, 3).toUpperCase()}` mountOptions.volname = `${sourcePath}-${Math.random().toString(36).substring(2, 3).toUpperCase()}`
} }
// Only genuinely global flags can ride the connection string (`global.` keys) — mount and // `_filter` is the correct RC channel for mount filters (rclone's own RC docs say so), so we
// VFS options go through the dedicated mountOpt/vfsOpt params below instead. Filter options // send it as a proper param rather than smuggling it into the fs string. Note: current rclone
// have no per-mount channel at all (rclone builds the mount's VFS on a background context, // ignores it for mounts — mountRc has the filter on its ctx, but Mount() builds the VFS with
// so per-call filters never reach it) and are kept here as a no-op until upstream fixes it. // context.Background() and discards it (only the *global* filter, set via CLI --exclude, reaches
const mergedOptions = { // a mount). Rclone still parses this value, but it only affects the mount if upstream threads
...(options.config || {}), // that request context into the VFS.
...(options.filter || {}), const configParam = toConfigParam(options.config)
} const filterParam = toFilterParam(options.filter)
const vfsOptions = { ...(options.vfs || {}) } const vfsOptions = { ...(options.vfs || {}) }
@@ -427,15 +450,24 @@ async function startMountInner({
// keys pass through untouched — rclone ignores unrecognized fields. // keys pass through untouched — rclone ignores unrecognized fields.
const toStructOptions = ( const toStructOptions = (
flags: Record<string, FlagValue>, flags: Record<string, FlagValue>,
infos: { Name: string; FieldName: string }[] | undefined infos: { Name: string; FieldName: string; Type: string }[] | undefined
) => { ) => {
const fieldNames = new Map((infos || []).map((info) => [info.Name, info.FieldName])) const optionsByName = new Map((infos || []).map((info) => [info.Name, info]))
return JSON.stringify( return JSON.stringify(
Object.fromEntries( Object.fromEntries(
Object.entries(flags).map(([key, value]) => [ Object.entries(flags).map(([key, value]) => {
fieldNames.get(key.replace(RE_DASH, '_')) || key, const normalized = (key.startsWith('--') ? key.slice(2) : key).replace(
value, RE_DASH,
]) '_'
)
const option = optionsByName.get(normalized)
return [
option?.FieldName || key,
option?.Type === 'stringArray' && !Array.isArray(value) && value !== null
? [String(value)]
: value,
]
})
) )
) )
} }
@@ -471,12 +503,13 @@ async function startMountInner({
params: { params: {
query: { query: {
fs: serializeOptions(srcFullDirPath, { fs: serializeOptions(srcFullDirPath, {
global: mergedOptions,
remote: srcOptions, remote: srcOptions,
}), }),
mountPoint: '*', mountPoint: '*',
// No mountType — Windows uses rclone's default resolution (cmount/WinFsp) // No mountType — Windows uses rclone's default resolution (cmount/WinFsp)
...structOptions, ...structOptions,
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
}, },
}, },
}), }),
@@ -592,7 +625,6 @@ async function startMountInner({
params: { params: {
query: { query: {
fs: serializeOptions(srcFullDirPath, { fs: serializeOptions(srcFullDirPath, {
global: mergedOptions,
remote: srcOptions, remote: srcOptions,
}), }),
mountPoint: (() => { mountPoint: (() => {
@@ -610,6 +642,8 @@ async function startMountInner({
})(), })(),
...(currentPlatform === 'macos' ? { mountType: 'nfsmount' } : {}), ...(currentPlatform === 'macos' ? { mountType: 'nfsmount' } : {}),
...structOptions, ...structOptions,
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
}, },
}, },
}), }),
@@ -622,14 +656,13 @@ async function startMountInner({
async function submitAsyncQuery( async function submitAsyncQuery(
endpoint: '/sync/sync' | '/sync/bisync', endpoint: '/sync/sync' | '/sync/bisync',
body: Record<string, any>, body: Record<string, any>,
watch: Pick<WatchedJob, 'operation' | 'sources' | 'destination'> watch: Pick<WatchedJob, 'operation' | 'sources' | 'destination'>,
isDryRun = false
) { ) {
// The builders emit body-form requests for the headless runner; the live client submits the // The builders emit body-form requests for the headless runner; the live client submits the
// same parameters as a query (rclone's RC treats them identically). // same parameters as a query (rclone's RC treats them identically).
const { _async, ...query } = body const { _async, ...query } = body
const submittedDuringDryRun = dryRunDepth > 0
const r = await pRetry( const r = await pRetry(
async () => async () =>
await rcloneAsync(endpoint, { await rcloneAsync(endpoint, {
@@ -645,9 +678,7 @@ async function submitAsyncQuery(
throw new Error('Failed to start operation') throw new Error('Failed to start operation')
} }
if (!submittedDuringDryRun) { registerSubmittedJob(r.jobid, watch, isDryRun)
registerWatchedJob(r.jobid, watch)
}
await new Promise((resolve) => setTimeout(resolve, 1000)) await new Promise((resolve) => setTimeout(resolve, 1000))
@@ -679,12 +710,15 @@ async function submitAsyncQuery(
} }
export async function startBisync(args: BisyncArgs) { export async function startBisync(args: BisyncArgs) {
const sourceExists = await hasStat(args.source) const [request] = buildBisyncRequests(args)
const sourceExists = await hasStat(args.source, {
configParam: request.body._config,
remotes: args.options.remotes,
})
if (!sourceExists) { if (!sourceExists) {
throw new Error(`Source does not exist, ${args.source} is missing`) throw new Error(`Source does not exist, ${args.source} is missing`)
} }
const [request] = buildBisyncRequests(args)
return submitAsyncQuery('/sync/bisync', request.body, { return submitAsyncQuery('/sync/bisync', request.body, {
operation: 'bisync', operation: 'bisync',
sources: [args.source], sources: [args.source],
@@ -692,42 +726,64 @@ export async function startBisync(args: BisyncArgs) {
}) })
} }
export async function startSync(args: SyncArgs) { export async function startSync(args: SyncArgs, isDryRun = false) {
const sourceExists = await hasStat(args.source) const [request] = buildSyncRequests(args)
const sourceExists = await hasStat(args.source, {
configParam: request.body._config,
remotes: args.options.remotes,
})
if (!sourceExists) { if (!sourceExists) {
throw new Error(`Source does not exist, ${args.source} is missing`) throw new Error(`Source does not exist, ${args.source} is missing`)
} }
const [request] = buildSyncRequests(args) return submitAsyncQuery(
return submitAsyncQuery('/sync/sync', request.body, { '/sync/sync',
request.body,
{
operation: 'sync', operation: 'sync',
sources: [args.source], sources: [args.source],
destination: args.destination, destination: args.destination,
}) },
isDryRun
)
} }
export async function startDelete({ sources, options }: DeleteArgs) { export async function startDelete({ sources, options }: DeleteArgs, isDryRun = false) {
const [request] = buildDeleteRequests({ sources, options })
for (const source of sources) { for (const source of sources) {
const sourceExists = await hasStat(source) const sourceExists = await hasStat(source, {
configParam: request.body._config,
remotes: options.remotes,
})
if (!sourceExists) { if (!sourceExists) {
throw new Error(`Source does not exist, ${source} is missing`) throw new Error(`Source does not exist, ${source} is missing`)
} }
} }
const [request] = buildDeleteRequests({ sources, options }) return startBatch(
return startBatch(request.body.inputs, { operation: 'delete', sources }) request.body.inputs,
{ operation: 'delete', sources },
{ isDryRun, configParam: request.body._config }
)
} }
export async function startPurge({ sources, options }: PurgeArgs) { export async function startPurge({ sources, options }: PurgeArgs) {
const [request] = buildPurgeRequests({ sources, options })
for (const source of sources) { for (const source of sources) {
const sourceExists = await hasStat(source) const sourceExists = await hasStat(source, {
configParam: request.body._config,
remotes: options.remotes,
})
if (!sourceExists) { if (!sourceExists) {
throw new Error(`Source does not exist, ${source} is missing`) throw new Error(`Source does not exist, ${source} is missing`)
} }
} }
const [request] = buildPurgeRequests({ sources, options }) return startBatch(
return startBatch(request.body.inputs, { operation: 'purge', sources }) request.body.inputs,
{ operation: 'purge', sources },
{ configParam: request.body._config }
)
} }
export async function startServe({ export async function startServe({
@@ -750,14 +806,8 @@ export async function startServe({
type, type,
fs, fs,
addr, addr,
_filter: _filter: toFilterParam(_filter),
_filter && Object.keys(_filter).length > 0 _config: toConfigParam(_config),
? JSON.stringify(parseRcloneOptions(_filter))
: undefined,
_config:
_config && Object.keys(_config).length > 0
? JSON.stringify(parseRcloneOptions(_config))
: undefined,
...(props && Object.keys(props).length > 0 ...(props && Object.keys(props).length > 0
? Object.fromEntries( ? Object.fromEntries(
Object.entries(props).map(([key, value]) => [ Object.entries(props).map(([key, value]) => [
@@ -773,7 +823,8 @@ export async function startServe({
export async function startBatch( export async function startBatch(
inputs: ({ _path: string } & Record<string, any>)[], inputs: ({ _path: string } & Record<string, any>)[],
meta?: Partial<Pick<WatchedJob, 'operation' | 'sources' | 'destination'>> meta?: Partial<Pick<WatchedJob, 'operation' | 'sources' | 'destination'>>,
options?: { isDryRun?: boolean; configParam?: string }
) { ) {
console.log('[startBatch] starting batch operation', { console.log('[startBatch] starting batch operation', {
inputCount: inputs.length, inputCount: inputs.length,
@@ -781,13 +832,12 @@ export async function startBatch(
}) })
console.log('[startBatch] inputs', JSON.stringify(inputs, null, 2)) console.log('[startBatch] inputs', JSON.stringify(inputs, null, 2))
const submittedDuringDryRun = dryRunDepth > 0
const r = await pRetry( const r = await pRetry(
async () => async () =>
await rclone('/job/batch', { await rclone('/job/batch', {
body: { body: {
inputs, inputs,
...(options?.configParam ? { _config: options.configParam } : {}),
_async: true, _async: true,
}, },
}), }),
@@ -796,13 +846,15 @@ export async function startBatch(
console.log('[startBatch] job created', { jobid: r.jobid }) console.log('[startBatch] job created', { jobid: r.jobid })
if (!submittedDuringDryRun) { registerSubmittedJob(
registerWatchedJob(r.jobid, { r.jobid,
{
operation: meta?.operation ?? 'batch', operation: meta?.operation ?? 'batch',
sources: meta?.sources, sources: meta?.sources,
destination: meta?.destination, destination: meta?.destination,
}) },
} options?.isDryRun ?? false
)
await new Promise((resolve) => setTimeout(resolve, 1000)) await new Promise((resolve) => setTimeout(resolve, 1000))
-7
View File
@@ -2,7 +2,6 @@ import { invoke } from '@tauri-apps/api/core'
import { appLocalDataDir, sep } from '@tauri-apps/api/path' import { appLocalDataDir, sep } from '@tauri-apps/api/path'
import { exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs' import { exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { useHostStore } from '../../store/host' import { useHostStore } from '../../store/host'
import type { FlagValue } from '../../types/rclone'
import { getConfigParentFolder } from '../format' import { getConfigParentFolder } from '../format'
import rclone from './client' import rclone from './client'
import { DOUBLE_BACKSLASH_REGEX } from './constants' import { DOUBLE_BACKSLASH_REGEX } from './constants'
@@ -159,12 +158,6 @@ export async function resolveDefaultConfigPath(): Promise<string> {
return appPrivate return appPrivate
} }
export function parseRcloneOptions(options: Record<string, FlagValue>) {
console.log('[parseRcloneOptions]', options)
return options
}
export function compareVersions(version1: string, version2: string): number { export function compareVersions(version1: string, version2: string): number {
const parseVersion = (version: string) => { const parseVersion = (version: string) => {
// Strip a leading 'v' and any pre-release suffix (e.g. "1.74.0-beta.x") before comparing; // Strip a leading 'v' and any pre-release suffix (e.g. "1.74.0-beta.x") before comparing;
+2 -2
View File
@@ -12,8 +12,8 @@ export const RCLONE_CONFIG_DEFAULTS = {
'checkers': 16, 'checkers': 16,
}, },
vfs: { vfs: {
'chunk_size': '4M', 'vfs_read_chunk_size': '4M',
'chunk_streams': 16, 'vfs_read_chunk_streams': 16,
}, },
} as const } as const
+251 -51
View File
@@ -7,6 +7,157 @@ import { getFsInfo } from '../format'
// save/run always happen on the same machine. // save/run always happen on the same machine.
const RE_WINDOWS_DRIVE_ROOT = /^:local:[a-zA-Z]:\/$/ const RE_WINDOWS_DRIVE_ROOT = /^:local:[a-zA-Z]:\/$/
const RE_DASH = /-/g
function normalizeOptionName(name: string) {
return (name.startsWith('--') ? name.slice(2) : name).replace(RE_DASH, '_')
}
function normalizeArrayValue(value: FlagValue): FlagValue {
return Array.isArray(value) || value === null ? value : [String(value)]
}
// A blank (empty or whitespace-only) string means the user cleared the field, i.e. "unset". It
// must be dropped before building _config/_filter: rclone reshapes those params all-or-nothing, so
// a single blank in a typed field (Duration/SizeSuffix/int/…) rejects the ENTIRE param and fails
// the whole operation. Omitting the key is exactly what "unset" should mean.
function isBlankString(value: FlagValue): boolean {
return typeof value === 'string' && value.trim() === ''
}
const FILTER_FIELD_NAMES: Record<string, string> = {
filter: 'FilterRule',
filter_from: 'FilterFrom',
exclude: 'ExcludeRule',
exclude_from: 'ExcludeFrom',
include: 'IncludeRule',
include_from: 'IncludeFrom',
exclude_if_present: 'ExcludeFile',
files_from: 'FilesFrom',
files_from_raw: 'FilesFromRaw',
delete_excluded: 'DeleteExcluded',
min_age: 'MinAge',
max_age: 'MaxAge',
min_size: 'MinSize',
max_size: 'MaxSize',
ignore_case: 'IgnoreCase',
hash_filter: 'HashFilter',
}
const METADATA_FILTER_FIELD_NAMES: Record<string, string> = {
metadata_filter: 'FilterRule',
metadata_filter_from: 'FilterFrom',
metadata_exclude: 'ExcludeRule',
metadata_exclude_from: 'ExcludeFrom',
metadata_include: 'IncludeRule',
metadata_include_from: 'IncludeFrom',
}
const FILTER_ARRAY_OPTIONS = new Set([
'filter',
'filter_from',
'exclude',
'exclude_from',
'include',
'include_from',
'exclude_if_present',
'files_from',
'files_from_raw',
...Object.keys(METADATA_FILTER_FIELD_NAMES),
])
export function toFilterParam(filter: Record<string, FlagValue> | undefined): string | undefined {
if (!filter || Object.keys(filter).length === 0) {
return undefined
}
const result: Record<string, FlagValue | Record<string, FlagValue>> = {}
const metadataRules: Record<string, FlagValue> = {}
for (const [key, value] of Object.entries(filter)) {
if (isBlankString(value)) {
continue
}
const normalized = normalizeOptionName(key)
let normalizedValue = FILTER_ARRAY_OPTIONS.has(normalized)
? normalizeArrayValue(value)
: value
if (
(normalized === 'min_age' || normalized === 'max_age') &&
typeof normalizedValue === 'number' &&
Number.isInteger(normalizedValue) &&
!Number.isSafeInteger(normalizedValue)
) {
normalizedValue = 'off'
}
const metadataFieldName = METADATA_FILTER_FIELD_NAMES[normalized]
if (metadataFieldName) {
metadataRules[metadataFieldName] = normalizedValue
} else {
result[FILTER_FIELD_NAMES[normalized] ?? key] = normalizedValue
}
}
if (Object.keys(metadataRules).length > 0) {
result.MetaRules = metadataRules
}
if (Object.keys(result).length === 0) {
return undefined
}
return JSON.stringify(result)
}
const CONFIG_FIELD_NAMES: Record<string, string> = {
contimeout: 'ConnectTimeout',
no_check_certificate: 'InsecureSkipVerify',
retries_sleep: 'RetriesInterval',
update: 'UpdateOlder',
no_gzip_encoding: 'NoGzip',
fast_list: 'UseListR',
stats_unit: 'DataRateUnit',
use_cookies: 'Cookie',
color: 'TerminalColorMode',
}
const CONFIG_ARRAY_OPTIONS = new Set(['compare_dest', 'copy_dest', 'ca_cert', 'name_transform'])
const CONFIG_SPACE_SEPARATED_OPTIONS = new Set(['password_command', 'metadata_mapper'])
export function toConfigParam(config: Record<string, FlagValue> | undefined): string | undefined {
if (!config) {
return undefined
}
const entries = Object.entries(config).filter(([, value]) => !isBlankString(value))
if (entries.length === 0) {
return undefined
}
return JSON.stringify(
Object.fromEntries(
entries.map(([key, value]) => {
const normalized = normalizeOptionName(key)
const fieldName =
CONFIG_FIELD_NAMES[normalized] ??
normalized
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
let normalizedValue = value
if (CONFIG_ARRAY_OPTIONS.has(normalized)) {
normalizedValue = normalizeArrayValue(value)
} else if (
CONFIG_SPACE_SEPARATED_OPTIONS.has(normalized) &&
!Array.isArray(value) &&
value !== null
) {
normalizedValue = String(value).trim().split(/\s+/).filter(Boolean)
}
return [fieldName, normalizedValue]
})
)
)
}
export interface CopyArgs { export interface CopyArgs {
sources: string[] sources: string[]
@@ -80,13 +231,12 @@ export interface RcRequest {
body: Record<string, any> body: Record<string, any>
} }
// Encodes a path as an rclone connection string with inlined per-remote and global options: // Encodes a path as an rclone connection string with inlined per-remote options:
// "<remoteName>,<k>=\"v\",global.<gk>=\"gv\":<path>". // "<remoteName>,<k>=\"v\":<path>".
export function serializeOptions( export function serializeOptions(
remotePath: string, remotePath: string,
options: { options: {
remote?: Record<string, FlagValue> remote?: Record<string, FlagValue>
global?: Record<string, FlagValue>
} }
) { ) {
console.log('[serializeRemoteOptions] ', remotePath) console.log('[serializeRemoteOptions] ', remotePath)
@@ -101,10 +251,7 @@ export function serializeOptions(
let serialized = `${remoteName}` let serialized = `${remoteName}`
if ( if (Object.keys(options.remote || {}).length > 0) {
Object.keys(options.remote || {}).length > 0 ||
Object.keys(options.global || {}).length > 0
) {
serialized += ',' serialized += ','
} }
@@ -114,12 +261,6 @@ export function serializeOptions(
.join(',') .join(',')
} }
if (options.global && Object.keys(options.global).length > 0) {
serialized += Object.entries(options.global)
.map(([key, value]) => `global.${key}="${value}"`)
.join(',')
}
serialized += ':' serialized += ':'
if (remoteName === ':local') { if (remoteName === ':local') {
@@ -149,12 +290,32 @@ export function serializeOptions(
return serialized return serialized
} }
// Names of the filter options the user actually set — blank (cleared) values are treated as unset
// here exactly as toFilterParam drops them, so a cleared field never trips these guards.
function activeFilterNames(filter?: Record<string, FlagValue>): Set<string> {
return new Set(
Object.entries(filter || {})
.filter(([, value]) => !isBlankString(value))
.map(([key]) => normalizeOptionName(key))
)
}
function assertIncludeRules(sources: string[], filter?: Record<string, FlagValue>) { function assertIncludeRules(sources: string[], filter?: Record<string, FlagValue>) {
if (sources.length > 1 && filter && ('include' in filter || 'include_from' in filter)) { const filterNames = activeFilterNames(filter)
if (sources.length > 1 && (filterNames.has('include') || filterNames.has('include_from'))) {
throw new Error('Include rules are not supported with multiple sources') throw new Error('Include rules are not supported with multiple sources')
} }
} }
function assertFolderFilters(sources: string[], filter?: Record<string, FlagValue>) {
if (
activeFilterNames(filter).size > 0 &&
sources.some((path) => !path.endsWith('/') && !path.endsWith('\\'))
) {
throw new Error('Filters are only supported when every selected source is a folder')
}
}
function remoteOptionsFor( function remoteOptionsFor(
remotes: Record<string, Record<string, FlagValue>> | undefined, remotes: Record<string, Record<string, FlagValue>> | undefined,
remoteName: string | undefined remoteName: string | undefined
@@ -167,7 +328,8 @@ function remoteOptionsFor(
function buildTransferInputs( function buildTransferInputs(
args: CopyArgs | MoveArgs, args: CopyArgs | MoveArgs,
paths: { folder: string; file: string }, paths: { folder: string; file: string },
mergedOptions: Record<string, FlagValue> configParam: string | undefined,
filterParam: string | undefined
): BatchInput[] { ): BatchInput[] {
const { sources, destination, options } = args const { sources, destination, options } = args
@@ -208,12 +370,13 @@ function buildTransferInputs(
_path: paths.folder, _path: paths.folder,
srcFs: serializeOptions(srcFullDirPath, { srcFs: serializeOptions(srcFullDirPath, {
remote: srcOptions, remote: srcOptions,
global: mergedOptions,
}), }),
dstFs: serializeOptions(`${dstFullDirPath}${srcName}`, { dstFs: serializeOptions(`${dstFullDirPath}${srcName}`, {
remote: dstOptions, remote: dstOptions,
}), }),
createEmptySrcDirs: true, createEmptySrcDirs: true,
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
}) })
continue continue
} }
@@ -227,8 +390,8 @@ function buildTransferInputs(
_path: paths.file, _path: paths.file,
srcFs: serializeOptions(srcRoot, { srcFs: serializeOptions(srcRoot, {
remote: srcOptions, remote: srcOptions,
global: mergedOptions,
}), }),
...(configParam ? { _config: configParam } : {}),
srcRemote: srcFilePath, srcRemote: srcFilePath,
dstFs: serializeOptions(dstRoot, { dstFs: serializeOptions(dstRoot, {
remote: dstOptions, remote: dstOptions,
@@ -242,42 +405,61 @@ function buildTransferInputs(
export function buildCopyRequests(args: CopyArgs): RcRequest[] { export function buildCopyRequests(args: CopyArgs): RcRequest[] {
assertIncludeRules(args.sources, args.options.filter) assertIncludeRules(args.sources, args.options.filter)
const mergedOptions = { assertFolderFilters(args.sources, args.options.filter)
...(args.options.config || {}), const configParam = toConfigParam({
...(args.options.copy || {}), ...(args.options.copy || {}),
...(args.options.filter || {}), ...(args.options.config || {}),
} })
const filterParam = toFilterParam(args.options.filter)
const inputs = buildTransferInputs( const inputs = buildTransferInputs(
args, args,
{ folder: 'sync/copy', file: 'operations/copyfile' }, { folder: 'sync/copy', file: 'operations/copyfile' },
mergedOptions configParam,
filterParam
) )
return [{ endpoint: '/job/batch', body: { inputs, _async: true } }] return [
{
endpoint: '/job/batch',
body: {
inputs,
...(configParam ? { _config: configParam } : {}),
_async: true,
},
},
]
} }
export function buildMoveRequests(args: MoveArgs): RcRequest[] { export function buildMoveRequests(args: MoveArgs): RcRequest[] {
assertIncludeRules(args.sources, args.options.filter) assertIncludeRules(args.sources, args.options.filter)
const mergedOptions = { assertFolderFilters(args.sources, args.options.filter)
...(args.options.config || {}), const configParam = toConfigParam({
...(args.options.move || {}), ...(args.options.move || {}),
...(args.options.filter || {}), ...(args.options.config || {}),
} })
const filterParam = toFilterParam(args.options.filter)
const inputs = buildTransferInputs( const inputs = buildTransferInputs(
args, args,
{ folder: 'sync/move', file: 'operations/movefile' }, { folder: 'sync/move', file: 'operations/movefile' },
mergedOptions configParam,
filterParam
) )
return [{ endpoint: '/job/batch', body: { inputs, _async: true } }] return [
{
endpoint: '/job/batch',
body: {
inputs,
...(configParam ? { _config: configParam } : {}),
_async: true,
},
},
]
} }
export function buildSyncRequests(args: SyncArgs): RcRequest[] { export function buildSyncRequests(args: SyncArgs): RcRequest[] {
const { source, destination, options } = args const { source, destination, options } = args
const mergedOptions = { const configParam = toConfigParam({ ...(options.sync || {}), ...(options.config || {}) })
...(options.config || {}), const filterParam = toFilterParam(options.filter)
...(options.sync || {}),
...(options.filter || {}),
}
const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source) const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source)
const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination) const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination)
@@ -287,13 +469,14 @@ export function buildSyncRequests(args: SyncArgs): RcRequest[] {
endpoint: '/sync/sync', endpoint: '/sync/sync',
body: { body: {
srcFs: serializeOptions(srcFullDirPath, { srcFs: serializeOptions(srcFullDirPath, {
global: mergedOptions,
remote: remoteOptionsFor(options.remotes, srcRemoteName), remote: remoteOptionsFor(options.remotes, srcRemoteName),
}), }),
dstFs: serializeOptions(dstFullDirPath, { dstFs: serializeOptions(dstFullDirPath, {
remote: remoteOptionsFor(options.remotes, dstRemoteName), remote: remoteOptionsFor(options.remotes, dstRemoteName),
}), }),
createEmptySrcDirs: true, createEmptySrcDirs: true,
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
_async: true, _async: true,
}, },
}, },
@@ -303,11 +486,8 @@ export function buildSyncRequests(args: SyncArgs): RcRequest[] {
export function buildBisyncRequests(args: BisyncArgs): RcRequest[] { export function buildBisyncRequests(args: BisyncArgs): RcRequest[] {
const { source, destination, options } = args const { source, destination, options } = args
const mergedOptions = { const configParam = toConfigParam({ ...(options.bisync || {}), ...(options.config || {}) })
...(options.config || {}), const filterParam = toFilterParam(options.filter)
...(options.bisync || {}),
...(options.filter || {}),
}
const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source) const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source)
const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination) const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination)
@@ -317,12 +497,13 @@ export function buildBisyncRequests(args: BisyncArgs): RcRequest[] {
endpoint: '/sync/bisync', endpoint: '/sync/bisync',
body: { body: {
path1: serializeOptions(srcFullDirPath, { path1: serializeOptions(srcFullDirPath, {
global: mergedOptions,
remote: remoteOptionsFor(options.remotes, srcRemoteName), remote: remoteOptionsFor(options.remotes, srcRemoteName),
}), }),
path2: serializeOptions(dstFullDirPath, { path2: serializeOptions(dstFullDirPath, {
remote: remoteOptionsFor(options.remotes, dstRemoteName), remote: remoteOptionsFor(options.remotes, dstRemoteName),
}), }),
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
...(options.outer && Object.keys(options.outer).length > 0 ...(options.outer && Object.keys(options.outer).length > 0
? Object.fromEntries( ? Object.fromEntries(
Object.entries(options.outer).map(([key, value]) => [ Object.entries(options.outer).map(([key, value]) => [
@@ -341,11 +522,10 @@ export function buildDeleteRequests(args: DeleteArgs): RcRequest[] {
const { sources, options } = args const { sources, options } = args
assertIncludeRules(sources, options.filter) assertIncludeRules(sources, options.filter)
assertFolderFilters(sources, options.filter)
const mergedOptions = { const configParam = toConfigParam(options.config || {})
...(options.config || {}), const filterParam = toFilterParam(options.filter)
...(options.filter || {}),
}
const inputs: BatchInput[] = [] const inputs: BatchInput[] = []
const handledSourcePaths: Record<string, true> = {} const handledSourcePaths: Record<string, true> = {}
@@ -372,9 +552,10 @@ export function buildDeleteRequests(args: DeleteArgs): RcRequest[] {
inputs.push({ inputs.push({
_path: 'operations/delete', _path: 'operations/delete',
fs: serializeOptions(source, { fs: serializeOptions(source, {
global: mergedOptions,
remote: srcOptions, remote: srcOptions,
}), }),
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
}) })
continue continue
} }
@@ -387,19 +568,29 @@ export function buildDeleteRequests(args: DeleteArgs): RcRequest[] {
inputs.push({ inputs.push({
_path: 'operations/deletefile', _path: 'operations/deletefile',
fs: serializeOptions(srcRoot, { fs: serializeOptions(srcRoot, {
global: mergedOptions,
remote: srcOptions, remote: srcOptions,
}), }),
...(configParam ? { _config: configParam } : {}),
remote: srcFilePath, remote: srcFilePath,
}) })
} }
return [{ endpoint: '/job/batch', body: { inputs, _async: true } }] return [
{
endpoint: '/job/batch',
body: {
inputs,
...(configParam ? { _config: configParam } : {}),
_async: true,
},
},
]
} }
export function buildPurgeRequests(args: PurgeArgs): RcRequest[] { export function buildPurgeRequests(args: PurgeArgs): RcRequest[] {
const { sources, options } = args const { sources, options } = args
const configParam = toConfigParam(options.config || {})
const inputs: BatchInput[] = [] const inputs: BatchInput[] = []
const handledSourcePaths: Record<string, true> = {} const handledSourcePaths: Record<string, true> = {}
@@ -413,7 +604,7 @@ export function buildPurgeRequests(args: PurgeArgs): RcRequest[] {
const { const {
root: srcRoot, root: srcRoot,
dirPath: srcDirPath, filePath: srcDirPath,
type: srcType, type: srcType,
remoteName: srcRemoteName, remoteName: srcRemoteName,
} = getFsInfo(source) } = getFsInfo(source)
@@ -425,14 +616,23 @@ export function buildPurgeRequests(args: PurgeArgs): RcRequest[] {
inputs.push({ inputs.push({
_path: 'operations/purge', _path: 'operations/purge',
fs: serializeOptions(srcRoot, { fs: serializeOptions(srcRoot, {
global: options.config,
remote: remoteOptionsFor(options.remotes, srcRemoteName), remote: remoteOptionsFor(options.remotes, srcRemoteName),
}), }),
...(configParam ? { _config: configParam } : {}),
remote: srcDirPath, remote: srcDirPath,
}) })
} }
return [{ endpoint: '/job/batch', body: { inputs, _async: true } }] return [
{
endpoint: '/job/batch',
body: {
inputs,
...(configParam ? { _config: configParam } : {}),
_async: true,
},
},
]
} }
/** Discriminated operation/args pair — ScheduledTask satisfies this. */ /** Discriminated operation/args pair — ScheduledTask satisfies this. */
+3 -1
View File
@@ -138,7 +138,9 @@ export default function JobDetailsDrawer({
input, input,
}: { key: string; input: { _path: string } & Record<string, any> }) => { }: { key: string; input: { _path: string } & Record<string, any> }) => {
setRetryStatus((prev) => new Map(prev).set(key, { status: 'pending' })) setRetryStatus((prev) => new Map(prev).set(key, { status: 'pending' }))
const jobId = await startBatch([input]) const jobId = await startBatch([input], undefined, {
configParam: typeof input._config === 'string' ? input._config : undefined,
})
return { key, jobId } return { key, jobId }
}, },
onSuccess: ({ key, jobId }) => { onSuccess: ({ key, jobId }) => {
+6 -1
View File
@@ -1083,10 +1083,15 @@ export default function OptionsSection({
globalOptions[ globalOptions[
option.FieldName as keyof typeof globalOptions option.FieldName as keyof typeof globalOptions
] ]
const isUnsafeInteger =
typeof defaultGlobalValue === 'number' &&
Number.isInteger(defaultGlobalValue) &&
!Number.isSafeInteger(defaultGlobalValue)
if ( if (
defaultGlobalValue !== null && defaultGlobalValue !== null &&
defaultGlobalValue !== undefined defaultGlobalValue !== undefined &&
!isUnsafeInteger
) { ) {
value = toOptionValue(defaultGlobalValue, option.Type) value = toOptionValue(defaultGlobalValue, option.Type)
} else { } else {
+2 -16
View File
@@ -96,14 +96,7 @@ export default function RemoteOptionsSection({
!IGNORED_OPTIONS.includes(o.Name) && !IGNORED_OPTIONS.includes(o.Name) &&
!!o.Help !!o.Help
) )
.map((o) => { .map((o) => ({ ...o, FieldName: o.Name }))
const newName = `s3_${o.Name}`
return {
...o,
Name: newName,
FieldName: newName,
}
})
.filter(Boolean) .filter(Boolean)
console.log('[RemoteOptionsSection] providerOptions', providerOptions) console.log('[RemoteOptionsSection] providerOptions', providerOptions)
return { return {
@@ -121,14 +114,7 @@ export default function RemoteOptionsSection({
options: [ options: [
...(backends.find((b) => b.Name === config.type)?.Options || []) ...(backends.find((b) => b.Name === config.type)?.Options || [])
.filter((o) => !IGNORED_OPTIONS.includes(o.Name) && !!o.Help) .filter((o) => !IGNORED_OPTIONS.includes(o.Name) && !!o.Help)
.map((o) => { .map((o) => ({ ...o, FieldName: o.Name }))
const newName = `${config.type}_${o.Name}`
return {
...o,
Name: newName,
FieldName: newName,
}
})
.filter(Boolean), .filter(Boolean),
], ],
} }
+46 -11
View File
@@ -34,6 +34,7 @@ import { useDebounce } from 'use-debounce'
import { formatErrorMessage } from '../../lib/errors' import { formatErrorMessage } from '../../lib/errors'
import { import {
FLAG_CATEGORIES, FLAG_CATEGORIES,
findFlagOption,
getJsonKeyCount, getJsonKeyCount,
getOptionsSubtitle, getOptionsSubtitle,
groupByCategory, groupByCategory,
@@ -45,6 +46,21 @@ import type { BackendOption, FlagValue } from '../../types/rclone'
import type { Template } from '../../types/template' import type { Template } from '../../types/template'
import OptionsSection from './OptionsSection' import OptionsSection from './OptionsSection'
// Strips one layer of matched surrounding quotes from an imported flag value: a pasted shell-style
// `--filter "+ *.jpg"` reaches the parser as `"+ *.jpg"`, which would otherwise become an invalid
// filter rule. Unmatched or absent quotes pass through unchanged.
function stripQuotes(value: string): string {
const first = value[0]
if (
value.length >= 2 &&
(first === '"' || first === "'") &&
value[value.length - 1] === first
) {
return value.slice(1, -1)
}
return value
}
export default function TemplateAddDrawer({ export default function TemplateAddDrawer({
isOpen, isOpen,
onClose, onClose,
@@ -175,19 +191,38 @@ export default function TemplateAddDrawer({
const flagStrings = flagString.split('--').filter(Boolean) const flagStrings = flagString.split('--').filter(Boolean)
for (const flagString of flagStrings) { for (const flagString of flagStrings) {
const [flag, value] = flagString.split(' ') const [flagToken, ...valueParts] = flagString.trim().split(/\s+/)
const equalsIndex = flagToken.indexOf('=')
const flag = equalsIndex === -1 ? flagToken : flagToken.slice(0, equalsIndex)
const normalizedFlag = flag.replace(/-/g, '_')
let value = equalsIndex === -1 ? undefined : flagToken.slice(equalsIndex + 1)
if (value === undefined && valueParts.length > 0) {
value = valueParts.join(' ')
}
if (value !== undefined) {
value = stripQuotes(value)
}
const flagInfo = findFlagOption(normalizedFlag, allFlags ?? {})
let v: FlagValue = value ? value.trim() : true let parsedValue: FlagValue = value ?? true
if (v === 'true') v = true if (flagInfo?.Type === 'bool') {
if (v === 'false') v = false parsedValue = value !== 'false'
if (v === 'null') v = null } else if (flagInfo?.Type === 'Tristate') {
parsedValue = value === 'null' ? null : value !== 'false'
} else if (flagInfo?.Type === 'stringArray') {
parsedValue = value === undefined ? [] : [value]
} else if (flagInfo?.Type === 'SpaceSepList') {
parsedValue = value?.split(/\s+/).filter(Boolean) ?? []
} else if (flagInfo?.Type && /^(u?int|float)/i.test(flagInfo.Type) && value) {
const numberValue = Number(value)
parsedValue = Number.isNaN(numberValue) ? value : numberValue
}
const parsedNumber = Number(v) const previousValue = flagGroups[normalizedFlag]
if (!isNaN(parsedNumber)) v = parsedNumber flagGroups[normalizedFlag] =
Array.isArray(previousValue) && Array.isArray(parsedValue)
if (value?.includes(',')) v = value.split(',') ? [...previousValue, ...parsedValue]
: parsedValue
flagGroups[flag.trim()] = v
} }
console.log('flag groups', JSON.stringify(flagGroups, null, 2)) console.log('flag groups', JSON.stringify(flagGroups, null, 2))
+6 -3
View File
@@ -142,8 +142,9 @@ export default function Copy() {
if (!sources || sources.length === 0 || !dest) { if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path') throw new Error('Please select both a source and destination path')
} }
return startDryRun(() => return startDryRun((isDryRun) =>
startCopy({ startCopy(
{
sources, sources,
destination: dest, destination: dest,
options: { options: {
@@ -152,7 +153,9 @@ export default function Copy() {
filter: filterGroup.options, filter: filterGroup.options,
remotes: remotesGroup.options, remotes: remotesGroup.options,
}, },
}) },
isDryRun
)
) )
}) })
+6 -3
View File
@@ -132,14 +132,17 @@ export default function Delete() {
if (!sourceFs) { if (!sourceFs) {
throw new Error('Please select a source path to delete') throw new Error('Please select a source path to delete')
} }
return startDryRun(() => return startDryRun((isDryRun) =>
startDelete({ startDelete(
{
sources: [sourceFs], sources: [sourceFs],
options: { options: {
filter: filterGroup.options, filter: filterGroup.options,
config: { ...configGroup.options, dry_run: true }, config: { ...configGroup.options, dry_run: true },
}, },
}) },
isDryRun
)
) )
}) })
+6 -3
View File
@@ -146,8 +146,9 @@ export default function Move() {
if (!sources || sources.length === 0 || !dest) { if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path') throw new Error('Please select both a source and destination path')
} }
return startDryRun(() => return startDryRun((isDryRun) =>
startMove({ startMove(
{
sources, sources,
destination: dest, destination: dest,
options: { options: {
@@ -156,7 +157,9 @@ export default function Move() {
filter: filterGroup.options, filter: filterGroup.options,
remotes: remotesGroup.options, remotes: remotesGroup.options,
}, },
}) },
isDryRun
)
) )
}) })
+6 -3
View File
@@ -161,8 +161,9 @@ export default function Sync() {
if (!source || !dest) { if (!source || !dest) {
throw new Error('Please select both a source and destination path') throw new Error('Please select both a source and destination path')
} }
return startDryRun(() => return startDryRun((isDryRun) =>
startSync({ startSync(
{
source, source,
destination: dest, destination: dest,
options: { options: {
@@ -171,7 +172,9 @@ export default function Sync() {
filter: filterGroup.options, filter: filterGroup.options,
remotes: remotesGroup.options, remotes: remotesGroup.options,
}, },
}) },
isDryRun
)
) )
}) })