diff --git a/lib/globalScope.ts b/lib/globalScope.ts index 30cb40cb..27c97e69 100644 --- a/lib/globalScope.ts +++ b/lib/globalScope.ts @@ -13,6 +13,7 @@ import type { import type { IFileAction, IFileListAction } from './ui/actions/index.ts' import type { FilesRegistry } from './ui/registry.ts' import type { ISidebarAction, ISidebarTab } from './ui/sidebar/index.ts' +import type { Uploader } from './upload/index.ts' interface InternalGlobalScope { davNamespaces?: DavProperty @@ -22,6 +23,8 @@ interface InternalGlobalScope { navigation?: Navigation registry?: FilesRegistry + uploader?: Uploader + fileActions?: Map fileListActions?: Map fileListFilters?: Map diff --git a/lib/upload/errors/UploadCancelledError.spec.ts b/lib/upload/errors/UploadCancelledError.spec.ts new file mode 100644 index 00000000..e270eff7 --- /dev/null +++ b/lib/upload/errors/UploadCancelledError.spec.ts @@ -0,0 +1,17 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from 'vitest' +import { UploadCancelledError } from './UploadCancelledError.ts' + +test('UploadCancelledError', () => { + const cause = new Error('Network error') + const error = new UploadCancelledError(cause) + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(UploadCancelledError) + expect(error.message).toBe('Upload has been cancelled') + expect(error.cause).toBe(cause) + expect(error).toHaveProperty('__UPLOAD_CANCELLED__') +}) diff --git a/lib/upload/errors/UploadCancelledError.ts b/lib/upload/errors/UploadCancelledError.ts new file mode 100644 index 00000000..480b47f6 --- /dev/null +++ b/lib/upload/errors/UploadCancelledError.ts @@ -0,0 +1,16 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export class UploadCancelledError extends Error { + __UPLOAD_CANCELLED__ = true + + public constructor(cause?: unknown) { + super('Upload has been cancelled', { cause }) + } + + public static isCancelledError(error: unknown): error is UploadCancelledError { + return typeof error === 'object' && error !== null && (error as UploadCancelledError).__UPLOAD_CANCELLED__ === true + } +} diff --git a/lib/upload/getUploader.spec.ts b/lib/upload/getUploader.spec.ts new file mode 100644 index 00000000..d5855974 --- /dev/null +++ b/lib/upload/getUploader.spec.ts @@ -0,0 +1,31 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { join } from '@nextcloud/paths' +import { expect, test } from 'vitest' +import { defaultRemoteURL, defaultRootPath } from '../dav/dav.ts' +import { scopedGlobals } from '../globalScope.ts' +import { Folder } from '../node/folder.ts' +import { getUploader } from './getUploader.ts' +import { Uploader } from './uploader/Uploader.ts' + +test('getUploader - should return the uploader instance from the global scope', async () => { + const uploader = new Uploader(false, new Folder({ owner: 'test', root: defaultRootPath, source: join(defaultRemoteURL, defaultRootPath) })) + scopedGlobals.uploader = uploader + const returnedUploader = getUploader() + expect(returnedUploader).toBe(uploader) +}) + +test('getUploader - should return the same instance on multiple calls', async () => { + const uploader1 = getUploader() + const uploader2 = getUploader() + expect(uploader1).toBe(uploader2) +}) + +test('getUploader - should not return the same instance on multiple calls with forceRecreate', async () => { + const uploader1 = getUploader(true) + const uploader2 = getUploader(true, true) + expect(uploader1).not.toBe(uploader2) +}) diff --git a/lib/upload/getUploader.ts b/lib/upload/getUploader.ts new file mode 100644 index 00000000..ed28bfd7 --- /dev/null +++ b/lib/upload/getUploader.ts @@ -0,0 +1,26 @@ +/*! + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { isPublicShare } from '@nextcloud/sharing/public' +import { scopedGlobals } from '../globalScope.ts' +import { Uploader } from './uploader/Uploader.ts' + +/** + * Get the global Uploader instance. + * + * Note: If you need a local uploader you can just create a new instance, + * this global instance will be shared with other apps and is mostly useful + * for the Files app web UI to keep track of all uploads and their progress. + * + * @param isPublic Set to true to use public upload endpoint (by default it is auto detected) + * @param forceRecreate Force a new uploader instance - main purpose is for testing + */ +export function getUploader(isPublic: boolean = isPublicShare(), forceRecreate = false): Uploader { + if (forceRecreate || scopedGlobals.uploader === undefined) { + scopedGlobals.uploader = new Uploader(isPublic) + } + + return scopedGlobals.uploader +} diff --git a/lib/upload/index.ts b/lib/upload/index.ts new file mode 100644 index 00000000..0891cb85 --- /dev/null +++ b/lib/upload/index.ts @@ -0,0 +1,12 @@ +/*! + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export type { Eta, EtaEventsMap } from './uploader/index.ts' +export type { Directory, IDirectory } from './utils/fileTree.ts' + +export { getUploader } from './getUploader.ts' +export { Upload, UploadStatus } from './uploader/Upload.ts' +export { EtaStatus, Uploader, UploaderStatus } from './uploader/index.ts' +export { getConflicts, hasConflict } from './utils/conflicts.ts' diff --git a/lib/upload/uploader/Eta.spec.ts b/lib/upload/uploader/Eta.spec.ts new file mode 100644 index 00000000..f5863a2c --- /dev/null +++ b/lib/upload/uploader/Eta.spec.ts @@ -0,0 +1,303 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { afterAll, beforeAll, describe, expect, it, test, vi } from 'vitest' +import { Eta, EtaStatus } from './Eta.ts' + +describe('ETA - status', () => { + it('has default set', () => { + const eta = new Eta() + expect(eta.progress).toBe(0) + expect(eta.time).toBe(Infinity) + expect(eta.speed).toBe(-1) + expect(eta.status).toBe(EtaStatus.Idle) + }) + + it('can autostart in constructor', () => { + const eta = new Eta({ start: true, total: 100 }) + expect(eta.status).toBe(EtaStatus.Running) + expect(eta.progress).toBe(0) + expect(eta.time).toBe(Infinity) + expect(eta.speed).toBe(-1) + }) + + it('can reset', () => { + const eta = new Eta({ start: true, total: 100 }) + expect(eta.status).toBe(EtaStatus.Running) + + eta.add(10) + expect(eta.progress).toBe(10) + + eta.reset() + expect(eta.status).toBe(EtaStatus.Idle) + expect(eta.progress).toBe(0) + }) + + it('does not update when idle', () => { + const eta = new Eta() + expect(eta.progress).toBe(0) + + eta.update(10, 100) + expect(eta.progress).toBe(0) + + eta.add(10) + expect(eta.progress).toBe(0) + expect(eta.status).toBe(EtaStatus.Idle) + }) + + it('does not update when paused', () => { + const eta = new Eta({ start: true, total: 100 }) + eta.add(10) + expect(eta.progress).toBe(10) + + eta.pause() + eta.add(10) + expect(eta.progress).toBe(10) + expect(eta.status).toBe(EtaStatus.Paused) + }) + + it('can resume', () => { + const eta = new Eta() + expect(eta.status).toBe(EtaStatus.Idle) + eta.resume() + expect(eta.status).toBe(EtaStatus.Running) + }) +}) + +describe('ETA - progress', () => { + beforeAll(() => vi.useFakeTimers()) + afterAll(() => vi.useRealTimers()) + + test('progress calculation', () => { + const eta = new Eta({ start: true, total: 100 * 1024 * 1024, cutoffTime: 2.5 }) + expect(eta.progress).toBe(0) + + // First upload some parts with about 5MiB/s which should take 3s (total 20s) + for (let i = 1; i <= 6; i++) { + vi.advanceTimersByTime(500) + eta.add(2.5 * 1024 * 1024) + expect(eta.progress).toBe(i * 2.5) + expect(eta.speed).toBe(-1) + expect(eta.time).toBe(Infinity) + } + + // this is reached after (virtual) 3s with 6 * 2.5MiB (=15MiB) data of 100MiB total + expect(eta.time).toBe(Infinity) + + // Adding another 500ms with 5MiB/s will result in enough information for estimating + vi.advanceTimersByTime(500) + eta.add(2.5 * 1024 * 1024) + expect(eta.progress).toBe(17.5) + expect(eta.speed).toMatchInlineSnapshot('4826778') + expect(eta.speedReadable).toMatchInlineSnapshot('"4.6 MB∕s"') + expect(eta.time).toMatchInlineSnapshot('18') + + // Skip forward another 4.5seconds + for (let i = 0; i < 9; i++) { + vi.advanceTimersByTime(500) + eta.add(2.5 * 1024 * 1024) + } + // See we made some progress + expect(eta.progress).toBe(40) + // See as we have constant speed, the speed is closing to 5MiB/s (5242880) + expect(eta.speed).toMatchInlineSnapshot('5060836') + expect(eta.speedReadable).toMatchInlineSnapshot('"4.8 MB∕s"') + expect(eta.time).toMatchInlineSnapshot('12') + + // Having a spike of 10MiB/s will not result in halfing the eta + vi.advanceTimersByTime(500) + eta.add(5 * 1024 * 1024) + expect(eta.progress).toBe(45) + // See the value is not doubled + expect(eta.speed).toMatchInlineSnapshot('5208613') + expect(eta.speedReadable).toMatchInlineSnapshot('"5 MB∕s"') + // And the time has not halved + expect(eta.time).toMatchInlineSnapshot('11') + + // Add another 3 seconds so we should see 'few seconds left' + for (let i = 0; i < 6; i++) { + vi.advanceTimersByTime(500) + eta.add(2.5 * 1024 * 1024) + } + expect(eta.progress).toBe(60) + expect(eta.speed).toMatchInlineSnapshot('5344192') + expect(eta.time).toMatchInlineSnapshot('8') + }) + + test('long running progress', () => { + const eta = new Eta({ start: true, total: 100 * 1024 * 1024, cutoffTime: 2.5 }) + expect(eta.progress).toBe(0) + + // First upload some parts with about 1MiB/s + for (let i = 1; i <= 6; i++) { + vi.advanceTimersByTime(500) + eta.add(512 * 1024) + expect(eta.progress).toBe(i / 2) + expect(eta.speed).toBe(-1) + expect(eta.time).toBe(Infinity) + } + + // Now we should be able to see some progress + vi.advanceTimersByTime(500) + eta.add(512 * 1024) + expect(eta.progress).toBe(3.5) + expect(eta.time).toBe(105) + + // Add another minute and we should see only seconds: + for (let i = 0; i < 120; i++) { + vi.advanceTimersByTime(500) + eta.add(512 * 1024) + expect(eta.progress).toBe(4 + 0.5 * i) + } + + // Now we have uploaded 63.5 MiB - so 36.5 MiB missing by having 1MiB/s upload speed we expect 37 seconds left: + expect(eta.progress).toBe(63.5) + expect(eta.time).toBe(37) + }) + + test('progress calculation for fast uploads', () => { + const eta = new Eta({ start: true, total: 100 * 1024 * 1024, cutoffTime: 2.5 }) + expect(eta.progress).toBe(0) + + // we have 100 MiB - when uploading with 40 MiB/s the time will be just like 2.5 seconds + // so not enough for estimation, instead we use the current speed to at least show that it is very fast + + // First chunk will not show any information as we initialize the system + vi.advanceTimersByTime(500) + eta.add(20 * 1024 * 1024) + expect(eta.progress).toBe(20) + expect(eta.speed).toBe(-1) + expect(eta.time).toBe(Infinity) + + // Now we have some information but not enough for normal estimation + // yet we show some information as the upload is very fast (40% per second) + vi.advanceTimersByTime(500) + eta.add(20 * 1024 * 1024) + expect(eta.progress).toBe(40) + expect(eta.time).toBe(1.5) + // still no speed information + expect(eta.speed).toBe(-1) + + // same check for the last 60MiB + for (let i = 1; i <= 3; i++) { + vi.advanceTimersByTime(500) + eta.add(20 * 1024 * 1024) + expect(eta.progress).toBe(40 + i * 20) + expect(eta.time).toBe(1.5 - (i / 2)) + // still no speed information + expect(eta.speed).toBe(-1) + } + expect(eta.progress).toBe(100) + }) + + it('can autostart in constructor', () => { + const eta = new Eta({ start: true, total: 100 }) + expect(eta.status).toBe(EtaStatus.Running) + expect(eta.progress).toBe(0) + expect(eta.time).toBe(Infinity) + expect(eta.speed).toBe(-1) + }) + + it('can reset', () => { + const eta = new Eta({ start: true, total: 100 }) + expect(eta.status).toBe(EtaStatus.Running) + + eta.add(10) + expect(eta.progress).toBe(10) + + eta.reset() + expect(eta.status).toBe(EtaStatus.Idle) + expect(eta.progress).toBe(0) + }) + + it('does not update when idle', () => { + const eta = new Eta() + expect(eta.progress).toBe(0) + + eta.update(10, 100) + expect(eta.progress).toBe(0) + + eta.add(10) + expect(eta.progress).toBe(0) + expect(eta.status).toBe(EtaStatus.Idle) + }) + + it('does not update when paused', () => { + const eta = new Eta({ start: true, total: 100 }) + eta.add(10) + expect(eta.progress).toBe(10) + + eta.pause() + eta.add(10) + expect(eta.progress).toBe(10) + expect(eta.status).toBe(EtaStatus.Paused) + }) + + it('can resume', () => { + const eta = new Eta() + expect(eta.status).toBe(EtaStatus.Idle) + eta.resume() + expect(eta.status).toBe(EtaStatus.Running) + }) +}) + +describe('ETA - events', () => { + it('emits updated event', () => { + const spy = vi.fn() + const eta = new Eta() + eta.addEventListener('update', spy) + + // only works when running so nothing should happen + eta.update(10, 100) + expect(spy).not.toBeCalled() + + // now start and update + eta.resume() + eta.update(10, 100) + expect(spy).toBeCalledTimes(1) + }) + + it('emits reset event', () => { + const spy = vi.fn() + const eta = new Eta() + eta.addEventListener('reset', spy) + + eta.reset() + expect(spy).toBeCalledTimes(1) + }) + + it('emits pause event', () => { + const spy = vi.fn() + const eta = new Eta() + eta.addEventListener('pause', spy) + + // cannot pause if not running + eta.pause() + expect(spy).toBeCalledTimes(0) + + // start + eta.resume() + expect(spy).toBeCalledTimes(0) + + // Pause - this time the event should be emitted + eta.pause() + expect(spy).toBeCalledTimes(1) + // double pause does nothing + eta.pause() + expect(spy).toBeCalledTimes(1) + }) + + it('emits resume event', () => { + const spy = vi.fn() + const eta = new Eta() + eta.addEventListener('resume', spy) + + eta.resume() + expect(spy).toBeCalledTimes(1) + // already resumed so nothing happens + eta.resume() + expect(spy).toBeCalledTimes(1) + }) +}) diff --git a/lib/upload/uploader/Eta.ts b/lib/upload/uploader/Eta.ts new file mode 100644 index 00000000..b80552f7 --- /dev/null +++ b/lib/upload/uploader/Eta.ts @@ -0,0 +1,208 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { TypedEventTarget } from 'typescript-event-target' +import { formatFileSize } from '../../utils/fileSize.ts' + +export const EtaStatus = Object.freeze({ + Idle: 0, + Paused: 1, + Running: 2, +}) + +type TEtaStatus = typeof EtaStatus[keyof typeof EtaStatus] + +interface EtaOptions { + /** Low pass filter cutoff time for smoothing the speed */ + cutoffTime?: number + /** Total number of bytes to be expected */ + total?: number + /** Start the estimation directly */ + start?: boolean +} + +export interface EtaEventsMap { + pause: CustomEvent + reset: CustomEvent + resume: CustomEvent + update: CustomEvent +} + +export class Eta extends TypedEventTarget { + /** Bytes done */ + private _done: number = 0 + /** Total bytes to do */ + private _total: number = 0 + /** Current progress (cached) as interval [0,1] */ + private _progress: number = 0 + /** Status of the ETA */ + private _status: TEtaStatus = EtaStatus.Idle + /** Time of the last update */ + private _startTime: number = -1 + /** Total elapsed time for current ETA */ + private _elapsedTime: number = 0 + /** Current speed in bytes per second */ + private _speed: number = -1 + /** Expected duration to finish in seconds */ + private _eta: number = Infinity + + /** + * Cutoff time for the low pass filter of the ETA. + * A higher value will consider more history information for calculation, + * and thus suppress spikes of the speed, + * but will make the overall resposiveness slower. + */ + private _cutoffTime = 2.5 + + public constructor(options: EtaOptions = {}) { + super() + if (options.start) { + this.resume() + } + if (options.total) { + this.update(0, options.total) + } + this._cutoffTime = options.cutoffTime ?? 2.5 + } + + /** + * Add more transferred bytes. + * + * @param done Additional bytes done. + */ + public add(done: number): void { + this.update(this._done + done) + } + + /** + * Update the transmission state. + * + * @param done The new value of transferred bytes. + * @param total Optionally also update the total bytes we expect. + */ + public update(done: number, total?: number): void { + if (this.status !== EtaStatus.Running) { + return + } + if (total && total > 0) { + this._total = total + } + + const deltaDone = done - this._done + const deltaTime = (Date.now() - this._startTime) / 1000 + + this._startTime = Date.now() + this._elapsedTime += deltaTime + this._done = done + this._progress = this._done / this._total + + // Only update speed when the history is large enough so we can estimate it + const historyNeeded = this._cutoffTime + deltaTime + if (this._elapsedTime > historyNeeded) { + // Filter the done bytes using a low pass filter to suppress speed spikes + const alpha = deltaTime / (deltaTime + (1 / this._cutoffTime)) + const filtered = (this._done - deltaDone) + (1 - alpha) * deltaDone + // bytes per second - filtered + this._speed = Math.round(filtered / this._elapsedTime) + } else if (this._speed === -1 && this._elapsedTime > deltaTime) { + // special case when uploading with high speed + // it could be that the upload is finished before we reach the curoff time + // so we already give an estimation + const remaining = this._total - done + const eta = remaining / (done / this._elapsedTime) + // Only set the ETA when we either already set it for a previous update + // or when the special case happened that we are in fast upload and we only got a couple of seconds for the whole upload + // meaning we are below 2x the cutoff time. + if (this._eta !== Infinity || eta <= 2 * this._cutoffTime) { + // We only take a couple of seconds so we set the eta to the current ETA using current speed. + // But we do not set the speed because we do not want to trigger the real ETA calculation below + // and especially because the speed would be very spiky (we still have no filters in place). + this._eta = eta + } + } + + // Update the eta if we have valid speed information (prevent divide by zero) + if (this._speed > 0) { + // Estimate transfer of remaining bytes with current average speed + this._eta = Math.round((this._total - this._done) / this._speed) + } + + this.dispatchTypedEvent('update', new CustomEvent('update', { cancelable: false })) + } + + public reset(): void { + this._done = 0 + this._total = 0 + this._progress = 0 + this._elapsedTime = 0 + this._eta = Infinity + this._speed = -1 + this._startTime = -1 + this._status = EtaStatus.Idle + this.dispatchTypedEvent('reset', new CustomEvent('reset')) + } + + /** + * Pause the ETA calculation. + */ + public pause(): void { + if (this._status === EtaStatus.Running) { + this._status = EtaStatus.Paused + this._elapsedTime += (Date.now() - this._startTime) / 1000 + this.dispatchTypedEvent('pause', new CustomEvent('pause')) + } + } + + /** + * Resume the ETA calculation. + */ + public resume(): void { + if (this._status !== EtaStatus.Running) { + this._startTime = Date.now() + this._status = EtaStatus.Running + this.dispatchTypedEvent('resume', new CustomEvent('resume')) + } + } + + /** + * Status of the Eta (paused, active, idle). + */ + public get status(): TEtaStatus { + return this._status + } + + /** + * Progress (percent done) + */ + public get progress(): number { + return Math.round(this._progress * 10000) / 100 + } + + /** + * Estimated time in seconds. + * If the time is not yet estimated, it will return `Infinity`. + */ + public get time(): number { + return this._eta + } + + /** + * Transfer speed in bytes per second. + * Returns `-1` if not yet estimated. + */ + public get speed(): number { + return this._speed + } + + /** + * Get the speed in human readable format using file sizes like 10KB/s. + * Returns the empty string if not yet estimated. + */ + public get speedReadable(): string { + return this._speed > 0 + ? `${formatFileSize(this._speed, true)}∕s` + : '' + } +} diff --git a/lib/upload/uploader/Upload.spec.ts b/lib/upload/uploader/Upload.spec.ts new file mode 100644 index 00000000..06f34a40 --- /dev/null +++ b/lib/upload/uploader/Upload.spec.ts @@ -0,0 +1,134 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Upload, UploadStatus } from './Upload.ts' + +describe('Upload', () => { + beforeEach(() => { + (window as any).OC = { + appConfig: { + files: { + max_chunk_size: 5 * 1024 * 1024, + }, + }, + } + }) + + afterEach(() => { + delete (window as any).OC + vi.useRealTimers() + }) + + it('initializes non-chunked uploads by default', () => { + const file = new File(['data'], 'document.txt') + const upload = new Upload('/remote.php/dav/files/user/document.txt', false, 1024, file) + + expect(upload.source).toBe('/remote.php/dav/files/user/document.txt') + expect(upload.file).toBe(file) + expect(upload.size).toBe(1024) + expect(upload.isChunked).toBe(false) + expect(upload.chunks).toBe(1) + expect(upload.status).toBe(UploadStatus.INITIALIZED) + expect(upload.uploaded).toBe(0) + expect(upload.startTime).toBe(0) + }) + + it('enables chunking when configured and multiple chunks are needed', () => { + const file = new File(['data'], 'video.mp4') + const upload = new Upload('/remote.php/dav/files/user/video.mp4', true, 12 * 1024 * 1024, file) + + expect(upload.isChunked).toBe(true) + expect(upload.chunks).toBe(3) + }) + + it('limits the number of chunks to 10000', () => { + const file = new File(['data'], 'huge.bin') + const upload = new Upload('/remote.php/dav/files/user/huge.bin', true, 5 * 1024 * 1024 * 20000, file) + + expect(upload.isChunked).toBe(true) + expect(upload.chunks).toBe(10000) + }) + + it('tracks upload progress and keeps first start time', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-03-09T10:00:00.000Z')) + + const file = new File(['data'], 'archive.zip') + const upload = new Upload('/remote.php/dav/files/user/archive.zip', false, 100, file) + upload.uploaded = 10 + + expect(upload.status).toBe(UploadStatus.UPLOADING) + expect(upload.uploaded).toBe(10) + expect(upload.startTime).toBe(new Date('2026-03-09T10:00:00.000Z').getTime()) + + vi.setSystemTime(new Date('2026-03-09T10:00:10.000Z')) + upload.uploaded = 20 + + expect(upload.status).toBe(UploadStatus.UPLOADING) + expect(upload.uploaded).toBe(20) + expect(upload.startTime).toBe(new Date('2026-03-09T10:00:00.000Z').getTime()) + }) + + it('marks non-chunked uploads as finished when all bytes are uploaded', () => { + const file = new File(['data'], 'photo.jpg') + const upload = new Upload('/remote.php/dav/files/user/photo.jpg', false, 10, file) + + upload.uploaded = 10 + + expect(upload.status).toBe(UploadStatus.FINISHED) + expect(upload.uploaded).toBe(10) + }) + + it('marks chunked uploads as assembling when all bytes are uploaded', () => { + const file = new File(['data'], 'dataset.csv') + const upload = new Upload('/remote.php/dav/files/user/dataset.csv', true, 12 * 1024 * 1024, file) + + upload.uploaded = 12 * 1024 * 1024 + + expect(upload.status).toBe(UploadStatus.ASSEMBLING) + expect(upload.uploaded).toBe(12 * 1024 * 1024) + }) + + it('stores and exposes the server response', () => { + const file = new File(['data'], 'result.txt') + const upload = new Upload('/remote.php/dav/files/user/result.txt', false, 10, file) + const response = { + status: 201, + statusText: 'Created', + headers: {}, + config: { headers: {} }, + data: { ok: true }, + } as any + + expect(upload.response).toBeNull() + upload.response = response + expect(upload.response).toBe(response) + + upload.response = null + expect(upload.response).toBeNull() + }) + + it('can update status directly', () => { + const file = new File(['data'], 'manual.txt') + const upload = new Upload('/remote.php/dav/files/user/manual.txt', false, 10, file) + + upload.status = UploadStatus.FAILED + + expect(upload.status).toBe(UploadStatus.FAILED) + }) + + it('aborts signal and marks upload as cancelled', () => { + const file = new File(['data'], 'cancelled.txt') + const upload = new Upload('/remote.php/dav/files/user/cancelled.txt', false, 10, file) + + expect(upload.signal.aborted).toBe(false) + + upload.cancel() + + expect(upload.signal.aborted).toBe(true) + expect(upload.status).toBe(UploadStatus.CANCELLED) + }) +}) diff --git a/lib/upload/uploader/Upload.ts b/lib/upload/uploader/Upload.ts new file mode 100644 index 00000000..fa0608d3 --- /dev/null +++ b/lib/upload/uploader/Upload.ts @@ -0,0 +1,127 @@ +/*! + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { AxiosResponse } from 'axios' + +import { getMaxChunksSize } from '../utils/config.ts' + +export const UploadStatus = Object.freeze({ + INITIALIZED: 0, + UPLOADING: 1, + ASSEMBLING: 2, + FINISHED: 3, + CANCELLED: 4, + FAILED: 5, +}) + +type TUploadStatus = typeof UploadStatus[keyof typeof UploadStatus] + +export class Upload { + private _source: string + private _file: File + private _isChunked: boolean + private _chunks: number + + private _size: number + private _uploaded = 0 + private _startTime = 0 + + private _status: TUploadStatus = UploadStatus.INITIALIZED + private _controller: AbortController + private _response: AxiosResponse | null = null + + constructor(source: string, chunked = false, size: number, file: File) { + const chunks = Math.min(getMaxChunksSize() > 0 ? Math.ceil(size / getMaxChunksSize()) : 1, 10000) + this._source = source + this._isChunked = chunked && getMaxChunksSize() > 0 && chunks > 1 + this._chunks = this._isChunked ? chunks : 1 + this._size = size + this._file = file + this._controller = new AbortController() + } + + get source(): string { + return this._source + } + + get file(): File { + return this._file + } + + get isChunked(): boolean { + return this._isChunked + } + + get chunks(): number { + return this._chunks + } + + get size(): number { + return this._size + } + + get startTime(): number { + return this._startTime + } + + set response(response: AxiosResponse | null) { + this._response = response + } + + get response(): AxiosResponse | null { + return this._response + } + + get uploaded(): number { + return this._uploaded + } + + /** + * Update the uploaded bytes of this upload + */ + set uploaded(length: number) { + if (length >= this._size) { + this._status = this._isChunked + ? UploadStatus.ASSEMBLING + : UploadStatus.FINISHED + this._uploaded = this._size + return + } + + this._status = UploadStatus.UPLOADING + this._uploaded = length + + // If first progress, let's log the start time + if (this._startTime === 0) { + this._startTime = new Date().getTime() + } + } + + get status(): TUploadStatus { + return this._status + } + + /** + * Update this upload status + */ + set status(status: TUploadStatus) { + this._status = status + } + + /** + * Returns the axios cancel token source + */ + get signal(): AbortSignal { + return this._controller.signal + } + + /** + * Cancel any ongoing requests linked to this upload + */ + cancel() { + this._controller.abort() + this._status = UploadStatus.CANCELLED + } +} diff --git a/lib/upload/uploader/Uploader.spec.ts b/lib/upload/uploader/Uploader.spec.ts new file mode 100644 index 00000000..0960f652 --- /dev/null +++ b/lib/upload/uploader/Uploader.spec.ts @@ -0,0 +1,188 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Folder } from '../../node/folder.ts' +import { Permission } from '../../permissions.ts' +import { UploadCancelledError } from '../errors/UploadCancelledError.ts' +import { UploadStatus } from './Upload.ts' +import { Uploader, UploaderStatus } from './Uploader.ts' + +const nextcloudAuth = vi.hoisted(() => ({ + getCurrentUser: vi.fn(() => ({ uid: 'test', displayName: 'Test', isAdmin: false })), +})) +vi.mock('@nextcloud/auth', () => nextcloudAuth) + +const nextcloudCapabilities = vi.hoisted(() => ({ + getCapabilities: vi.fn(() => ({ + files: { chunked_upload: { max_parallel_count: 2 } }, + dav: { public_shares_chunking: true }, + })), +})) +vi.mock('@nextcloud/capabilities', () => nextcloudCapabilities) + +const nextcloudAxios = vi.hoisted(() => ({ + default: { + request: vi.fn(), + }, + isCancel: vi.fn(() => false), +})) +vi.mock('@nextcloud/axios', () => nextcloudAxios) + +const dav = vi.hoisted(() => ({ + getClient: vi.fn(() => ({ + createDirectory: vi.fn(), + })), + defaultRemoteURL: 'https://localhost/remote.php/dav', + defaultRootPath: '/files/test', +})) +vi.mock('../../dav/dav.ts', () => dav) + +const uploadUtils = vi.hoisted(() => ({ + getChunk: vi.fn(async (file: File) => new Blob([file], { type: file.type || 'application/octet-stream' })), + initChunkWorkspace: vi.fn(), + uploadData: vi.fn(async (_url: string, _blob: Blob, options: any) => { + options?.onUploadProgress?.({ bytes: 50 }) + return { + status: 201, + statusText: 'Created', + headers: {}, + config: { headers: {} }, + data: { ok: true }, + } + }), +})) +vi.mock('../utils/upload.ts', () => uploadUtils) + +vi.mock('../utils/filesystem.ts', () => ({ + isFileSystemDirectoryEntry: vi.fn(() => false), + isFileSystemFileEntry: vi.fn(() => false), +})) + +vi.mock('../../utils/logger.ts', () => ({ + default: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +describe('Uploader', () => { + beforeEach(() => { + (window as any).OC = { + appConfig: { + files: { + max_chunk_size: 0, + }, + }, + } + + nextcloudAuth.getCurrentUser.mockReturnValue({ uid: 'test', displayName: 'Test', isAdmin: false }) + nextcloudCapabilities.getCapabilities.mockReturnValue({ + files: { chunked_upload: { max_parallel_count: 2 } }, + dav: { public_shares_chunking: true }, + }) + nextcloudAxios.isCancel.mockReturnValue(false) + uploadUtils.getChunk.mockClear() + uploadUtils.uploadData.mockClear() + dav.getClient.mockClear() + }) + + afterEach(() => { + delete (window as any).OC + vi.restoreAllMocks() + }) + + it('initializes with the default destination for logged in users', () => { + const uploader = new Uploader() + + expect(uploader.destination).toBeInstanceOf(Folder) + expect(uploader.destination.owner).toBe('test') + expect(uploader.root).toBe('https://localhost/remote.php/dav/files/test') + expect(uploader.info.status).toBe(UploaderStatus.IDLE) + }) + + it('throws when no user is logged in and uploader is not public', () => { + nextcloudAuth.getCurrentUser.mockReturnValue(null as any) + + expect(() => new Uploader(false)).toThrowError('User is not logged in') + }) + + it('uses anonymous owner in public mode', () => { + nextcloudAuth.getCurrentUser.mockReturnValue(null as any) + const uploader = new Uploader(true) + + expect(uploader.destination.owner).toBe('anonymous') + }) + + it('manages custom headers through a cloned public getter', () => { + const uploader = new Uploader() + uploader.setCustomHeader('X-NC-Test', '1') + + const headers = uploader.customHeaders + headers['X-NC-Test'] = '2' + + expect(uploader.customHeaders['X-NC-Test']).toBe('1') + + uploader.deleteCustomerHeader('X-NC-Test') + expect(uploader.customHeaders['X-NC-Test']).toBeUndefined() + }) + + it('rejects invalid destination values', () => { + const uploader = new Uploader() + + expect(() => { + uploader.destination = { type: null, source: '' } as any + }).toThrowError('Invalid destination folder') + }) + + it('uploads files in regular mode and notifies listeners', async () => { + const uploader = new Uploader() + uploader.setCustomHeader('X-NC-Test', 'value') + const notifier = vi.fn() + uploader.addNotifier(notifier) + + const file = new File(['x'.repeat(100)], 'report.txt', { type: 'text/plain', lastModified: 1000 }) + const upload = await uploader.upload('/docs/report.txt', file) + + await vi.waitFor(() => { + expect(upload.status).toBe(UploadStatus.FINISHED) + }) + + expect(upload.uploaded).toBe(upload.size) + expect(upload.response?.status).toBe(201) + expect(uploadUtils.getChunk).toHaveBeenCalledTimes(1) + expect(uploadUtils.uploadData).toHaveBeenCalledTimes(1) + expect(notifier).toHaveBeenCalledTimes(1) + expect(notifier).toHaveBeenCalledWith(upload) + + await vi.waitFor(() => { + expect(uploader.info.status).toBe(UploaderStatus.IDLE) + }) + }) + + it('converts callback cancellation in batch upload to UploadCancelledError', async () => { + const uploader = new Uploader(false, new Folder({ + id: 1, + owner: 'test', + permissions: Permission.ALL, + root: '/files/test', + source: 'https://localhost/dav/files/test', + })) + + const notifier = vi.fn() + uploader.addNotifier(notifier) + const file = new File(['data'], 'a.txt', { type: 'text/plain' }) + + await expect(uploader.batchUpload('/uploads', [file], { + callback: async () => false, + })).rejects.toBeInstanceOf(UploadCancelledError) + + expect(dav.getClient).toHaveBeenCalledTimes(1) + expect(notifier).toHaveBeenCalledTimes(1) + expect(notifier.mock.calls[0][0].status).toBe(UploadStatus.CANCELLED) + }) +}) diff --git a/lib/upload/uploader/Uploader.ts b/lib/upload/uploader/Uploader.ts new file mode 100644 index 00000000..05ce2b96 --- /dev/null +++ b/lib/upload/uploader/Uploader.ts @@ -0,0 +1,726 @@ +/*! + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { AxiosError, AxiosResponse } from 'axios' +import type { WebDAVClient } from 'webdav' +import type { IFolder } from '../../node/folder.ts' +import type { IDirectory } from '../utils/fileTree.ts' + +import { getCurrentUser } from '@nextcloud/auth' +import axios, { isCancel } from '@nextcloud/axios' +import { getCapabilities } from '@nextcloud/capabilities' +import { encodePath } from '@nextcloud/paths' +import PQueue from 'p-queue' +import { normalize } from 'path' +import { defaultRemoteURL, defaultRootPath, getClient } from '../../dav/dav.ts' +import { FileType, Folder } from '../../node/index.ts' +import { Permission } from '../../permissions.ts' +import logger from '../../utils/logger.ts' +import { UploadCancelledError } from '../errors/UploadCancelledError.ts' +import { getMaxChunksSize } from '../utils/config.ts' +import { isFileSystemFileEntry } from '../utils/filesystem.ts' +import { Directory } from '../utils/fileTree.ts' +import { getChunk, initChunkWorkspace, uploadData } from '../utils/upload.ts' +import { Eta } from './Eta.ts' +import { Upload, UploadStatus } from './Upload.ts' + +export const UploaderStatus = Object.freeze({ + IDLE: 0, + UPLOADING: 1, + PAUSED: 2, +}) + +type TUploaderStatus = typeof UploaderStatus[keyof typeof UploaderStatus] + +interface BaseOptions { + /** + * Abort signal to cancel the upload + */ + signal?: AbortSignal +} + +interface UploadOptions extends BaseOptions { + /** + * The root folder where to upload + */ + root?: string + + /** + * Number of retries for the upload + * + * @default 5 + */ + retries?: number +} + +interface DirectoryUploadOptions extends BaseOptions { + destination: string + directory: Directory + client: WebDAVClient +} + +interface BatchUploadOptions extends BaseOptions { + callback?: (nodes: Array, currentPath: string) => Promise | false> +} + +export class Uploader { + // Initialized via setter in the constructor + private _destinationFolder!: IFolder + private _isPublic: boolean + private _customHeaders: Record + + // Global upload queue + private _uploadQueue: Array = [] + private _jobQueue: PQueue = new PQueue({ + // Maximum number of concurrent uploads + // @ts-expect-error TS2339 Object has no defined properties + concurrency: getCapabilities().files?.chunked_upload?.max_parallel_count ?? 5, + }) + + private _queueSize = 0 + private _queueProgress = 0 + private _queueStatus: TUploaderStatus = UploaderStatus.IDLE + + private _eta = new Eta() + + private _notifiers: Array<(upload: Upload) => void> = [] + + /** + * Initialize uploader + * + * @param isPublic are we in public mode ? + * @param destinationFolder the context folder to operate, relative to the root folder + */ + constructor( + isPublic = false, + destinationFolder?: IFolder, + ) { + this._isPublic = isPublic + this._customHeaders = {} + + if (!destinationFolder) { + const source = `${defaultRemoteURL}${defaultRootPath}` + let owner: string + + if (isPublic) { + owner = 'anonymous' + } else { + const user = getCurrentUser()?.uid + if (!user) { + throw new Error('User is not logged in') + } + owner = user + } + + destinationFolder = new Folder({ + id: 0, + owner, + permissions: Permission.ALL, + root: defaultRootPath, + source, + }) + } + this.destination = destinationFolder + + logger.debug('Upload workspace initialized', { + destination: this.destination, + root: this.root, + isPublic, + maxChunksSize: getMaxChunksSize(), + }) + } + + /** + * Get the upload destination path relative to the root folder + */ + get destination(): IFolder { + return this._destinationFolder + } + + /** + * Set the upload destination path relative to the root folder + */ + set destination(folder: IFolder) { + if (!folder || folder.type !== FileType.Folder || !folder.source) { + throw new Error('Invalid destination folder') + } + + logger.debug('Destination set', { folder }) + this._destinationFolder = folder + } + + /** + * Get the root folder + */ + get root() { + return this._destinationFolder.source + } + + /** + * Get registered custom headers for uploads + */ + get customHeaders(): Record { + return structuredClone(this._customHeaders) + } + + /** + * Set a custom header + * + * @param name The header to set + * @param value The string value + */ + setCustomHeader(name: string, value: string = ''): void { + this._customHeaders[name] = value + } + + /** + * Unset a custom header + * + * @param name The header to unset + */ + deleteCustomerHeader(name: string): void { + delete this._customHeaders[name] + } + + /** + * Get the upload queue + */ + get queue(): Upload[] { + return this._uploadQueue + } + + private reset() { + // Reset the ETA + this._eta.reset() + // If there is no upload in the queue and no job in the queue + if (this._uploadQueue.length === 0 && this._jobQueue.size === 0) { + return + } + + // Reset upload queue but keep the reference + this._uploadQueue.splice(0, this._uploadQueue.length) + this._jobQueue.clear() + this._queueSize = 0 + this._queueProgress = 0 + this._queueStatus = UploaderStatus.IDLE + logger.debug('Uploader state reset') + } + + /** + * Pause any ongoing upload(s) + */ + public pause() { + this._eta.pause() + this._jobQueue.pause() + this._queueStatus = UploaderStatus.PAUSED + this.updateStats() + logger.debug('Uploader paused') + } + + /** + * Resume any pending upload(s) + */ + public start() { + this._eta.resume() + this._jobQueue.start() + this._queueStatus = UploaderStatus.UPLOADING + this.updateStats() + logger.debug('Uploader resumed') + } + + /** + * Get the estimation for the uploading time. + */ + get eta(): Eta { + return this._eta + } + + /** + * Get the upload queue stats + */ + get info() { + return { + size: this._queueSize, + progress: this._queueProgress, + status: this._queueStatus, + } + } + + private updateStats() { + const size = this._uploadQueue.map((upload) => upload.size) + .reduce((partialSum, a) => partialSum + a, 0) + const uploaded = this._uploadQueue.map((upload) => upload.uploaded) + .reduce((partialSum, a) => partialSum + a, 0) + + this._eta.update(uploaded, size) + this._queueSize = size + this._queueProgress = uploaded + + // If already paused keep it that way + if (this._queueStatus !== UploaderStatus.PAUSED) { + const pending = this._uploadQueue.find(({ status }) => ([UploadStatus.INITIALIZED, UploadStatus.UPLOADING, UploadStatus.ASSEMBLING] as number[]).includes(status)) + if (this._jobQueue.size > 0 || pending) { + this._queueStatus = UploaderStatus.UPLOADING + } else { + this.eta.reset() + this._queueStatus = UploaderStatus.IDLE + } + } + } + + addNotifier(notifier: (upload: Upload) => void) { + this._notifiers.push(notifier) + } + + /** + * Notify listeners of the upload completion + * + * @param upload The upload that finished + */ + private _notifyAll(upload: Upload): void { + for (const notifier of this._notifiers) { + try { + notifier(upload) + } catch (error) { + logger.warn('Error in upload notifier', { error, source: upload.source }) + } + } + } + + /** + * Uploads multiple files or folders while preserving the relative path (if available) + * + * @param destination The destination path relative to the root folder. e.g. /foo/bar (a file "a.txt" will be uploaded then to "/foo/bar/a.txt") + * @param files The files and/or folders to upload + * @param options - optional parameters + * @param options.callback Callback that receives the nodes in the current folder and the current path to allow resolving conflicts, all nodes that are returned will be uploaded (if a folder does not exist it will be created) + * @throws {UploadCancelledError} - If the upload was canceled by the user via the abort signal + * + * @example + * ```ts + * // For example this is from handling the onchange event of an input[type=file] + * async handleFiles(files: File[]) { + * this.uploads = await this.uploader.batchUpload('uploads', files, { callback: this.handleConflicts }) + * } + * + * async handleConflicts(nodes: File[], currentPath: string) { + * const conflicts = getConflicts(nodes, this.fetchContent(currentPath)) + * if (conflicts.length === 0) { + * // No conflicts so upload all + * return nodes + * } else { + * // Open the conflict picker to resolve conflicts + * try { + * const { selected, renamed } = await openConflictPicker(currentPath, conflicts, this.fetchContent(currentPath), { recursive: true }) + * return [...selected, ...renamed] + * } catch (e) { + * return false + * } + * } + * } + * ``` + */ + async batchUpload( + destination: string, + files: (File | FileSystemEntry)[], + options?: BatchUploadOptions, + ): Promise { + const rootFolder = new Directory('') + await rootFolder.addChildren(files) + // create a meta upload to ensure all ongoing child requests are listed + const target = `${this.root.replace(/\/$/, '')}/${destination.replace(/^\//, '')}` + const upload = new Upload(target, false, 0, rootFolder) + upload.status = UploadStatus.UPLOADING + this._uploadQueue.push(upload) + + logger.debug('Starting new batch upload', { target }) + try { + // setup client with root and custom header + const client = getClient(this.root, this._customHeaders) + // Create the promise for the virtual root directory + const promise = this.uploadDirectory({ + ...options, + destination, + directory: rootFolder, + client, + }) + // await the uploads and resolve with "finished" status + const uploads = await promise + upload.status = UploadStatus.FINISHED + return uploads + } catch (error) { + if (isCancel(error) || error instanceof UploadCancelledError || (error instanceof DOMException && error.name === 'AbortError')) { + logger.info('Upload cancelled by user', { error }) + upload.status = UploadStatus.CANCELLED + throw new UploadCancelledError(error) + } else { + logger.error('Error in batch upload', { error }) + upload.status = UploadStatus.FAILED + throw error + } + } finally { + // Upload queue is cleared when all the uploading jobs are done + // Meta upload unlike real uploading does not create a job + // Removing it manually here to make sure it is remove even when no uploading happened and there was nothing to finish + this._uploadQueue.splice(this._uploadQueue.indexOf(upload), 1) + this._notifyAll(upload) + this.updateStats() + } + } + + /** + * Helper to create a directory wrapped inside an Upload class + * + * @param options - the options for the directory upload + * @param options.destination Destination where to create the directory + * @param options.directory The directory to create + * @param options.client The cached WebDAV client + */ + private async createDirectory(options: DirectoryUploadOptions): Promise { + if (!options.directory.name) { + throw new Error('Can not create empty directory') + } + + const folderPath = normalize(`${options.destination}/${options.directory.name}`).replace(/\/$/, '') + const rootPath = `${this.root.replace(/\/$/, '')}/${folderPath.replace(/^\//, '')}` + + // Add a new upload to the upload queue + const currentUpload: Upload = new Upload(rootPath, false, 0, options.directory) + if (options.signal) { + options.signal.addEventListener('abort', currentUpload.cancel) + } + this._uploadQueue.push(currentUpload) + + try { + // Add the request to the job queue -> wait for finish to resolve the promise + return await this._jobQueue.add(async () => { + currentUpload.status = UploadStatus.UPLOADING + await options.client.createDirectory(folderPath, { signal: currentUpload.signal }) + return currentUpload + }) + } catch (error) { + if (isCancel(error) || error instanceof UploadCancelledError || (error instanceof DOMException && error.name === 'AbortError')) { + currentUpload.status = UploadStatus.CANCELLED + throw new UploadCancelledError(error) + } else if (error && typeof error === 'object' && 'status' in error && error.status === 405) { + // Directory already exists, so just write into it and ignore the error + logger.debug('Directory already exists, writing into it', { directory: options.directory.name }) + currentUpload.status = UploadStatus.FINISHED + return currentUpload + } else { + // Another error happened, so abort uploading the directory + currentUpload.status = UploadStatus.FAILED + throw error + } + } finally { + // Update statistics + this._notifyAll(currentUpload) + this.updateStats() + } + } + + // Helper for uploading directories (recursively) + private async uploadDirectory(options: BatchUploadOptions & DirectoryUploadOptions): Promise { + // we use an internal abort controller to also cancel uploads if an error happened. + // So if a signal is provided we connect it to our controller. + const internalAbortController = new AbortController() + if (options.signal) { + options.signal.addEventListener('abort', () => internalAbortController.abort()) + } + + const internalOptions = { ...options, signal: internalAbortController.signal } + const folderPath = normalize(`${internalOptions.destination}/${internalOptions.directory.name}`).replace(/\/$/, '') + + // Let the user handle conflicts + const selectedForUpload = await (internalOptions.callback?.(internalOptions.directory.children, folderPath) ?? internalOptions.directory.children) + if (selectedForUpload === false) { + logger.debug('Upload canceled by user', { directory: internalOptions.directory }) + throw new UploadCancelledError('Conflict resolution cancelled by user') + } else if (selectedForUpload.length === 0 && internalOptions.directory.children.length > 0) { + logger.debug('Skipping directory, as all files were skipped by user', { directory: internalOptions.directory }) + return [] + } + + logger.debug('Start directory upload', { directory: internalOptions.directory }) + const directories: Promise[] = [] + const uploads: Promise[] = [] + try { + if (internalOptions.directory.name) { + // If not the virtual root we need to create the directory first before uploading + // Make sure the promise is listed in the final result + uploads.push(this.createDirectory(internalOptions)) + // Ensure the directory is created before uploading / creating children + await uploads.at(-1) + } + + for (const node of selectedForUpload) { + if (node instanceof Directory) { + directories.push(this.uploadDirectory({ ...internalOptions, directory: node })) + } else { + uploads.push(this.upload(`${folderPath}/${node.name}`, node, { signal: internalOptions.signal })) + } + } + + const resolvedUploads = await Promise.all(uploads) + const resolvedDirectoryUploads = await Promise.all(directories) + return [resolvedUploads, ...resolvedDirectoryUploads].flat() + } catch (e) { + // Ensure a failure cancels all other requests + internalAbortController.abort() + throw e + } + } + + /** + * Upload a file to the given path + * + * @param destination - The destination path relative to the root folder. e.g. /foo/bar.txt + * @param fileHandle - The file to upload + * @param options - Optional parameters + */ + async upload(destination: string, fileHandle: File | FileSystemFileEntry, options?: UploadOptions): Promise { + const root = options?.root ?? this.root + const destinationPath = `${root.replace(/\/$/, '')}/${destination.replace(/^\//, '')}` + + // Get the encoded source url to this object for requests purposes + const { origin } = new URL(destinationPath) + const encodedDestinationFile = origin + encodePath(destinationPath.slice(origin.length)) + + this.eta.resume() + logger.debug(`Uploading ${fileHandle.name} to ${encodedDestinationFile}`) + + // Handle file system entries by retrieving the file handle + if (isFileSystemFileEntry(fileHandle)) { + fileHandle = await new Promise((resolve, reject) => (fileHandle as FileSystemFileEntry).file(resolve, reject)) + } + // We can cast here as we handled system entries in the if above + const file = fileHandle as File + + // @ts-expect-error TS2339 Object has no defined properties + const supportsPublicChunking = getCapabilities().dav?.public_shares_chunking ?? false + const maxChunkSize = getMaxChunksSize('size' in file ? file.size : undefined) + // If manually disabled or if the file is too small + const disabledChunkUpload = (this._isPublic && !supportsPublicChunking) + || maxChunkSize === 0 + || ('size' in file && file.size < maxChunkSize) + + const upload = new Upload(destinationPath, !disabledChunkUpload, file.size, file) + this._uploadQueue.push(upload) + this.updateStats() + + // Register cancellation caller + if (options?.signal) { + options.signal.addEventListener('abort', upload.cancel) + } + + const retries = options?.retries ?? 5 + if (!disabledChunkUpload) { + logger.debug('Initializing chunked upload', { file, upload }) + + // Let's initialize a chunk upload + const tempUrl = await initChunkWorkspace(encodedDestinationFile, retries, this._isPublic, this._customHeaders) + const chunksQueue: Array> = [] + + // Generate chunks array + for (let chunk = 0; chunk < upload.chunks; chunk++) { + const bufferStart = chunk * maxChunkSize + // Don't go further than the file size + const bufferEnd = Math.min(bufferStart + maxChunkSize, upload.size) + // Make it a Promise function for better memory management + const blob = () => getChunk(file, bufferStart, maxChunkSize) + + // Init request queue + const request = () => { + // bytes uploaded on this chunk (as upload.uploaded tracks all chunks) + let chunkBytes = 0 + return uploadData( + `${tempUrl}/${chunk + 1}`, + blob, + { + signal: upload.signal, + destinationFile: encodedDestinationFile, + retries, + onUploadProgress: ({ bytes }) => { + // Only count 90% of bytes as the request is not yet processed by server + // we set the remaining 10% when the request finished (server responded). + const progressBytes = bytes * 0.9 + chunkBytes += progressBytes + upload.uploaded += progressBytes + this.updateStats() + }, + onUploadRetry: () => { + // Current try failed, so reset the stats for this chunk + // meaning remove the uploaded chunk bytes from stats + upload.uploaded -= chunkBytes + chunkBytes = 0 + this.updateStats() + }, + headers: { + ...this._customHeaders, + ...this._mtimeHeader(file), + 'OC-Total-Length': file.size, + 'Content-Type': 'application/octet-stream', + }, + }, + ) + // Update upload progress on chunk completion + .then(() => { + // request fully done so we uploaded the full chunk + // we first remove the intermediate chunkBytes from progress events + // and then add the real full size + upload.uploaded += bufferEnd - bufferStart - chunkBytes + this.updateStats() + }) + .catch((error) => { + if (error?.response?.status === 507) { + logger.error('Upload failed, not enough space on the server or quota exceeded. Cancelling the remaining chunks', { error, upload }) + upload.cancel() + upload.status = UploadStatus.FAILED + throw error + } + + if (!isCancel(error)) { + logger.error(`Chunk ${chunk + 1} ${bufferStart} - ${bufferEnd} uploading failed`, { error, upload }) + upload.cancel() + upload.status = UploadStatus.FAILED + } + throw error + }) + } + chunksQueue.push(this._jobQueue.add(request)) + } + + const request = async () => { + try { + // Once all chunks are sent, assemble the final file + await Promise.all(chunksQueue) + + // Assemble the chunks + upload.status = UploadStatus.ASSEMBLING + this.updateStats() + + // Send the assemble request + upload.response = await axios.request({ + method: 'MOVE', + url: `${tempUrl}/.file`, + headers: { + ...this._customHeaders, + ...this._mtimeHeader(file), + 'OC-Total-Length': file.size, + Destination: encodedDestinationFile, + }, + }) + upload.status = UploadStatus.FINISHED + this.updateStats() + + logger.debug(`Successfully uploaded ${file.name}`, { file, upload }) + return upload + } catch (error) { + // Cleaning up temp directory + axios.request({ + method: 'DELETE', + url: `${tempUrl}`, + }) + + if (isCancel(error) || error instanceof UploadCancelledError) { + upload.status = UploadStatus.CANCELLED + throw new UploadCancelledError(error) + } else { + upload.status = UploadStatus.FAILED + throw new Error('Failed to assemble the chunks together') + } + } finally { + // Notify listeners of the upload completion + this._notifyAll(upload) + } + } + + this._jobQueue.add(request) + } else { + logger.debug('Initializing regular upload', { file, upload }) + + // Generating upload limit + const blob = await getChunk(file, 0, upload.size) + const request = async () => { + try { + upload.response = await uploadData( + encodedDestinationFile, + blob, + { + signal: upload.signal, + onUploadProgress: ({ bytes }) => { + // As this is only the sent bytes not the processed ones we only count 90%. + // When the upload is finished (server acknowledged the upload) the remaining 10% will be correctly set. + upload.uploaded += bytes * 0.9 + this.updateStats() + }, + onUploadRetry: () => { + upload.uploaded = 0 + this.updateStats() + }, + headers: { + ...this._customHeaders, + ...this._mtimeHeader(file), + 'Content-Type': file.type, + }, + }, + ) + + // Update progress - now we set the uploaded size to 100% of the file size + upload.uploaded = upload.size + this.updateStats() + + // Resolve + logger.debug(`Successfully uploaded ${file.name}`, { file, upload }) + return upload + } catch (error) { + if (isCancel(error) || error instanceof UploadCancelledError) { + upload.status = UploadStatus.CANCELLED + throw new UploadCancelledError(error) + } + + // Attach response to the upload object + if ((error as AxiosError)?.response) { + upload.response = (error as AxiosError).response as AxiosResponse + } + + upload.status = UploadStatus.FAILED + logger.error(`Failed uploading ${file.name}`, { error, file, upload }) + throw new Error('Failed to upload the file') + } finally { + // Notify listeners of the upload completion + this._notifyAll(upload) + } + } + this._jobQueue.add(request) + } + + // Reset when upload queue is done + // Only when we know we're closing on the last chunks + // and/or assembling we can reset the uploader. + // Otherwise he queue might be idle for a short time + // and clear the Upload queue before we're done. + this._jobQueue.onIdle() + .then(() => this.reset()) + + // Finally return the Upload + return upload + } + + /** + * Create modification time headers if valid value is available. + * It can be invalid on Android devices if SD cards with NTFS / FAT are used, + * as those files might use the NT epoch for time so the value will be negative. + * + * @param file The file to upload + */ + private _mtimeHeader(file: File): { 'X-OC-Mtime'?: number } { + const mtime = Math.floor(file.lastModified / 1000) + if (mtime > 0) { + return { 'X-OC-Mtime': mtime } + } + return {} + } +} diff --git a/lib/upload/uploader/index.ts b/lib/upload/uploader/index.ts new file mode 100644 index 00000000..13de3ad6 --- /dev/null +++ b/lib/upload/uploader/index.ts @@ -0,0 +1,16 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export { + type Eta, + type EtaEventsMap, + + EtaStatus, +} from './Eta.ts' + +export { + Uploader, + UploaderStatus, +} from './Uploader.ts' diff --git a/lib/upload/utils/config.ts b/lib/upload/utils/config.ts new file mode 100644 index 00000000..511460a6 --- /dev/null +++ b/lib/upload/utils/config.ts @@ -0,0 +1,31 @@ +/*! + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Get the maximum chunk size for chunked uploads based on the server configuration and file size. + * + * @param fileSize - The size of the file to be uploaded. If not provided, the function will return the default chunk size. + */ +export function getMaxChunksSize(fileSize: number | undefined = undefined): number { + const maxChunkSize = window.OC?.appConfig?.files?.max_chunk_size + if (maxChunkSize <= 0) { + return 0 + } + + // If invalid return default + if (!Number(maxChunkSize)) { + return 10 * 1024 * 1024 + } + + // v2 of chunked upload requires chunks to be 5 MB at minimum + const minimumChunkSize = Math.max(Number(maxChunkSize), 5 * 1024 * 1024) + + if (fileSize === undefined) { + return minimumChunkSize + } + + // Adapt chunk size to fit the file in 10000 chunks for chunked upload v2 + return Math.max(minimumChunkSize, Math.ceil(fileSize / 10000)) +} diff --git a/lib/upload/utils/conflicts.ts b/lib/upload/utils/conflicts.ts new file mode 100644 index 00000000..2d4e5adb --- /dev/null +++ b/lib/upload/utils/conflicts.ts @@ -0,0 +1,34 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode } from '../../node/index.ts' + +/** + * Check if there is a conflict between two sets of files + * + * @param files the incoming files + * @param content all the existing files in the directory + * @return true if there is a conflict + */ +export function hasConflict(files: (File | FileSystemEntry | INode)[], content: INode[]): boolean { + return getConflicts(files, content).length > 0 +} + +/** + * Get the conflicts between two sets of files + * + * @param files the incoming files + * @param content all the existing files in the directory + * @return true if there is a conflict + */ +export function getConflicts(files: T[], content: INode[]): T[] { + const contentNames = content.map((node: INode) => node.basename) + const conflicts = files.filter((node: File | FileSystemEntry | INode) => { + const name = 'basename' in node ? node.basename : node.name + return contentNames.indexOf(name) !== -1 + }) + + return conflicts +} diff --git a/lib/upload/utils/fileTree.ts b/lib/upload/utils/fileTree.ts new file mode 100644 index 00000000..b9cbbd65 --- /dev/null +++ b/lib/upload/utils/fileTree.ts @@ -0,0 +1,127 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +/** + * Helpers to generate a file tree when the File and Directory API is used (e.g. Drag and Drop or ) + */ + +import { basename } from '@nextcloud/paths' +import { isFileSystemDirectoryEntry, isFileSystemFileEntry } from './filesystem.ts' + +/** + * This is a helper class to allow building a file tree for uploading + * It allows to create virtual directories + */ +export class Directory extends File { + private _originalName: string + private _path: string + private _children: Map + + constructor(path: string) { + super([], basename(path), { type: 'httpd/unix-directory', lastModified: 0 }) + this._children = new Map() + this._originalName = basename(path) + this._path = path + } + + get size(): number { + return this.children.reduce((sum, file) => sum + file.size, 0) + } + + get lastModified(): number { + return this.children.reduce((latest, file) => Math.max(latest, file.lastModified), 0) + } + + // We need this to keep track of renamed files + get originalName(): string { + return this._originalName + } + + get children(): Array { + return Array.from(this._children.values()) + } + + get webkitRelativePath(): string { + return this._path + } + + getChild(name: string): File | Directory | null { + return this._children.get(name) ?? null + } + + /** + * Add multiple children at once + * + * @param files The files to add + */ + async addChildren(files: Array): Promise { + for (const file of files) { + await this.addChild(file) + } + } + + /** + * Add a child to the directory. + * If it is a nested child the parents will be created if not already exist. + * + * @param file The child to add + */ + async addChild(file: File | FileSystemEntry) { + const rootPath = this._path && `${this._path}/` + if (isFileSystemFileEntry(file)) { + file = await new Promise((resolve, reject) => (file as FileSystemFileEntry).file(resolve, reject)) + } else if (isFileSystemDirectoryEntry(file)) { + const reader = file.createReader() + const entries = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)) + + // Create a new child directory and add the entries + const child = new Directory(`${rootPath}${file.name}`) + await child.addChildren(entries) + this._children.set(file.name, child) + return + } + + // Make Typescript calm - we ensured it is not a file system entry above. + file = file as File + + const filePath = file.webkitRelativePath ?? file.name + // Handle plain files + if (!filePath.includes('/')) { + // Direct child of the directory + this._children.set(file.name, file) + } else { + // Check if file is a child + if (!filePath.startsWith(this._path)) { + throw new Error(`File ${filePath} is not a child of ${this._path}`) + } + + // If file is a child check if we need to nest it + const relPath = filePath.slice(rootPath.length) + const name = basename(relPath) + + if (name === relPath) { + // It is a direct child so we can add it + this._children.set(name, file) + } else { + // It is not a direct child so we need to create intermediate nodes + const base = relPath.slice(0, relPath.indexOf('/')) + if (this._children.has(base)) { + // It is a grandchild so we can add it directly + await (this._children.get(base) as Directory).addChild(file) + } else { + // We do not know any parent of that child + // so we need to add a new child on the current level + const child = new Directory(`${rootPath}${base}`) + await child.addChild(file) + this._children.set(base, child) + } + } + } + } +} + +/** + * Interface of the internal Directory class + */ +export type IDirectory = Pick diff --git a/lib/upload/utils/filesystem.ts b/lib/upload/utils/filesystem.ts new file mode 100644 index 00000000..0602cc49 --- /dev/null +++ b/lib/upload/utils/filesystem.ts @@ -0,0 +1,12 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +// Helpers for the File and Directory API + +// Helper to support browser that do not support the API +export const isFileSystemDirectoryEntry = (o: unknown): o is FileSystemDirectoryEntry => 'FileSystemDirectoryEntry' in window && o instanceof FileSystemDirectoryEntry + +export const isFileSystemFileEntry = (o: unknown): o is FileSystemFileEntry => 'FileSystemFileEntry' in window && o instanceof FileSystemFileEntry + +export const isFileSystemEntry = (o: unknown): o is FileSystemEntry => 'FileSystemEntry' in window && o instanceof FileSystemEntry diff --git a/lib/upload/utils/upload.ts b/lib/upload/utils/upload.ts new file mode 100644 index 00000000..83f28d16 --- /dev/null +++ b/lib/upload/utils/upload.ts @@ -0,0 +1,154 @@ +/*! + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { AxiosError, AxiosProgressEvent, AxiosResponse } from 'axios' + +import { getCurrentUser } from '@nextcloud/auth' +import axios from '@nextcloud/axios' +import { generateRemoteUrl, getBaseUrl } from '@nextcloud/router' +import { getSharingToken } from '@nextcloud/sharing/public' +import axiosRetry, { exponentialDelay, isNetworkOrIdempotentRequestError } from 'axios-retry' +import logger from '../../utils/logger.ts' + +axiosRetry(axios, { retries: 0 }) + +type UploadData = Blob | (() => Promise) + +interface UploadDataOptions { + /** The abort signal */ + signal: AbortSignal + /** Upload progress event callback */ + onUploadProgress?: (event: AxiosProgressEvent) => void + /** Request retry callback (e.g. network error of previous try) */ + onUploadRetry?: () => void + /** The final destination file (for chunked uploads) */ + destinationFile?: string + /** Additional headers */ + headers?: Record + /** Number of retries */ + retries?: number +} + +/** + * Upload some data to a given path + * + * @param url the url to upload to + * @param uploadData the data to upload + * @param uploadOptions upload options + */ +export async function uploadData( + url: string, + uploadData: UploadData, + uploadOptions: UploadDataOptions, +): Promise { + const options = { + headers: {}, + onUploadProgress: () => {}, + onUploadRetry: () => {}, + retries: 5, + ...uploadOptions, + } + + let data: Blob + + // If the upload data is a blob, we can directly use it + // Otherwise, we need to wait for the promise to resolve + if (uploadData instanceof Blob) { + data = uploadData + } else { + data = await uploadData() + } + + // Helps the server to know what to do with the file afterwards (e.g. chunked upload) + if (options.destinationFile) { + options.headers.Destination = options.destinationFile + } + + // If no content type is set, we default to octet-stream + if (!options.headers['Content-Type']) { + options.headers['Content-Type'] = 'application/octet-stream' + } + + return await axios.request({ + method: 'PUT', + url, + data, + signal: options.signal, + onUploadProgress: options.onUploadProgress, + headers: options.headers, + 'axios-retry': { + retries: options.retries, + retryDelay: (retryCount: number, error: AxiosError) => exponentialDelay(retryCount, error, 1000), + retryCondition(error: AxiosError): boolean { + // Do not retry on insufficient storage - this is permanent + if (error.status === 507) { + return false + } + // Do a retry on locked error as this is often just some preview generation + if (error.status === 423) { + return true + } + // Otherwise fallback to default behavior + return isNetworkOrIdempotentRequestError(error) + }, + onRetry: options.onUploadRetry, + }, + }) +} + +/** + * Get chunk of the file. + * Doing this on the fly give us a big performance boost and proper garbage collection + * + * @param file File to upload + * @param start Offset to start upload + * @param length Size of chunk to upload + */ +export function getChunk(file: File, start: number, length: number): Promise { + if (start === 0 && file.size <= length) { + return Promise.resolve(new Blob([file], { type: file.type || 'application/octet-stream' })) + } + + return Promise.resolve(new Blob([file.slice(start, start + length)], { type: 'application/octet-stream' })) +} + +/** + * Create a temporary upload workspace to upload the chunks to + * + * @param destinationFile The file name after finishing the chunked upload + * @param retries number of retries + * @param isPublic whether this upload is in a public share or not + * @param customHeaders Custom HTTP headers used when creating the workspace (e.g. X-NC-Nickname for file drops) + */ +export async function initChunkWorkspace(destinationFile: string | undefined = undefined, retries: number = 5, isPublic: boolean = false, customHeaders: Record = {}): Promise { + let chunksWorkspace: string + if (isPublic) { + chunksWorkspace = `${getBaseUrl()}/public.php/dav/uploads/${getSharingToken()}` + } else { + chunksWorkspace = generateRemoteUrl(`dav/uploads/${getCurrentUser()?.uid}`) + } + + const hash = [...Array(16)].map(() => Math.floor(Math.random() * 16).toString(16)).join('') + const tempWorkspace = `web-file-upload-${hash}` + const url = `${chunksWorkspace}/${tempWorkspace}` + const headers = customHeaders + if (destinationFile) { + headers.Destination = destinationFile + } + + await axios.request({ + method: 'MKCOL', + url, + headers, + 'axios-retry': { + retries, + retryDelay: (retryCount: number, error: AxiosError) => exponentialDelay(retryCount, error, 1000), + }, + }) + + logger.debug('Created temporary upload workspace', { url }) + + return url +} diff --git a/lib/window.d.ts b/lib/window.d.ts index 519145e3..6dd4ac3e 100644 --- a/lib/window.d.ts +++ b/lib/window.d.ts @@ -6,7 +6,13 @@ declare global { interface Window { - OC: Nextcloud.v32.OC + OC: Nextcloud.v32.OC & { + appConfig: { + files: { + max_chunk_size: number + } + } + } // eslint-disable-next-line @typescript-eslint/no-explicit-any OCA: any _nc_files_scope?: Record> diff --git a/package-lock.json b/package-lock.json index 7bb01876..b7851f7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,16 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@nextcloud/auth": "^2.5.3", + "@nextcloud/axios": "^2.5.2", "@nextcloud/capabilities": "^1.2.1", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.3", "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.1.0", "@nextcloud/sharing": "^0.4.0", + "axios-retry": "^4.5.0", "is-svg": "^6.1.0", + "p-queue": "^9.1.0", "typescript-event-target": "^1.1.2", "webdav": "^5.9.0" }, @@ -202,6 +205,7 @@ "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -558,6 +562,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -598,6 +603,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1140,7 +1146,6 @@ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", @@ -1156,7 +1161,6 @@ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.17.0" }, @@ -1183,7 +1187,6 @@ "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1208,7 +1211,6 @@ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1222,7 +1224,6 @@ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1252,7 +1253,6 @@ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1329,7 +1329,6 @@ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18.0" } @@ -1340,7 +1339,6 @@ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" @@ -1355,7 +1353,6 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -1380,7 +1377,6 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -1620,6 +1616,20 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } }, + "node_modules/@nextcloud/axios": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@nextcloud/axios/-/axios-2.5.2.tgz", + "integrity": "sha512-8frJb77jNMbz00TjsSqs1PymY0nIEbNM4mVmwen2tXY7wNgRai6uXilIlXKOYB9jR/F/HKRj6B4vUwVwZbhdbw==", + "license": "GPL-3.0-or-later", + "dependencies": { + "@nextcloud/auth": "^2.5.1", + "@nextcloud/router": "^3.0.1", + "axios": "^1.12.2" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, "node_modules/@nextcloud/browser-storage": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@nextcloud/browser-storage/-/browser-storage-0.5.0.tgz", @@ -1922,6 +1932,7 @@ "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -2086,7 +2097,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", @@ -2129,7 +2139,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2151,7 +2160,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2173,7 +2181,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2195,7 +2202,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2217,7 +2223,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2239,7 +2244,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2261,7 +2265,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2283,7 +2286,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2305,7 +2307,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2327,7 +2328,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2349,7 +2349,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2371,7 +2370,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2393,7 +2391,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2868,6 +2865,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3192,6 +3190,7 @@ "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3266,6 +3265,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3726,7 +3726,6 @@ "integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.0", "@vue/compiler-core": "3.5.18", @@ -3745,7 +3744,6 @@ "integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.18", "@vue/shared": "3.5.18" @@ -3832,7 +3830,6 @@ "integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/shared": "3.5.18" } @@ -3843,7 +3840,6 @@ "integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.18", "@vue/shared": "3.5.18" @@ -3855,7 +3851,6 @@ "integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.18", "@vue/runtime-core": "3.5.18", @@ -3869,7 +3864,6 @@ "integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.18", "@vue/shared": "3.5.18" @@ -3891,6 +3885,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3923,7 +3918,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4069,6 +4063,12 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4085,6 +4085,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "license": "Apache-2.0", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4155,7 +4179,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4167,7 +4190,6 @@ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "optional": true, - "peer": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -4344,6 +4366,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -4442,7 +4465,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4475,7 +4497,6 @@ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -4562,7 +4583,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4608,6 +4628,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comment-parser": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", @@ -4637,8 +4669,7 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/confbox": { "version": "0.2.2", @@ -4746,7 +4777,6 @@ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4856,8 +4886,7 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", @@ -4918,8 +4947,7 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/define-data-property": { "version": "1.1.4", @@ -4955,6 +4983,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -4993,7 +5030,6 @@ "dev": true, "license": "Apache-2.0", "optional": true, - "peer": true, "bin": { "detect-libc": "bin/detect-libc.js" }, @@ -5072,7 +5108,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5128,7 +5163,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5138,7 +5172,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -5154,7 +5187,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5163,6 +5195,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", @@ -5473,7 +5520,6 @@ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -5535,7 +5581,6 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -5575,11 +5620,16 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5629,16 +5679,14 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", @@ -5716,7 +5764,6 @@ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -5730,7 +5777,6 @@ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "optional": true, - "peer": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -5760,7 +5806,6 @@ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -5774,8 +5819,27 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } }, "node_modules/for-each": { "version": "0.3.5", @@ -5793,6 +5857,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -5837,7 +5917,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -5865,7 +5944,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5890,7 +5968,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5916,7 +5993,6 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -5938,7 +6014,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5992,7 +6067,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6005,7 +6079,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6046,7 +6119,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -6178,7 +6250,6 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -6188,8 +6259,7 @@ "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/import-fresh": { "version": "3.3.1", @@ -6197,7 +6267,6 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -6225,7 +6294,6 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.19" } @@ -6287,7 +6355,6 @@ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6317,7 +6384,6 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "peer": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -6348,7 +6414,6 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=0.12.0" } @@ -6392,6 +6457,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-svg": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-6.1.0.tgz", @@ -6434,8 +6511,7 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/isomorphic-timers-promises": { "version": "1.0.1", @@ -6546,7 +6622,6 @@ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6642,24 +6717,21 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -6692,7 +6764,6 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -6777,8 +6848,7 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lru-cache": { "version": "5.1.1", @@ -6868,7 +6938,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6928,7 +6997,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -6958,6 +7026,27 @@ "dev": true, "license": "MIT" }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -6978,7 +7067,6 @@ "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -7088,8 +7176,7 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/node-domexception": { "version": "1.0.0", @@ -7281,7 +7368,6 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -7331,6 +7417,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", + "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-name-regex": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/package-name-regex/-/package-name-regex-2.0.6.tgz", @@ -7357,7 +7471,6 @@ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -7446,7 +7559,6 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -7499,7 +7611,6 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=8.6" }, @@ -7612,6 +7723,12 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", @@ -7741,7 +7858,6 @@ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -7801,7 +7917,6 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -7889,6 +8004,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -8163,7 +8279,6 @@ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -8177,7 +8292,6 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -8599,6 +8713,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8657,7 +8772,6 @@ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "optional": true, - "peer": true, "dependencies": { "is-number": "^7.0.0" }, @@ -8851,6 +8965,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9057,6 +9172,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9670,6 +9786,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9683,6 +9800,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -9811,7 +9929,6 @@ "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", @@ -9836,7 +9953,6 @@ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -9994,7 +10110,6 @@ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -10050,7 +10165,6 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index df8a9504..d6563941 100644 --- a/package.json +++ b/package.json @@ -50,13 +50,16 @@ }, "dependencies": { "@nextcloud/auth": "^2.5.3", + "@nextcloud/axios": "^2.5.2", "@nextcloud/capabilities": "^1.2.1", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.3", "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.1.0", "@nextcloud/sharing": "^0.4.0", + "axios-retry": "^4.5.0", "is-svg": "^6.1.0", + "p-queue": "^9.1.0", "typescript-event-target": "^1.1.2", "webdav": "^5.9.0" },