params fixing
This commit is contained in:
+29
-2
@@ -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.')) {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user