diff --git a/package.json b/package.json index 43940d8..5e9e33e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-pdf-extractor", - "version": "0.2.0", + "version": "0.2.1", "description": "This library allows you to extract pdfs file data using matches specifics patterns.", "main": "lib/commonjs/index.js", "module": "lib/module/index.js", @@ -61,7 +61,8 @@ }, "peerDependencies": { "react": "*", - "react-native": "*" + "react-native": "*", + "react-native-modal": "*" }, "commitlint": { "extends": [ diff --git a/sample/src/App.tsx b/sample/src/App.tsx index f4f61d1..493e24a 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -3,7 +3,7 @@ import { Button, FlatList, StyleSheet, Text, View } from 'react-native'; import DocumentPicker from 'react-native-document-picker'; import { Extractor, Patterns } from '../..'; -import { TransientObject } from '../../src/types'; +import { Transient } from '../../src/types'; const App: React.FC = (): JSX.Element => { const [isEncrypted, setIsEncrypted] = useState(false); @@ -22,7 +22,7 @@ const App: React.FC = (): JSX.Element => { setUri(data.uri); }; - const onResult = (data: TransientObject) => { + const onResult = (data: Transient) => { setPages(data.pages); setIsEncrypted(data.isEncrypted); setUri(data.uri); diff --git a/src/extractors/__tests__/Common.spec.ts b/src/extractors/__tests__/Common.spec.ts new file mode 100644 index 0000000..63f433a --- /dev/null +++ b/src/extractors/__tests__/Common.spec.ts @@ -0,0 +1,144 @@ +import { Platform } from 'react-native'; +import { BaseExtractor } from '../core/Base'; +import { CommonExtractor } from '../core/Common'; + +const platformMock = (platform: 'android' | 'ios') => { + Object.defineProperty(Platform, 'OS', { get: jest.fn(() => platform) }); +}; + +const asyncMock = (value: T) => jest.fn().mockResolvedValue(value); + +describe('CommonExtractor', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('file', () => { + it('Should call setUri', async () => { + BaseExtractor.setUri = asyncMock('fake://return'); + const spy = jest.spyOn(BaseExtractor, 'setUri'); + + await CommonExtractor.file({ uri: 'file://fake-uri' }); + + expect(spy).toBeCalledTimes(1); + }); + + it('Should throw error when try setUri', async () => { + BaseExtractor.setUri = asyncMock(null); + const spy = jest.spyOn(BaseExtractor, 'setUri'); + + try { + await CommonExtractor.file({ uri: 'file://fake-uri' }); + } catch (error: any) { + expect(spy).toBeCalledTimes(1); + expect(error.message).toBe( + "Invalid uri: 'file://fake-uri'. Cannot find the file." + ); + } + }); + + it('Should call getUri when platform is Android', async () => { + platformMock('android'); + const spy = jest.spyOn(BaseExtractor, 'getUri'); + + try { + await CommonExtractor.file({ uri: undefined }); + } catch (error: any) { + expect(error.message).toBe('Could not perfom extraction without URI.'); + } + + expect(spy).toBeCalledTimes(1); + }); + + it('Should not call getUri when platform is iOS', async () => { + platformMock('ios'); + + const spy = jest.spyOn(BaseExtractor, 'getUri'); + + try { + await CommonExtractor.file({ uri: undefined }); + } catch { } + + expect(spy).toBeCalledTimes(0); + }); + }); + + describe('check', () => { + it('Should call canIExtract', async () => { + BaseExtractor.canIExtract = asyncMock(true); + const spy = jest.spyOn(BaseExtractor, 'canIExtract'); + + await CommonExtractor.check({}); + + expect(spy).toBeCalledTimes(1); + }); + + it('Should throw error when call canIExtract', async () => { + BaseExtractor.canIExtract = asyncMock(false); + const spy = jest.spyOn(BaseExtractor, 'canIExtract'); + + try { + await CommonExtractor.check({ uri: undefined }); + } catch (error: any) { + expect(error.message).toBe('You cannot continue with extraction.'); + } + + expect(spy).toBeCalledTimes(1); + }); + }); + + describe('encrypted', () => { + it('Should call isEncrypted', async () => { + BaseExtractor.isEncrypted = asyncMock(false); + const spy = jest.spyOn(BaseExtractor, 'isEncrypted'); + + await CommonExtractor.encrypted({}); + + expect(spy).toBeCalledTimes(1); + }); + }); + + describe('pages', () => { + it('Should call getNumberOfPages', async () => { + BaseExtractor.getNumberOfPages = asyncMock(10); + const spy = jest.spyOn(BaseExtractor, 'getNumberOfPages'); + + await CommonExtractor.pages({ max: 10 }); + + expect(spy).toBeCalledTimes(1); + }); + + it('Should call getNumberOfPages and throw error', async () => { + BaseExtractor.getNumberOfPages = asyncMock(12); + const spy = jest.spyOn(BaseExtractor, 'getNumberOfPages'); + + try { + await CommonExtractor.pages({ max: 10 }); + } catch (error: any) { + expect(error.message).toBe( + 'This file exceeds maximum size of 10 pages.' + ); + } + + expect(spy).toBeCalledTimes(1); + }); + }); + + describe('matches', () => { + it('Should call getText', async () => { + BaseExtractor.getText = asyncMock(['abc']); + const spy = jest.spyOn(BaseExtractor, 'getText'); + + await CommonExtractor.matches({ max: 1 }); + + expect(spy).toBeCalledTimes(1); + }); + + it('Should call getTextWithPattern', async () => { + BaseExtractor.getTextWithPattern = asyncMock(['abc']); + const spy = jest.spyOn(BaseExtractor, 'getTextWithPattern'); + + await CommonExtractor.matches({ max: 1, patterns: /[0-9]{2}/ }); + + expect(spy).toBeCalledTimes(1); + }); + }); +}); diff --git a/src/extractors/core/Common.ts b/src/extractors/core/Common.ts new file mode 100644 index 0000000..ff27e92 --- /dev/null +++ b/src/extractors/core/Common.ts @@ -0,0 +1,71 @@ +import { Platform } from 'react-native'; +import { BaseExtractor } from './Base'; +import type { ExtraTransient, Transient } from '../../types'; + +export class CommonExtractor { + /** + * Verifies if file exists based on URI gave + * or got from intent provider (Android only) + */ + static async file(data: Transient): Promise { + if (data.uri) { + const path = await BaseExtractor.setUri(data.uri); + + if (path) return { ...data, uri: path }; + + throw new Error(`Invalid uri: '${data.uri}'. Cannot find the file.`); + } + + // From Intent (android only) + if (Platform.OS === 'android') { + const path = await BaseExtractor.getUri(); + if (path) return { ...data, uri: path }; + } + + throw new Error('Could not perfom extraction without URI.'); + } + + /** + * Checks if params satisfies full specification + * to proceed with data extraction + */ + static async check(data: Transient): Promise { + const canIExtract = await BaseExtractor.canIExtract(); + + if (canIExtract) return data; + + throw new Error('You cannot continue with extraction.'); + } + + /** + * Checks if file is encrypted + */ + static async encrypted(data: Transient): Promise { + const isEncrypted = await BaseExtractor.isEncrypted(); + return { ...data, isEncrypted }; + } + + /** + * Counts the number of pages + */ + static async pages(data: ExtraTransient): Promise { + const total = await BaseExtractor.getNumberOfPages(data.password); + + if (total > data.max) { + throw new Error(`This file exceeds maximum size of ${data.max} pages.`); + } + + return { ...data, pages: total }; + } + + /** + * Applies matches + */ + static async matches(data: ExtraTransient): Promise { + const text = !data.patterns + ? await BaseExtractor.getText(data.password) + : await BaseExtractor.getTextWithPattern(data.patterns, data.password); + + return { ...data, text }; + } +} diff --git a/src/extractors/core/Extractor.tsx b/src/extractors/core/Extractor.tsx index 0cd554f..99e092b 100644 --- a/src/extractors/core/Extractor.tsx +++ b/src/extractors/core/Extractor.tsx @@ -1,33 +1,23 @@ import React, { memo, useCallback, useEffect, useState } from 'react'; -import { - Platform, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; +import { Text, TextInput, TouchableOpacity, View } from 'react-native'; import Modal from 'react-native-modal'; -import { BaseExtractor } from './Base'; import { Chain, ChainLink } from '../../chains'; import Styles from './Styles'; -import type { - Action, - Patterns, - TransientObject, - WithPassword, -} from '../../types'; +import type { Action, Patterns, Transient } from '../../types'; +import { CommonExtractor } from './Common'; type ExtractorProps = { cancel?: string; fromIntent?: boolean; - onResult: (data: TransientObject) => void; + onResult: (data: Transient | null) => void; submit?: string; patterns?: Patterns; placeholder?: string; title?: string; uri?: string; + max?: number; }; export const Extractor: React.FC = memo( @@ -40,6 +30,7 @@ export const Extractor: React.FC = memo( placeholder = 'Password', title = 'This file is protected', uri, + max = 10, }) => { const [locker, setLocker] = useState(); const [value, setValue] = useState(); @@ -62,146 +53,69 @@ export const Extractor: React.FC = memo( }, [value]); /** - * Verifies if file exists based on URI gave - * or got from intent provider (Android only) + * Verifies if needs user interaction to provide password + * and shows component if needed */ - const file = useCallback( - async (data: TransientObject): Promise => { - if (data.uri) { - const path = await BaseExtractor.setUri(data.uri); - - if (path) { - return { ...data, uri: path }; - } - - throw new Error( - `Invalid uri: '${data.uri}'. We cannot find the file.` - ); - } - - // From Intent (android only) - if (Platform.OS === 'android' && fromIntent) { - const path = await BaseExtractor.getUri(); - - if (path) { - return { ...data, uri: path }; - } - - // eslint-disable-next-line prettier/prettier - throw new Error('Cannot get URI from Intent. Check your app Intent provider config.'); - } + const verify = useCallback(async () => { + const data: Transient = await new Chain([ + new ChainLink(CommonExtractor.file as Action), + new ChainLink(CommonExtractor.check as Action), + new ChainLink(CommonExtractor.encrypted as Action), + ]).exec({ uri, patterns }); + + if (data.isEncrypted && !password) { + setVisibility(true); + } - throw new Error('Could not perfom extraction without URI.'); - }, - [fromIntent] - ); + return data; + }, [uri, patterns, password]); /** - * Checks if params satisfies full specification - * to proceed with data extraction + * Perform data extraction */ - const check = async (data: TransientObject): Promise => { - const canIExtract = await BaseExtractor.canIExtract(); - if (canIExtract) { - return data; - } + const extract = useCallback( + async (data: Transient) => { + const start = new Date().getTime(); - throw new Error('You cannot continue with extraction.'); - }; + const result = await new Chain([ + new ChainLink(CommonExtractor.pages as Action), + new ChainLink(CommonExtractor.matches as Action), + ]).exec({ ...data, password, max: max }); - /** - * Checks if file is encrypted - */ - const encrypted = async ( - data: TransientObject - ): Promise => { - const isEncrypted = await BaseExtractor.isEncrypted(); - return { ...data, isEncrypted }; - }; + const finish = new Date().getTime(); - /** - * Counts the number of pages - */ - const pages = useCallback( - async (data: WithPassword): Promise => { - const total = await BaseExtractor.getNumberOfPages(data.password); - return { ...data, pages: total }; + return { ...result, duration: `${finish - start}ms` }; }, - [] + [max, password] ); /** - * Applies matches + * Call data extraction functions */ - const matches = useCallback( - async (data: WithPassword): Promise => { - const text = !data.patterns - ? await BaseExtractor.getText(data.password) - : await BaseExtractor.getTextWithPattern( - data.patterns, - data.password - ); - - return { ...data, text }; - }, - [] - ); - - /** - * Verifies if needs user interaction to provide password - * and shows component if needed - */ - const verify = useCallback(async () => { + const exec = useCallback(async (): Promise => { try { - const data: TransientObject = await new Chain([ - new ChainLink(file as Action), - new ChainLink(check as Action), - new ChainLink(encrypted as Action), - ]).exec({ uri, patterns }); - - if (data.isEncrypted && !password) { - setVisibility(true); - } + const data = await verify(); - return data; + if ((data?.isEncrypted && password) || !data?.isEncrypted) { + const result = await extract(data); + onResult(result); + } } catch (error) { console.warn(error); - return null; - } - }, [file, uri, patterns, password]); - - /** - * Execute data extraction - */ - const exec = useCallback(async (): Promise => { - const start = new Date().getTime(); - const data = await verify(); - - if ( - (data?.isEncrypted && password && !visibility) || - (!data?.isEncrypted && !visibility) - ) { - const result = await new Chain([ - new ChainLink(pages as Action), - new ChainLink(matches as Action), - ]).exec({ ...data, password }); - - const finish = new Date().getTime(); - - onResult({ ...result, duration: `${finish - start}ms` }); + onResult(null); } - }, [matches, onResult, pages, password, verify, visibility]); + }, [extract, onResult, password, verify]); /** * Verifies if can re-render component or runs data extraction */ useEffect(() => { - const lock = `${uri}|${patterns}|${fromIntent}|${password}`; - const withPatterns = (uri && patterns) || (fromIntent && patterns); const withoutPatterns = uri || fromIntent; + const withPatterns = (uri && patterns) || (fromIntent && patterns); + const lock = `${uri}|${patterns}|${fromIntent}|${password}`; - if (lock !== locker && (withPatterns || withoutPatterns)) { + if (lock !== locker && Boolean(withPatterns || withoutPatterns)) { setLocker(lock); exec(); } @@ -212,7 +126,6 @@ export const Extractor: React.FC = memo( hideModalContentWhileAnimating={false} isVisible={visibility} onBackButtonPress={close} - onBackdropPress={close} useNativeDriver > diff --git a/src/types/index.ts b/src/types/index.ts index f1b6571..aeaa66d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,7 +9,7 @@ export type TextResult = (string | null | undefined)[]; export type Action = (data: T) => Promise; -export type TransientObject = { +export type Transient = { duration?: string; fromIntent?: boolean; isEncrypted?: boolean; @@ -21,6 +21,10 @@ export type TransientObject = { export type WithPassword = T & { password?: string }; +export type WithMaxSize = T & { max: number }; + +export type ExtraTransient = WithMaxSize>; + export interface PDFExtractor { getUri: () => Promise; isEncrypted: () => Promise; @@ -31,6 +35,6 @@ export interface PDFExtractor { } export interface DataExtractor { - extract: (uri?: string, patterns?: Patterns) => Promise; - extractFromIntent: (patterns?: Patterns) => Promise; + extract: (uri?: string, patterns?: Patterns) => Promise; + extractFromIntent: (patterns?: Patterns) => Promise; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 62ef706..8b60bb8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,3 +8,4 @@ export const Match = (pattern: RegExp, data?: string[]): string[] => { const matches = data.map(Reducer(pattern)).flat(); return [...new Set(matches)].filter((value) => value) as string[]; }; + diff --git a/website/docs/api/extractors/Extractor.md b/website/docs/api/extractors/Extractor.md index 3ed4c38..3ca4e2a 100644 --- a/website/docs/api/extractors/Extractor.md +++ b/website/docs/api/extractors/Extractor.md @@ -15,7 +15,7 @@ This extractor is built on top of [BaseExtractor](/docs/api/extractors/BaseExtra onResult - (data: TransientObject) => void + (data: Transient) => void true Callback function called when data extraction ends. @@ -72,4 +72,4 @@ This extractor is built on top of [BaseExtractor](/docs/api/extractors/BaseExtra -> :bulb: You can see a full implementation at [Playground](../../getting-started/playground.md). \ No newline at end of file +> :bulb: You can see a full implementation at [Playground](../../getting-started/playground.md). diff --git a/website/docs/getting-started/usage.md b/website/docs/getting-started/usage.md index 24cfc0a..abe39d1 100644 --- a/website/docs/getting-started/usage.md +++ b/website/docs/getting-started/usage.md @@ -15,10 +15,10 @@ import { Extractor, Patterns } from 'react-native-pdf-extractor'; // Some code snippets was be hidden for readability -const callback = (data: TransientObject) => { - // Your implementation here - console.log(data); - /* +const callback = (data: Transient) => { + // Your implementation here + console.log(data); + /* { duration: '40ms', <-----------------------------: Time spent to match isEncrypted: false, <---------------------------: Was file encrypted? @@ -27,29 +27,25 @@ const callback = (data: TransientObject) => { text: ['name@mail.com'], <----------------------: List of found matches on file uri: 'content://some-file-path.pdf' <-----------: File path } - */ + */ }; return ( - -) + +); ``` -The second case is applicable when the app receives an __Android Intent Action__ with the file path, in this case the library extracts the path behind the scene and than trigger data extraction. You can see more about it on [Intent | Android developers](https://developer.android.com/reference/android/content/Intent). +The second case is applicable when the app receives an **Android Intent Action** with the file path, in this case the library extracts the path behind the scene and than trigger data extraction. You can see more about it on [Intent | Android developers](https://developer.android.com/reference/android/content/Intent). ```ts import { Extractor, Patterns } from 'react-native-pdf-extractor'; // Some code snippets was be hidden for readability -const callback = (data: TransientObject) => { - // Your implementation here - console.log(data); - /* +const callback = (data: Transient) => { + // Your implementation here + console.log(data); + /* { duration: '40ms', <-----------------------------: Time spent to match isEncrypted: false, <---------------------------: Was file encrypted? @@ -58,16 +54,16 @@ const callback = (data: TransientObject) => { text: ['name@mail.com'], <----------------------: List of found matches on file uri: 'content://some-file-path.pdf' <-----------: File path } - */ + */ }; return ( - -) + +); ``` ## Dependency