@@ -1,4 +1,4 @@ | |||||
# TSDX Bootstrap | |||||
# File Commons | |||||
Useful methods for file-related functions. | Useful methods for file-related functions. | ||||
@@ -1,5 +1,5 @@ | |||||
{ | { | ||||
"version": "0.1.0", | |||||
"version": "1.0.1", | |||||
"license": "MIT", | "license": "MIT", | ||||
"main": "dist/index.js", | "main": "dist/index.js", | ||||
"typings": "dist/index.d.ts", | "typings": "dist/index.d.ts", | ||||
@@ -26,8 +26,12 @@ | |||||
"pre-commit": "tsdx lint" | "pre-commit": "tsdx lint" | ||||
} | } | ||||
}, | }, | ||||
"name": "file-commons", | |||||
"author": "Allan Crisostomo", | |||||
"name": "@theoryofnekomata/file-commons", | |||||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||||
"repository": { | |||||
"type": "git", | |||||
"url":"https://code.modal.sh/TheoryOfNekomata/file-commons.git" | |||||
}, | |||||
"module": "dist/file-commons.esm.js", | "module": "dist/file-commons.esm.js", | ||||
"devDependencies": { | "devDependencies": { | ||||
"@types/crypto-js": "^3.1.47", | "@types/crypto-js": "^3.1.47", | ||||
@@ -0,0 +1,25 @@ | |||||
import * as fc from 'fast-check' | |||||
import calculateHash from './calculateHash' | |||||
it('should exist', () => { | |||||
expect(calculateHash).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof calculateHash).toBe('function') | |||||
}) | |||||
it('should accept a minimum of 1 argument', () => { | |||||
expect(calculateHash).toHaveLength(1) | |||||
}) | |||||
it('should return a string', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.string(), | |||||
s => { | |||||
expect(typeof calculateHash(s)).toBe('string') | |||||
} | |||||
) | |||||
) | |||||
}) |
@@ -0,0 +1,25 @@ | |||||
import * as fc from 'fast-check' | |||||
import dataUriToBlob from './dataUriToBlob' | |||||
it('should exist', () => { | |||||
expect(dataUriToBlob).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof dataUriToBlob).toBe('function') | |||||
}) | |||||
it('should accept 2 arguments', () => { | |||||
expect(dataUriToBlob).toHaveLength(2) | |||||
}) | |||||
it('should throw an error for invalid parameters', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(s => typeof s !== 'string'), | |||||
s => { | |||||
expect(() => dataUriToBlob(s as string)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) |
@@ -1,11 +1,19 @@ | |||||
type DataUriToBlob = (dataUri: string, name?: string) => 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) => { | const dataUriToBlob: DataUriToBlob = (dataUri, name) => { | ||||
if (typeof dataUri as unknown !== 'string') { | |||||
throw TypeError('Argument should be a string.') | |||||
} | |||||
const [encoding, base64] = dataUri | const [encoding, base64] = dataUri | ||||
.slice('data:'.length) | |||||
.split(',') | |||||
.slice(DATA_URI_PREFIX.length) | |||||
.split(DATA_START) | |||||
const binary = atob(base64) | const binary = atob(base64) | ||||
const [type,] = encoding.split(';') | |||||
const [type,] = encoding.split(DATA_TYPE_DELIMITER) | |||||
const ab = new ArrayBuffer(binary.length) | const ab = new ArrayBuffer(binary.length) | ||||
const ia = new Uint8Array(ab) | const ia = new Uint8Array(ab) | ||||
for (let i = 0; i < binary.length; i++) { | for (let i = 0; i < binary.length; i++) { | ||||
@@ -18,3 +26,5 @@ const dataUriToBlob: DataUriToBlob = (dataUri, name) => { | |||||
} | } | ||||
export default dataUriToBlob | export default dataUriToBlob | ||||
// TODO make code portable to Node! Maybe return a buffer instead of blob? |
@@ -47,7 +47,7 @@ describe('on non-numeric arguments', () => { | |||||
fc.property( | fc.property( | ||||
fc.anything().filter(v => typeof v !== 'number'), | fc.anything().filter(v => typeof v !== 'number'), | ||||
v => { | v => { | ||||
expect(() => formatFileSize(v)).toThrow(TypeError) | |||||
expect(() => formatFileSize(v as number)).toThrow(TypeError) | |||||
} | } | ||||
) | ) | ||||
) | ) | ||||
@@ -1,13 +1,12 @@ | |||||
import numeral from 'numeral' | import numeral from 'numeral' | ||||
type FormatFileSize = (maybeNumber: unknown) => string | |||||
type FormatFileSize = (n: number) => string | |||||
const formatFileSize: FormatFileSize = maybeNumber => { | |||||
if (typeof maybeNumber! !== 'number') { | |||||
const formatFileSize: FormatFileSize = n => { | |||||
if (typeof n as unknown !== 'number') { | |||||
throw TypeError('Argument should be a number.') | throw TypeError('Argument should be a number.') | ||||
} | } | ||||
const n = maybeNumber as number | |||||
if (isNaN(n)) { | if (isNaN(n)) { | ||||
throw RangeError('Cannot format NaN.') | throw RangeError('Cannot format NaN.') | ||||
} | } | ||||
@@ -18,7 +18,7 @@ it('should throw an error on non-numeric arguments', () => { | |||||
fc.property( | fc.property( | ||||
fc.anything().filter(v => typeof v !== 'number'), | fc.anything().filter(v => typeof v !== 'number'), | ||||
v => { | v => { | ||||
expect(() => formatRawFileSize(v)).toThrow(TypeError) | |||||
expect(() => formatRawFileSize(v as number)).toThrow(TypeError) | |||||
} | } | ||||
) | ) | ||||
) | ) | ||||
@@ -1,14 +1,12 @@ | |||||
import numeral from 'numeral' | import numeral from 'numeral' | ||||
type FormatRawFileSize = (maybeNumber: unknown) => string | |||||
type FormatRawFileSize = (n: number) => string | |||||
const formatRawFileSize: FormatRawFileSize = maybeNumber => { | |||||
if (typeof maybeNumber! !== 'number') { | |||||
const formatRawFileSize: FormatRawFileSize = n => { | |||||
if (typeof n as unknown !== 'number') { | |||||
throw TypeError('Argument should be a number.') | throw TypeError('Argument should be a number.') | ||||
} | } | ||||
const n = maybeNumber as number | |||||
if (isNaN(n)) { | if (isNaN(n)) { | ||||
throw RangeError('Cannot format NaN.') | throw RangeError('Cannot format NaN.') | ||||
} | } | ||||
@@ -2,10 +2,14 @@ import calculateHash from './calculateHash' | |||||
import dataUriToBlob from './dataUriToBlob' | import dataUriToBlob from './dataUriToBlob' | ||||
import formatFileSize from './formatFileSize' | import formatFileSize from './formatFileSize' | ||||
import formatRawFileSize from './formatRawFileSize' | import formatRawFileSize from './formatRawFileSize' | ||||
import isValidFilename from './isValidFileName' | |||||
import isValidMimeType from './isValidMimeType' | |||||
export { | export { | ||||
calculateHash, | calculateHash, | ||||
dataUriToBlob, | dataUriToBlob, | ||||
formatFileSize, | formatFileSize, | ||||
formatRawFileSize, | formatRawFileSize, | ||||
isValidFilename, | |||||
isValidMimeType, | |||||
} | } |
@@ -0,0 +1,119 @@ | |||||
import * as fc from 'fast-check' | |||||
import isValidFilename, { IsValidFilenameConfig } from './isValidFileName' | |||||
it('should exist', () => { | |||||
expect(isValidFilename).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof isValidFilename).toBe('function') | |||||
}) | |||||
it('should accept a minimum of 1 argument', () => { | |||||
expect(isValidFilename).toHaveLength(1) | |||||
}) | |||||
it('should throw an error given invalid param shapes', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.object().filter(o => !('validExtensions' in o)), | |||||
params => { | |||||
expect(() => isValidFilename(params as unknown as IsValidFilenameConfig)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
describe('on valid extensions', () => { | |||||
it('should throw an error given non-string and non-array values', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.record({ | |||||
validExtensions: fc.anything().filter(e => !(typeof e === 'string' || Array.isArray(e))) | |||||
}), | |||||
params => { | |||||
expect(() => isValidFilename(params as unknown as IsValidFilenameConfig)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should throw an error given empty arrays', () => { | |||||
expect(() => isValidFilename({ validExtensions: [] })).toThrow(RangeError) | |||||
}) | |||||
it('should throw an error given non-string arrays', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.record({ | |||||
validExtensions: fc.array(fc.anything().filter(e => typeof e !== 'string'), 1, 20), | |||||
}), | |||||
params => { | |||||
expect(() => isValidFilename(params as unknown as IsValidFilenameConfig)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return a main callable given strings', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.record<IsValidFilenameConfig>({ | |||||
validExtensions: fc.string(), | |||||
}), | |||||
params => { | |||||
expect(typeof isValidFilename(params)).toBe('function') | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return a main callable given string arrays', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.record<IsValidFilenameConfig>({ | |||||
validExtensions: fc.array(fc.string(), 1, 20), | |||||
}), | |||||
params => { | |||||
expect(typeof isValidFilename(params)).toBe('function') | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) | |||||
describe('on main callable', () => { | |||||
it('should throw an error for non-string params', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.tuple( | |||||
fc.record<IsValidFilenameConfig>({ | |||||
validExtensions: fc.oneof( | |||||
fc.string(), | |||||
fc.array(fc.string(), 1, 20), | |||||
), | |||||
}), | |||||
fc.anything().filter(v => typeof v !== 'string'), | |||||
), | |||||
([params, maybeFileName]) => { | |||||
expect(() => isValidFilename(params)(maybeFileName as string)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return a boolean for string params', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.tuple( | |||||
fc.record<IsValidFilenameConfig>({ | |||||
validExtensions: fc.oneof( | |||||
fc.string(), | |||||
fc.array(fc.string(), 1, 20), | |||||
), | |||||
}), | |||||
fc.string(), | |||||
), | |||||
([params, maybeFileName]) => { | |||||
expect(typeof isValidFilename(params)(maybeFileName)).toBe('boolean') | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) |
@@ -0,0 +1,65 @@ | |||||
const EXTENSION_DELIMITER = '.' | |||||
const MULTIPLE_VALID_EXTENSION_DELIMITER = ' ' | |||||
export type IsValidFilenameConfig = { | |||||
validExtensions: string | string[] | |||||
allowMultipleExtensions?: boolean | |||||
} | |||||
type IsValidFileName = (config: IsValidFilenameConfig) => (maybeFileName: string) => boolean | |||||
const isValidFileName: IsValidFileName = config => { | |||||
let validExtensionsArray: string[] | |||||
const maybeValidExtensions = config.validExtensions as unknown | |||||
if (typeof maybeValidExtensions === 'string') { | |||||
validExtensionsArray = (config.validExtensions as string) | |||||
.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') | |||||
) { | |||||
if ((config.validExtensions as unknown[]).length < 1) { | |||||
throw RangeError('There must be at least 1 valid extension defined.') | |||||
} | |||||
validExtensionsArray = config.validExtensions as string[] | |||||
} else { | |||||
throw TypeError('Valid extensions should be a space-delimited string or a string array.') | |||||
} | |||||
// strip . in valid extensions for easier matching | |||||
const validExtensions = validExtensionsArray.map(e => ( | |||||
e.startsWith(EXTENSION_DELIMITER) | |||||
? e.slice(EXTENSION_DELIMITER.length) | |||||
: e | |||||
)) | |||||
return maybeFileName => { | |||||
if (typeof maybeFileName as unknown !== 'string') { | |||||
throw TypeError('Argument should be a string.') | |||||
} | |||||
const fileName = maybeFileName as string | |||||
const [, ...extensions] = fileName.split(EXTENSION_DELIMITER) | |||||
if (config.allowMultipleExtensions) { | |||||
return validExtensions.reduce<boolean>( | |||||
(isValid, validExtension) => ( | |||||
isValid | |||||
|| extensions.includes(validExtension) | |||||
), | |||||
false | |||||
) | |||||
} | |||||
return validExtensions.reduce<boolean>( | |||||
(isValid, validExtension) => ( | |||||
isValid | |||||
|| extensions.join(EXTENSION_DELIMITER).endsWith(validExtension) | |||||
), | |||||
false | |||||
) | |||||
} | |||||
} | |||||
export default isValidFileName |
@@ -0,0 +1,119 @@ | |||||
import * as fc from 'fast-check' | |||||
import isValidMimeType, { IsValidMimeTypeConfig } from './isValidMimeType' | |||||
it('should exist', () => { | |||||
expect(isValidMimeType).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof isValidMimeType).toBe('function') | |||||
}) | |||||
it('should accept a minimum of 1 argument', () => { | |||||
expect(isValidMimeType).toHaveLength(1) | |||||
}) | |||||
it('should throw an error given invalid param shapes', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.object().filter(o => !('validMimeTypes' in o)), | |||||
params => { | |||||
expect(() => isValidMimeType(params as unknown as IsValidMimeTypeConfig)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
describe('on valid MIME types', () => { | |||||
it('should throw an error given non-string and non-array values', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.record({ | |||||
validMimeTypes: fc.anything().filter(e => !(typeof e === 'string' || Array.isArray(e))) | |||||
}), | |||||
params => { | |||||
expect(() => isValidMimeType(params as unknown as IsValidMimeTypeConfig)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should throw an error given empty arrays', () => { | |||||
expect(() => isValidMimeType({ validMimeTypes: [] })).toThrow(RangeError) | |||||
}) | |||||
it('should throw an error given non-string arrays', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.record({ | |||||
validMimeTypes: fc.array(fc.anything().filter(e => typeof e !== 'string'), 1, 20), | |||||
}), | |||||
params => { | |||||
expect(() => isValidMimeType(params as unknown as IsValidMimeTypeConfig)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return a main callable given strings', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.record<IsValidMimeTypeConfig>({ | |||||
validMimeTypes: fc.string(), | |||||
}), | |||||
params => { | |||||
expect(typeof isValidMimeType(params)).toBe('function') | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return a main callable given string arrays', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.record<IsValidMimeTypeConfig>({ | |||||
validMimeTypes: fc.array(fc.string(), 1, 20), | |||||
}), | |||||
params => { | |||||
expect(typeof isValidMimeType(params)).toBe('function') | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) | |||||
describe('on main callable', () => { | |||||
it('should throw an error for non-string params', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.tuple( | |||||
fc.record<IsValidMimeTypeConfig>({ | |||||
validMimeTypes: fc.oneof( | |||||
fc.string(), | |||||
fc.array(fc.string(), 1, 20), | |||||
), | |||||
}), | |||||
fc.anything().filter(v => typeof v !== 'string'), | |||||
), | |||||
([params, maybeFileName]) => { | |||||
expect(() => isValidMimeType(params)(maybeFileName as string)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return a boolean for string params', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.tuple( | |||||
fc.record<IsValidMimeTypeConfig>({ | |||||
validMimeTypes: fc.oneof( | |||||
fc.string(), | |||||
fc.array(fc.string(), 1, 20), | |||||
), | |||||
}), | |||||
fc.string(), | |||||
), | |||||
([params, maybeFileName]) => { | |||||
expect(typeof isValidMimeType(params)(maybeFileName)).toBe('boolean') | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) |
@@ -0,0 +1,61 @@ | |||||
const MULTIPLE_VALID_MIME_TYPE_DELIMITER = ' ' | |||||
const CATCH_ALL_MIME_TYPES = ['*', '*/*'] | |||||
const MIME_TYPE_FORMAT_DELIMITER = '/' | |||||
// catch-all for format, e.g. "image/*" matches all MIME types starting with "image/" | |||||
const MIME_TYPE_FORMAT_CATCH_ALL = '*' | |||||
export type IsValidMimeTypeConfig = { | |||||
validMimeTypes: string | string[] | |||||
} | |||||
type IsValidMimeType = (config: IsValidMimeTypeConfig) => (mimeType: string) => boolean | |||||
const isValidMimeType: IsValidMimeType = config => { | |||||
let validMimeTypes: string[] | |||||
const maybeValidMimeTypes = config.validMimeTypes as unknown | |||||
if (typeof maybeValidMimeTypes === 'string') { | |||||
validMimeTypes = (config.validMimeTypes as string) | |||||
.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') | |||||
) { | |||||
if ((config.validMimeTypes as unknown[]).length < 1) { | |||||
throw RangeError('There must be at least 1 valid extension defined.') | |||||
} | |||||
validMimeTypes = config.validMimeTypes as string[] | |||||
} else { | |||||
throw TypeError('Valid MimeTypes should be a space-delimited string or a string array.') | |||||
} | |||||
return mimeType => { | |||||
if (typeof mimeType as unknown !== 'string') { | |||||
throw TypeError('Argument should be a string.') | |||||
} | |||||
// short-circuit valid MIME types that are catch-all | |||||
if (validMimeTypes.some(a => CATCH_ALL_MIME_TYPES.includes(a))) { | |||||
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, | |||||
) | |||||
) | |||||
} | |||||
} | |||||
export default isValidMimeType |