diff --git a/README.md b/README.md index f2b2830..a687e60 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# TSDX Bootstrap +# File Commons Useful methods for file-related functions. diff --git a/package.json b/package.json index e122a1a..b189bf3 100644 --- a/package.json +++ b/package.json @@ -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 ", + "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", diff --git a/src/calculateHash.test.ts b/src/calculateHash.test.ts new file mode 100644 index 0000000..1b05336 --- /dev/null +++ b/src/calculateHash.test.ts @@ -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') + } + ) + ) +}) diff --git a/src/dataUriToBlob.test.ts b/src/dataUriToBlob.test.ts new file mode 100644 index 0000000..b17e518 --- /dev/null +++ b/src/dataUriToBlob.test.ts @@ -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) + } + ) + ) +}) diff --git a/src/dataUriToBlob.ts b/src/dataUriToBlob.ts index 83bcab7..868ef86 100644 --- a/src/dataUriToBlob.ts +++ b/src/dataUriToBlob.ts @@ -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? diff --git a/src/formatFileSize.test.ts b/src/formatFileSize.test.ts index c9b8219..bb73e76 100644 --- a/src/formatFileSize.test.ts +++ b/src/formatFileSize.test.ts @@ -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) } ) ) diff --git a/src/formatFileSize.ts b/src/formatFileSize.ts index 7a3c91d..3a7c1a5 100644 --- a/src/formatFileSize.ts +++ b/src/formatFileSize.ts @@ -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.') } diff --git a/src/formatRawFileSize.test.ts b/src/formatRawFileSize.test.ts index 5c2ac95..303edbf 100644 --- a/src/formatRawFileSize.test.ts +++ b/src/formatRawFileSize.test.ts @@ -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) } ) ) diff --git a/src/formatRawFileSize.ts b/src/formatRawFileSize.ts index 05bf44f..4ab00c8 100644 --- a/src/formatRawFileSize.ts +++ b/src/formatRawFileSize.ts @@ -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.') } diff --git a/src/index.ts b/src/index.ts index 7ca45ba..0dac6d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, } diff --git a/src/isValidFileName.test.ts b/src/isValidFileName.test.ts new file mode 100644 index 0000000..c07013c --- /dev/null +++ b/src/isValidFileName.test.ts @@ -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({ + 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({ + 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({ + 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({ + validExtensions: fc.oneof( + fc.string(), + fc.array(fc.string(), 1, 20), + ), + }), + fc.string(), + ), + ([params, maybeFileName]) => { + expect(typeof isValidFilename(params)(maybeFileName)).toBe('boolean') + } + ) + ) + }) +}) diff --git a/src/isValidFileName.ts b/src/isValidFileName.ts new file mode 100644 index 0000000..a8352b1 --- /dev/null +++ b/src/isValidFileName.ts @@ -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( + (isValid, validExtension) => ( + isValid + || extensions.includes(validExtension) + ), + false + ) + } + + return validExtensions.reduce( + (isValid, validExtension) => ( + isValid + || extensions.join(EXTENSION_DELIMITER).endsWith(validExtension) + ), + false + ) + } +} + +export default isValidFileName diff --git a/src/isValidMimeType.test.ts b/src/isValidMimeType.test.ts new file mode 100644 index 0000000..46902ce --- /dev/null +++ b/src/isValidMimeType.test.ts @@ -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({ + 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({ + 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({ + 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({ + validMimeTypes: fc.oneof( + fc.string(), + fc.array(fc.string(), 1, 20), + ), + }), + fc.string(), + ), + ([params, maybeFileName]) => { + expect(typeof isValidMimeType(params)(maybeFileName)).toBe('boolean') + } + ) + ) + }) +}) diff --git a/src/isValidMimeType.ts b/src/isValidMimeType.ts new file mode 100644 index 0000000..3fe1b8e --- /dev/null +++ b/src/isValidMimeType.ts @@ -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( + (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