From 5ba82e91daab652f13aa97d2af14b8e6c944838f Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:01:52 +0300 Subject: [PATCH] params fixing --- lib/flags.ts | 31 ++- lib/notifications.ts | 3 +- lib/rclone/api.ts | 256 ++++++++++++-------- lib/rclone/common.ts | 7 - lib/rclone/constants.ts | 4 +- lib/rclone/requests.ts | 302 ++++++++++++++++++++---- src/components/JobDetailsDrawer.tsx | 4 +- src/components/OptionsSection.tsx | 7 +- src/components/RemoteOptionsSection.tsx | 18 +- src/components/TemplateAddDrawer.tsx | 57 ++++- src/pages/Copy.tsx | 23 +- src/pages/Delete.tsx | 17 +- src/pages/Move.tsx | 23 +- src/pages/Sync.tsx | 23 +- 14 files changed, 544 insertions(+), 231 deletions(-) diff --git a/lib/flags.ts b/lib/flags.ts index 2938ad1..7f4c402 100644 --- a/lib/flags.ts +++ b/lib/flags.ts @@ -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 ) { 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 +) { + 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.')) { diff --git a/lib/notifications.ts b/lib/notifications.ts index c90638e..e2ae17c 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -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) { diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index f1a43ab..b35bf9e 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -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(operation: () => Promise): Promise { - 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(operation: (isDryRun: true) => Promise): Promise { + 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, + 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> + } +) { // 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, - 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, - watch: Pick + watch: Pick, + 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)[], - meta?: Partial> + meta?: Partial>, + 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)) diff --git a/lib/rclone/common.ts b/lib/rclone/common.ts index 52c66c9..bd39ef2 100644 --- a/lib/rclone/common.ts +++ b/lib/rclone/common.ts @@ -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 { return appPrivate } -export function parseRcloneOptions(options: Record) { - 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; diff --git a/lib/rclone/constants.ts b/lib/rclone/constants.ts index c98b7cd..fee26c2 100644 --- a/lib/rclone/constants.ts +++ b/lib/rclone/constants.ts @@ -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 diff --git a/lib/rclone/requests.ts b/lib/rclone/requests.ts index 111d0ff..953fb75 100644 --- a/lib/rclone/requests.ts +++ b/lib/rclone/requests.ts @@ -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 = { + 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 = { + 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 | undefined): string | undefined { + if (!filter || Object.keys(filter).length === 0) { + return undefined + } + + const result: Record> = {} + const metadataRules: Record = {} + + 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 = { + 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 | 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 } -// Encodes a path as an rclone connection string with inlined per-remote and global options: -// ",=\"v\",global.=\"gv\":". +// Encodes a path as an rclone connection string with inlined per-remote options: +// ",=\"v\":". export function serializeOptions( remotePath: string, options: { remote?: Record - global?: Record } ) { 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): Set { + return new Set( + Object.entries(filter || {}) + .filter(([, value]) => !isBlankString(value)) + .map(([key]) => normalizeOptionName(key)) + ) +} + function assertIncludeRules(sources: string[], filter?: Record) { - 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) { + 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> | undefined, remoteName: string | undefined @@ -167,7 +328,8 @@ function remoteOptionsFor( function buildTransferInputs( args: CopyArgs | MoveArgs, paths: { folder: string; file: string }, - mergedOptions: Record + 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 = {} @@ -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 = {} @@ -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. */ diff --git a/src/components/JobDetailsDrawer.tsx b/src/components/JobDetailsDrawer.tsx index af297cb..421f372 100644 --- a/src/components/JobDetailsDrawer.tsx +++ b/src/components/JobDetailsDrawer.tsx @@ -138,7 +138,9 @@ export default function JobDetailsDrawer({ input, }: { key: string; input: { _path: string } & Record }) => { 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 }) => { diff --git a/src/components/OptionsSection.tsx b/src/components/OptionsSection.tsx index 1916e30..66cc331 100644 --- a/src/components/OptionsSection.tsx +++ b/src/components/OptionsSection.tsx @@ -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 { diff --git a/src/components/RemoteOptionsSection.tsx b/src/components/RemoteOptionsSection.tsx index 5024dc1..d94b1f7 100644 --- a/src/components/RemoteOptionsSection.tsx +++ b/src/components/RemoteOptionsSection.tsx @@ -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), ], } diff --git a/src/components/TemplateAddDrawer.tsx b/src/components/TemplateAddDrawer.tsx index 3c1cd9a..860af57 100644 --- a/src/components/TemplateAddDrawer.tsx +++ b/src/components/TemplateAddDrawer.tsx @@ -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)) diff --git a/src/pages/Copy.tsx b/src/pages/Copy.tsx index 2da4f10..df56da0 100644 --- a/src/pages/Copy.tsx +++ b/src/pages/Copy.tsx @@ -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 + ) ) }) diff --git a/src/pages/Delete.tsx b/src/pages/Delete.tsx index ed551af..cce4b3a 100644 --- a/src/pages/Delete.tsx +++ b/src/pages/Delete.tsx @@ -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 + ) ) }) diff --git a/src/pages/Move.tsx b/src/pages/Move.tsx index 3a83130..0598582 100644 --- a/src/pages/Move.tsx +++ b/src/pages/Move.tsx @@ -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 + ) ) }) diff --git a/src/pages/Sync.tsx b/src/pages/Sync.tsx index a188169..e787a3b 100644 --- a/src/pages/Sync.tsx +++ b/src/pages/Sync.tsx @@ -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 + ) ) })