@uppy/utils: refactor to TS (#4699)

Co-authored-by: Mikael Finstad <finstaden@gmail.com>
Co-authored-by: Nick Rutten <2504906+nickrttn@users.noreply.github.com>
Co-authored-by: Murderlon <merlijn@soverin.net>
Co-authored-by: Artur Paikin <artur@arturpaikin.com>
This commit is contained in:
Antoine du Hamel 2023-11-06 15:01:50 +01:00 committed by GitHub
parent 43178f1c4f
commit 51ecc66e64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
99 changed files with 1083 additions and 703 deletions

View file

@ -1,3 +1,6 @@
// This references the old types on purpose, to make sure to not create breaking changes for TS consumers.
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path="../../utils/types/index.d.ts"/>
import * as UppyUtils from '@uppy/utils'
// Utility types

View file

@ -72,6 +72,7 @@
"preact": "^10.5.13"
},
"devDependencies": {
"@types/lodash": "^4.14.199",
"vitest": "^0.34.5"
}
}

View file

@ -1,13 +0,0 @@
import hasOwnProperty from './hasProperty.js'
/**
* Little AbortController proxy module so we can swap out the implementation easily later.
*/
export const { AbortController } = globalThis
export const { AbortSignal } = globalThis
export const createAbortError = (message = 'Aborted', options) => {
const err = new DOMException(message, 'AbortError')
if (options != null && hasOwnProperty(options, 'cause')) {
Object.defineProperty(err, 'cause', { __proto__: null, configurable: true, writable: true, value: options.cause })
}
return err
}

View file

