From 36ce6716aa398e132daf44ff44576b3752311ca9 Mon Sep 17 00:00:00 2001 From: roman Date: Fri, 18 Sep 2026 13:52:25 +0200 Subject: [PATCH] refactor(utils): migrate utility helpers from Flow to TypeScript --- .../{error.test.js => error.test.ts} | 0 .../__tests__/{file.test.js => file.test.ts} | 5 +- .../{flatten.test.js => flatten.test.ts} | 20 ++++- .../{function.test.js => function.test.ts} | 4 +- ...uzzySearch.test.js => fuzzySearch.test.ts} | 0 ...etFileSize.test.js => getFileSize.test.ts} | 0 ...{validators.test.js => validators.test.ts} | 2 - .../{webcrypto.test.js => webcrypto.test.ts} | 83 ++++++++++--------- .../{domPolyfill.js => domPolyfill.js.flow} | 0 src/utils/domPolyfill.ts | 30 +++++++ src/utils/{error.js => error.js.flow} | 0 src/utils/error.ts | 40 +++++++++ src/utils/{file.js => file.js.flow} | 2 +- src/utils/file.ts | 47 +++++++++++ src/utils/{flatten.js => flatten.js.flow} | 0 src/utils/flatten.ts | 52 ++++++++++++ src/utils/{function.js => function.js.flow} | 0 src/utils/function.ts | 44 ++++++++++ .../{fuzzySearch.js => fuzzySearch.js.flow} | 0 src/utils/fuzzySearch.ts | 54 ++++++++++++ .../{getFileSize.js => getFileSize.js.flow} | 0 src/utils/getFileSize.ts | 26 ++++++ .../{validators.js => validators.js.flow} | 0 src/utils/validators.ts | 44 ++++++++++ src/utils/{webcrypto.js => webcrypto.js.flow} | 0 src/utils/webcrypto.ts | 52 ++++++++++++ 26 files changed, 459 insertions(+), 46 deletions(-) rename src/utils/__tests__/{error.test.js => error.test.ts} (100%) rename src/utils/__tests__/{file.test.js => file.test.ts} (92%) rename src/utils/__tests__/{flatten.test.js => flatten.test.ts} (75%) rename src/utils/__tests__/{function.test.js => function.test.ts} (95%) rename src/utils/__tests__/{fuzzySearch.test.js => fuzzySearch.test.ts} (100%) rename src/utils/__tests__/{getFileSize.test.js => getFileSize.test.ts} (100%) rename src/utils/__tests__/{validators.test.js => validators.test.ts} (99%) rename src/utils/__tests__/{webcrypto.test.js => webcrypto.test.ts} (54%) rename src/utils/{domPolyfill.js => domPolyfill.js.flow} (100%) create mode 100644 src/utils/domPolyfill.ts rename src/utils/{error.js => error.js.flow} (100%) create mode 100644 src/utils/error.ts rename src/utils/{file.js => file.js.flow} (97%) create mode 100644 src/utils/file.ts rename src/utils/{flatten.js => flatten.js.flow} (100%) create mode 100644 src/utils/flatten.ts rename src/utils/{function.js => function.js.flow} (100%) create mode 100644 src/utils/function.ts rename src/utils/{fuzzySearch.js => fuzzySearch.js.flow} (100%) create mode 100644 src/utils/fuzzySearch.ts rename src/utils/{getFileSize.js => getFileSize.js.flow} (100%) create mode 100644 src/utils/getFileSize.ts rename src/utils/{validators.js => validators.js.flow} (100%) create mode 100644 src/utils/validators.ts rename src/utils/{webcrypto.js => webcrypto.js.flow} (100%) create mode 100644 src/utils/webcrypto.ts diff --git a/src/utils/__tests__/error.test.js b/src/utils/__tests__/error.test.ts similarity index 100% rename from src/utils/__tests__/error.test.js rename to src/utils/__tests__/error.test.ts diff --git a/src/utils/__tests__/file.test.js b/src/utils/__tests__/file.test.ts similarity index 92% rename from src/utils/__tests__/file.test.js rename to src/utils/__tests__/file.test.ts index 7bc97b4833..7768461838 100644 --- a/src/utils/__tests__/file.test.js +++ b/src/utils/__tests__/file.test.ts @@ -42,11 +42,14 @@ describe('util/file', () => { ['filename.txt', 'txt'], ['filename.backup.mp4', 'mp4'], ['filename..temp.pdf', 'pdf'], - [{ name: 'test.pdf' }, ''], ['invalidfilenamepdf', ''], ])('should return extension of file correctly', (filename, extension) => { expect(getFileExtension(filename)).toBe(extension); }); + + test('should return empty string when filename is not a string', () => { + expect(getFileExtension({ name: 'test.pdf' } as unknown as string)).toBe(''); + }); }); describe('isGSuiteExtension()', () => { diff --git a/src/utils/__tests__/flatten.test.js b/src/utils/__tests__/flatten.test.ts similarity index 75% rename from src/utils/__tests__/flatten.test.js rename to src/utils/__tests__/flatten.test.ts index afa9792c80..2d9f8d2cdb 100644 --- a/src/utils/__tests__/flatten.test.js +++ b/src/utils/__tests__/flatten.test.ts @@ -21,6 +21,17 @@ const file = new FileAPI({ cache }); const folder = new FolderAPI({ cache }); const weblink = new WebLinkAPI({ cache }); +const getThrownError = (callback: () => unknown): Error => { + try { + callback(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + return error as Error; + } + + throw new Error('Expected callback to throw'); +}; + describe('util/flatten', () => { test('should flatten the list and create new cache entries', () => { const items = flatten(list, folder, file, weblink); @@ -45,16 +56,19 @@ describe('util/flatten', () => { test('should throw with a bad type', () => { const badList = [{ id: '1', type: 'foo' }]; - expect(flatten.bind(flatten, badList, folder, file, weblink)).toThrow(Error, /Unknown Type/); + const error = getThrownError(flatten.bind(flatten, badList, folder, file, weblink)); + expect(error.message).toMatch(/Unknown Type/); }); test('should throw with a bad item when no id', () => { const badList = [{ type: 'foo' }]; - expect(flatten.bind(flatten, badList, folder, file, weblink)).toThrow(Error, /Bad box item/); + const error = getThrownError(flatten.bind(flatten, badList, folder, file, weblink)); + expect(error.message).toMatch(/Bad box item/); }); test('should throw with a bad item when no type', () => { const badList = [{ id: 'foo' }]; - expect(flatten.bind(flatten, badList, folder, file, weblink)).toThrow(Error, /Bad box item/); + const error = getThrownError(flatten.bind(flatten, badList, folder, file, weblink)); + expect(error.message).toMatch(/Bad box item/); }); }); diff --git a/src/utils/__tests__/function.test.js b/src/utils/__tests__/function.test.ts similarity index 95% rename from src/utils/__tests__/function.test.js rename to src/utils/__tests__/function.test.ts index 86ed85853b..fa0d46871d 100644 --- a/src/utils/__tests__/function.test.js +++ b/src/utils/__tests__/function.test.ts @@ -49,7 +49,7 @@ describe('util/function', () => { setTimeout(() => { promise.catch(sandbox.mock()); - expect(inner.callCount).to.equal(2); + expect(inner.callCount).toBe(2); }, 100); }); @@ -62,7 +62,7 @@ describe('util/function', () => { setTimeout(() => { promise.catch(sandbox.mock()); - expect(inner.callCount).to.equal(1); + expect(inner.callCount).toBe(1); }, 100); }); }); diff --git a/src/utils/__tests__/fuzzySearch.test.js b/src/utils/__tests__/fuzzySearch.test.ts similarity index 100% rename from src/utils/__tests__/fuzzySearch.test.js rename to src/utils/__tests__/fuzzySearch.test.ts diff --git a/src/utils/__tests__/getFileSize.test.js b/src/utils/__tests__/getFileSize.test.ts similarity index 100% rename from src/utils/__tests__/getFileSize.test.js rename to src/utils/__tests__/getFileSize.test.ts diff --git a/src/utils/__tests__/validators.test.js b/src/utils/__tests__/validators.test.ts similarity index 99% rename from src/utils/__tests__/validators.test.js rename to src/utils/__tests__/validators.test.ts index eefd574b37..4fea7a73e5 100644 --- a/src/utils/__tests__/validators.test.js +++ b/src/utils/__tests__/validators.test.ts @@ -1,5 +1,3 @@ -// @flow - import { domainNameValidator, emailValidator, hostnameValidator, ipv4AddressValidator } from '../validators'; describe('util/validators', () => { diff --git a/src/utils/__tests__/webcrypto.test.js b/src/utils/__tests__/webcrypto.test.ts similarity index 54% rename from src/utils/__tests__/webcrypto.test.js rename to src/utils/__tests__/webcrypto.test.ts index 84d0a48093..dbac6284c5 100644 --- a/src/utils/__tests__/webcrypto.test.js +++ b/src/utils/__tests__/webcrypto.test.ts @@ -3,9 +3,17 @@ import { digest, getRandomValues } from '../webcrypto'; jest.mock('js-sha1'); +type WindowWithMsCrypto = Omit & { crypto?: Crypto; msCrypto?: Crypto }; +type Sha1WithArrayBuffer = typeof sha1 & { arrayBuffer: jest.Mock }; +type CryptoOperation = { + oncomplete?: (event: { target: { result: ArrayBuffer } }) => void; + onerror?: (reason?: unknown) => void; +}; + +const sha1Mock = sha1 as Sha1WithArrayBuffer; + describe('util/webcrypto', () => { beforeEach(() => { - // eslint-disable-next-line no-undef Object.defineProperty(globalThis, 'window', { value: { ...window, @@ -17,107 +25,108 @@ describe('util/webcrypto', () => { describe('getRandomValues()', () => { test('should call getRandomValues() to get an array of random values', () => { const getRandomValuesMock = jest.fn(); - window.crypto = { + (window as WindowWithMsCrypto).crypto = { getRandomValues: getRandomValuesMock, - }; + } as unknown as Crypto; - getRandomValues(); + getRandomValues(new Uint8Array()); expect(getRandomValuesMock).toHaveBeenCalled(); }); }); describe('digest()', () => { const algorithm = 'a'; - const buffer = new Uint8Array([1, 2]); + const { buffer } = new Uint8Array([1, 2]); const digestVal = 'd'; test('should return the return value of digest() when the crypto lib is not msCrypto', () => { const digestMock = jest.fn().mockReturnValueOnce(digestVal); - window.crypto = { + (window as WindowWithMsCrypto).crypto = { subtle: { digest: digestMock, }, - }; + } as unknown as Crypto; expect(digest(algorithm, buffer)).toBe(digestVal); expect(digestMock).toHaveBeenCalledWith(algorithm, buffer); }); describe('msCrypto', () => { - test('should return a promise which resolves properly when the crypto lib is msCrypto', () => { - sha1.arrayBuffer = jest.fn().mockImplementation(() => new ArrayBuffer()); - const cryptoOperation = {}; + test('should return a promise which resolves properly when the crypto lib is msCrypto', async () => { + sha1Mock.arrayBuffer = jest.fn().mockImplementation(() => new ArrayBuffer(0)); + const cryptoOperation: CryptoOperation = {}; const digestMock = jest.fn().mockReturnValueOnce(cryptoOperation); + const expectedDigest = new ArrayBuffer(0); - window.crypto = undefined; - window.msCrypto = { + (window as WindowWithMsCrypto).crypto = undefined; + (window as WindowWithMsCrypto).msCrypto = { subtle: { digest: digestMock, }, - }; + } as unknown as Crypto; - digest(algorithm, buffer); + const digestPromise = digest(algorithm, buffer); - cryptoOperation.oncomplete({ + expect(cryptoOperation.oncomplete).toEqual(expect.any(Function)); + cryptoOperation.oncomplete!({ target: { - result: 'digest', + result: expectedDigest, }, }); + await expect(digestPromise).resolves.toBe(expectedDigest); expect(digestMock).toHaveBeenCalledWith({ name: algorithm }, buffer); - expect(sha1.arrayBuffer).not.toHaveBeenCalled(); + expect(sha1Mock.arrayBuffer).not.toHaveBeenCalled(); }); - test('should return a promise which rejects properly when the crypto lib is msCrypto', () => { - const cryptoOperation = {}; + test('should return a promise which rejects properly when the crypto lib is msCrypto', async () => { + const cryptoOperation: CryptoOperation = {}; const digestMock = jest.fn().mockReturnValueOnce(cryptoOperation); - window.crypto = undefined; - window.msCrypto = { + (window as WindowWithMsCrypto).crypto = undefined; + (window as WindowWithMsCrypto).msCrypto = { subtle: { digest: digestMock, }, - }; + } as unknown as Crypto; const expectedError = new Error('ERROR'); + const digestPromise = digest(algorithm, buffer); - digest(algorithm, buffer).catch(error => { - expect(error).toBe(expectedError); - }); - - cryptoOperation.onerror(expectedError); - expect.assertions(1); + expect(cryptoOperation.onerror).toEqual(expect.any(Function)); + cryptoOperation.onerror!(expectedError); + await expect(digestPromise).rejects.toBe(expectedError); }); }); describe('js-sha1', () => { test('should use js-sha1 for calculating hash in IE-11 SHA-1 digest scenarios', async () => { // ie11 does not support sha-1, so we use a library - sha1.arrayBuffer = jest.fn().mockImplementation(() => new ArrayBuffer()); + sha1Mock.arrayBuffer = jest.fn().mockImplementation(() => new ArrayBuffer(0)); const digestMock = jest.fn().mockReturnValueOnce({}); - window.crypto = undefined; + (window as WindowWithMsCrypto).crypto = undefined; - window.msCrypto = { + (window as WindowWithMsCrypto).msCrypto = { subtle: { digest: digestMock, }, - }; + } as unknown as Crypto; const hash = await digest('SHA-1', buffer); expect(hash).toBeDefined(); expect(digestMock).not.toHaveBeenCalled(); - expect(sha1.arrayBuffer).toHaveBeenCalledWith(buffer); + expect(sha1Mock.arrayBuffer).toHaveBeenCalledWith(buffer); }); test('should return a promise which rejects properly when js-sha1 fails', () => { const expectedError = new Error('ERROR'); // ie11 does not support sha-1, so we use a library - sha1.arrayBuffer = jest.fn().mockRejectedValue(expectedError); + sha1Mock.arrayBuffer = jest.fn().mockRejectedValue(expectedError); const digestMock = jest.fn().mockReturnValueOnce({}); - window.crypto = undefined; + (window as WindowWithMsCrypto).crypto = undefined; - window.msCrypto = { + (window as WindowWithMsCrypto).msCrypto = { subtle: { digest: digestMock, }, - }; + } as unknown as Crypto; digest('SHA-1', buffer).catch(error => { expect(error).toBe(expectedError); diff --git a/src/utils/domPolyfill.js b/src/utils/domPolyfill.js.flow similarity index 100% rename from src/utils/domPolyfill.js rename to src/utils/domPolyfill.js.flow diff --git a/src/utils/domPolyfill.ts b/src/utils/domPolyfill.ts new file mode 100644 index 0000000000..fa7265d5f1 --- /dev/null +++ b/src/utils/domPolyfill.ts @@ -0,0 +1,30 @@ +/** + * Polyfills the .closest method for Element + * Currently being used for scrollIntoView + * For reference: https://github.com/zloirock/core-js/issues/317 + */ +window.Element.prototype.closest = function closest(this: Element, s: string): Element | null { + const elementProto = window.Element.prototype as Element & { + msMatchesSelector?: (selector: string) => boolean; + webkitMatchesSelector?: (selector: string) => boolean; + }; + if (!window.Element.prototype.matches) { + window.Element.prototype.matches = (elementProto.msMatchesSelector || + elementProto.webkitMatchesSelector) as typeof Element.prototype.matches; + } + + if (this.matches(s)) { + return this; + } + + let el: Element | Node | null = this.parentElement || this.parentNode; + while (el !== null && el.nodeType === 1) { + const element = el as Element; + if (element.matches(s)) { + return element; + } + el = element.parentElement || element.parentNode; + } + + return null; +}; diff --git a/src/utils/error.js b/src/utils/error.js.flow similarity index 100% rename from src/utils/error.js rename to src/utils/error.js.flow diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000000..c20d36ef06 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,40 @@ +import { + HTTP_STATUS_CODE_CONFLICT, + HTTP_STATUS_CODE_UNAUTHORIZED, + HTTP_STATUS_CODE_RATE_LIMIT, + HTTP_STATUS_CODE_INTERNAL_SERVER_ERROR, +} from '../constants'; + +const getBadItemError = (): Error => new Error('Bad box item!'); + +const getBadPermissionsError = (): Error => new Error('Insufficient Permissions!'); + +const getBadUserError = (): Error => new Error('Bad box user!'); + +const getMissingItemTextOrStatus = (): Error => new Error('Missing text or status!'); + +const isUserCorrectableError = (status: number): boolean => + status === HTTP_STATUS_CODE_RATE_LIMIT || + status === HTTP_STATUS_CODE_UNAUTHORIZED || + status === HTTP_STATUS_CODE_CONFLICT || + status >= HTTP_STATUS_CODE_INTERNAL_SERVER_ERROR; + +const getAbortError = (): Error => { + class AbortError extends Error { + constructor(message: string) { + super(message); + this.name = 'AbortError'; + } + } + + return new AbortError('Aborted'); +}; + +export { + getAbortError, + getBadItemError, + getBadPermissionsError, + getBadUserError, + getMissingItemTextOrStatus, + isUserCorrectableError, +}; diff --git a/src/utils/file.js b/src/utils/file.js.flow similarity index 97% rename from src/utils/file.js rename to src/utils/file.js.flow index cc352d101e..db1e56d47f 100644 --- a/src/utils/file.js +++ b/src/utils/file.js.flow @@ -76,7 +76,7 @@ export function isGSuiteExtension(extension: string): boolean { * @param {string} filename a Box file * @return {string} typed id for file */ -export function getFileExtension(filename: string | void): string { +export function getFileExtension(filename?: string): string { if (typeof filename !== 'string') { return ''; } diff --git a/src/utils/file.ts b/src/utils/file.ts new file mode 100644 index 0000000000..80261fdca0 --- /dev/null +++ b/src/utils/file.ts @@ -0,0 +1,47 @@ +import getProp from 'lodash/get'; +import { + TYPED_ID_FILE_PREFIX, + TYPED_ID_FOLDER_PREFIX, + FILE_EXTENSION_BOX_CANVAS, + FILE_EXTENSION_BOX_NOTE, + FILE_EXTENSION_GOOGLE_DOC, + FILE_EXTENSION_GOOGLE_SHEET, + FILE_EXTENSION_GOOGLE_SLIDE, + FILE_EXTENSION_GOOGLE_SLIDE_LEGACY, +} from '../constants'; +import type { BoxItem } from '../common/types/core'; + +const FILE_EXT_REGEX = /\.([0-9a-z]+)$/i; // Case insensitive regex to extract file extension without "." + +/** + * Returns typed id for file. Useful for when + * making file based XHRs where auth token + * can be per file as used by Preview. + */ +export const getTypedFileId = (id: string): string => `${TYPED_ID_FILE_PREFIX}${id}`; + +/** Returns typed id for folder. */ +export const getTypedFolderId = (id: string): string => `${TYPED_ID_FOLDER_PREFIX}${id}`; + +/** Determines if the file is a box note. */ +export const isBoxNote = (file: BoxItem): boolean => file.extension === FILE_EXTENSION_BOX_NOTE; + +/** Determines if the file is box canvas. */ +export const isBoxCanvas = (file: BoxItem): boolean => file.extension === FILE_EXTENSION_BOX_CANVAS; + +/** Determines whether a file extension is associated with a G Suite file. */ +export const isGSuiteExtension = (extension: string): boolean => + extension === FILE_EXTENSION_GOOGLE_DOC || + extension === FILE_EXTENSION_GOOGLE_SHEET || + extension === FILE_EXTENSION_GOOGLE_SLIDE || + extension === FILE_EXTENSION_GOOGLE_SLIDE_LEGACY; + +/** Returns the extension from the file name. */ +export const getFileExtension = (filename?: string): string => { + if (typeof filename !== 'string') { + return ''; + } + + const result = FILE_EXT_REGEX.exec(filename); + return getProp(result, '[1]', ''); +}; diff --git a/src/utils/flatten.js b/src/utils/flatten.js.flow similarity index 100% rename from src/utils/flatten.js rename to src/utils/flatten.js.flow diff --git a/src/utils/flatten.ts b/src/utils/flatten.ts new file mode 100644 index 0000000000..ac8c910a2b --- /dev/null +++ b/src/utils/flatten.ts @@ -0,0 +1,52 @@ +import { getBadItemError } from './error'; +import { TYPE_FOLDER, TYPE_FILE, TYPE_WEBLINK } from '../constants'; +import type { BoxItem } from '../common/types/core'; +import type FolderAPI from '../api/Folder'; +import type FileAPI from '../api/File'; +import type WebLinkAPI from '../api/WebLink'; +import type APICache from './Cache'; + +/** + * Takes an item list and flattens it by moving + * all item entries into the cache and replacing the list + * entries with references to those items in the cache. + * Web links are trated as files. + */ +const flatten = (list: BoxItem[], folderAPI: FolderAPI, fileAPI: FileAPI, weblinkAPI: WebLinkAPI): string[] => { + const items: string[] = []; + list.forEach((item: BoxItem) => { + const { id, type }: BoxItem = item; + if (!id || !type) { + throw getBadItemError(); + } + + let api; + switch (type) { + case TYPE_FOLDER: + api = folderAPI; + break; + case TYPE_FILE: + api = fileAPI; + break; + case TYPE_WEBLINK: + api = weblinkAPI; + break; + default: + throw new Error('Unknown Type!'); + } + + const cache: APICache = api.getCache(); + const key: string = api.getCacheKey(id); + + if (cache.has(key)) { + cache.merge(key, item); + } else { + cache.set(key, item); + } + + items.push(key); + }); + return items; +}; + +export default flatten; diff --git a/src/utils/function.js b/src/utils/function.js.flow similarity index 100% rename from src/utils/function.js rename to src/utils/function.js.flow diff --git a/src/utils/function.ts b/src/utils/function.ts new file mode 100644 index 0000000000..c088ce7788 --- /dev/null +++ b/src/utils/function.ts @@ -0,0 +1,44 @@ +/** + * Retries a promise factory up to `times`. The factory receives resolve, reject, + * and hardReject so it can stop retrying immediately. + */ +const retryNumOfTimes = ( + func: Function, + times: number, + initialTimeout: number = 0, + backoffFactor: number = 1, +): Promise => { + let tries = 0; + let timeout = initialTimeout; + + return new Promise((resolve, hardReject) => { + function doTry() { + tries += 1; + + new Promise((tryResolve, tryReject) => { + func(tryResolve, tryReject, hardReject); + }) + .then(resolve) + .catch(reason => { + if (tries < times) { + timeout *= backoffFactor; + // eslint-disable-next-line @typescript-eslint/no-use-before-define + executeAfterTimeout(timeout); + return; + } + + hardReject(reason); + }); + } + + function executeAfterTimeout(time: number): void { + setTimeout(() => { + doTry(); + }, time); + } + + executeAfterTimeout(timeout); + }); +}; + +export { retryNumOfTimes }; diff --git a/src/utils/fuzzySearch.js b/src/utils/fuzzySearch.js.flow similarity index 100% rename from src/utils/fuzzySearch.js rename to src/utils/fuzzySearch.js.flow diff --git a/src/utils/fuzzySearch.ts b/src/utils/fuzzySearch.ts new file mode 100644 index 0000000000..d0f52268b7 --- /dev/null +++ b/src/utils/fuzzySearch.ts @@ -0,0 +1,54 @@ +const fuzzySearch = ( + search: string, + content?: string | null, + minCharacters: number = 3, + maxGaps: number = 2, +): boolean => { + if (!content) { + return false; + } + const uniformContent = content.toLowerCase().replace(/\s/g, ''); + const uniformSearch = search.toLowerCase().replace(/\s/g, ''); + const contentLength = uniformContent.length; + const searchLength = uniformSearch.length; + if (searchLength < minCharacters || searchLength > contentLength) { + return false; + } + let matched = false; + let totalScore = 0; + for (let i = 0; i < contentLength; i += 1) { + if (contentLength - i < searchLength) { + break; + } + let searchIndex = 0; + let currentScore = 0; + let subScore = 0; + for (let j = i; j < contentLength; j += 1) { + if (uniformContent[j] === uniformSearch[searchIndex]) { + searchIndex += 1; + // For streaks of matched characters score should increase exponentially + currentScore += 1 + currentScore; + } else { + currentScore = 0; + } + subScore += currentScore; + } + if (searchIndex !== searchLength) { + break; + } + if (subScore > totalScore) { + totalScore = subScore; + } + } + if (totalScore > 0) { + const maxGroups = Math.min(maxGaps, searchLength); + // minScore is calculated as a near-worst-case score given an even distribution of gaps + // since the algorithm rewards streak of characters breaking them up evenly is the worst case + // minimum score should also be better than just each character individually + const minScore = Math.max(maxGroups * 2 ** Math.floor(searchLength / maxGroups - 1), searchLength + 1); + matched = totalScore >= minScore; + } + return matched; +}; + +export default fuzzySearch; diff --git a/src/utils/getFileSize.js b/src/utils/getFileSize.js.flow similarity index 100% rename from src/utils/getFileSize.js rename to src/utils/getFileSize.js.flow diff --git a/src/utils/getFileSize.ts b/src/utils/getFileSize.ts new file mode 100644 index 0000000000..a158466fc4 --- /dev/null +++ b/src/utils/getFileSize.ts @@ -0,0 +1,26 @@ +import filesize from 'filesize'; + +const defaultDigitalUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; +const bcp47TagToDigitalUnits: Record = { + fi: ['t', 'kt', 'Mt', 'Gt', 'Tt', 'Pt', 'Et', 'Zt', 'Yt'], + fr: ['o', 'Ko', 'Mo', 'Go', 'To', 'Po', 'Eo', 'Zo', 'Yo'], + ru: ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ', 'ПБ', 'ЭБ', 'ЗБ', 'ЙБ'], +}; + +/** Formats a byte count as a human-readable, localized string. */ +const getFileSize = (size: number, locale: string = 'en'): string => { + const settings: { round: number; locale: string; symbols?: Record } = { round: 1, locale }; + + const localizedUnits = bcp47TagToDigitalUnits[locale]; + if (localizedUnits) { + // map default units to localized units, ex. { B: Б, KB: КБ, ... } + settings.symbols = defaultDigitalUnits.reduce>((symbols, unit, index) => { + symbols[unit] = localizedUnits[index]; + return symbols; + }, {}); + } + + return filesize(size, settings); +}; + +export default getFileSize; diff --git a/src/utils/validators.js b/src/utils/validators.js.flow similarity index 100% rename from src/utils/validators.js rename to src/utils/validators.js.flow diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000000..18390fbe62 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,44 @@ +import Address from '@hapi/address'; +import tldsHapi from '@hapi/address/lib/tlds'; + +const hostnameValidator = (value: string): boolean => { + // @see https://github.com/hapijs/joi/blame/3516cf0b995c9fe415634c4612c0ac2f8792f0b4/lib/types/string/index.js#L530 + const regex = + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; + return regex.test(value); +}; + +const ipv4AddressValidator = (value: string): boolean => { + // @see https://www.oreilly.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html + const regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + return regex.test(value); +}; + +const domainNameValidator = (value: string): boolean => Address.domain.isValid(value); + +// @sideway/address ^4 has exhaustive TLDs, but upgrading requires a TextEncoder polyfill (such as fast-text-encoder) for IE11 support. +// We can freely upgrade and remove manual TLD supplementation after IE11 EOL. + +// Diff of '@hapi/address/lib/tlds' and: +// https://data.iana.org/TLD/tlds-alpha-by-domain.txt +// Version 2022020100, Last Updated Tue Feb 1 07:07:01 2022 UTC, +const tldSupplements = [ + 'AMAZON', + 'CPA', + 'LLP', + 'MUSIC', + 'SPA', + 'XN--4DBRK0CE', + 'XN--CCKWCXETD', + 'XN--JLQ480N2RG', + 'XN--MGBCPQ6GPA1A', + 'XN--Q7CE6A', + 'ZW', +]; + +const emailValidator = (value: string): boolean => + Address.email.isValid(value, { + tlds: { allow: new Set([...tldsHapi, ...tldSupplements.map(tld => tld.toLowerCase())]) }, + }); + +export { domainNameValidator, emailValidator, hostnameValidator, ipv4AddressValidator }; diff --git a/src/utils/webcrypto.js b/src/utils/webcrypto.js.flow similarity index 100% rename from src/utils/webcrypto.js rename to src/utils/webcrypto.js.flow diff --git a/src/utils/webcrypto.ts b/src/utils/webcrypto.ts new file mode 100644 index 0000000000..4cc0d2246a --- /dev/null +++ b/src/utils/webcrypto.ts @@ -0,0 +1,52 @@ +import sha1 from 'js-sha1'; + +type WindowWithMsCrypto = Window & { msCrypto?: Crypto }; + +/** Returns the correct crypto library based on browser implementation. */ +const getCrypto = (): Crypto => window.crypto || (window as WindowWithMsCrypto).msCrypto; + +/** Returns a digest promise; uses the msCrypto callback API when native SubtleCrypto is unavailable. */ +const digest = (algorithm: string, buffer: ArrayBuffer): Promise => { + const cryptoRef = getCrypto(); + + if (cryptoRef !== (window as WindowWithMsCrypto).msCrypto) { + return cryptoRef.subtle.digest(algorithm, buffer); + } + + // IE11 implements an early version of the SubtleCrypto interface which doesn't use Promises + // See http://web-developer-articles.blogspot.com/2015/05/web-cryptography-api.html + return new Promise((resolve, reject) => { + // Microsoft has dropped support for SHA-1 and so SHA-1 needs to be calculated differently + if (algorithm === 'SHA-1') { + try { + const hashBuffer = (sha1 as unknown as { arrayBuffer: (buf: ArrayBuffer) => ArrayBuffer }).arrayBuffer( + buffer, + ); + resolve(hashBuffer); + } catch (e) { + reject(e); + } + } else { + const cryptoOperation = cryptoRef.subtle.digest({ name: algorithm }, buffer) as unknown as { + oncomplete: (event: { target: { result: ArrayBuffer } }) => void; + onerror: (reason?: unknown) => void; + }; + + cryptoOperation.oncomplete = event => { + resolve(event.target.result); + }; + cryptoOperation.onerror = reject; + } + }); +}; + +/** Given a buffer/byteArray fills it with random values and returns the same array */ +const getRandomValues = (buffer: Uint8Array): Uint8Array => { + const cryptoRef = getCrypto(); + const copy = new Uint8Array(buffer); + cryptoRef.getRandomValues(copy); + + return copy; +}; + +export { getCrypto, digest, getRandomValues };