@@ -1,4 +1,4 @@ | |||
# TSDX Bootstrap | |||
# File Commons | |||
Useful methods for file-related functions. | |||
@@ -1,5 +1,5 @@ | |||
{ | |||
"version": "0.1.0", | |||
"version": "1.0.1", | |||
"license": "MIT", | |||
"main": "dist/index.js", | |||
"typings": "dist/index.d.ts", | |||
@@ -26,8 +26,12 @@ | |||
"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", | |||
"devDependencies": { | |||
"@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 | |||
const DATA_URI_PREFIX = 'data:' | |||
const DATA_TYPE_DELIMITER = ';' | |||
const DATA_START = ',' | |||
const dataUriToBlob: DataUriToBlob = (dataUri, name) => { | |||
if (typeof dataUri as unknown !== 'string') { | |||
throw TypeError('Argument should be a string.') | |||
} | |||
const [encoding, base64] = dataUri | |||
.slice('data:'.length) | |||
.split(',') | |||
.slice(DATA_URI_PREFIX.length) | |||
.split(DATA_START) | |||
const binary = atob(base64) | |||
const [type,] = encoding.split(';') | |||
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++) { | |||
@@ -18,3 +26,5 @@ const dataUriToBlob: DataUriToBlob = (dataUri, name) => { | |||
} | |||
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.anything().filter(v => typeof v !== 'number'), | |||
v => { | |||
expect(() => formatFileSize(v)).toThrow(TypeError) | |||
expect(() => formatFileSize(v as number)).toThrow(TypeError) | |||
} | |||
) | |||
) | |||
@@ -1,13 +1,12 @@ | |||
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.') | |||
} | |||
const n = maybeNumber as number | |||
if (isNaN(n)) { | |||
throw RangeError('Cannot format NaN.') | |||
} | |||
@@ -18,7 +18,7 @@ it('should throw an error on non-numeric arguments', () => { | |||
fc.property( | |||
fc.anything().filter(v => typeof v !== 'number'), | |||
v => { | |||
expect(() => formatRawFileSize(v)).toThrow(TypeError) | |||
expect(() => formatRawFileSize(v as number)).toThrow(TypeError) | |||
} | |||
) | |||
) | |||
@@ -1,14 +1,12 @@ | |||
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.') | |||
} | |||
const n = maybeNumber as number | |||
if (isNaN(n)) { | |||
throw RangeError('Cannot format NaN.') | |||
} | |||
@@ -2,10 +2,14 @@ import calculateHash from './calculateHash' | |||
import dataUriToBlob from './dataUriToBlob' | |||
import formatFileSize from './formatFileSize' | |||
import formatRawFileSize from './formatRawFileSize' | |||
import isValidFilename from './isValidFileName' | |||
import isValidMimeType from './isValidMimeType' | |||
export { | |||
calculateHash, | |||
dataUriToBlob, | |||
formatFileSize, | |||
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 |