@ -1,8 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { AbortController, AbortSignal } from './AbortController.js'
import { AbortController, AbortSignal } from './AbortController.ts'
function flushInstantTimeouts () {
return new Promise(resolve => setTimeout(resolve, 0))
function flushInstantTimeouts() {
return new Promise((resolve) => setTimeout(resolve, 0))
}
describe('AbortController', () => {

View file

@ -0,0 +1,22 @@
import hasOwnProperty from './hasProperty.ts'
/**
* Little AbortController proxy module so we can swap out the implementation easily later.
*/
export const { AbortController } = globalThis
export const { AbortSignal } = globalThis
export const createAbortError = (
message = 'Aborted',
options?: Parameters<typeof Error>[1],
): DOMException => {
const err = new DOMException(message, 'AbortError')
if (options != null && hasOwnProperty(options, 'cause')) {
Object.defineProperty(err, 'cause', {
// @ts-expect-error TS is drunk
__proto__: null,
configurable: true,
writable: true,
value: options.cause,
})
}
return err
}

View file

@ -1,13 +0,0 @@
import hasProperty from './hasProperty.js'
class ErrorWithCause extends Error {
constructor (message, options = {}) {
super(message)
this.cause = options.cause
if (this.cause && hasProperty(this.cause, 'isNetworkError')) {
this.isNetworkError = this.cause.isNetworkError
}
}
}
export default ErrorWithCause

View file

@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest'
import ErrorWithCause from './ErrorWithCause.js'
import NetworkError from './NetworkError.js'
import isNetworkError from './isNetworkError.js'
describe('ErrorWithCause', () => {
it('should support a `{ cause }` option', () => {
const cause = new Error('cause')
expect(new ErrorWithCause('message').cause).toEqual(undefined)
expect(new ErrorWithCause('message', {}).cause).toEqual(undefined)
expect(new ErrorWithCause('message', { cause }).cause).toEqual(cause)
})
it('should propagate isNetworkError', () => {
const regularError = new Error('cause')
const networkError = new NetworkError('cause')
expect(isNetworkError(new ErrorWithCause('message', { cause: networkError }).isNetworkError)).toEqual(true)
expect(isNetworkError(new ErrorWithCause('message', { cause: regularError }).isNetworkError)).toEqual(false)
expect(isNetworkError(new ErrorWithCause('message', {}).isNetworkError)).toEqual(false)
expect(isNetworkError(new ErrorWithCause('message').isNetworkError)).toEqual(false)
})
})

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import ErrorWithCause from './ErrorWithCause.ts'
import NetworkError from './NetworkError.ts'
describe('ErrorWithCause', () => {
it('should support a `{ cause }` option', () => {
const cause = new Error('cause')
expect(new ErrorWithCause('message').cause).toEqual(undefined)
expect(new ErrorWithCause('message', {}).cause).toEqual(undefined)
expect(new ErrorWithCause('message', { cause }).cause).toEqual(cause)
})
it('should propagate isNetworkError', () => {
const regularError = new Error('cause')
const networkError = new NetworkError('cause')
expect(
new ErrorWithCause('message', { cause: networkError }).isNetworkError,
).toEqual(true)
expect(
new ErrorWithCause('message', { cause: regularError }).isNetworkError,
).toEqual(false)
expect(new ErrorWithCause('message', {}).isNetworkError).toEqual(false)
expect(new ErrorWithCause('message').isNetworkError).toEqual(false)
})
})

View file

@ -0,0 +1,23 @@
import type NetworkError from './NetworkError.ts'
import hasProperty from './hasProperty.ts'
class ErrorWithCause extends Error {
public isNetworkError: boolean
public cause: Error['cause']
constructor(
message?: ConstructorParameters<ErrorConstructor>[0],
options?: ConstructorParameters<ErrorConstructor>[1],
) {
super(message)
this.cause = options?.cause
if (this.cause && hasProperty(this.cause, 'isNetworkError')) {
this.isNetworkError = (this.cause as NetworkError).isNetworkError
} else {
this.isNetworkError = false
}
}
}
export default ErrorWithCause

View file

@ -1,83 +0,0 @@
/**
* Create a wrapper around an event emitter with a `remove` method to remove
* all events that were added using the wrapped emitter.
*/
export default class EventManager {
#uppy
#events = []
constructor (uppy) {
this.#uppy = uppy
}
on (event, fn) {
this.#events.push([event, fn])
return this.#uppy.on(event, fn)
}
remove () {
for (const [event, fn] of this.#events.splice(0)) {
this.#uppy.off(event, fn)
}
}
onFilePause (fileID, cb) {
this.on('upload-pause', (targetFileID, isPaused) => {
if (fileID === targetFileID) {
cb(isPaused)
}
})
}
onFileRemove (fileID, cb) {
this.on('file-removed', (file) => {
if (fileID === file.id) cb(file.id)
})
}
onPause (fileID, cb) {
this.on('upload-pause', (targetFileID, isPaused) => {
if (fileID === targetFileID) {
// const isPaused = this.#uppy.pauseResume(fileID)
cb(isPaused)
}
})
}
onRetry (fileID, cb) {
this.on('upload-retry', (targetFileID) => {
if (fileID === targetFileID) {
cb()
}
})
}
onRetryAll (fileID, cb) {
this.on('retry-all', () => {
if (!this.#uppy.getFile(fileID)) return
cb()
})
}
onPauseAll (fileID, cb) {
this.on('pause-all', () => {
if (!this.#uppy.getFile(fileID)) return
cb()
})
}
onCancelAll (fileID, eventHandler) {
this.on('cancel-all', (...args) => {
if (!this.#uppy.getFile(fileID)) return
eventHandler(...args)
})
}
onResumeAll (fileID, cb) {
this.on('resume-all', () => {
if (!this.#uppy.getFile(fileID)) return
cb()
})
}
}

View file

@ -0,0 +1,115 @@
import type {
Uppy,
UploadPauseCallback,
FileRemovedCallback,
UploadRetryCallback,
GenericEventCallback,
} from '@uppy/core'
import type { UppyFile } from './UppyFile'
/**
* Create a wrapper around an event emitter with a `remove` method to remove
* all events that were added using the wrapped emitter.
*/
export default class EventManager {
#uppy: Uppy
#events: Array<Parameters<typeof Uppy.prototype.on>> = []
constructor(uppy: Uppy) {
this.#uppy = uppy
}
on(
event: Parameters<typeof Uppy.prototype.on>[0],
fn: Parameters<typeof Uppy.prototype.on>[1],
): Uppy {
this.#events.push([event, fn])
return this.#uppy.on(event, fn)
}
remove(): void {
for (const [event, fn] of this.#events.splice(0)) {
this.#uppy.off(event, fn)
}
}
onFilePause(fileID: UppyFile['id'], cb: (isPaused: boolean) => void): void {
this.on(
'upload-pause',
(
targetFileID: Parameters<UploadPauseCallback>[0],
isPaused: Parameters<UploadPauseCallback>[1],
) => {
if (fileID === targetFileID) {
cb(isPaused)
}
},
)
}
onFileRemove(
fileID: UppyFile['id'],
cb: (isPaused: UppyFile['id']) => void,
): void {
this.on('file-removed', (file: Parameters<FileRemovedCallback<any>>[0]) => {
if (fileID === file.id) cb(file.id)
})
}
onPause(fileID: UppyFile['id'], cb: (isPaused: boolean) => void): void {
this.on(
'upload-pause',
(
targetFileID: Parameters<UploadPauseCallback>[0],
isPaused: Parameters<UploadPauseCallback>[1],
) => {
if (fileID === targetFileID) {
// const isPaused = this.#uppy.pauseResume(fileID)
cb(isPaused)
}
},
)
}
onRetry(fileID: UppyFile['id'], cb: () => void): void {
this.on(
'upload-retry',
(targetFileID: Parameters<UploadRetryCallback>[0]) => {
if (fileID === targetFileID) {
cb()
}
},
)
}
onRetryAll(fileID: UppyFile['id'], cb: () => void): void {
this.on('retry-all', () => {
if (!this.#uppy.getFile(fileID)) return
cb()
})
}
onPauseAll(fileID: UppyFile['id'], cb: () => void): void {
this.on('pause-all', () => {
if (!this.#uppy.getFile(fileID)) return
cb()
})
}
onCancelAll(
fileID: UppyFile['id'],
eventHandler: GenericEventCallback,
): void {
this.on('cancel-all', (...args: Parameters<GenericEventCallback>) => {
if (!this.#uppy.getFile(fileID)) return
eventHandler(...args)
})
}
onResumeAll(fileID: UppyFile['id'], cb: () => void): void {
this.on('resume-all', () => {
if (!this.#uppy.getFile(fileID)) return
cb()
})
}
}

View file

@ -0,0 +1,18 @@
interface FileProgressBase {
progress: number
uploadComplete: boolean
percentage: number
bytesTotal: number
}
// FileProgress is either started or not started. We want to make sure TS doesn't
// let us mix the two cases, and for that effect, we have one type for each case:
export type FileProgressStarted = FileProgressBase & {
uploadStarted: number
bytesUploaded: number
}
export type FileProgressNotStarted = FileProgressBase & {
uploadStarted: null
bytesUploaded: false
}
export type FileProgress = FileProgressStarted | FileProgressNotStarted

View file

@ -1,11 +0,0 @@
class NetworkError extends Error {
constructor (error, xhr = null) {
super(`This looks like a network error, the endpoint might be blocked by an internet provider or a firewall.`)
this.cause = error
this.isNetworkError = true
this.request = xhr
}
}
export default NetworkError

View file

@ -0,0 +1,19 @@
class NetworkError extends Error {
public cause: unknown
public isNetworkError: true
public request: null | XMLHttpRequest
constructor(error: unknown, xhr: null | XMLHttpRequest = null) {
super(
`This looks like a network error, the endpoint might be blocked by an internet provider or a firewall.`,
)
this.cause = error
this.isNetworkError = true
this.request = xhr
}
}
export default NetworkError

View file

@ -5,20 +5,23 @@
* Call `timer.done()` when the upload has completed.
*/
class ProgressTimeout {
#aliveTimer
#aliveTimer?: ReturnType<typeof setTimeout>
#isDone = false
#onTimedOut
#onTimedOut: Parameters<typeof setTimeout>[0]
#timeout
#timeout: number
constructor (timeout, timeoutHandler) {
constructor(
timeout: number,
timeoutHandler: Parameters<typeof setTimeout>[0],
) {
this.#timeout = timeout
this.#onTimedOut = timeoutHandler
}
progress () {
progress(): void {
// Some browsers fire another progress event when the upload is
// cancelled, so we have to ignore progress after the timer was
// told to stop.
@ -30,10 +33,10 @@ class ProgressTimeout {
}
}
done () {
done(): void {
if (!this.#isDone) {
clearTimeout(this.#aliveTimer)
this.#aliveTimer = null
this.#aliveTimer = undefined
this.#isDone = true
}
}

View file

@ -1,11 +1,10 @@
import { describe, expect, it } from 'vitest'
import { RateLimitedQueue } from './RateLimitedQueue.js'
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
import delay from './delay.ts'
describe('RateLimitedQueue', () => {
let pending = 0
function fn () {
function fn() {
pending++
return delay(15).then(() => pending--)
}
@ -15,9 +14,16 @@ describe('RateLimitedQueue', () => {
const fn2 = queue.wrapPromiseFunction(fn)
const result = Promise.all([
fn2(), fn2(), fn2(), fn2(),
fn2(), fn2(), fn2(), fn2(),
fn2(), fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
])
expect(pending).toBe(4)
@ -34,9 +40,16 @@ describe('RateLimitedQueue', () => {
const fn2 = queue.wrapPromiseFunction(fn)
const result = Promise.all([
fn2(), fn2(), fn2(), fn2(),
fn2(), fn2(), fn2(), fn2(),
fn2(), fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
])
expect(pending).toBe(10)
@ -48,13 +61,22 @@ describe('RateLimitedQueue', () => {
it('should accept non-promise function in wrapPromiseFunction()', () => {
const queue = new RateLimitedQueue(1)
function syncFn () { return 1 }
function syncFn() {
return 1
}
const fn2 = queue.wrapPromiseFunction(syncFn)
return Promise.all([
fn2(), fn2(), fn2(), fn2(),
fn2(), fn2(), fn2(), fn2(),
fn2(), fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
fn2(),
])
})
})

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import Translator from './Translator.js'
import Translator, { type Locale } from './Translator.ts'
const english = {
const english: Locale<0 | 1> = {
strings: {
chooseFile: 'Choose a file',
youHaveChosen: 'You have chosen: %{fileName}',
@ -9,12 +9,12 @@ const english = {
0: '%{smart_count} file selected',
1: '%{smart_count} files selected',
},
pluralize (n) {
if (n === 1) {
return 0
}
return 1
},
},
pluralize(n) {
if (n === 1) {
return 0
}
return 1
},
}
@ -28,7 +28,7 @@ const russian = {
2: 'Выбрано %{smart_count} файлов',
},
},
pluralize (n) {
pluralize(n: number) {
if (n % 10 === 1 && n % 100 !== 11) {
return 0
}
@ -50,41 +50,58 @@ describe('Translator', () => {
it('should translate a string with non-string elements', () => {
const translator = new Translator({
pluralize: english.pluralize,
strings: {
test: 'Hello %{who}!',
test2: 'Hello %{who}',
},
})
const who = Symbol('who')
expect(translator.translateArray('test', { who })).toEqual(['Hello ', who, '!'])
const who = Symbol('who') as any as string
expect(translator.translateArray('test', { who })).toEqual([
'Hello ',
who,
'!',
])
// No empty string at the end.
expect(translator.translateArray('test2', { who })).toEqual(['Hello ', who])
expect(translator.translateArray('test2', { who })).toEqual([
'Hello ',
who,
])
})
})
describe('translation strings inheritance / overriding', () => {
const launguagePackLoadedInCore = english
const defaultStrings = {
pluralize: english.pluralize,
strings: {
youHaveChosen: 'You have chosen 123: %{fileName}',
},
}
const userSuppliedStrings = {
pluralize: english.pluralize,
strings: {
youHaveChosen: 'Beep boop: %{fileName}',
},
}
it('should prioritize language pack strings from Core over default', () => {
const translator = new Translator([defaultStrings, launguagePackLoadedInCore])
const translator = new Translator([
defaultStrings,
launguagePackLoadedInCore,
])
expect(
translator.translate('youHaveChosen', { fileName: 'img.jpg' }),
).toEqual('You have chosen: img.jpg')
})
it('should prioritize user-supplied strings over language pack from Core', () => {
const translator = new Translator([defaultStrings, launguagePackLoadedInCore, userSuppliedStrings])
const translator = new Translator([
defaultStrings,
launguagePackLoadedInCore,
userSuppliedStrings,
])
expect(
translator.translate('youHaveChosen', { fileName: 'img.jpg' }),
).toEqual('Beep boop: img.jpg')
@ -103,17 +120,17 @@ describe('Translator', () => {
describe('pluralization', () => {
it('should translate a string', () => {
const translator = new Translator(russian)
expect(
translator.translate('filesChosen', { smart_count: 18 }),
).toEqual('Выбрано 18 файлов')
expect(translator.translate('filesChosen', { smart_count: 18 })).toEqual(
'Выбрано 18 файлов',
)
expect(
translator.translate('filesChosen', { smart_count: 1 }),
).toEqual('Выбран 1 файл')
expect(translator.translate('filesChosen', { smart_count: 1 })).toEqual(
'Выбран 1 файл',
)
expect(
translator.translate('filesChosen', { smart_count: 0 }),
).toEqual('Выбрано 0 файлов')
expect(translator.translate('filesChosen', { smart_count: 0 })).toEqual(
'Выбрано 0 файлов',
)
})
it('should support strings without plural forms', () => {
@ -124,12 +141,12 @@ describe('Translator', () => {
pluralize: () => 0,
})
expect(
translator.translate('theAmount', { smart_count: 0 }),
).toEqual('het aantal is 0')
expect(
translator.translate('theAmount', { smart_count: 1 }),
).toEqual('het aantal is 1')
expect(translator.translate('theAmount', { smart_count: 0 })).toEqual(
'het aantal is 0',
)
expect(translator.translate('theAmount', { smart_count: 1 })).toEqual(
'het aantal is 1',
)
expect(
translator.translate('theAmount', { smart_count: 1202530 }),
).toEqual('het aantal is 1202530')
@ -143,11 +160,14 @@ describe('Translator', () => {
1: '%{smart_count} tests',
},
},
pluralize: () => 1,
})
expect(() => {
translator.translate('test')
}).toThrow('Attempted to use a string with plural forms, but no value was given for %{smart_count}')
}).toThrow(
'Attempted to use a string with plural forms, but no value was given for %{smart_count}',
)
})
})
})

View file

@ -1,7 +1,23 @@
import has from './hasProperty.js'
import has from './hasProperty.ts'
function insertReplacement (source, rx, replacement) {
const newParts = []
// We're using a generic because languages have different plural rules.
export interface Locale<T extends number = number> {
strings: Record<string, string | Record<T, string>>
pluralize: (n: number) => T
}
type Options = {
smart_count?: number
} & {
[key: string]: string | number
}
function insertReplacement(
source: Array<string | unknown>,
rx: RegExp,
replacement: string,
): Array<string | unknown> {
const newParts: Array<string | unknown> = []
source.forEach((chunk) => {
// When the source contains multiple placeholders for interpolation,
// we should ignore chunks that are not strings, because those
@ -32,14 +48,16 @@ function insertReplacement (source, rx, replacement) {
* @license https://github.com/airbnb/polyglot.js/blob/master/LICENSE
* taken from https://github.com/airbnb/polyglot.js/blob/master/lib/polyglot.js#L299
*
* @param {string} phrase that needs interpolation, with placeholders
* @param {object} options with values that will be used to replace placeholders
* @returns {any[]} interpolated
* @param phrase that needs interpolation, with placeholders
* @param options with values that will be used to replace placeholders
*/
function interpolate (phrase, options) {
function interpolate(
phrase: string,
options?: Options,
): Array<string | unknown> {
const dollarRegex = /\$/g
const dollarBillsYall = '$$$$'
let interpolated = [phrase]
let interpolated: Array<string | unknown> = [phrase]
if (options == null) return interpolated
@ -55,7 +73,11 @@ function interpolate (phrase, options) {
// We create a new `RegExp` each time instead of using a more-efficient
// string replace so that the same argument can be replaced multiple times
// in the same phrase.
interpolated = insertReplacement(interpolated, new RegExp(`%\\{${arg}\\}`, 'g'), replacement)
interpolated = insertReplacement(
interpolated,
new RegExp(`%\\{${arg}\\}`, 'g'),
replacement as string,
)
}
}
@ -74,13 +96,12 @@ function interpolate (phrase, options) {
* Usage example: `translator.translate('files_chosen', {smart_count: 3})`
*/
export default class Translator {
/**
* @param {object|Array<object>} locales - locale or list of locales.
*/
constructor (locales) {
protected locale: Locale
constructor(locales: Locale | Locale[]) {
this.locale = {
strings: {},
pluralize (n) {
pluralize(n: number): 0 | 1 {
if (n === 1) {
return 0
}
@ -95,35 +116,36 @@ export default class Translator {
}
}
#apply (locale) {
#apply(locale?: Locale): void {
if (!locale?.strings) {
return
}
const prevLocale = this.locale
this.locale = { ...prevLocale, strings: { ...prevLocale.strings, ...locale.strings } }
this.locale = {
...prevLocale,
strings: { ...prevLocale.strings, ...locale.strings },
} as any
this.locale.pluralize = locale.pluralize || prevLocale.pluralize
}
/**
* Public translate method
*
* @param {string} key
* @param {object} options with values that will be used later to replace placeholders in string
* @returns {string} translated (and interpolated)
* @param key
* @param options with values that will be used later to replace placeholders in string
* @returns string translated (and interpolated)
*/
translate (key, options) {
translate(key: string, options?: Options): string {
return this.translateArray(key, options).join('')
}
/**
* Get a translation and return the translated and interpolated parts as an array.
*
* @param {string} key
* @param {object} options with values that will be used to replace placeholders
* @returns {Array} The translated and interpolated parts, in order.
* @returns The translated and interpolated parts, in order.
*/
translateArray (key, options) {
translateArray(key: string, options?: Options): Array<string | unknown> {
if (!has(this.locale.strings, key)) {
throw new Error(`missing string: ${key}`)
}
@ -136,7 +158,9 @@ export default class Translator {
const plural = this.locale.pluralize(options.smart_count)
return interpolate(string[plural], options)
}
throw new Error('Attempted to use a string with plural forms, but no value was given for %{smart_count}')
throw new Error(
'Attempted to use a string with plural forms, but no value was given for %{smart_count}',
)
}
return interpolate(string, options)

View file

@ -0,0 +1,41 @@
import type { FileProgress } from './FileProgress'
interface IndexedObject<T> {
[key: string]: T
[key: number]: T
}
export type InternalMetadata = { name: string; type?: string }
export interface UppyFile<
TMeta = IndexedObject<any>,
TBody = IndexedObject<any>,
> {
data: Blob | File
error?: Error
extension: string
id: string
isPaused?: boolean
isRestored?: boolean
isRemote: boolean
meta: InternalMetadata & TMeta
name: string
preview?: string
progress?: FileProgress
remote?: {
host: string
url: string
body?: Record<string, unknown>
provider?: string
companionUrl: string
}
serverToken: string
size: number
source?: string
type?: string
response?: {
body: TBody
status: number
uploadURL: string | undefined
}
}

View file

@ -4,7 +4,11 @@
* @param {HTMLCanvasElement} canvas
* @returns {Promise}
*/
export default function canvasToBlob (canvas, type, quality) {
export default function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality?: number,
): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob(resolve, type, quality)
})

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import dataURItoBlob from './dataURItoBlob.js'
import dataURItoBlob from './dataURItoBlob.ts'
import sampleImageDataURI from './sampleImageDataURI.js'
describe('dataURItoBlob', () => {

View file

@ -1,21 +1,25 @@
const DATA_URL_PATTERN = /^data:([^/]+\/[^,;]+(?:[^,]*?))(;base64)?,([\s\S]*)$/
export default function dataURItoBlob (dataURI, opts, toFile) {
export default function dataURItoBlob(
dataURI: string,
opts: { mimeType?: string; name?: string },
toFile?: boolean,
): Blob | File {
// get the base64 data
const dataURIData = DATA_URL_PATTERN.exec(dataURI)
// user may provide mime type, if not get it from data URI
const mimeType = opts.mimeType ?? dataURIData?.[1] ?? 'plain/text'
let data
if (dataURIData[2] != null) {
let data!: BlobPart[] // We add `!` to tell TS we're OK with `data` being not defined when the dataURI is invalid.
if (dataURIData?.[2] != null) {
const binary = atob(decodeURIComponent(dataURIData[3]))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
data = [bytes]
} else {
} else if (dataURIData?.[3] != null) {
data = [decodeURIComponent(dataURIData[3])]
}

View file

@ -1,5 +0,0 @@
import dataURItoBlob from './dataURItoBlob.js'
export default function dataURItoFile (dataURI, opts) {
return dataURItoBlob(dataURI, opts, true)
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import dataURItoFile from './dataURItoFile.js'
import dataURItoFile from './dataURItoFile.ts'
import sampleImageDataURI from './sampleImageDataURI.js'
describe('dataURItoFile', () => {

View file

@ -0,0 +1,8 @@
import dataURItoBlob from './dataURItoBlob.ts'
export default function dataURItoFile(
dataURI: string,
opts: { mimeType?: string; name?: string },
): File | Blob {
return dataURItoBlob(dataURI, opts, true)
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { AbortController } from './AbortController.js'
import delay from './delay.js'
import { AbortController } from './AbortController.ts'
import delay from './delay.ts'
describe('delay', () => {
it('should wait for the specified time', async () => {
@ -12,9 +12,12 @@ describe('delay', () => {
})
it('should reject if signal is already aborted', async () => {
const signal = { aborted: true }
const signal = { aborted: true } as any as AbortSignal
const start = Date.now()
await expect(delay(100, { signal })).rejects.toHaveProperty('name', 'AbortError')
await expect(delay(100, { signal })).rejects.toHaveProperty(
'name',
'AbortError',
)
// should really be instant but using a very large range in case CI decides
// to be super busy and block the event loop for a while.
expect(Date.now() - start).toBeLessThan(50)

View file

@ -1,13 +1,12 @@
import { createAbortError } from './AbortController.js'
import { createAbortError } from './AbortController.ts'
/**
* Return a Promise that resolves after `ms` milliseconds.
*
* @param {number} ms - Number of milliseconds to wait.
* @param {{ signal?: AbortSignal }} [opts] - An abort signal that can be used to cancel the delay early.
* @returns {Promise<void>} A Promise that resolves after the given amount of `ms`.
*/
export default function delay (ms, opts) {
export default function delay(
ms: number,
opts?: { signal: AbortSignal },
): Promise<void> {
return new Promise((resolve, reject) => {
if (opts?.signal?.aborted) {
return reject(createAbortError())
@ -18,13 +17,13 @@ export default function delay (ms, opts) {
resolve()
}, ms)
function onabort () {
function onabort(): void {
clearTimeout(timeout)
cleanup() // eslint-disable-line no-use-before-define
reject(createAbortError())
}
opts?.signal?.addEventListener('abort', onabort)
function cleanup () {
function cleanup(): void {
opts?.signal?.removeEventListener('abort', onabort)
}
return undefined

View file

@ -1,17 +0,0 @@
/**
* Low-pass filter using Exponential Moving Averages (aka exponential smoothing)
* Filters a sequence of values by updating the mixing the previous output value
* with the new input using the exponential window function
*
* @param {*} newValue the n-th value of the sequence
* @param {*} previousSmoothedValue the exponential average of the first n-1 values
* @param {*} halfLife value of `dt` to move the smoothed value halfway between `previousFilteredValue` and `newValue`
* @param {*} dt time elapsed between adding the (n-1)th and the n-th values
* @returns the exponential average of the first n values
*/
export default function emaFilter (newValue, previousSmoothedValue, halfLife, dt) {
if (halfLife === 0 || newValue === previousSmoothedValue) return newValue
if (dt === 0) return previousSmoothedValue
return newValue + (previousSmoothedValue - newValue) * (2 ** (-dt / halfLife))
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import emaFilter from './emaFilter.js'
import emaFilter from './emaFilter.ts'
describe('emaFilter', () => {
it('should calculate the exponential average', () => {
@ -25,7 +25,9 @@ describe('emaFilter', () => {
let lastFilteredValue = firstValue
for (let i = 0; i < 10; ++i) {
lastFilteredValue = emaFilter(newValue, lastFilteredValue, halfLife, step)
expect(lastFilteredValue).toBeCloseTo(emaFilter(newValue, firstValue, halfLife, step * (i + 1)))
expect(lastFilteredValue).toBeCloseTo(
emaFilter(newValue, firstValue, halfLife, step * (i + 1)),
)
}
})
})

View file

@ -0,0 +1,22 @@
/**
* Low-pass filter using Exponential Moving Averages (aka exponential smoothing)
* Filters a sequence of values by updating the mixing the previous output value
* with the new input using the exponential window function
*
* @param newValue the n-th value of the sequence
* @param previousSmoothedValue the exponential average of the first n-1 values
* @param halfLife value of `dt` to move the smoothed value halfway between `previousFilteredValue` and `newValue`
* @param dt time elapsed between adding the (n-1)th and the n-th values
* @returns the exponential average of the first n values
*/
export default function emaFilter(
newValue: number,
previousSmoothedValue: number,
halfLife: number,
dt: number,
): number {
if (halfLife === 0 || newValue === previousSmoothedValue) return newValue
if (dt === 0) return previousSmoothedValue
return newValue + (previousSmoothedValue - newValue) * 2 ** (-dt / halfLife)
}

View file

@ -1,6 +1,12 @@
import throttle from 'lodash/throttle.js'
import type { UppyFile } from './UppyFile'
import type { FileProgress } from './FileProgress'
function emitSocketProgress (uploader, progressData, file) {
function emitSocketProgress(
uploader: any,
progressData: FileProgress,
file: UppyFile,
): void {
const { progress, bytesUploaded, bytesTotal } = progressData
if (progress) {
uploader.uppy.log(`Upload progress: ${progress}`)

View file

@ -1,15 +0,0 @@
import NetworkError from './NetworkError.js'
/**
* Wrapper around window.fetch that throws a NetworkError when appropriate
*/
export default function fetchWithNetworkError (...options) {
return fetch(...options)
.catch((err) => {
if (err.name === 'AbortError') { // todo maybe instead see npm package is-network-error
throw err
} else {
throw new NetworkError(err)
}
})
}

View file

@ -0,0 +1,16 @@
import NetworkError from './NetworkError.ts'
/**
* Wrapper around window.fetch that throws a NetworkError when appropriate
*/
export default function fetchWithNetworkError(
...options: Parameters<typeof globalThis.fetch>
): ReturnType<typeof globalThis.fetch> {
return fetch(...options).catch((err) => {
if (err.name === 'AbortError') {
throw err
} else {
throw new NetworkError(err)
}
})
}

View file

@ -1,10 +0,0 @@
export function filterNonFailedFiles (files) {
const hasError = (file) => 'error' in file && file.error
return files.filter((file) => !hasError(file))
}
// Don't double-emit upload-started for Golden Retriever-restored files that were already started
export function filterFilesToEmitUploadStarted (files) {
return files.filter((file) => !file.progress.uploadStarted || !file.isRestored)
}

View file

@ -0,0 +1,14 @@
import type { UppyFile } from './UppyFile'
export function filterNonFailedFiles(files: UppyFile[]): UppyFile[] {
const hasError = (file: UppyFile): boolean => 'error' in file && !!file.error
return files.filter((file) => !hasError(file))
}
// Don't double-emit upload-started for Golden Retriever-restored files that were already started
export function filterFilesToEmitUploadStarted(files: UppyFile[]): UppyFile[] {
return files.filter(
(file) => !file.progress?.uploadStarted || !file.isRestored,
)
}

View file

@ -1,12 +1,11 @@
import isDOMElement from './isDOMElement.js'
import isDOMElement from './isDOMElement.ts'
/**
* Find one or more DOM elements.
*
* @param {string|Node} element
* @returns {Node[]|null}
*/
export default function findAllDOMElements (element) {
export default function findAllDOMElements(
element: string | Node,
): Node[] | null {
if (typeof element === 'string') {
const elements = document.querySelectorAll(element)
return elements.length === 0 ? null : Array.from(elements)

View file

@ -1,12 +1,12 @@
import isDOMElement from './isDOMElement.js'
import isDOMElement from './isDOMElement.ts'
/**
* Find a DOM element.
*
* @param {Node|string} element
* @returns {Node|null}
*/
export default function findDOMElement (element, context = document) {
export default function findDOMElement(
element: Node | string,
context = document,
): Node | null {
if (typeof element === 'string') {
return context.querySelector(element)
}

View file

@ -3,22 +3,14 @@ import findIndex from './findIndex.js'
describe('findIndex', () => {
it('should return index of an object in an array, that matches a predicate', () => {
const arr = [
{ name: 'foo' },
{ name: 'bar' },
{ name: '123' },
]
const index = findIndex(arr, item => item.name === 'bar')
const arr = [{ name: 'foo' }, { name: 'bar' }, { name: '123' }]
const index = findIndex(arr, (item) => item.name === 'bar')
expect(index).toEqual(1)
})
it('should return -1 when no object in an array matches a predicate', () => {
const arr = [
{ name: 'foo' },
{ name: 'bar' },
{ name: '123' },
]
const index = findIndex(arr, item => item.name === 'hello')
const arr = [{ name: 'foo' }, { name: 'bar' }, { name: '123' }]
const index = findIndex(arr, (item) => item.name === 'hello')
expect(index).toEqual(-1)
})
})

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import generateFileID from './generateFileID.js'
import generateFileID from './generateFileID.ts'
describe('generateFileID', () => {
it('should take the filename object and produce a lowercase file id made up of uppy- prefix, file name (cleaned up to be lowercase, letters and numbers only), type, relative path (folder) from file.meta.relativePath, size and lastModified date', () => {

View file

@ -1,25 +1,25 @@
import getFileType from './getFileType.js'
import type { UppyFile } from './UppyFile'
import getFileType from './getFileType.ts'
function encodeCharacter (character) {
function encodeCharacter(character: string): string {
return character.charCodeAt(0).toString(32)
}
function encodeFilename (name) {
function encodeFilename(name: string): string {
let suffix = ''
return name.replace(/[^A-Z0-9]/ig, (character) => {
suffix += `-${encodeCharacter(character)}`
return '/'
}) + suffix
return (
name.replace(/[^A-Z0-9]/gi, (character) => {
suffix += `-${encodeCharacter(character)}`
return '/'
}) + suffix
)
}
/**
* Takes a file object and turns it into fileID, by converting file.name to lowercase,
* removing extra characters and adding type, size and lastModified
*
* @param {object} file
* @returns {string} the fileID
*/
export default function generateFileID (file) {
export default function generateFileID(file: UppyFile): string {
// It's tempting to do `[items].filter(Boolean).join('-')` here, but that
// is slower! simple string concatenation is fast
@ -39,8 +39,8 @@ export default function generateFileID (file) {
if (file.data.size !== undefined) {
id += `-${file.data.size}`
}
if (file.data.lastModified !== undefined) {
id += `-${file.data.lastModified}`
if ((file.data as File).lastModified !== undefined) {
id += `-${(file.data as File).lastModified}`
}
return id
@ -48,14 +48,20 @@ export default function generateFileID (file) {
// If the provider has a stable, unique ID, then we can use that to identify the file.
// Then we don't have to generate our own ID, and we can add the same file many times if needed (different path)
function hasFileStableId (file) {
function hasFileStableId(file: UppyFile): boolean {
if (!file.isRemote || !file.remote) return false
// These are the providers that it seems like have stable IDs for their files. The other's I haven't checked yet.
const stableIdProviders = new Set(['box', 'dropbox', 'drive', 'facebook', 'unsplash'])
return stableIdProviders.has(file.remote.provider)
const stableIdProviders = new Set([
'box',
'dropbox',
'drive',
'facebook',
'unsplash',
])
return stableIdProviders.has(file.remote.provider as any)
}
export function getSafeFileId (file) {
export function getSafeFileId(file: UppyFile): string {
if (hasFileStableId(file)) return file.id
const fileType = getFileType(file)

View file

@ -1,3 +0,0 @@
export default function getBytesRemaining (fileProgress) {
return fileProgress.bytesTotal - fileProgress.bytesUploaded
}

View file

@ -1,11 +1,15 @@
import { describe, expect, it } from 'vitest'
import getBytesRemaining from './getBytesRemaining.js'
import getBytesRemaining from './getBytesRemaining.ts'
describe('getBytesRemaining', () => {
it('should calculate the bytes remaining given a fileProgress object', () => {
const fileProgress = {
bytesUploaded: 1024,
bytesTotal: 3096,
progress: 0,
uploadComplete: false,
percentage: 0,
uploadStarted: 0,
}
expect(getBytesRemaining(fileProgress)).toEqual(2072)
})

View file

@ -0,0 +1,5 @@
import type { FileProgress } from './FileProgress'
export default function getBytesRemaining(fileProgress: FileProgress): number {
return fileProgress.bytesTotal - (fileProgress.bytesUploaded as number)
}

View file

@ -1,5 +1,5 @@
import webkitGetAsEntryApi from './utils/webkitGetAsEntryApi/index.js'
import fallbackApi from './utils/fallbackApi.js'
import webkitGetAsEntryApi from './utils/webkitGetAsEntryApi/index.ts'
import fallbackApi from './utils/fallbackApi.ts'
/**
* Returns a promise that resolves to the array of dropped files (if a folder is
@ -8,22 +8,29 @@ import fallbackApi from './utils/fallbackApi.js'
* Each file has .relativePath prop appended to it (e.g. "/docs/Prague/ticket_from_prague_to_ufa.pdf")
* if browser supports it. Otherwise it's undefined.
*
* @param {DataTransfer} dataTransfer
* @param {Function} logDropError - a function that's called every time some
* @param dataTransfer
* @param options
* @param options.logDropError - a function that's called every time some
* folder or some file error out (e.g. because of the folder name being too long
* on Windows). Notice that resulting promise will always be resolved anyway.
*
* @returns {Promise} - Array<File>
*/
export default async function getDroppedFiles (dataTransfer, { logDropError = () => {} } = {}) {
export default async function getDroppedFiles(
dataTransfer: DataTransfer,
options?: {
logDropError?: any
},
): Promise<File[]> {
// Get all files from all subdirs. Works (at least) in Chrome, Mozilla, and Safari
const logDropError = options?.logDropError ?? Function.prototype
try {
const accumulator = []
for await (const file of webkitGetAsEntryApi(dataTransfer, logDropError)) {
accumulator.push(file)
accumulator.push(file as File)
}
return accumulator
// Otherwise just return all first-order files
// Otherwise just return all first-order files
} catch {
return fallbackApi(dataTransfer)
}

View file

@ -1,7 +1,9 @@
import toArray from '../../toArray.js'
import toArray from '../../toArray.ts'
// .files fallback, should be implemented in any browser
export default function fallbackApi (dataTransfer) {
export default function fallbackApi(
dataTransfer: DataTransfer,
): Promise<File[]> {
const files = toArray(dataTransfer.files)
return Promise.resolve(files)
}

View file

@ -1,12 +1,12 @@
/**
* Recursive function, calls the original callback() when the directory is entirely parsed.
*
* @param {FileSystemDirectoryReader} directoryReader
* @param {Array} oldEntries
* @param {Function} logDropError
* @param {Function} callback - called with ([ all files and directories in that directoryReader ])
*/
export default function getFilesAndDirectoriesFromDirectory (directoryReader, oldEntries, logDropError, { onSuccess }) {
export default function getFilesAndDirectoriesFromDirectory(
directoryReader: FileSystemDirectoryReader,
oldEntries: FileSystemEntry[],
logDropError: (error?: unknown) => void,
{ onSuccess }: { onSuccess: (newEntries: FileSystemEntry[]) => void },
): void {
directoryReader.readEntries(
(entries) => {
const newEntries = [...oldEntries, ...entries]
@ -14,9 +14,14 @@ export default function getFilesAndDirectoriesFromDirectory (directoryReader, ol
// must be called until it calls the onSuccess with an empty array.
if (entries.length) {
queueMicrotask(() => {
getFilesAndDirectoriesFromDirectory(directoryReader, newEntries, logDropError, { onSuccess })
getFilesAndDirectoriesFromDirectory(
directoryReader,
newEntries,
logDropError,
{ onSuccess },
)
})
// Done iterating this particular directory
// Done iterating this particular directory
} else {
onSuccess(newEntries)
}

View file

@ -1,96 +0,0 @@
import getFilesAndDirectoriesFromDirectory from './getFilesAndDirectoriesFromDirectory.js'
/**
* Polyfill for the new (experimental) getAsFileSystemHandle API (using the popular webkitGetAsEntry behind the scenes)
* so that we can switch to the getAsFileSystemHandle API once it (hopefully) becomes standard
*/
function getAsFileSystemHandleFromEntry (entry, logDropError) {
if (entry == null) return entry
return {
// eslint-disable-next-line no-nested-ternary
kind: entry.isFile ? 'file' : entry.isDirectory ? 'directory' : undefined,
name: entry.name,
getFile () {
return new Promise((resolve, reject) => entry.file(resolve, reject))
},
async* values () {
// If the file is a directory.
const directoryReader = entry.createReader()
const entries = await new Promise(resolve => {
getFilesAndDirectoriesFromDirectory(directoryReader, [], logDropError, {
onSuccess: (dirEntries) => resolve(dirEntries.map(file => getAsFileSystemHandleFromEntry(file, logDropError))),
})
})
yield* entries
},
}
}
async function* createPromiseToAddFileOrParseDirectory (entry, relativePath, lastResortFile = undefined) {
const getNextRelativePath = () => `${relativePath}/${entry.name}`
// For each dropped item, - make sure it's a file/directory, and start deepening in!
if (entry.kind === 'file') {
const file = await entry.getFile()
if (file != null) {
file.relativePath = relativePath ? getNextRelativePath() : null
yield file
} else if (lastResortFile != null) yield lastResortFile
} else if (entry.kind === 'directory') {
for await (const handle of entry.values()) {
// Recurse on the directory, appending the dir name to the relative path
yield* createPromiseToAddFileOrParseDirectory(handle, relativePath ? getNextRelativePath() : entry.name)
}
} else if (lastResortFile != null) yield lastResortFile
}
/**
* Load all files from data transfer, and recursively read any directories.
* Note that IE is not supported for drag-drop, because IE doesn't support Data Transfers
*
* @param {DataTransfer} dataTransfer
* @param {*} logDropError on error
*/
export default async function* getFilesFromDataTransfer (dataTransfer, logDropError) {
// Retrieving the dropped items must happen synchronously
// otherwise only the first item gets treated and the other ones are garbage collected.
// https://github.com/transloadit/uppy/pull/3998
const fileSystemHandles = await Promise.all(Array.from(dataTransfer.items, async item => {
let fileSystemHandle
// TODO enable getAsFileSystemHandle API once we can get it working with subdirectories
// IMPORTANT: Need to check isSecureContext *before* calling getAsFileSystemHandle
// or else Chrome will crash when running in HTTP: https://github.com/transloadit/uppy/issues/4133
// if (window.isSecureContext && item.getAsFileSystemHandle != null) entry = await item.getAsFileSystemHandle()
// `webkitGetAsEntry` exists in all popular browsers (including non-WebKit browsers),
// however it may be renamed to getAsEntry() in the future, so you should code defensively, looking for both.
// from https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry
const getAsEntry = () => (typeof item.getAsEntry === 'function' ? item.getAsEntry() : item.webkitGetAsEntry())
// eslint-disable-next-line prefer-const
fileSystemHandle ??= getAsFileSystemHandleFromEntry(getAsEntry(), logDropError)
return {
fileSystemHandle,
lastResortFile: item.getAsFile(), // can be used as a fallback in case other methods fail
}
}))
for (const { lastResortFile, fileSystemHandle } of fileSystemHandles) {
// fileSystemHandle and lastResortFile can be null when we drop an url.
if (fileSystemHandle != null) {
try {
yield* createPromiseToAddFileOrParseDirectory(fileSystemHandle, '', lastResortFile)
} catch (err) {
// Example: If dropping a symbolic link, Chromium will throw:
// "DOMException: A requested file or directory could not be found at the time an operation was processed.",
// So we will use lastResortFile instead. See https://github.com/transloadit/uppy/issues/3505.
if (lastResortFile != null) {
yield lastResortFile
} else {
logDropError(err)
}
}
} else if (lastResortFile != null) yield lastResortFile
}
}

View file

@ -0,0 +1,154 @@
import getFilesAndDirectoriesFromDirectory from './getFilesAndDirectoriesFromDirectory.ts'
interface FileSystemFileHandle extends FileSystemHandle {
getFile(): Promise<File>
}
interface FileSystemDirectoryHandle extends FileSystemHandle {
values(): AsyncGenerator<
FileSystemDirectoryHandle | FileSystemFileHandle,
void,
undefined
>
}
/**
* Polyfill for the new (experimental) getAsFileSystemHandle API (using the popular webkitGetAsEntry behind the scenes)
* so that we can switch to the getAsFileSystemHandle API once it (hopefully) becomes standard
*/
function getAsFileSystemHandleFromEntry(
entry: FileSystemEntry | null | undefined,
logDropError: Parameters<typeof getFilesAndDirectoriesFromDirectory>[2],
): FileSystemFileHandle | FileSystemDirectoryHandle | null | undefined {
if (entry == null) return entry
return {
// eslint-disable-next-line no-nested-ternary
kind: entry.isFile
? 'file'
: entry.isDirectory
? 'directory'
: (undefined as never),
name: entry.name,
getFile(): ReturnType<FileSystemFileHandle['getFile']> {
return new Promise((resolve, reject) =>
(entry as FileSystemFileEntry).file(resolve, reject),
)
},
async *values(): ReturnType<FileSystemDirectoryHandle['values']> {
// If the file is a directory.
const directoryReader = (entry as FileSystemDirectoryEntry).createReader()
const entries = await new Promise<
Array<NonNullable<ReturnType<typeof getAsFileSystemHandleFromEntry>>>
>((resolve) => {
getFilesAndDirectoriesFromDirectory(directoryReader, [], logDropError, {
onSuccess: (dirEntries) =>
resolve(
dirEntries.map(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(file) => getAsFileSystemHandleFromEntry(file, logDropError)!,
),
),
})
})
yield* entries
},
isSameEntry: undefined as any as FileSystemDirectoryHandle['isSameEntry'],
}
}
async function* createPromiseToAddFileOrParseDirectory(
entry: FileSystemFileHandle | FileSystemDirectoryHandle,
relativePath: string,
lastResortFile: File | null | undefined = undefined,
): AsyncGenerator<File> {
const getNextRelativePath = (): string => `${relativePath}/${entry.name}`
// For each dropped item, - make sure it's a file/directory, and start deepening in!
if (entry.kind === 'file') {
const file = await (entry as FileSystemFileHandle).getFile()
if (file != null) {
;(file as any).relativePath = relativePath ? getNextRelativePath() : null
yield file
} else if (lastResortFile != null) yield lastResortFile
} else if (entry.kind === 'directory') {
for await (const handle of (entry as FileSystemDirectoryHandle).values()) {
// Recurse on the directory, appending the dir name to the relative path
yield* createPromiseToAddFileOrParseDirectory(
handle,
relativePath ? getNextRelativePath() : entry.name,
)
}
} else if (lastResortFile != null) yield lastResortFile
}
/**
* Load all files from data transfer, and recursively read any directories.
* Note that IE is not supported for drag-drop, because IE doesn't support Data Transfers
*
* @param {DataTransfer} dataTransfer
* @param {*} logDropError on error
*/
export default async function* getFilesFromDataTransfer(
dataTransfer: DataTransfer,
logDropError: Parameters<typeof getFilesAndDirectoriesFromDirectory>[2],
): ReturnType<typeof createPromiseToAddFileOrParseDirectory> {
// Retrieving the dropped items must happen synchronously
// otherwise only the first item gets treated and the other ones are garbage collected.
// https://github.com/transloadit/uppy/pull/3998
const fileSystemHandles = await Promise.all(
Array.from(dataTransfer.items, async (item) => {
let fileSystemHandle:
| FileSystemFileHandle
| FileSystemDirectoryHandle
| null
| undefined
// TODO enable getAsFileSystemHandle API once we can get it working with subdirectories
// IMPORTANT: Need to check isSecureContext *before* calling getAsFileSystemHandle
// or else Chrome will crash when running in HTTP: https://github.com/transloadit/uppy/issues/4133
// if (window.isSecureContext && item.getAsFileSystemHandle != null)
// fileSystemHandle = await item.getAsFileSystemHandle()
// `webkitGetAsEntry` exists in all popular browsers (including non-WebKit browsers),
// however it may be renamed to getAsEntry() in the future, so you should code defensively, looking for both.
// from https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry
const getAsEntry = (): ReturnType<
DataTransferItem['webkitGetAsEntry']
> =>
typeof (item as any).getAsEntry === 'function'
? (item as any).getAsEntry()
: item.webkitGetAsEntry()
// eslint-disable-next-line prefer-const
fileSystemHandle ??= getAsFileSystemHandleFromEntry(
getAsEntry(),
logDropError,
)
return {
fileSystemHandle,
lastResortFile: item.getAsFile(), // can be used as a fallback in case other methods fail
}
}),
)
for (const { lastResortFile, fileSystemHandle } of fileSystemHandles) {
// fileSystemHandle and lastResortFile can be null when we drop an url.
if (fileSystemHandle != null) {
try {
yield* createPromiseToAddFileOrParseDirectory(
fileSystemHandle,
'',
lastResortFile,
)
} catch (err) {
// Example: If dropping a symbolic link, Chromium will throw:
// "DOMException: A requested file or directory could not be found at the time an operation was processed.",
// So we will use lastResortFile instead. See https://github.com/transloadit/uppy/issues/3505.
if (lastResortFile != null) {
yield lastResortFile
} else {
logDropError(err)
}
}
} else if (lastResortFile != null) yield lastResortFile
}
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import getETA from './getETA.js'
import getETA from './getETA.ts'
describe('getETA', () => {
it('should get the ETA remaining based on a fileProgress object', () => {

View file

@ -1,7 +1,8 @@
import getSpeed from './getSpeed.js'
import getBytesRemaining from './getBytesRemaining.js'
import getSpeed from './getSpeed.ts'
import getBytesRemaining from './getBytesRemaining.ts'
import type { FileProgress } from './FileProgress.ts'
export default function getETA (fileProgress) {
export default function getETA(fileProgress: FileProgress): number {
if (!fileProgress.bytesUploaded) return 0
const uploadSpeed = getSpeed(fileProgress)

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import getFileNameAndExtension from './getFileNameAndExtension.js'
import getFileNameAndExtension from './getFileNameAndExtension.ts'
describe('getFileNameAndExtension', () => {
it('should return the filename and extension as an array', () => {

View file

@ -1,10 +1,10 @@
/**
* Takes a full filename string and returns an object {name, extension}
*
* @param {string} fullFileName
* @returns {object} {name, extension}
*/
export default function getFileNameAndExtension (fullFileName) {
export default function getFileNameAndExtension(fullFileName: string): {
name: string
extension: string | undefined
} {
const lastDot = fullFileName.lastIndexOf('.')
// these count as no extension: "no-dot", "trailing-dot."
if (lastDot === -1 || lastDot === fullFileName.length - 1) {

View file

@ -1,14 +0,0 @@
import getFileNameAndExtension from './getFileNameAndExtension.js'
import mimeTypes from './mimeTypes.js'
export default function getFileType (file) {
if (file.type) return file.type
const fileExtension = file.name ? getFileNameAndExtension(file.name).extension?.toLowerCase() : null
if (fileExtension && fileExtension in mimeTypes) {
// else, see if we can map extension to a mime type
return mimeTypes[fileExtension]
}
// if all fails, fall back to a generic byte stream type
return 'application/octet-stream'
}

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import getFileType from './getFileType.js'
import getFileType from './getFileType.ts'
import type { UppyFile } from './UppyFile.ts'
describe('getFileType', () => {
it('should trust the filetype if the file comes from a remote source', () => {
@ -7,7 +8,7 @@ describe('getFileType', () => {
isRemote: true,
type: 'audio/webm',
name: 'foo.webm',
}
} as any as UppyFile
expect(getFileType(file)).toEqual('audio/webm')
})
@ -16,7 +17,7 @@ describe('getFileType', () => {
type: 'audio/webm',
name: 'foo.webm',
data: 'sdfsdfhq9efbicw',
}
} as any as UppyFile
expect(getFileType(file)).toEqual('audio/webm')
})
@ -24,24 +25,27 @@ describe('getFileType', () => {
const fileMP3 = {
name: 'foo.mp3',
data: 'sdfsfhfh329fhwihs',
}
} as any as UppyFile
const fileYAML = {
name: 'bar.yaml',
data: 'sdfsfhfh329fhwihs',
}
} as any as UppyFile
const fileMKV = {
name: 'bar.mkv',
data: 'sdfsfhfh329fhwihs',
}
} as any as UppyFile
const fileDicom = {
name: 'bar.dicom',
data: 'sdfsfhfh329fhwihs',
}
} as any as UppyFile
const fileWebp = {
name: 'bar.webp',
data: 'sdfsfhfh329fhwihs',
}
const toUpper = (file) => ({ ...file, name: file.name.toUpperCase() })
} as any as UppyFile
const toUpper = (file: UppyFile) => ({
...file,
name: file.name.toUpperCase(),
})
expect(getFileType(fileMP3)).toEqual('audio/mp3')
expect(getFileType(toUpper(fileMP3))).toEqual('audio/mp3')
expect(getFileType(fileYAML)).toEqual('text/yaml')
@ -58,7 +62,7 @@ describe('getFileType', () => {
const file = {
name: 'foobar',
data: 'sdfsfhfh329fhwihs',
}
} as any as UppyFile
expect(getFileType(file)).toEqual('application/octet-stream')
})
})

View file

@ -0,0 +1,17 @@
import type { UppyFile } from './UppyFile'
import getFileNameAndExtension from './getFileNameAndExtension.ts'
import mimeTypes from './mimeTypes.ts'
export default function getFileType(file: UppyFile): string {
if (file.type) return file.type
const fileExtension = file.name
? getFileNameAndExtension(file.name).extension?.toLowerCase()
: null
if (fileExtension && fileExtension in mimeTypes) {
// else, see if we can map extension to a mime type
return mimeTypes[fileExtension as keyof typeof mimeTypes]
}
// if all fails, fall back to a generic byte stream type
return 'application/octet-stream'
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import getFileTypeExtension from './getFileTypeExtension.js'
import getFileTypeExtension from './getFileTypeExtension.ts'
describe('getFileTypeExtension', () => {
it('should return the filetype based on the specified mime type', () => {

View file

@ -1,4 +1,5 @@
const mimeToExtensions = {
__proto__: null,
'audio/mp3': 'mp3',
'audio/mp4': 'mp4',
'audio/ogg': 'ogg',
@ -15,11 +16,11 @@ const mimeToExtensions = {
'video/webm': 'webm',
'video/x-matroska': 'mkv',
'video/x-msvideo': 'avi',
}
} as unknown as Record<string, string>
export default function getFileTypeExtension (mimeType) {
export default function getFileTypeExtension(mimeType: string): string | null {
// Remove the ; bit in 'video/x-matroska;codecs=avc1'
// eslint-disable-next-line no-param-reassign
[mimeType] = mimeType.split(';', 1)
;[mimeType] = mimeType.split(';', 1)
return mimeToExtensions[mimeType] || null
}

View file

@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest'
import getSocketHost from './getSocketHost.js'
describe('getSocketHost', () => {
it('should get the host from the specified url', () => {
expect(
getSocketHost('https://foo.bar/a/b/cd?e=fghi&l=k&m=n'),
).toEqual('wss://foo.bar/a/b/cd?e=fghi&l=k&m=n')
expect(
getSocketHost('Https://foo.bar/a/b/cd?e=fghi&l=k&m=n'),
).toEqual('wss://foo.bar/a/b/cd?e=fghi&l=k&m=n')
expect(
getSocketHost('foo.bar/a/b/cd?e=fghi&l=k&m=n'),
).toEqual('wss://foo.bar/a/b/cd?e=fghi&l=k&m=n')
expect(
getSocketHost('http://foo.bar/a/b/cd?e=fghi&l=k&m=n'),
).toEqual('ws://foo.bar/a/b/cd?e=fghi&l=k&m=n')
})
})

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import getSocketHost from './getSocketHost.ts'
describe('getSocketHost', () => {
it('should get the host from the specified url', () => {
expect(getSocketHost('https://foo.bar/a/b/cd?e=fghi&l=k&m=n')).toEqual(
'wss://foo.bar/a/b/cd?e=fghi&l=k&m=n',
)
expect(getSocketHost('Https://foo.bar/a/b/cd?e=fghi&l=k&m=n')).toEqual(
'wss://foo.bar/a/b/cd?e=fghi&l=k&m=n',
)
expect(getSocketHost('foo.bar/a/b/cd?e=fghi&l=k&m=n')).toEqual(
'wss://foo.bar/a/b/cd?e=fghi&l=k&m=n',
)
expect(getSocketHost('http://foo.bar/a/b/cd?e=fghi&l=k&m=n')).toEqual(
'ws://foo.bar/a/b/cd?e=fghi&l=k&m=n',
)
})
})

View file

@ -1,7 +1,7 @@
export default function getSocketHost (url) {
export default function getSocketHost(url: string): string {
// get the host domain
const regex = /^(?:https?:\/\/|\/\/)?(?:[^@\n]+@)?(?:www\.)?([^\n]+)/i
const host = regex.exec(url)[1]
const host = regex.exec(url)?.[1]
const socketProtocol = /^http:\/\//i.test(url) ? 'ws' : 'wss'
return `${socketProtocol}://${host}`

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import getSpeed from './getSpeed.js'
import getSpeed from './getSpeed.ts'
describe('getSpeed', () => {
it('should calculate the speed given a fileProgress object', () => {
@ -7,7 +7,11 @@ describe('getSpeed', () => {
const date5SecondsAgo = new Date(dateNow.getTime() - 5 * 1000)
const fileProgress = {
bytesUploaded: 1024,
uploadStarted: date5SecondsAgo,
uploadStarted: date5SecondsAgo.getTime(),
progress: 0,
uploadComplete: false,
percentage: 0,
bytesTotal: 0,
}
expect(Math.round(getSpeed(fileProgress))).toEqual(Math.round(205))
})

View file

@ -1,4 +1,6 @@
export default function getSpeed (fileProgress) {
import type { FileProgress } from './FileProgress'
export default function getSpeed(fileProgress: FileProgress): number {
if (!fileProgress.bytesUploaded) return 0
const timeElapsed = Date.now() - fileProgress.uploadStarted

View file

@ -1,11 +1,8 @@
/**
* Get the declared text direction for an element.
*
* @param {Node} element
* @returns {string|undefined}
*/
function getTextDirection (element) {
function getTextDirection(element?: HTMLElement): string | undefined {
// There is another way to determine text direction using getComputedStyle(), as done here:
// https://github.com/pencil-js/text-direction/blob/2a235ce95089b3185acec3b51313cbba921b3811/text-direction.js
//
@ -14,7 +11,7 @@ function getTextDirection (element) {
// bidirectional CSS style sheets work.
while (element && !element.dir) {
// eslint-disable-next-line no-param-reassign
element = element.parentNode
element = element.parentNode as HTMLElement
}
return element?.dir
}

View file

@ -1,17 +1,14 @@
/**
* Adds zero to strings shorter than two characters.
*
* @param {number} number
* @returns {string}
*/
function pad (number) {
function pad(number: number): string {
return number < 10 ? `0${number}` : number.toString()
}
/**
* Returns a timestamp in the format of `hours:minutes:seconds`
*/
export default function getTimeStamp () {
export default function getTimeStamp(): string {
const date = new Date()
const hours = pad(date.getHours())
const minutes = pad(date.getMinutes())

View file

@ -1,3 +0,0 @@
export default function has (object, key) {
return Object.prototype.hasOwnProperty.call(object, key)
}

View file

@ -0,0 +1,6 @@
export default function has(
object: Parameters<typeof Object.hasOwn>[0],
key: Parameters<typeof Object.hasOwn>[1],
): ReturnType<typeof Object.hasOwn> {
return Object.prototype.hasOwnProperty.call(object, key)
}

View file

@ -1,8 +0,0 @@
/**
* Check if an object is a DOM element. Duck-typing based on `nodeType`.
*
* @param {*} obj
*/
export default function isDOMElement (obj) {
return obj?.nodeType === Node.ELEMENT_NODE
}

View file

@ -0,0 +1,8 @@
/**
* Check if an object is a DOM element. Duck-typing based on `nodeType`.
*/
export default function isDOMElement(obj: unknown): obj is Element {
if (typeof obj !== 'object' || obj === null) return false
if (!('nodeType' in obj)) return false
return obj.nodeType === Node.ELEMENT_NODE
}

View file

@ -1,9 +1,7 @@
/**
* Checks if the browser supports Drag & Drop (not supported on mobile devices, for example).
*
* @returns {boolean}
*/
export default function isDragDropSupported () {
export default function isDragDropSupported(): boolean {
const div = document.body
if (!('draggable' in div) || !('ondragstart' in div && 'ondrop' in div)) {

View file

@ -1,15 +0,0 @@
/**
* Checks if current device reports itself as mobile.
* Very simple, not very reliable.
*
* @returns {boolean}
*/
export default function isMobileDevice () {
if (typeof window !== 'undefined'
&& window.navigator
&& window.navigator.userAgent
&& window.navigator.userAgent.match(/Mobi/)) {
return true
}
return false
}

View file

@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest'
import isMobileDevice from './isMobileDevice.js'
let fakeUserAgent = null
Object.defineProperty(globalThis.navigator, 'userAgent', {
get () {
return fakeUserAgent
},
})
function setUserAgent (userAgent) {
fakeUserAgent = userAgent
}
describe('isMobileDevice', () => {
it('should return true if the specified user agent is mobile', () => {
setUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1 Mobile/15E148 Safari/604.1')
expect(isMobileDevice()).toEqual(true)
setUserAgent('Mozilla/5.0 (Linux; Android 7.0; SM-G570M Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/69.0.3497.100 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/192.0.0.34.85;]')
expect(isMobileDevice()).toEqual(true)
})
it('should return false if the user agent is not mobile', () => {
setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/605.1.15 (KHTML, like Gecko)')
expect(isMobileDevice()).toEqual(false)
setUserAgent('Mozilla/5.0 (SMART-TV; Linux; Tizen 2.4.0) AppleWebkit/538.1 (KHTML, like Gecko) SamsungBrowser/1.1 TV Safari/538.1')
expect(isMobileDevice()).toEqual(false)
})
})

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import isMobileDevice from './isMobileDevice.ts'
let fakeUserAgent: string | null = null
Object.defineProperty(globalThis.navigator, 'userAgent', {
get() {
return fakeUserAgent
},
})
function setUserAgent(userAgent: string | null) {
fakeUserAgent = userAgent
}
describe('isMobileDevice', () => {
it('should return true if the specified user agent is mobile', () => {
setUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1 Mobile/15E148 Safari/604.1',
)
expect(isMobileDevice()).toEqual(true)
setUserAgent(
'Mozilla/5.0 (Linux; Android 7.0; SM-G570M Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/69.0.3497.100 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/192.0.0.34.85;]',
)
expect(isMobileDevice()).toEqual(true)
})
it('should return false if the user agent is not mobile', () => {
setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/605.1.15 (KHTML, like Gecko)',
)
expect(isMobileDevice()).toEqual(false)
setUserAgent(
'Mozilla/5.0 (SMART-TV; Linux; Tizen 2.4.0) AppleWebkit/538.1 (KHTML, like Gecko) SamsungBrowser/1.1 TV Safari/538.1',
)
expect(isMobileDevice()).toEqual(false)
})
})

View file

@ -0,0 +1,15 @@
/**
* Checks if current device reports itself as mobile.
* Very simple, not very reliable.
*/
export default function isMobileDevice(): boolean {
if (
typeof window !== 'undefined' &&
window.navigator &&
window.navigator.userAgent &&
window.navigator.userAgent.match(/Mobi/)
) {
return true
}
return false
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import isNetworkError from './isNetworkError.js'
import isNetworkError from './isNetworkError.ts'
describe('isNetworkError', () => {
it('should return true if the specified xhr object contains a network error', () => {
@ -7,25 +7,25 @@ describe('isNetworkError', () => {
readyState: 4,
responseText: '',
status: 0,
}
} as any as XMLHttpRequest
const xhrNetworkError2Mock = {
readyState: 2,
responseText: '',
status: 300,
}
} as any as XMLHttpRequest
const xhrRegularErrorMock = {
readyState: 4,
responseText: 'Failed',
status: 400,
}
} as any as XMLHttpRequest
const xhrNetworkSuccessMock = {
readyState: 4,
responseText: 'Success',
status: 200,
}
} as any as XMLHttpRequest
expect(isNetworkError(xhrNetworkErrorMock)).toEqual(true)
expect(isNetworkError(xhrNetworkError2Mock)).toEqual(true)

View file

@ -1,4 +1,4 @@
function isNetworkError (xhr) {
function isNetworkError(xhr?: XMLHttpRequest): boolean {
if (!xhr) {
return false
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import isObjectURL from './isObjectURL.js'
import isObjectURL from './isObjectURL.ts'
describe('isObjectURL', () => {
it('should return true if the specified url is an object url', () => {

View file

@ -1,9 +1,6 @@
/**
* Check if a URL string is an object URL from `URL.createObjectURL`.
*
* @param {string} url
* @returns {boolean}
*/
export default function isObjectURL (url) {
export default function isObjectURL(url: string): boolean {
return url.startsWith('blob:')
}

View file

@ -1,12 +0,0 @@
import { describe, expect, it } from 'vitest'
import isPreviewSupported from './isPreviewSupported.js'
describe('isPreviewSupported', () => {
it('should return true for any filetypes that browsers can preview', () => {
const supported = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml', 'image/bmp', 'image/jpg', 'image/webp', 'image/avif']
supported.forEach(ext => {
expect(isPreviewSupported(ext)).toEqual(true)
})
expect(isPreviewSupported('foo')).toEqual(false)
})
})

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import isPreviewSupported from './isPreviewSupported.ts'
describe('isPreviewSupported', () => {
it('should return true for any filetypes that browsers can preview', () => {
const supported = [
'image/jpeg',
'image/gif',
'image/png',
'image/svg',
'image/svg+xml',
'image/bmp',
'image/jpg',
'image/webp',
'image/avif',
]
supported.forEach((ext) => {
expect(isPreviewSupported(ext)).toEqual(true)
})
expect(isPreviewSupported('foo')).toEqual(false)
})
})

View file

@ -1,4 +1,4 @@
export default function isPreviewSupported (fileType) {
export default function isPreviewSupported(fileType: string): boolean {
if (!fileType) return false
// list of images that browsers can preview
return /^[^/]+\/(jpe?g|gif|png|svg|svg\+xml|bmp|webp|avif)$/.test(fileType)

View file

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import isTouchDevice from './isTouchDevice.js'
import isTouchDevice from './isTouchDevice.ts'
describe('isTouchDevice', () => {
const RealTouchStart = globalThis.window.ontouchstart

View file

@ -1,3 +1,3 @@
export default function isTouchDevice () {
export default function isTouchDevice(): boolean {
return 'ontouchstart' in window || 'maxTouchPoints' in navigator
}

View file

@ -1,58 +0,0 @@
// ___Why not add the mime-types package?
// It's 19.7kB gzipped, and we only need mime types for well-known extensions (for file previews).
// ___Where to take new extensions from?
// https://github.com/jshttp/mime-db/blob/master/db.json
export default {
md: 'text/markdown',
markdown: 'text/markdown',
mp4: 'video/mp4',
mp3: 'audio/mp3',
svg: 'image/svg+xml',
jpg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
heic: 'image/heic',
heif: 'image/heif',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
tsv: 'text/tab-separated-values',
tab: 'text/tab-separated-values',
avi: 'video/x-msvideo',
mks: 'video/x-matroska',
mkv: 'video/x-matroska',
mov: 'video/quicktime',
dicom: 'application/dicom',
doc: 'application/msword',
docm: 'application/vnd.ms-word.document.macroenabled.12',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
dot: 'application/msword',
dotm: 'application/vnd.ms-word.template.macroenabled.12',
dotx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
xla: 'application/vnd.ms-excel',
xlam: 'application/vnd.ms-excel.addin.macroenabled.12',
xlc: 'application/vnd.ms-excel',
xlf: 'application/x-xliff+xml',
xlm: 'application/vnd.ms-excel',
xls: 'application/vnd.ms-excel',
xlsb: 'application/vnd.ms-excel.sheet.binary.macroenabled.12',
xlsm: 'application/vnd.ms-excel.sheet.macroenabled.12',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
xlt: 'application/vnd.ms-excel',
xltm: 'application/vnd.ms-excel.template.macroenabled.12',
xltx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
xlw: 'application/vnd.ms-excel',
txt: 'text/plain',
text: 'text/plain',
conf: 'text/plain',
log: 'text/plain',
pdf: 'application/pdf',
zip: 'application/zip',
'7z': 'application/x-7z-compressed',
rar: 'application/x-rar-compressed',
tar: 'application/x-tar',
gz: 'application/gzip',
dmg: 'application/x-apple-diskimage',
}

View file

@ -0,0 +1,59 @@
// ___Why not add the mime-types package?
// It's 19.7kB gzipped, and we only need mime types for well-known extensions (for file previews).
// ___Where to take new extensions from?
// https://github.com/jshttp/mime-db/blob/master/db.json
export default {
__proto__: null as never,
md: 'text/markdown' as const,
markdown: 'text/markdown' as const,
mp4: 'video/mp4' as const,
mp3: 'audio/mp3' as const,
svg: 'image/svg+xml' as const,
jpg: 'image/jpeg' as const,
png: 'image/png' as const,
webp: 'image/webp' as const,
gif: 'image/gif' as const,
heic: 'image/heic' as const,
heif: 'image/heif' as const,
yaml: 'text/yaml' as const,
yml: 'text/yaml' as const,
csv: 'text/csv' as const,
tsv: 'text/tab-separated-values' as const,
tab: 'text/tab-separated-values' as const,
avi: 'video/x-msvideo' as const,
mks: 'video/x-matroska' as const,
mkv: 'video/x-matroska' as const,
mov: 'video/quicktime' as const,
dicom: 'application/dicom' as const,
doc: 'application/msword' as const,
docm: 'application/vnd.ms-word.document.macroenabled.12' as const,
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' as const,
dot: 'application/msword' as const,
dotm: 'application/vnd.ms-word.template.macroenabled.12' as const,
dotx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.template' as const,
xla: 'application/vnd.ms-excel' as const,
xlam: 'application/vnd.ms-excel.addin.macroenabled.12' as const,
xlc: 'application/vnd.ms-excel' as const,
xlf: 'application/x-xliff+xml' as const,
xlm: 'application/vnd.ms-excel' as const,
xls: 'application/vnd.ms-excel' as const,
xlsb: 'application/vnd.ms-excel.sheet.binary.macroenabled.12' as const,
xlsm: 'application/vnd.ms-excel.sheet.macroenabled.12' as const,
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' as const,
xlt: 'application/vnd.ms-excel' as const,
xltm: 'application/vnd.ms-excel.template.macroenabled.12' as const,
xltx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.template' as const,
xlw: 'application/vnd.ms-excel' as const,
txt: 'text/plain' as const,
text: 'text/plain' as const,
conf: 'text/plain' as const,
log: 'text/plain' as const,
pdf: 'application/pdf' as const,
zip: 'application/zip' as const,
'7z': 'application/x-7z-compressed' as const,
rar: 'application/x-rar-compressed' as const,
tar: 'application/x-tar' as const,
gz: 'application/gzip' as const,
dmg: 'application/x-apple-diskimage' as const,
}

View file

@ -1,14 +0,0 @@
import secondsToTime from './secondsToTime.js'
export default function prettyETA (seconds) {
const time = secondsToTime(seconds)
// Only display hours and minutes if they are greater than 0 but always
// display minutes if hours is being displayed
// Display a leading zero if the there is a preceding unit: 1m 05s, but 5s
const hoursStr = time.hours === 0 ? '' : `${time.hours}h`
const minutesStr = time.minutes === 0 ? '' : `${time.hours === 0 ? time.minutes : ` ${time.minutes.toString(10).padStart(2, '0')}`}m`
const secondsStr = time.hours !== 0 ? '' : `${time.minutes === 0 ? time.seconds : ` ${time.seconds.toString(10).padStart(2, '0')}`}s`
return `${hoursStr}${minutesStr}${secondsStr}`
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import prettyETA from './prettyETA.js'
import prettyETA from './prettyETA.ts'
describe('prettyETA', () => {
it('should convert the specified number of seconds to a pretty ETA', () => {

View file

@ -0,0 +1,28 @@
import secondsToTime from './secondsToTime.ts'
export default function prettyETA(seconds: number): string {
const time = secondsToTime(seconds)
// Only display hours and minutes if they are greater than 0 but always
// display minutes if hours is being displayed
// Display a leading zero if the there is a preceding unit: 1m 05s, but 5s
const hoursStr = time.hours === 0 ? '' : `${time.hours}h`
const minutesStr =
time.minutes === 0
? ''
: `${
time.hours === 0
? time.minutes
: ` ${time.minutes.toString(10).padStart(2, '0')}`
}m`
const secondsStr =
time.hours !== 0
? ''
: `${
time.minutes === 0
? time.seconds
: ` ${time.seconds.toString(10).padStart(2, '0')}`
}s`
return `${hoursStr}${minutesStr}${secondsStr}`
}

View file

@ -1,9 +0,0 @@
import getFileNameAndExtension from './getFileNameAndExtension.js'
export default function remoteFileObjToLocal (file) {
return {
...file,
type: file.mimeType,
extension: file.name ? getFileNameAndExtension(file.name).extension : null,
}
}

View file

@ -0,0 +1,19 @@
import getFileNameAndExtension from './getFileNameAndExtension.ts'
interface ObjectWithMIMEAndName {
name?: string
mimeType: unknown
}
export default function remoteFileObjToLocal<T extends ObjectWithMIMEAndName>(
file: T,
): T & {
type: T['mimeType']
extension: string | undefined | null
} {
return {
...file,
type: file.mimeType,
extension: file.name ? getFileNameAndExtension(file.name).extension : null,
}
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import secondsToTime from './secondsToTime.js'
import secondsToTime from './secondsToTime.ts'
describe('secondsToTime', () => {
it('converts seconds to an { hours, minutes, seconds } object', () => {

View file

@ -1,4 +1,10 @@
export default function secondsToTime (rawSeconds) {
interface Time {
hours: number
minutes: number
seconds: number
}
export default function secondsToTime(rawSeconds: number): Time {
const hours = Math.floor(rawSeconds / 3600) % 24
const minutes = Math.floor(rawSeconds / 60) % 60
const seconds = Math.floor(rawSeconds % 60)

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import toArray from './toArray.js'
import toArray from './toArray.ts'
describe('toArray', () => {
it('should convert a array-like object into an array', () => {
@ -12,12 +12,6 @@ describe('toArray', () => {
length: 5,
}
expect(toArray(obj)).toEqual([
'zero',
'one',
'two',
'three',
'four',
])
expect(toArray(obj)).toEqual(['zero', 'one', 'two', 'three', 'four'])
})
})

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import truncateString from './truncateString.js'
import truncateString from './truncateString.ts'
describe('truncateString', () => {
it('should truncate the string to the length', () => {

View file

@ -1,19 +1,19 @@
/**
* Truncates a string to the given number of chars (maxLength) by inserting '...' in the middle of that string.
* Partially taken from https://stackoverflow.com/a/5723274/3192470.
*
* @param {string} string - string to be truncated
* @param {number} maxLength - maximum size of the resulting string
* @returns {string}
*/
const separator = '...'
export default function truncateString (string, maxLength) {
export default function truncateString(
string: string,
maxLength: number,
): string {
// Return the empty string if maxLength is zero
if (maxLength === 0) return ''
// Return original string if it's already shorter than maxLength
if (string.length <= maxLength) return string
// Return truncated substring appended of the ellipsis char if string can't be meaningfully truncated
if (maxLength <= separator.length + 1) return `${string.slice(0, maxLength - 1)}`
if (maxLength <= separator.length + 1)
return `${string.slice(0, maxLength - 1)}`
const charsToShow = maxLength - separator.length
const frontChars = Math.ceil(charsToShow / 2)

View file

@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"outDir": "./lib",
"rootDir": "./src",
"allowJs": true,
"skipLibCheck": true
},
"include": ["./src/**/*.*"],
"exclude": ["./src/**/*.test.ts"],
"references": []
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"allowJs": true,
"emitDeclarationOnly": false,
"noEmit": true
},
"include": ["src/*.ts"],
"references": []
}

View file

@ -8197,6 +8197,13 @@ __metadata:
languageName: node
linkType: hard
"@types/lodash@npm:^4.14.199":
version: 4.14.199
resolution: "@types/lodash@npm:4.14.199"
checksum: e68d1fcbbfce953ed87b296a628573f62939227bcda0c934954e862b421e8a34c5e71cad6fea27b9980567909e6a4698f09025692958e36d64ea9ed99ec6fb2e
languageName: node
linkType: hard
"@types/mdast@npm:^3.0.0":
version: 3.0.10
resolution: "@types/mdast@npm:3.0.10"
@ -9805,6 +9812,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@uppy/utils@workspace:packages/@uppy/utils"
dependencies:
"@types/lodash": ^4.14.199
lodash: ^4.17.21
preact: ^10.5.13
vitest: ^0.34.5