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 { SERVE_TYPES } from './rclone/constants'
const RE_DASH = /-/g
export const FLAG_CATEGORIES = [
'copy',
'sync',
@@ -26,7 +28,7 @@ export function getFlagCategory(
flags: Record<string, { Name: string; Groups?: string }[]>
) {
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)
let foundFlag = null
@@ -68,6 +70,31 @@ export function getFlagCategory(
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 }) {
return flag1.Name.localeCompare(flag2.Name)
}
@@ -107,7 +134,7 @@ export function groupByCategory(
}
for (const [k, v] of Object.entries(flags)) {
const normalizedKey = k.replace(/-/g, '_')
const normalizedKey = k.replace(RE_DASH, '_')
const category = getFlagCategory(k, allFlags)
if (!category) continue
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
* 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() {
statusFailures.clear()
seenJobIds.clear()
handledJobIds.clear()
useStore.setState({ watchedJobs: {} })
useStore.setState({ watchedJobs: {}, dryRunJobIds: [] })
}
function onWatchedJobsChange(watchedJobs: Record<number, WatchedJob>) {
+154 -102
View File
@@ -11,7 +11,6 @@ import { getFsInfo } from '../format'
import { dispatchNotification } from '../notifications'
import { restartActiveRclone, runRcloneCli } from './cli'
import rclone, { rcloneAsync } from './client'
import { parseRcloneOptions } from './common'
import {
type BisyncArgs,
type CopyArgs,
@@ -26,6 +25,8 @@ import {
buildPurgeRequests,
buildSyncRequests,
serializeOptions,
toConfigParam,
toFilterParam,
} from './requests'
const RE_BACKSLASH = /\\/g
@@ -39,34 +40,10 @@ const RETRY_OPTIONS = {
shouldRetry: ({ error }: { error: unknown }) => !(error instanceof UserCancelledError),
}
// Non-zero while a dry run is in flight. The start* functions capture this at submission time
// so a dry-run job is never registered with the watcher — checking it at registration time
// instead would wrongly suppress a real job that overlaps a concurrent dry run.
let dryRunDepth = 0
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 },
},
})
}
// Dry-run state travels with each submission so a preview never changes daemon-global options or
// suppresses a real job that overlaps it.
export function startDryRun<T>(operation: (isDryRun: true) => Promise<T>): Promise<T> {
return operation(true)
}
// 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
// masked as "Source does not exist". A genuinely missing path returns a response with no
// 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', {
params: {
query: {
fs: root === ':local:' ? ':local:/' : root,
fs,
remote: filePath,
...(options?.configParam ? { _config: options.configParam } : {}),
},
},
})
return !!r?.item
}
export async function startCopy(args: CopyArgs) {
export async function startCopy(args: CopyArgs, isDryRun = false) {
console.log('[startCopy] starting', {
sources: args.sources,
destination: args.destination,
optionKeys: Object.keys(args.options),
})
const [request] = buildCopyRequests(args)
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) {
throw new Error(`Source does not exist, ${source} is missing`)
}
}
const [request] = buildCopyRequests(args)
console.log('[startCopy] submitting batch', { jobCount: request.body.inputs.length })
return startBatch(request.body.inputs, {
operation: 'copy',
sources: args.sources,
destination: args.destination,
})
return startBatch(
request.body.inputs,
{
operation: 'copy',
sources: args.sources,
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', {
sources: args.sources,
destination: args.destination,
optionKeys: Object.keys(args.options),
})
const [request] = buildMoveRequests(args)
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) {
throw new Error(`Source does not exist, ${source} is missing`)
}
}
const [request] = buildMoveRequests(args)
console.log('[startMove] submitting batch', { jobCount: request.body.inputs.length })
return startBatch(request.body.inputs, {
operation: 'move',
sources: args.sources,
destination: args.destination,
})
return startBatch(
request.body.inputs,
{
operation: 'move',
sources: args.sources,
destination: args.destination,
},
{ isDryRun, configParam: request.body._config }
)
}
/* JOBS */
@@ -411,14 +434,14 @@ async function startMountInner({
mountOptions.volname = `${sourcePath}-${Math.random().toString(36).substring(2, 3).toUpperCase()}`
}
// Only genuinely global flags can ride the connection string (`global.` keys) — mount and
// VFS options go through the dedicated mountOpt/vfsOpt params below instead. Filter options
// have no per-mount channel at all (rclone builds the mount's VFS on a background context,
// so per-call filters never reach it) and are kept here as a no-op until upstream fixes it.
const mergedOptions = {
...(options.config || {}),
...(options.filter || {}),
}
// `_filter` is the correct RC channel for mount filters (rclone's own RC docs say so), so we
// send it as a proper param rather than smuggling it into the fs string. Note: current rclone
// ignores it for mounts — mountRc has the filter on its ctx, but Mount() builds the VFS with
// context.Background() and discards it (only the *global* filter, set via CLI --exclude, reaches
// a mount). Rclone still parses this value, but it only affects the mount if upstream threads
// that request context into the VFS.
const configParam = toConfigParam(options.config)
const filterParam = toFilterParam(options.filter)
const vfsOptions = { ...(options.vfs || {}) }
@@ -427,15 +450,24 @@ async function startMountInner({
// keys pass through untouched — rclone ignores unrecognized fields.
const toStructOptions = (
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(
Object.fromEntries(
Object.entries(flags).map(([key, value]) => [
fieldNames.get(key.replace(RE_DASH, '_')) || key,
value,
])
Object.entries(flags).map(([key, value]) => {
const normalized = (key.startsWith('--') ? key.slice(2) : key).replace(
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: {
query: {
fs: serializeOptions(srcFullDirPath, {
global: mergedOptions,
remote: srcOptions,
}),
mountPoint: '*',
// No mountType — Windows uses rclone's default resolution (cmount/WinFsp)
...structOptions,
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
},
},
}),
@@ -592,7 +625,6 @@ async function startMountInner({
params: {
query: {
fs: serializeOptions(srcFullDirPath, {
global: mergedOptions,
remote: srcOptions,
}),
mountPoint: (() => {
@@ -610,6 +642,8 @@ async function startMountInner({
})(),
...(currentPlatform === 'macos' ? { mountType: 'nfsmount' } : {}),
...structOptions,
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
},
},
}),
@@ -622,14 +656,13 @@ async function startMountInner({
async function submitAsyncQuery(
endpoint: '/sync/sync' | '/sync/bisync',
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
// same parameters as a query (rclone's RC treats them identically).
const { _async, ...query } = body
const submittedDuringDryRun = dryRunDepth > 0
const r = await pRetry(
async () =>
await rcloneAsync(endpoint, {
@@ -645,9 +678,7 @@ async function submitAsyncQuery(
throw new Error('Failed to start operation')
}
if (!submittedDuringDryRun) {
registerWatchedJob(r.jobid, watch)
}
registerSubmittedJob(r.jobid, watch, isDryRun)
await new Promise((resolve) => setTimeout(resolve, 1000))
@@ -679,12 +710,15 @@ async function submitAsyncQuery(
}
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) {
throw new Error(`Source does not exist, ${args.source} is missing`)
}
const [request] = buildBisyncRequests(args)
return submitAsyncQuery('/sync/bisync', request.body, {
operation: 'bisync',
sources: [args.source],
@@ -692,42 +726,64 @@ export async function startBisync(args: BisyncArgs) {
})
}
export async function startSync(args: SyncArgs) {
const sourceExists = await hasStat(args.source)
export async function startSync(args: SyncArgs, isDryRun = false) {
const [request] = buildSyncRequests(args)
const sourceExists = await hasStat(args.source, {
configParam: request.body._config,
remotes: args.options.remotes,
})
if (!sourceExists) {
throw new Error(`Source does not exist, ${args.source} is missing`)
}
const [request] = buildSyncRequests(args)
return submitAsyncQuery('/sync/sync', request.body, {
operation: 'sync',
sources: [args.source],
destination: args.destination,
})
return submitAsyncQuery(
'/sync/sync',
request.body,
{
operation: 'sync',
sources: [args.source],
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) {
const sourceExists = await hasStat(source)
const sourceExists = await hasStat(source, {
configParam: request.body._config,
remotes: options.remotes,
})
if (!sourceExists) {
throw new Error(`Source does not exist, ${source} is missing`)
}
}
const [request] = buildDeleteRequests({ sources, options })
return startBatch(request.body.inputs, { operation: 'delete', sources })
return startBatch(
request.body.inputs,
{ operation: 'delete', sources },
{ isDryRun, configParam: request.body._config }
)
}
export async function startPurge({ sources, options }: PurgeArgs) {
const [request] = buildPurgeRequests({ sources, options })
for (const source of sources) {
const sourceExists = await hasStat(source)
const sourceExists = await hasStat(source, {
configParam: request.body._config,
remotes: options.remotes,
})
if (!sourceExists) {
throw new Error(`Source does not exist, ${source} is missing`)
}
}
const [request] = buildPurgeRequests({ sources, options })
return startBatch(request.body.inputs, { operation: 'purge', sources })
return startBatch(
request.body.inputs,
{ operation: 'purge', sources },
{ configParam: request.body._config }
)
}
export async function startServe({
@@ -750,14 +806,8 @@ export async function startServe({
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,
_filter: toFilterParam(_filter),
_config: toConfigParam(_config),
...(props && Object.keys(props).length > 0
? Object.fromEntries(
Object.entries(props).map(([key, value]) => [
@@ -773,7 +823,8 @@ export async function startServe({
export async function startBatch(
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', {
inputCount: inputs.length,
@@ -781,13 +832,12 @@ export async function startBatch(
})
console.log('[startBatch] inputs', JSON.stringify(inputs, null, 2))
const submittedDuringDryRun = dryRunDepth > 0
const r = await pRetry(
async () =>
await rclone('/job/batch', {
body: {
inputs,
...(options?.configParam ? { _config: options.configParam } : {}),
_async: true,
},
}),
@@ -796,13 +846,15 @@ export async function startBatch(
console.log('[startBatch] job created', { jobid: r.jobid })
if (!submittedDuringDryRun) {
registerWatchedJob(r.jobid, {
registerSubmittedJob(
r.jobid,
{
operation: meta?.operation ?? 'batch',
sources: meta?.sources,
destination: meta?.destination,
})
}
},
options?.isDryRun ?? false
)
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 { exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { useHostStore } from '../../store/host'
import type { FlagValue } from '../../types/rclone'
import { getConfigParentFolder } from '../format'
import rclone from './client'
import { DOUBLE_BACKSLASH_REGEX } from './constants'
@@ -159,12 +158,6 @@ export async function resolveDefaultConfigPath(): Promise<string> {
return appPrivate
}
export function parseRcloneOptions(options: Record<string, FlagValue>) {
console.log('[parseRcloneOptions]', options)
return options
}
export function compareVersions(version1: string, version2: string): number {
const parseVersion = (version: string) => {
// 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,
},
vfs: {
'chunk_size': '4M',
'chunk_streams': 16,
'vfs_read_chunk_size': '4M',
'vfs_read_chunk_streams': 16,
},
} as const
+251 -51
View File
@@ -7,6 +7,157 @@ import { getFsInfo } from '../format'
// save/run always happen on the same machine.
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 {
sources: string[]
@@ -80,13 +231,12 @@ export interface RcRequest {
body: Record<string, any>
}
// Encodes a path as an rclone connection string with inlined per-remote and global options:
// "<remoteName>,<k>=\"v\",global.<gk>=\"gv\":<path>".
// Encodes a path as an rclone connection string with inlined per-remote options:
// "<remoteName>,<k>=\"v\":<path>".
export function serializeOptions(
remotePath: string,
options: {
remote?: Record<string, FlagValue>
global?: Record<string, FlagValue>
}
) {
console.log('[serializeRemoteOptions] ', remotePath)
@@ -101,10 +251,7 @@ export function serializeOptions(
let serialized = `${remoteName}`
if (
Object.keys(options.remote || {}).length > 0 ||
Object.keys(options.global || {}).length > 0
) {
if (Object.keys(options.remote || {}).length > 0) {
serialized += ','
}
@@ -114,12 +261,6 @@ export function serializeOptions(
.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') {
@@ -149,12 +290,32 @@ export function serializeOptions(
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>) {
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')
}
}
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(
remotes: Record<string, Record<string, FlagValue>> | undefined,
remoteName: string | undefined
@@ -167,7 +328,8 @@ function remoteOptionsFor(
function buildTransferInputs(
args: CopyArgs | MoveArgs,
paths: { folder: string; file: string },
mergedOptions: Record<string, FlagValue>
configParam: string | undefined,
filterParam: string | undefined
): BatchInput[] {
const { sources, destination, options } = args
@@ -208,12 +370,13 @@ function buildTransferInputs(
_path: paths.folder,
srcFs: serializeOptions(srcFullDirPath, {
remote: srcOptions,
global: mergedOptions,
}),
dstFs: serializeOptions(`${dstFullDirPath}${srcName}`, {
remote: dstOptions,
}),
createEmptySrcDirs: true,
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
})
continue
}
@@ -227,8 +390,8 @@ function buildTransferInputs(
_path: paths.file,
srcFs: serializeOptions(srcRoot, {
remote: srcOptions,
global: mergedOptions,
}),
...(configParam ? { _config: configParam } : {}),
srcRemote: srcFilePath,
dstFs: serializeOptions(dstRoot, {
remote: dstOptions,
@@ -242,42 +405,61 @@ function buildTransferInputs(
export function buildCopyRequests(args: CopyArgs): RcRequest[] {
assertIncludeRules(args.sources, args.options.filter)
const mergedOptions = {
...(args.options.config || {}),
assertFolderFilters(args.sources, args.options.filter)
const configParam = toConfigParam({
...(args.options.copy || {}),
...(args.options.filter || {}),
}
...(args.options.config || {}),
})
const filterParam = toFilterParam(args.options.filter)
const inputs = buildTransferInputs(
args,
{ 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[] {
assertIncludeRules(args.sources, args.options.filter)
const mergedOptions = {
...(args.options.config || {}),
assertFolderFilters(args.sources, args.options.filter)
const configParam = toConfigParam({
...(args.options.move || {}),
...(args.options.filter || {}),
}
...(args.options.config || {}),
})
const filterParam = toFilterParam(args.options.filter)
const inputs = buildTransferInputs(
args,
{ 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[] {
const { source, destination, options } = args
const mergedOptions = {
...(options.config || {}),
...(options.sync || {}),
...(options.filter || {}),
}
const configParam = toConfigParam({ ...(options.sync || {}), ...(options.config || {}) })
const filterParam = toFilterParam(options.filter)
const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source)
const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination)
@@ -287,13 +469,14 @@ export function buildSyncRequests(args: SyncArgs): RcRequest[] {
endpoint: '/sync/sync',
body: {
srcFs: serializeOptions(srcFullDirPath, {
global: mergedOptions,
remote: remoteOptionsFor(options.remotes, srcRemoteName),
}),
dstFs: serializeOptions(dstFullDirPath, {
remote: remoteOptionsFor(options.remotes, dstRemoteName),
}),
createEmptySrcDirs: true,
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
_async: true,
},
},
@@ -303,11 +486,8 @@ export function buildSyncRequests(args: SyncArgs): RcRequest[] {
export function buildBisyncRequests(args: BisyncArgs): RcRequest[] {
const { source, destination, options } = args
const mergedOptions = {
...(options.config || {}),
...(options.bisync || {}),
...(options.filter || {}),
}
const configParam = toConfigParam({ ...(options.bisync || {}), ...(options.config || {}) })
const filterParam = toFilterParam(options.filter)
const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source)
const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination)
@@ -317,12 +497,13 @@ export function buildBisyncRequests(args: BisyncArgs): RcRequest[] {
endpoint: '/sync/bisync',
body: {
path1: serializeOptions(srcFullDirPath, {
global: mergedOptions,
remote: remoteOptionsFor(options.remotes, srcRemoteName),
}),
path2: serializeOptions(dstFullDirPath, {
remote: remoteOptionsFor(options.remotes, dstRemoteName),
}),
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
...(options.outer && Object.keys(options.outer).length > 0
? Object.fromEntries(
Object.entries(options.outer).map(([key, value]) => [
@@ -341,11 +522,10 @@ export function buildDeleteRequests(args: DeleteArgs): RcRequest[] {
const { sources, options } = args
assertIncludeRules(sources, options.filter)
assertFolderFilters(sources, options.filter)
const mergedOptions = {
...(options.config || {}),
...(options.filter || {}),
}
const configParam = toConfigParam(options.config || {})
const filterParam = toFilterParam(options.filter)
const inputs: BatchInput[] = []
const handledSourcePaths: Record<string, true> = {}
@@ -372,9 +552,10 @@ export function buildDeleteRequests(args: DeleteArgs): RcRequest[] {
inputs.push({
_path: 'operations/delete',
fs: serializeOptions(source, {
global: mergedOptions,
remote: srcOptions,
}),
...(configParam ? { _config: configParam } : {}),
...(filterParam ? { _filter: filterParam } : {}),
})
continue
}
@@ -387,19 +568,29 @@ export function buildDeleteRequests(args: DeleteArgs): RcRequest[] {
inputs.push({
_path: 'operations/deletefile',
fs: serializeOptions(srcRoot, {
global: mergedOptions,
remote: srcOptions,
}),
...(configParam ? { _config: configParam } : {}),
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[] {
const { sources, options } = args
const configParam = toConfigParam(options.config || {})
const inputs: BatchInput[] = []
const handledSourcePaths: Record<string, true> = {}
@@ -413,7 +604,7 @@ export function buildPurgeRequests(args: PurgeArgs): RcRequest[] {
const {
root: srcRoot,
dirPath: srcDirPath,
filePath: srcDirPath,
type: srcType,
remoteName: srcRemoteName,
} = getFsInfo(source)
@@ -425,14 +616,23 @@ export function buildPurgeRequests(args: PurgeArgs): RcRequest[] {
inputs.push({
_path: 'operations/purge',
fs: serializeOptions(srcRoot, {
global: options.config,
remote: remoteOptionsFor(options.remotes, srcRemoteName),
}),
...(configParam ? { _config: configParam } : {}),
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. */
+3 -1
View File
@@ -138,7 +138,9 @@ export default function JobDetailsDrawer({
input,
}: { key: string; input: { _path: string } & Record<string, any> }) => {
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 }
},
onSuccess: ({ key, jobId }) => {
+6 -1
View File
@@ -1083,10 +1083,15 @@ export default function OptionsSection({
globalOptions[
option.FieldName as keyof typeof globalOptions
]
const isUnsafeInteger =
typeof defaultGlobalValue === 'number' &&
Number.isInteger(defaultGlobalValue) &&
!Number.isSafeInteger(defaultGlobalValue)
if (
defaultGlobalValue !== null &&
defaultGlobalValue !== undefined
defaultGlobalValue !== undefined &&
!isUnsafeInteger
) {
value = toOptionValue(defaultGlobalValue, option.Type)
} else {
+2 -16
View File
@@ -96,14 +96,7 @@ export default function RemoteOptionsSection({
!IGNORED_OPTIONS.includes(o.Name) &&
!!o.Help
)
.map((o) => {
const newName = `s3_${o.Name}`
return {
...o,
Name: newName,
FieldName: newName,
}
})
.map((o) => ({ ...o, FieldName: o.Name }))
.filter(Boolean)
console.log('[RemoteOptionsSection] providerOptions', providerOptions)
return {
@@ -121,14 +114,7 @@ export default function RemoteOptionsSection({
options: [
...(backends.find((b) => b.Name === config.type)?.Options || [])
.filter((o) => !IGNORED_OPTIONS.includes(o.Name) && !!o.Help)
.map((o) => {
const newName = `${config.type}_${o.Name}`
return {
...o,
Name: newName,
FieldName: newName,
}
})
.map((o) => ({ ...o, FieldName: o.Name }))
.filter(Boolean),
],
}
+46 -11
View File
@@ -34,6 +34,7 @@ import { useDebounce } from 'use-debounce'
import { formatErrorMessage } from '../../lib/errors'
import {
FLAG_CATEGORIES,
findFlagOption,
getJsonKeyCount,
getOptionsSubtitle,
groupByCategory,
@@ -45,6 +46,21 @@ import type { BackendOption, FlagValue } from '../../types/rclone'
import type { Template } from '../../types/template'
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({
isOpen,
onClose,
@@ -175,19 +191,38 @@ export default function TemplateAddDrawer({
const flagStrings = flagString.split('--').filter(Boolean)
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
if (v === 'true') v = true
if (v === 'false') v = false
if (v === 'null') v = null
let parsedValue: FlagValue = value ?? true
if (flagInfo?.Type === 'bool') {
parsedValue = value !== 'false'
} 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)
if (!isNaN(parsedNumber)) v = parsedNumber
if (value?.includes(',')) v = value.split(',')
flagGroups[flag.trim()] = v
const previousValue = flagGroups[normalizedFlag]
flagGroups[normalizedFlag] =
Array.isArray(previousValue) && Array.isArray(parsedValue)
? [...previousValue, ...parsedValue]
: parsedValue
}
console.log('flag groups', JSON.stringify(flagGroups, null, 2))
+13 -10
View File
@@ -142,17 +142,20 @@ export default function Copy() {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
return startDryRun(() =>
startCopy({
sources,
destination: dest,
options: {
config: { ...configGroup.options, dry_run: true },
copy: copyGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
return startDryRun((isDryRun) =>
startCopy(
{
sources,
destination: dest,
options: {
config: { ...configGroup.options, dry_run: true },
copy: copyGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
},
},
})
isDryRun
)
)
})
+10 -7
View File
@@ -132,14 +132,17 @@ export default function Delete() {
if (!sourceFs) {
throw new Error('Please select a source path to delete')
}
return startDryRun(() =>
startDelete({
sources: [sourceFs],
options: {
filter: filterGroup.options,
config: { ...configGroup.options, dry_run: true },
return startDryRun((isDryRun) =>
startDelete(
{
sources: [sourceFs],
options: {
filter: filterGroup.options,
config: { ...configGroup.options, dry_run: true },
},
},
})
isDryRun
)
)
})
+13 -10
View File
@@ -146,17 +146,20 @@ export default function Move() {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
return startDryRun(() =>
startMove({
sources,
destination: dest,
options: {
config: { ...configGroup.options, dry_run: true },
move: moveGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
return startDryRun((isDryRun) =>
startMove(
{
sources,
destination: dest,
options: {
config: { ...configGroup.options, dry_run: true },
move: moveGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
},
},
})
isDryRun
)
)
})
+13 -10
View File
@@ -161,17 +161,20 @@ export default function Sync() {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
}
return startDryRun(() =>
startSync({
source,
destination: dest,
options: {
config: { ...configGroup.options, dry_run: true },
sync: syncGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
return startDryRun((isDryRun) =>
startSync(
{
source,
destination: dest,
options: {
config: { ...configGroup.options, dry_run: true },
sync: syncGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
},
},
})
isDryRun
)
)
})