Improve test cases. Also merged formatFileSize and formatRawFileSize through specifying a separate boolean argument to switch between modes. Add Node support for File and Blob (TODO test each implementation)master
@@ -3,3 +3,4 @@ | |||
node_modules | |||
dist | |||
.idea/ | |||
coverage/ |
@@ -1,5 +1,5 @@ | |||
{ | |||
"version": "1.0.1", | |||
"version": "1.0.2", | |||
"license": "MIT", | |||
"main": "dist/index.js", | |||
"typings": "dist/index.d.ts", | |||
@@ -19,7 +19,8 @@ | |||
}, | |||
"peerDependencies": { | |||
"crypto-js": "^4.0.0", | |||
"numeral": "^2.0.6" | |||
"numeral": "^2.0.6", | |||
"node-blob": "^0.0.2" | |||
}, | |||
"husky": { | |||
"hooks": { | |||
@@ -30,20 +31,19 @@ | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"repository": { | |||
"type": "git", | |||
"url":"https://code.modal.sh/TheoryOfNekomata/file-commons.git" | |||
"url": "https://code.modal.sh/TheoryOfNekomata/file-commons.git" | |||
}, | |||
"module": "dist/file-commons.esm.js", | |||
"devDependencies": { | |||
"@types/crypto-js": "^3.1.47", | |||
"@types/numeral": "^0.0.28", | |||
"crypto-js": "^4.0.0", | |||
"fast-check": "^2.3.0", | |||
"husky": "^4.3.0", | |||
"numeral": "^2.0.6", | |||
"tsdx": "^0.13.3", | |||
"tslib": "^2.0.1", | |||
"typescript": "^4.0.2" | |||
}, | |||
"dependencies": { | |||
"crypto-js": "^4.0.0", | |||
"numeral": "^2.0.6" | |||
"typescript": "^4.0.2", | |||
"node-blob": "^0.0.2" | |||
} | |||
} |
@@ -15,11 +15,8 @@ it('should accept a minimum of 1 argument', () => { | |||
it('should return a string', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.string(), | |||
s => { | |||
expect(typeof calculateHash(s)).toBe('string') | |||
} | |||
) | |||
fc.property(fc.string(), s => { | |||
expect(typeof calculateHash(s)).toBe('string') | |||
}) | |||
) | |||
}) |
@@ -1,4 +1,4 @@ | |||
import {LibWordArray} from 'crypto-js' | |||
import { LibWordArray } from 'crypto-js' | |||
import sha512 from 'crypto-js/sha512' | |||
import Hex from 'crypto-js/enc-hex' | |||
@@ -1,30 +1,44 @@ | |||
import NodeBlob from 'node-blob' | |||
type DataUriToBlob = (dataUri: string, name?: string) => Blob | |||
const DATA_URI_PREFIX = 'data:' | |||
const DATA_TYPE_DELIMITER = ';' | |||
const DATA_START = ',' | |||
const dataUriToBlob: DataUriToBlob = (dataUri, name) => { | |||
if (typeof dataUri as unknown !== 'string') { | |||
interface NodeFileConfig { | |||
lastModified: number | |||
} | |||
class NodeFile extends NodeBlob { | |||
public readonly name: string | |||
public readonly lastModified: number | |||
constructor(blobParts: unknown[], name: string, config: Partial<NodeFileConfig>) { | |||
super(blobParts, config) | |||
this.name = name | |||
this.lastModified = typeof config.lastModified! === 'number' ? config.lastModified : Date.now() | |||
} | |||
} | |||
const dataUriToBlob: DataUriToBlob = (dataUri, name?) => { | |||
if ((typeof dataUri as unknown) !== 'string') { | |||
throw TypeError('Argument should be a string.') | |||
} | |||
const [encoding, base64] = dataUri | |||
.slice(DATA_URI_PREFIX.length) | |||
.split(DATA_START) | |||
const [encoding, base64] = dataUri.slice(DATA_URI_PREFIX.length).split(DATA_START) | |||
const binary = atob(base64) | |||
const [type,] = encoding.split(DATA_TYPE_DELIMITER) | |||
const [type] = encoding.split(DATA_TYPE_DELIMITER) | |||
const ab = new ArrayBuffer(binary.length) | |||
const ia = new Uint8Array(ab) | |||
for (let i = 0; i < binary.length; i++) { | |||
ia[i] = binary.charCodeAt(i) | |||
} | |||
if (typeof name! === 'string') { | |||
return new File([ab], name, { type, }) | |||
const FileCtor = typeof window !== 'undefined' ? window.File : NodeFile | |||
return new FileCtor([ab], name, { type }) | |||
} | |||
return new Blob([ab], { type, }) | |||
const BlobCtor = typeof window !== 'undefined' ? window.Blob : NodeBlob | |||
return new BlobCtor([ab], { type }) | |||
} | |||
export default dataUriToBlob | |||
// TODO make code portable to Node! Maybe return a buffer instead of blob? |
@@ -9,8 +9,8 @@ it('should be a function', () => { | |||
expect(typeof formatFileSize).toBe('function') | |||
}) | |||
it('should take 1 argument', () => { | |||
expect(formatFileSize).toHaveLength(1) | |||
it('should accept 2 arguments', () => { | |||
expect(formatFileSize).toHaveLength(2) | |||
}) | |||
describe('on numeric arguments', () => { | |||
@@ -53,3 +53,36 @@ describe('on non-numeric arguments', () => { | |||
) | |||
}) | |||
}) | |||
describe('on raw', () => { | |||
it('should throw an error on non-numeric arguments', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.anything().filter(v => typeof v !== 'number'), | |||
v => { | |||
expect(() => formatFileSize(v as number, true)).toThrow(TypeError) | |||
} | |||
) | |||
) | |||
}) | |||
it('should throw an error on NaN', () => { | |||
expect(() => formatFileSize(NaN, true)).toThrow(RangeError) | |||
}) | |||
it('should return string on numeric values', () => { | |||
fc.assert( | |||
fc.property(fc.integer(), v => { | |||
expect(typeof formatFileSize(v, true)).toBe('string') | |||
}) | |||
) | |||
}) | |||
it('should format numeric values', () => { | |||
fc.assert( | |||
fc.property(fc.integer(), v => { | |||
expect(formatFileSize(v, true)).toMatch(/^-?\d[,\d]* .?B$/) | |||
}) | |||
) | |||
}) | |||
}) |
@@ -1,9 +1,9 @@ | |||
import numeral from 'numeral' | |||
type FormatFileSize = (n: number) => string | |||
type FormatFileSize = (n: number, raw?: boolean) => string | |||
const formatFileSize: FormatFileSize = n => { | |||
if (typeof n as unknown !== 'number') { | |||
const formatFileSize: FormatFileSize = (n, raw = false) => { | |||
if ((typeof n as unknown) !== 'number') { | |||
throw TypeError('Argument should be a number.') | |||
} | |||
@@ -11,8 +11,14 @@ const formatFileSize: FormatFileSize = n => { | |||
throw RangeError('Cannot format NaN.') | |||
} | |||
const base = numeral(Math.abs(n)).format(Math.abs(n) < 1000 ? '0 b' : '0.00 b') | |||
if (raw) { | |||
const absValue = Math.abs(n) | |||
const base = numeral(absValue < 1000 ? absValue : 999).format('0 b') | |||
const suffix = base.slice(base.indexOf(' ') + ' '.length) | |||
return `${n < 0 ? '-' : ''}${numeral(absValue).format('0,0')} ${suffix}` | |||
} | |||
const base = numeral(Math.abs(n)).format(Math.abs(n) < 1000 ? '0 b' : '0.00 b') | |||
return `${n < 0 ? '-' : ''}${base}` | |||
} | |||
@@ -1,51 +0,0 @@ | |||
import * as fc from 'fast-check' | |||
import formatRawFileSize from './formatRawFileSize' | |||
it('should exist', () => { | |||
expect(formatRawFileSize).toBeDefined() | |||
}) | |||
it('should be a function', () => { | |||
expect(typeof formatRawFileSize).toBe('function') | |||
}) | |||
it('should take 1 argument', () => { | |||
expect(formatRawFileSize).toHaveLength(1) | |||
}) | |||
it('should throw an error on non-numeric arguments', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.anything().filter(v => typeof v !== 'number'), | |||
v => { | |||
expect(() => formatRawFileSize(v as number)).toThrow(TypeError) | |||
} | |||
) | |||
) | |||
}) | |||
it('should throw an error on NaN', () => { | |||
expect(() => formatRawFileSize(NaN)).toThrow(RangeError) | |||
}) | |||
it('should return string on numeric values', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.integer(), | |||
v => { | |||
expect(typeof formatRawFileSize(v)).toBe('string') | |||
} | |||
) | |||
) | |||
}) | |||
it('should format numeric values', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.integer(), | |||
v => { | |||
expect(formatRawFileSize(v)).toMatch(/^-?\d[,\d]* .?B$/) | |||
} | |||
) | |||
) | |||
}) |
@@ -1,20 +0,0 @@ | |||
import numeral from 'numeral' | |||
type FormatRawFileSize = (n: number) => string | |||
const formatRawFileSize: FormatRawFileSize = n => { | |||
if (typeof n as unknown !== 'number') { | |||
throw TypeError('Argument should be a number.') | |||
} | |||
if (isNaN(n)) { | |||
throw RangeError('Cannot format NaN.') | |||
} | |||
const absValue = Math.abs(n) | |||
const base = numeral(absValue < 1000 ? absValue : 999).format('0 b') | |||
const suffix = base.slice(base.indexOf(' ') + ' '.length) | |||
return `${n < 0 ? '-' : ''}${numeral(absValue).format('0,0')} ${suffix}` | |||
} | |||
export default formatRawFileSize |
@@ -0,0 +1 @@ | |||
declare module 'node-blob' |
@@ -0,0 +1,31 @@ | |||
import * as index from './index' | |||
describe('calculateHash', () => { | |||
it('should exist', () => { | |||
expect(index.calculateHash).toBeDefined() | |||
}) | |||
}) | |||
describe('dataUriToBlob', () => { | |||
it('should exist', () => { | |||
expect(index.dataUriToBlob).toBeDefined() | |||
}) | |||
}) | |||
describe('formatFileSize', () => { | |||
it('should exist', () => { | |||
expect(index.formatFileSize).toBeDefined() | |||
}) | |||
}) | |||
describe('isValidFileName', () => { | |||
it('should exist', () => { | |||
expect(index.isValidFileName).toBeDefined() | |||
}) | |||
}) | |||
describe('isValidMimeType', () => { | |||
it('should exist', () => { | |||
expect(index.isValidMimeType).toBeDefined() | |||
}) | |||
}) |
@@ -1,15 +1,7 @@ | |||
import calculateHash from './calculateHash' | |||
import dataUriToBlob from './dataUriToBlob' | |||
import formatFileSize from './formatFileSize' | |||
import formatRawFileSize from './formatRawFileSize' | |||
import isValidFilename from './isValidFileName' | |||
import isValidFileName from './isValidFileName' | |||
import isValidMimeType from './isValidMimeType' | |||
export { | |||
calculateHash, | |||
dataUriToBlob, | |||
formatFileSize, | |||
formatRawFileSize, | |||
isValidFilename, | |||
isValidMimeType, | |||
} | |||
export { calculateHash, dataUriToBlob, formatFileSize, isValidFileName, isValidMimeType } |
@@ -18,7 +18,7 @@ it('should throw an error given invalid param shapes', () => { | |||
fc.property( | |||
fc.object().filter(o => !('validExtensions' in o)), | |||
params => { | |||
expect(() => isValidFilename(params as unknown as IsValidFilenameConfig)).toThrow(TypeError) | |||
expect(() => isValidFilename((params as unknown) as IsValidFilenameConfig)).toThrow(TypeError) | |||
} | |||
) | |||
) | |||
@@ -29,10 +29,10 @@ describe('on valid extensions', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.record({ | |||
validExtensions: fc.anything().filter(e => !(typeof e === 'string' || Array.isArray(e))) | |||
validExtensions: fc.anything().filter(e => !(typeof e === 'string' || Array.isArray(e))), | |||
}), | |||
params => { | |||
expect(() => isValidFilename(params as unknown as IsValidFilenameConfig)).toThrow(TypeError) | |||
expect(() => isValidFilename((params as unknown) as IsValidFilenameConfig)).toThrow(TypeError) | |||
} | |||
) | |||
) | |||
@@ -44,10 +44,14 @@ describe('on valid extensions', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.record({ | |||
validExtensions: fc.array(fc.anything().filter(e => typeof e !== 'string'), 1, 20), | |||
validExtensions: fc.array( | |||
fc.anything().filter(e => typeof e !== 'string'), | |||
1, | |||
20 | |||
), | |||
}), | |||
params => { | |||
expect(() => isValidFilename(params as unknown as IsValidFilenameConfig)).toThrow(TypeError) | |||
expect(() => isValidFilename((params as unknown) as IsValidFilenameConfig)).toThrow(TypeError) | |||
} | |||
) | |||
) | |||
@@ -84,12 +88,9 @@ describe('on main callable', () => { | |||
fc.property( | |||
fc.tuple( | |||
fc.record<IsValidFilenameConfig>({ | |||
validExtensions: fc.oneof( | |||
fc.string(), | |||
fc.array(fc.string(), 1, 20), | |||
), | |||
validExtensions: fc.oneof(fc.string(), fc.array(fc.string(), 1, 20)), | |||
}), | |||
fc.anything().filter(v => typeof v !== 'string'), | |||
fc.anything().filter(v => typeof v !== 'string') | |||
), | |||
([params, maybeFileName]) => { | |||
expect(() => isValidFilename(params)(maybeFileName as string)).toThrow(TypeError) | |||
@@ -103,12 +104,9 @@ describe('on main callable', () => { | |||
fc.property( | |||
fc.tuple( | |||
fc.record<IsValidFilenameConfig>({ | |||
validExtensions: fc.oneof( | |||
fc.string(), | |||
fc.array(fc.string(), 1, 20), | |||
), | |||
validExtensions: fc.oneof(fc.string(), fc.array(fc.string(), 1, 20)), | |||
}), | |||
fc.string(), | |||
fc.string() | |||
), | |||
([params, maybeFileName]) => { | |||
expect(typeof isValidFilename(params)(maybeFileName)).toBe('boolean') | |||
@@ -16,8 +16,8 @@ const isValidFileName: IsValidFileName = config => { | |||
.split(MULTIPLE_VALID_EXTENSION_DELIMITER) | |||
.filter(p => p.trim().length > 0) | |||
} else if ( | |||
Array.isArray(maybeValidExtensions) | |||
&& (config.validExtensions as unknown[]).every(s => typeof s === 'string') | |||
Array.isArray(maybeValidExtensions) && | |||
(config.validExtensions as unknown[]).every(s => typeof s === 'string') | |||
) { | |||
if ((config.validExtensions as unknown[]).length < 1) { | |||
throw RangeError('There must be at least 1 valid extension defined.') | |||
@@ -28,14 +28,12 @@ const isValidFileName: IsValidFileName = config => { | |||
} | |||
// strip . in valid extensions for easier matching | |||
const validExtensions = validExtensionsArray.map(e => ( | |||
e.startsWith(EXTENSION_DELIMITER) | |||
? e.slice(EXTENSION_DELIMITER.length) | |||
: e | |||
)) | |||
const validExtensions = validExtensionsArray.map(e => | |||
e.startsWith(EXTENSION_DELIMITER) ? e.slice(EXTENSION_DELIMITER.length) : e | |||
) | |||
return maybeFileName => { | |||
if (typeof maybeFileName as unknown !== 'string') { | |||
if ((typeof maybeFileName as unknown) !== 'string') { | |||
throw TypeError('Argument should be a string.') | |||
} | |||
@@ -44,19 +42,13 @@ const isValidFileName: IsValidFileName = config => { | |||
if (config.allowMultipleExtensions) { | |||
return validExtensions.reduce<boolean>( | |||
(isValid, validExtension) => ( | |||
isValid | |||
|| extensions.includes(validExtension) | |||
), | |||
(isValid, validExtension) => isValid || extensions.includes(validExtension), | |||
false | |||
) | |||
} | |||
return validExtensions.reduce<boolean>( | |||
(isValid, validExtension) => ( | |||
isValid | |||
|| extensions.join(EXTENSION_DELIMITER).endsWith(validExtension) | |||
), | |||
(isValid, validExtension) => isValid || extensions.join(EXTENSION_DELIMITER).endsWith(validExtension), | |||
false | |||
) | |||
} | |||
@@ -18,7 +18,7 @@ it('should throw an error given invalid param shapes', () => { | |||
fc.property( | |||
fc.object().filter(o => !('validMimeTypes' in o)), | |||
params => { | |||
expect(() => isValidMimeType(params as unknown as IsValidMimeTypeConfig)).toThrow(TypeError) | |||
expect(() => isValidMimeType((params as unknown) as IsValidMimeTypeConfig)).toThrow(TypeError) | |||
} | |||
) | |||
) | |||
@@ -29,10 +29,10 @@ describe('on valid MIME types', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.record({ | |||
validMimeTypes: fc.anything().filter(e => !(typeof e === 'string' || Array.isArray(e))) | |||
validMimeTypes: fc.anything().filter(e => !(typeof e === 'string' || Array.isArray(e))), | |||
}), | |||
params => { | |||
expect(() => isValidMimeType(params as unknown as IsValidMimeTypeConfig)).toThrow(TypeError) | |||
expect(() => isValidMimeType((params as unknown) as IsValidMimeTypeConfig)).toThrow(TypeError) | |||
} | |||
) | |||
) | |||
@@ -44,10 +44,14 @@ describe('on valid MIME types', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.record({ | |||
validMimeTypes: fc.array(fc.anything().filter(e => typeof e !== 'string'), 1, 20), | |||
validMimeTypes: fc.array( | |||
fc.anything().filter(e => typeof e !== 'string'), | |||
1, | |||
20 | |||
), | |||
}), | |||
params => { | |||
expect(() => isValidMimeType(params as unknown as IsValidMimeTypeConfig)).toThrow(TypeError) | |||
expect(() => isValidMimeType((params as unknown) as IsValidMimeTypeConfig)).toThrow(TypeError) | |||
} | |||
) | |||
) | |||
@@ -84,12 +88,9 @@ describe('on main callable', () => { | |||
fc.property( | |||
fc.tuple( | |||
fc.record<IsValidMimeTypeConfig>({ | |||
validMimeTypes: fc.oneof( | |||
fc.string(), | |||
fc.array(fc.string(), 1, 20), | |||
), | |||
validMimeTypes: fc.oneof(fc.string(), fc.array(fc.string(), 1, 20)), | |||
}), | |||
fc.anything().filter(v => typeof v !== 'string'), | |||
fc.anything().filter(v => typeof v !== 'string') | |||
), | |||
([params, maybeFileName]) => { | |||
expect(() => isValidMimeType(params)(maybeFileName as string)).toThrow(TypeError) | |||
@@ -103,12 +104,9 @@ describe('on main callable', () => { | |||
fc.property( | |||
fc.tuple( | |||
fc.record<IsValidMimeTypeConfig>({ | |||
validMimeTypes: fc.oneof( | |||
fc.string(), | |||
fc.array(fc.string(), 1, 20), | |||
), | |||
validMimeTypes: fc.oneof(fc.string(), fc.array(fc.string(), 1, 20)), | |||
}), | |||
fc.string(), | |||
fc.string() | |||
), | |||
([params, maybeFileName]) => { | |||
expect(typeof isValidMimeType(params)(maybeFileName)).toBe('boolean') | |||
@@ -116,4 +114,26 @@ describe('on main callable', () => { | |||
) | |||
) | |||
}) | |||
it('should return true when catch-all MIME type is specified', () => { | |||
fc.assert( | |||
fc.property(fc.tuple(fc.array(fc.string(), 1, 20), fc.string()), ([validMimeTypes, maybeFileName]) => { | |||
expect(isValidMimeType({ validMimeTypes: [...validMimeTypes, '*/*'] })(maybeFileName)).toBe(true) | |||
}) | |||
) | |||
}) | |||
it('should return true when catch-all for specified MIME type class is specified', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.tuple( | |||
fc.string().filter(s => !s.includes('/')), | |||
fc.string().filter(s => !s.includes('/')) | |||
), | |||
([mimeTypeClass, test]) => { | |||
expect(isValidMimeType({ validMimeTypes: [`${mimeTypeClass}/*`] })(`${mimeTypeClass}/${test}`)).toBe(true) | |||
} | |||
) | |||
) | |||
}) | |||
}) |
@@ -19,8 +19,8 @@ const isValidMimeType: IsValidMimeType = config => { | |||
.split(MULTIPLE_VALID_MIME_TYPE_DELIMITER) | |||
.filter(p => p.trim().length > 0) | |||
} else if ( | |||
Array.isArray(maybeValidMimeTypes) | |||
&& (config.validMimeTypes as unknown[]).every(s => typeof s === 'string') | |||
Array.isArray(maybeValidMimeTypes) && | |||
(config.validMimeTypes as unknown[]).every(s => typeof s === 'string') | |||
) { | |||
if ((config.validMimeTypes as unknown[]).length < 1) { | |||
throw RangeError('There must be at least 1 valid extension defined.') | |||
@@ -31,7 +31,7 @@ const isValidMimeType: IsValidMimeType = config => { | |||
} | |||
return mimeType => { | |||
if (typeof mimeType as unknown !== 'string') { | |||
if ((typeof mimeType as unknown) !== 'string') { | |||
throw TypeError('Argument should be a string.') | |||
} | |||
@@ -40,21 +40,16 @@ const isValidMimeType: IsValidMimeType = config => { | |||
return true | |||
} | |||
return ( | |||
validMimeTypes.reduce<boolean>( | |||
(isValid, validMimeType) => { | |||
const [type, format] = validMimeType.split(MIME_TYPE_FORMAT_DELIMITER) | |||
// maybe short circuit matching format catch-all valid MIME types? | |||
if (format === MIME_TYPE_FORMAT_CATCH_ALL) { | |||
return isValid || mimeType.startsWith(type + MIME_TYPE_FORMAT_DELIMITER) | |||
} | |||
return isValid || mimeType === validMimeType | |||
}, | |||
false, | |||
) | |||
) | |||
return validMimeTypes.reduce<boolean>((isValid, validMimeType) => { | |||
const [type, format] = validMimeType.split(MIME_TYPE_FORMAT_DELIMITER) | |||
// maybe short circuit matching format catch-all valid MIME types? | |||
if (format === MIME_TYPE_FORMAT_CATCH_ALL) { | |||
return isValid || mimeType.startsWith(type + MIME_TYPE_FORMAT_DELIMITER) | |||
} | |||
return isValid || mimeType === validMimeType | |||
}, false) | |||
} | |||
} | |||
@@ -4215,6 +4215,11 @@ no-case@^2.2.0: | |||
dependencies: | |||
lower-case "^1.1.1" | |||
node-blob@^0.0.2: | |||
version "0.0.2" | |||
resolved "https://kabahagi-922648072964.d.codeartifact.ap-southeast-1.amazonaws.com:443/npm/core/node-blob/-/node-blob-0.0.2.tgz#12abb5e722dc4bc396f85c2c2f0073e25eafc0fd" | |||
integrity sha512-82wiGzMht96gPQDUYaZBdZEVvYD9aEhU6Bt9KLCr4rADZPRd7dQVY2Yj0ZG/1vp4DhVkL49nJT/M3CiMTAt3ag== | |||
node-int64@^0.4.0: | |||
version "0.4.0" | |||
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" | |||