Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/upload/uploader/Upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { describe, expect, it } from 'vitest'
import { Upload } from './Upload.ts'

class TestUpload extends Upload {
public source: string = '/destination/file.txt'

public async start(queue: PQueue): Promise<void> {
queue.add(() => Promise.resolve())
}
Expand All @@ -21,4 +23,10 @@ describe('Upload', () => {
a.cancel()
expect(a.signal.aborted).toBe(true)
})

it('rebases an upload', () => {
const a = new TestUpload()
a.rebase('/other/renamed.txt')
expect(a.source).toBe('/other/renamed.txt')
})
})
18 changes: 18 additions & 0 deletions lib/upload/uploader/Upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,28 @@ export interface IUpload extends TypedEventTarget<UploadEvents> {
export abstract class Upload extends TypedEventTarget<UploadEvents> implements Partial<IUpload> {
#abortController = new AbortController()

/**
* The destination of this upload.
* This is *not* URL encoded, it is encoded when the upload requests are made.
*/
public abstract source: string

get signal(): AbortSignal {
return this.#abortController.signal
}

/**
* Move this upload to a new destination.
*
* This is needed when a parent folder is renamed while resolving conflicts,
* as the child uploads are already initialized with the previous destination.
*
* @param source - The new destination of this upload
*/
public rebase(source: string): void {
this.source = source
}

/**
* Cancels the upload
*/
Expand Down
11 changes: 10 additions & 1 deletion lib/upload/uploader/UploadFile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe('upload status and events', () => {
const queue = { add: vi.fn((fn: () => Promise<void>) => fn()) }

await uploadFile.start(queue as never)
await queue.add.mock.calls[0][0]()
await Promise.all(queue.add.mock.results.map((r) => r.value))

expect(uploadFile.source).toBe('/destination/a b&c.txt')
expect(uploadDataMock).toHaveBeenCalledWith('/destination/a%20b%26c.txt', expect.anything(), expect.anything())
Expand All @@ -218,6 +218,15 @@ describe('upload status and events', () => {
expect(requestSpy.mock.lastCall![0].headers!.Destination).toBe('/destination/a%20b%26c.txt')
})

it('rebases the upload to a new destination', () => {
isPublicShareMock.mockReturnValue(false)
getMaxChunksSizeMock.mockReturnValue(1024)

const uploadFile = new UploadFile('/destination/a.txt', new File(['x'], 'a.txt'), { noChunking: true })
uploadFile.rebase('/destination/folder (2)/a.txt')
expect(uploadFile.source).toBe('/destination/folder (2)/a.txt')
})

it('scheduled', async () => {
isPublicShareMock.mockReturnValue(false)
getMaxChunksSizeMock.mockReturnValue(1024)
Expand Down
39 changes: 35 additions & 4 deletions lib/upload/uploader/UploadFileTree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const uploadFileMocks = vi.hoisted(() => {
signal: AbortSignal
start: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
rebase: ReturnType<typeof vi.fn>
status: number
}> = []

Expand All @@ -44,6 +45,10 @@ const uploadFileMocks = vi.hoisted(() => {
this.status = UploadStatus.CANCELLED
})

public rebase = vi.fn((source: string) => {
this.source = source
})

// eslint-disable-next-line @typescript-eslint/no-unused-vars
public constructor(source: string, _file: File, _options: unknown) {
this.source = source
Expand Down Expand Up @@ -191,8 +196,35 @@ describe('UploadFileTree', () => {
expect(tree.status).toBe(UploadStatus.FINISHED)
})

it('rebases already initialized children when a folder is renamed', async () => {
// every MKCOL reports an existing directory so conflicts are resolved on all levels
axiosRequestMock.mockRejectedValue({ response: { status: 405 } })
isAxiosErrorMock.mockReturnValue(true)

const conflictCallback = vi.fn(async (nodes: string[]) => Object.fromEntries(nodes.map((node) => [node, node === 'folder' ? 'folder (2)' : node])))

const directory = await createDirectoryTree()
const tree = new UploadFileTree('/destination', directory, { callback: conflictCallback })
tree.initialize()

const nested = uploadFileMocks.instances[0]
expect(nested.source).toBe('/destination/folder/nested.txt')

await tree.start(createQueue())

expect(tree.children[0].source).toBe('/destination/folder (2)')
// the grandchild was already initialized, but still moved with its parent
expect(nested.source).toBe('/destination/folder (2)/nested.txt')
// so the renamed folder is created - and entered - under its new name
expect(axiosRequestMock.mock.calls.map(([{ url }]) => url)).toEqual([
'/destination',
'/destination/folder%20(2)',
])
expect(conflictCallback).toHaveBeenCalledWith(['nested.txt'], '/destination/folder (2)')
expect(tree.status).toBe(UploadStatus.FINISHED)
})

it('keeps sources unencoded but encodes them for requests', async () => {
// MKCOL fails with 405 so the directories already exist and conflicts need to be resolved
axiosRequestMock.mockRejectedValue({ response: { status: 405 } })
isAxiosErrorMock.mockReturnValue(true)

Expand All @@ -206,7 +238,7 @@ describe('UploadFileTree', () => {
const tree = new UploadFileTree('/destination', directory, { callback: conflictCallback })
const children = tree.initialize()

// the sources are the plain (unencoded) names so they can be matched by the conflict callback
// the sources are the plain names, so the conflict callback can match them
expect(children.map((child) => child.source)).toEqual([
'/destination/sub folder',
'/destination/a b&c.txt',
Expand All @@ -215,10 +247,9 @@ describe('UploadFileTree', () => {

await tree.start(createQueue())

// the conflict callback receives plain names, not encoded ones
expect(conflictCallback).toHaveBeenCalledWith(['sub folder', 'a b&c.txt'], '/destination')
expect(conflictCallback).toHaveBeenCalledWith(['näme #1.txt'], '/destination/sub folder')
// … while the requests use the encoded URLs
// … while the requests use encoded URLs
expect(axiosRequestMock.mock.calls.map(([{ url }]) => url)).toEqual([
'/destination',
'/destination/sub%20folder',
Expand Down
19 changes: 18 additions & 1 deletion lib/upload/uploader/UploadFileTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ export class UploadFileTree extends Upload implements IUpload {
return [...this.#children]
}

/**
* Move this upload - and all already initialized descendants - to a new destination.
*
* The whole tree is initialized upfront, so renaming a directory while resolving
* conflicts also needs to re-base all of its children.
*
* @param source - The new destination of this upload
*/
public rebase(source: string): void {
for (const child of this.#children) {
// read the name before the parent source is updated
child.rebase(concatUrl(source, basename(child.source)))
}
super.rebase(source)
}

/**
* Set up all child uploads for this upload tree.
*/
Expand Down Expand Up @@ -152,7 +168,8 @@ export class UploadFileTree extends Upload implements IUpload {
if (newName === undefined) {
childUpload.cancel()
} else if (newName !== originalName) {
Object.defineProperty(childUpload, 'source', { value: concatUrl(this.source, newName) })
// for directories this also re-bases all of their children
childUpload.rebase(concatUrl(this.source, newName))
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/upload/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function concatUrl(base: string, path: string): string {

/**
* URL encode the path of a decoded URL, leaving a potential origin untouched.
* This must be used whenever a source is used for a request (URL or `Destination` header).
* This must be used whenever a source is used for a request (as URL or `Destination` header).
*
* @param url - The decoded URL, either absolute ("https://example.com/dav/a b.txt") or a path ("/dav/a b.txt")
*/
Expand Down
Loading