From 6c1d32c7af1144984f00bdfa6e2bb9a9d811e6bc Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 26 Feb 2025 17:26:00 +0100 Subject: [PATCH 1/6] feat(uploader): add ETA implementation with upload speed information This feature implements the following: - Implement the ETA class our selfe - Provide upload speed information in the ETA class - Move the ETA information to the uploader class - Remove custom ETA from uploader component and add uploading speed. Signed-off-by: Ferdinand Thiessen --- .../components/UploadPicker/progress.cy.ts | 43 ++- lib/components/UploadPicker.vue | 112 ++----- lib/index.ts | 4 +- lib/uploader/eta.spec.ts | 278 ++++++++++++++++++ lib/uploader/eta.ts | 210 +++++++++++++ lib/uploader/index.ts | 15 + lib/{ => uploader}/uploader.ts | 65 ++-- package-lock.json | 10 +- package.json | 2 +- 9 files changed, 615 insertions(+), 124 deletions(-) create mode 100644 lib/uploader/eta.spec.ts create mode 100644 lib/uploader/eta.ts create mode 100644 lib/uploader/index.ts rename lib/{ => uploader}/uploader.ts (93%) diff --git a/cypress/components/UploadPicker/progress.cy.ts b/cypress/components/UploadPicker/progress.cy.ts index 4ca16c2b..dfaddbd1 100644 --- a/cypress/components/UploadPicker/progress.cy.ts +++ b/cypress/components/UploadPicker/progress.cy.ts @@ -74,10 +74,36 @@ describe('UploadPicker: progress handling', () => { cy.get('[data-cy-upload-picker] [data-cy-upload-picker-progress-label]').as('progressLabel').should('exist') }) - it('has increasing progress bar during non-chunked upload', () => { - // Start in paused mode - const uploader = getUploader() + it('has upload speed information', () => { + cy.get('@input').attachFile({ + // file of 5 MiB + fileContent: new Blob([new ArrayBuffer(5 * 1024 * 1024)]), + fileName: 'file.txt', + mimeType: 'text/plain', + encoding: 'utf8', + lastModified: new Date().getTime(), + }) + + cy.intercept('PUT', '/remote.php/dav/files/user/file.txt', { statusCode: 201 }) + + // 512 KB/s + throttleUpload(512 * 1024) + // See there is no progress yet + cy.get('@progress') + .should('be.visible') + .should('have.value', 0) + cy.get('@progressLabel') + .should('contain.text', 'paused') + // start the uploader + .then(() => getUploader().start()) + + // See the upload has started + cy.get('@progressLabel', { timeout: 10000 }) + .should((el) => expect(el.text()).to.match(/\d+(\.\d+)?\s?KB∕s/)) + }) + + it('has increasing progress bar during non-chunked upload', () => { cy.get('@input').attachFile({ // file of 5 MiB fileContent: new Blob([new ArrayBuffer(5 * 1024 * 1024)]), @@ -106,7 +132,7 @@ describe('UploadPicker: progress handling', () => { cy.get('@progressLabel') .should('contain.text', 'paused') // start the uploader - .then(() => uploader.start()) + .then(() => getUploader().start()) // See the upload has started cy.get('@progressLabel') @@ -150,9 +176,6 @@ describe('UploadPicker: progress handling', () => { rq.reply({ statusCode: 201 }) }).as('move') - // Start in paused mode - const uploader = getUploader() - // 3 MiB/s meaning upload will take 5 seconds throttleUpload(3 * 1024 * 1024) @@ -172,7 +195,7 @@ describe('UploadPicker: progress handling', () => { cy.get('@progressLabel') .should('contain.text', 'paused') // start the uploader - .then(() => uploader.start()) + .then(() => getUploader().start()) // See the upload has started cy.get('@progressLabel') @@ -197,10 +220,10 @@ describe('UploadPicker: progress handling', () => { cy.get('@progress') .should('have.value', 90) - // Now the upload (sending) is done - if we trigger the resolve the value will increase to 97% (or 95 if we resolve only chunk2) + // Now the upload (sending) is done - if we trigger the resolve the value will increase to 96% (or 95 if we resolve only chunk2) .then(() => resolveChunk1()) cy.get('@progress') - .should('have.value', 97) + .should((e) => expect(Math.round(e.val() as number)).to.be.eq(97)) .then(() => resolveChunk2()) // now the progress should be 100 meaning the progress bar is hidden cy.get('@progress') diff --git a/lib/components/UploadPicker.vue b/lib/components/UploadPicker.vue index fde44638..eaeaaa68 100644 --- a/lib/components/UploadPicker.vue +++ b/lib/components/UploadPicker.vue @@ -110,10 +110,21 @@ :aria-describedby="progressTimeId" data-cy-upload-picker-progress :error="hasFailure" - :value="progress" + :value="uploadManager.eta.progress" size="medium" />

- {{ status }} + + {{ t('paused') }} + + + {{ t('assembling') }} + + + {{ uploadManager.eta.timeReadable }} + + ({{ uploadManager.eta.speedReadable }}) + +

@@ -149,7 +160,6 @@ import { defineComponent } from 'vue' import { Folder, NewMenuEntryCategory, getNewFileMenuEntries } from '@nextcloud/files' // @ts-expect-error missing types import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js' -import makeEta from 'simple-eta' import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' import NcActionCaption from '@nextcloud/vue/dist/Components/NcActionCaption.js' @@ -165,9 +175,9 @@ import IconPlus from 'vue-material-design-icons/Plus.vue' import IconUpload from 'vue-material-design-icons/Upload.vue' import { getUploader } from '../index.ts' -import { Status } from '../uploader.ts' +import { UploaderStatus } from '../uploader/uploader.ts' import { Status as UploadStatus } from '../upload.ts' -import { n, t } from '../utils/l10n.ts' +import { t } from '../utils/l10n.ts' import { uploadConflictHandler } from '../utils/conflicts.ts' import logger from '../utils/logger.ts' @@ -257,11 +267,8 @@ export default defineComponent({ data() { return { - eta: null as null|ReturnType, - openedMenu: false, - status: '', - newFileMenuEntries: [] as Entry[], + openedMenu: false, uploadManager: getUploader(), } }, @@ -278,6 +285,7 @@ export default defineComponent({ menuEntriesOther(): Entry[] { return this.newFileMenuEntries.filter((entry) => entry.category === NewMenuEntryCategory.Other) }, + /** * Check whether the current browser supports uploading directories * Hint: This does not check if the current connection supports this, as some browsers require a secure context! @@ -286,18 +294,6 @@ export default defineComponent({ return this.allowFolders && 'webkitdirectory' in document.createElement('input') }, - totalQueueSize(): number { - return this.uploadManager.info?.size || 0 - }, - - uploadedQueueSize(): number { - return this.uploadManager.info?.progress || 0 - }, - - progress(): number { - return Math.round(this.uploadedQueueSize / this.totalQueueSize * 100) || 0 - }, - queue(): Upload[] { return this.uploadManager.queue as Upload[] }, @@ -318,11 +314,7 @@ export default defineComponent({ )) }, isPaused(): boolean { - return this.uploaderStatus === Status.PAUSED - }, - - uploaderStatus(): Status { - return this.uploadManager.info.status + return this.uploadManager.info?.status === UploaderStatus.PAUSED }, buttonLabel(): string { @@ -356,27 +348,12 @@ export default defineComponent({ this.setDestination(destination) }, - totalQueueSize(size) { - this.eta = makeEta({ min: 0, max: size }) - this.updateStatus() - }, - - uploadedQueueSize(size) { - this.eta?.report?.(size) - this.updateStatus() - }, - - uploaderStatus(status, oldStatus) { - if (status === Status.PAUSED) { + isPaused(isPaused) { + if (isPaused) { this.$emit('paused', this.queue) - } else if (oldStatus === Status.PAUSED) { + } else { this.$emit('resumed', this.queue) } - this.updateStatus() - }, - - isOnlyAssembling() { - this.updateStatus() }, }, @@ -441,14 +418,18 @@ export default defineComponent({ /** * Start uploading */ - onPick() { + async onPick() { const input = this.$refs.input as HTMLInputElement const files = input.files ? Array.from(input.files) : [] - this.uploadManager - .batchUpload('', files, uploadConflictHandler(this.getContent)) - .catch((error) => logger.debug('Error while uploading', { error })) - .finally(() => this.resetForm()) + try { + await this.uploadManager + .batchUpload('', files, uploadConflictHandler(this.getContent)) + } catch (error) { + logger.debug('Error while uploading', { error }) + } finally { + this.resetForm() + } }, resetForm() { @@ -460,43 +441,12 @@ export default defineComponent({ * Cancel ongoing queue */ onCancel() { - this.uploadManager.queue.forEach(upload => { + this.uploadManager.queue.forEach((upload: Upload) => { upload.cancel() }) this.resetForm() }, - updateStatus() { - if (this.isPaused) { - this.status = t('paused') - return - } - - if (this.isOnlyAssembling) { - this.status = t('assembling') - return - } - - const estimate = Math.round(this.eta?.estimate?.() || 0) - - if (estimate === Infinity) { - this.status = t('estimating time left') - return - } - if (estimate < 10) { - this.status = t('a few seconds left') - return - } - if (estimate > 60) { - const date = new Date(0) - date.setSeconds(estimate) - const time = date.toISOString().slice(11, 11 + 8) - this.status = t('{time} left', { time }) // TRANSLATORS time has the format 00:00:00 - return - } - this.status = n('{seconds} seconds left', '{seconds} seconds left', estimate, { seconds: estimate }) - }, - setDestination(destination: Folder) { if (!this.destination) { logger.debug('Invalid destination') diff --git a/lib/index.ts b/lib/index.ts index a088ea91..a1e02bcd 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -8,13 +8,13 @@ import type { AsyncComponent } from 'vue' import { isPublicShare } from '@nextcloud/sharing/public' import Vue, { defineAsyncComponent } from 'vue' -import { Uploader } from './uploader' +import { Uploader } from './uploader/uploader' import UploadPicker from './components/UploadPicker.vue' export type { IDirectory, Directory } from './utils/fileTree' export { getConflicts, hasConflict, uploadConflictHandler } from './utils/conflicts' export { Upload, Status as UploadStatus } from './upload' -export { Uploader, Status as UploaderStatus } from './uploader' +export * from './uploader/index.ts' export type ConflictResolutionResult = { selected: T[], diff --git a/lib/uploader/eta.spec.ts b/lib/uploader/eta.spec.ts new file mode 100644 index 00000000..c3db2643 --- /dev/null +++ b/lib/uploader/eta.spec.ts @@ -0,0 +1,278 @@ +/*! + * 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.timeReadable).toBe('estimating time left') + 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.timeReadable).toBe('estimating time left') + 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.speedReadable).toBe('') + expect(eta.time).toBe(Infinity) + } + + // this is reached after (virtual) 3s with 6 * 2.5MiB (=15MiB) data of 100MiB total + expect(eta.timeReadable).toBe('estimating time left') + + // 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') + expect(eta.timeReadable).toMatchInlineSnapshot('"18 seconds left"') + + // 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') + expect(eta.timeReadable).toMatchInlineSnapshot('"12 seconds left"') + + // 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') + expect(eta.timeReadable).toMatchInlineSnapshot('"11 seconds left"') + + // 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') + expect(eta.timeReadable).toMatchInlineSnapshot('"a few seconds left"') + }) + + 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) + // time is over 1 minute so we see the formatted output + expect(eta.timeReadable).toMatchInlineSnapshot('"00:01:45 left"') + + // 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) + expect(eta.timeReadable).toMatchInlineSnapshot('"37 seconds left"') + }) + + 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.timeReadable).toBe('estimating time left') + 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/uploader/eta.ts b/lib/uploader/eta.ts new file mode 100644 index 00000000..71736f1f --- /dev/null +++ b/lib/uploader/eta.ts @@ -0,0 +1,210 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { TypedEventTarget } from 'typescript-event-target' +import { n, t } from '../utils/l10n.ts' +import { formatFileSize } from '@nextcloud/files' + +export enum EtaStatus { + Idle = 0, + Paused = 1, + Running = 2, +} + +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: EtaStatus = 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) + } + + // 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(): EtaStatus { + return this._status + } + + /** + * Progress (percent done) + */ + public get progress(): number { + return Math.round(this._progress * 10000) / 100 + } + + /** + * Estimated time in seconds. + */ + public get time(): number { + return this._eta + } + + /** + * Human readable version of the estimated time. + */ + public get timeReadable(): string { + if (this._eta === Infinity) { + return t('estimating time left') + } else if (this._eta < 10) { + return t('a few seconds left') + } else if (this._eta < 60) { + return n('{seconds} seconds left', '{seconds} seconds left', this._eta, { seconds: this._eta }) + } + + const hours = String(Math.floor(this._eta / 3600)).padStart(2, '0') + const minutes = String(Math.floor((this._eta % 3600) / 60)).padStart(2, '0') + const seconds = String(this._eta % 60).padStart(2, '0') + return t('{time} left', { time: `${hours}:${minutes}:${seconds}` }) // TRANSLATORS time has the format 00:00:00 + } + + /** + * 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/uploader/index.ts b/lib/uploader/index.ts new file mode 100644 index 00000000..1111408d --- /dev/null +++ b/lib/uploader/index.ts @@ -0,0 +1,15 @@ +/*! + * 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/uploader.ts b/lib/uploader/uploader.ts similarity index 93% rename from lib/uploader.ts rename to lib/uploader/uploader.ts index 07b784a0..655f7d67 100644 --- a/lib/uploader.ts +++ b/lib/uploader/uploader.ts @@ -4,7 +4,7 @@ */ import type { AxiosError, AxiosResponse } from 'axios' import type { WebDAVClient } from 'webdav' -import type { IDirectory } from './utils/fileTree' +import type { IDirectory } from '../utils/fileTree.ts' import { getCurrentUser } from '@nextcloud/auth' import { FileType, Folder, Permission, davGetClient, davRemoteURL, davRootPath } from '@nextcloud/files' @@ -16,16 +16,17 @@ import axios, { isCancel } from '@nextcloud/axios' import PCancelable from 'p-cancelable' import PQueue from 'p-queue' -import { UploadCancelledError } from './errors/UploadCancelledError.ts' -import { getChunk, initChunkWorkspace, uploadData } from './utils/upload.js' -import { getMaxChunksSize } from './utils/config.js' -import { Status as UploadStatus, Upload } from './upload.js' -import { isFileSystemFileEntry } from './utils/filesystem.js' -import { Directory } from './utils/fileTree.js' -import { t } from './utils/l10n.js' -import logger from './utils/logger.js' - -export enum Status { +import { UploadCancelledError } from '../errors/UploadCancelledError.ts' +import { getChunk, initChunkWorkspace, uploadData } from '../utils/upload.ts' +import { getMaxChunksSize } from '../utils/config.ts' +import { Status as UploadStatus, Upload } from '../upload.ts' +import { isFileSystemFileEntry } from '../utils/filesystem.ts' +import { Directory } from '../utils/fileTree.ts' +import { t } from '../utils/l10n.ts' +import logger from '../utils/logger.ts' +import { Eta } from './eta.ts' + +export enum UploaderStatus { IDLE = 0, UPLOADING = 1, PAUSED = 2 @@ -48,7 +49,9 @@ export class Uploader { private _queueSize = 0 private _queueProgress = 0 - private _queueStatus: Status = Status.IDLE + private _queueStatus: UploaderStatus = UploaderStatus.IDLE + + private _eta = new Eta() private _notifiers: Array<(upload: Upload) => void> = [] @@ -150,11 +153,13 @@ export class Uploader { /** * Get the upload queue */ - get 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 @@ -165,7 +170,7 @@ export class Uploader { this._jobQueue.clear() this._queueSize = 0 this._queueProgress = 0 - this._queueStatus = Status.IDLE + this._queueStatus = UploaderStatus.IDLE logger.debug('Uploader state reset') } @@ -173,20 +178,29 @@ export class Uploader { * Pause any ongoing upload(s) */ public pause() { + this._eta.pause() this._jobQueue.pause() - this._queueStatus = Status.PAUSED + this._queueStatus = UploaderStatus.PAUSED this.updateStats() - logger.debug('Upload paused') + logger.debug('Uploader paused') } /** * Resume any pending upload(s) */ public start() { + this._eta.resume() this._jobQueue.start() - this._queueStatus = Status.UPLOADING + this._queueStatus = UploaderStatus.UPLOADING this.updateStats() - logger.debug('Upload resumed') + logger.debug('Uploader resumed') + } + + /** + * Get the estimation for the uploading time. + */ + get eta(): Eta { + return this._eta } /** @@ -206,16 +220,20 @@ export class Uploader { 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 === Status.PAUSED) { - return + if (this._queueStatus !== UploaderStatus.PAUSED) { + const pending = this._uploadQueue.find(({ status }) => [UploadStatus.INITIALIZED, UploadStatus.UPLOADING, UploadStatus.ASSEMBLING].includes(status)) + if (this._jobQueue.size > 0 || pending) { + this._queueStatus = UploaderStatus.UPLOADING + } else { + this.eta.reset() + this._queueStatus = UploaderStatus.IDLE + } } - this._queueStatus = this._jobQueue.size > 0 - ? Status.UPLOADING - : Status.IDLE } addNotifier(notifier: (upload: Upload) => void) { @@ -445,6 +463,7 @@ export class Uploader { const { origin } = new URL(destinationPath) const encodedDestinationFile = origin + encodePath(destinationPath.slice(origin.length)) + this.eta.resume() logger.debug(`Uploading ${fileHandle.name} to ${encodedDestinationFile}`) const promise = new PCancelable(async (resolve, reject, onCancel): Promise => { diff --git a/package-lock.json b/package-lock.json index da87b91b..0338abba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "crypto-browserify": "^3.12.1", "p-cancelable": "^4.0.1", "p-queue": "^8.1.0", - "simple-eta": "^3.0.2" + "typescript-event-target": "^1.1.1" }, "devDependencies": { "@codecov/vite-plugin": "^1.9.0", @@ -11901,11 +11901,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, - "node_modules/simple-eta": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/simple-eta/-/simple-eta-3.0.2.tgz", - "integrity": "sha512-+OmPgi01yHK/bRNQDoehUcV8fqs9nNJkG2DoWCnnLvj0lmowab7BH3v9776BG0y7dGEOLh0F7mfd37k+ht26Yw==" - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -12932,7 +12927,8 @@ "node_modules/typescript-event-target": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.1.tgz", - "integrity": "sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==" + "integrity": "sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==", + "license": "MIT" }, "node_modules/uc.micro": { "version": "2.1.0", diff --git a/package.json b/package.json index 4f8e63e0..519c2e83 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "crypto-browserify": "^3.12.1", "p-cancelable": "^4.0.1", "p-queue": "^8.1.0", - "simple-eta": "^3.0.2" + "typescript-event-target": "^1.1.1" }, "devDependencies": { "@codecov/vite-plugin": "^1.9.0", From 259ec514309d0000110bc321f5fedddf7022a16c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 26 Feb 2025 18:30:03 +0100 Subject: [PATCH 2/6] fix(UploadPicker): Add accessible information for uploader button Signed-off-by: Ferdinand Thiessen --- .../UploadPicker/UploadPicker.cy.ts | 41 ++++++++++--------- lib/components/UploadPicker.vue | 33 ++++++++------- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/cypress/components/UploadPicker/UploadPicker.cy.ts b/cypress/components/UploadPicker/UploadPicker.cy.ts index 6368da0b..f8b94686 100644 --- a/cypress/components/UploadPicker/UploadPicker.cy.ts +++ b/cypress/components/UploadPicker/UploadPicker.cy.ts @@ -3,9 +3,7 @@ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -// dist file might not be built when running eslint only -// eslint-disable-next-line import/no-unresolved,n/no-missing-import -import { Folder, Permission, addNewFileMenuEntry, type Entry } from '@nextcloud/files' +import { Folder, Permission } from '@nextcloud/files' import { generateRemoteUrl } from '@nextcloud/router' import { UploadPicker, UploadStatus, getUploader } from '../../../lib/index.ts' @@ -39,8 +37,9 @@ describe('UploadPicker rendering', () => { } cy.mount(UploadPicker, { propsData }) cy.get('[data-cy-upload-picker]').should('be.visible') - cy.get('[data-cy-upload-picker]').shouldHaveTrimmedText('New') + cy.get('[data-cy-upload-picker]').should('contain.text', 'New') cy.get('[data-cy-upload-picker] [data-cy-upload-picker-input]').should('exist') + cy.get('[data-cy-upload-picker] [data-cy-upload-picker-progress]').should('not.be.visible') }) it('Does NOT render without a destination', () => { @@ -68,22 +67,22 @@ describe('UploadPicker valid uploads', () => { cy.mount(UploadPicker, { propsData }).as('uploadPicker') // Label is displayed before upload - cy.get('[data-cy-upload-picker]').shouldHaveTrimmedText('New') + cy.get('[data-cy-upload-picker]') + .contains('button', 'New') + .should('be.visible') // Check and init aliases cy.get('[data-cy-upload-picker] [data-cy-upload-picker-input]').as('input').should('exist') - cy.get('[data-cy-upload-picker] .upload-picker__progress').as('progress').should('exist') + cy.get('[data-cy-upload-picker] [data-cy-upload-picker-progress]').as('progress').should('exist') }) afterEach(() => resetDocument()) it('Uploads a file', () => { - // Intercept single upload + const { promise, resolve } = Promise.withResolvers() cy.intercept('PUT', '/remote.php/dav/files/*/*', (req) => { - req.reply({ - statusCode: 201, - delay: 2000, - }) + req.reply({ statusCode: 201 }) + req.on('response', async () => await promise) }).as('upload') cy.get('@input').attachFile({ @@ -95,16 +94,20 @@ describe('UploadPicker valid uploads', () => { lastModified: new Date().getTime(), }) - cy.get('[data-cy-upload-picker] .upload-picker__progress') - .as('progress') - .should('be.visible') - - // Label gets hidden during upload - cy.get('[data-cy-upload-picker]').should('not.have.text', 'New') + cy.get('@progress').should('be.visible') + cy.get('[data-cy-upload-picker]') + .within(() => { + // Label gets hidden during upload + cy.contains('button', 'New').should('not.exist') + // but the button exists + cy.get('button[data-cy-upload-picker-add]') + .should('be.visible') + .and('have.attr', 'aria-label', 'New') + }) + .then(() => resolve()) cy.wait('@upload').then(() => { - cy.get('[data-cy-upload-picker] .upload-picker__progress') - .as('progress') + cy.get('@progress') .should('not.be.visible') // Label is displayed again after upload diff --git a/lib/components/UploadPicker.vue b/lib/components/UploadPicker.vue index eaeaaa68..e6b24c0c 100644 --- a/lib/components/UploadPicker.vue +++ b/lib/components/UploadPicker.vue @@ -10,6 +10,7 @@ data-cy-upload-picker> - {{ buttonName }} +