From 04c6bcb3ba8868d70af3e19fc5c8b0d7ff8b5b68 Mon Sep 17 00:00:00 2001 From: Pablo Giraud-Carrier Date: Fri, 5 Jun 2026 18:43:00 +0200 Subject: [PATCH] feat: model status API, useDataDetector hook, Android languages - Rename downloadModel() -> prepareModel({ language? }); keep downloadModel as a deprecated alias. - Add getModelStatus()/isModelReady() to query model availability (iOS always ready). - Add useDataDetector() hook: tracks status (notDownloaded/downloading/ready/error), auto-downloads on Android, exposes detect/prepare. - Android multi-language: detect/prepareModel/getModelStatus/useDataDetector accept a language option (15 ISO 639-1 codes -> ML Kit models); default 'en'. iOS is language-agnostic and ignores it. - Add @types/react + react devDependencies; document the new API and update the example. Version bump and changelog cut are intentionally left for release time. --- CHANGELOG.md | 28 +++- README.md | 102 +++++++++++++-- .../ReactNativeDataDetectorModule.kt | 50 ++++++- example/App.tsx | 95 ++++++++++---- ios/ReactNativeDataDetectorModule.swift | 13 +- package-lock.json | 20 ++- package.json | 2 + src/ReactNativeDataDetector.ts | 60 +++++++-- src/ReactNativeDataDetector.types.ts | 52 ++++++++ src/index.ts | 19 ++- src/useDataDetector.ts | 122 ++++++++++++++++++ 11 files changed, 506 insertions(+), 57 deletions(-) create mode 100644 src/useDataDetector.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 158ff87..179df40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,22 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed +### Added -- iOS: return UTF-16 offsets from `NSDataDetector` instead of grapheme-cluster - distances, so `start`/`end` align with JavaScript string indices and Android's - ML Kit char offsets. Previously mismatched on text containing emoji, accented, - or non-BMP characters. -- Android: read the library version from `package.json` instead of a hardcoded - value, so `build.gradle` can no longer drift from the published version. +- `useDataDetector` hook — tracks model readiness (`status`/`isReady`/`error`), + exposes `detect`, and auto-downloads the model on Android (opt out with + `autoPrepare: false`). +- `getModelStatus()` and `isModelReady()` to query whether a language model is + downloaded. On iOS both always report ready. +- Multi-language support on Android: `detect`, `prepareModel`, `getModelStatus`, + `isModelReady`, and `useDataDetector` all accept a `language` option (ISO 639-1 + code) selecting one of 15 ML Kit models. Defaults to `'en'`. Ignored on iOS. ### Changed +- Renamed `downloadModel()` to `prepareModel()` — clearer intent and now accepts a + `{ language }` option. `downloadModel()` remains as a deprecated alias (targets + the default `'en'` model) and will be removed in a future major version. - Ship an explicit `files` allowlist in `package.json` and drop the stale `.npmignore`; development config (ESLint, Prettier, EditorConfig, CONTRIBUTING) is no longer included in the published tarball. - README hero image now uses an absolute URL so it renders on npmjs.com. +### Fixed + +- iOS: return UTF-16 offsets from `NSDataDetector` instead of grapheme-cluster + distances, so `start`/`end` align with JavaScript string indices and Android's + ML Kit char offsets. Previously mismatched on text containing emoji, accented, + or non-BMP characters. +- Android: read the library version from `package.json` instead of a hardcoded + value, so `build.gradle` can no longer drift from the published version. + ## [0.2.0] - Initial public release: cross-platform text data detection using diff --git a/README.md b/README.md index ea1d613..217f342 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Cross-platform text data detection for React Native. Uses **NSDataDetector** on - **Addresses** — Detect street addresses with parsed components (iOS) - **Dates** — Detect dates and times with ISO 8601 output - **Native accuracy** — Uses battle-tested platform APIs instead of regex +- **React hook** — [`useDataDetector`](#usedatadetectoroptions) tracks model readiness and auto-downloads on Android +- **Multiple languages** — Choose from 15 ML Kit language models on Android - **Expo Modules API** — Built with the modern Expo native module system ## Installation @@ -32,15 +34,17 @@ npx pod-install ### Android -The ML Kit entity extraction model (~5.6MB) is downloaded on the user's device at runtime. You can control when this happens using [`downloadModel()`](#downloadmodel) — for example, calling it at app startup to ensure `detect()` works offline later. If you don't call it explicitly, the model will be downloaded automatically on the first `detect()` call. +The ML Kit entity extraction model (~5.6MB per language) is downloaded on the user's device at runtime. You can control when this happens using [`prepareModel()`](#preparemodeloptions) or the [`useDataDetector`](#usedatadetectoroptions) hook (which downloads automatically on mount) — for example, to ensure `detect()` works offline later. If you don't trigger it explicitly, the model is downloaded automatically on the first `detect()` call. ## Usage +### Functions + ```typescript -import { detect, downloadModel } from 'react-native-data-detector'; +import { detect, prepareModel } from 'react-native-data-detector'; // Pre-download the ML Kit model at app startup (Android only, no-op on iOS) -await downloadModel(); +await prepareModel(); // Detect all entity types const entities = await detect('Call me at 555-1234 or email john@example.com'); @@ -56,22 +60,94 @@ const phones = await detect('Call 555-1234 or visit https://example.com', { // [ // { type: 'phoneNumber', text: '555-1234', start: 5, end: 13, data: { phoneNumber: '555-1234' } } // ] + +// Use a specific language model (Android only, ignored on iOS) +const fr = await detect('Appelez-moi au 01 23 45 67 89', { language: 'fr' }); +``` + +### Hook + +The `useDataDetector` hook tracks model readiness and, on Android, downloads the +model automatically on mount. On iOS the model is always ready. + +```tsx +import { useDataDetector } from 'react-native-data-detector'; + +function MyComponent() { + const { detect, status, isReady } = useDataDetector(); + + const onAnalyze = async (text: string) => { + const entities = await detect(text, { types: ['email', 'phoneNumber'] }); + // … + }; + + // status: 'notDownloaded' | 'downloading' | 'ready' | 'error' + if (!isReady) return Preparing model… ({status}); + // … +} ``` ## API -### `downloadModel()` +### `prepareModel(options?)` -Pre-downloads the ML Kit entity extraction model on Android. On iOS, this is a no-op that resolves immediately — `NSDataDetector` is built into the OS and requires no model download. +Pre-downloads the entity-detection model so `detect()` can run offline afterwards. On iOS, this is a no-op that resolves immediately — `NSDataDetector` is built into the OS and requires no model download. Call this at app startup or before the first `detect()` call to ensure the model is available offline. +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `options.language` | `ModelLanguage` | Which language model to prepare (Android only, default `'en'`). Ignored on iOS. | + **Returns:** `Promise` — `true` when the model is ready. | Platform | Behavior | |----------|----------| | **iOS** | No-op, resolves `true` immediately | -| **Android** | Downloads the ML Kit model (~5.6MB) if not already cached. Requires internet on first call. | +| **Android** | Downloads the ML Kit model (~5.6MB) for the language if not already cached. Requires internet on first call. | + +### `getModelStatus(options?)` + +Returns the download status of the model for the given language. + +**Parameters:** `options.language` — `ModelLanguage` (Android only, default `'en'`). + +**Returns:** `Promise` — `'ready'` or `'notDownloaded'`. On iOS always resolves `'ready'`. (A pure query never returns `'downloading'` or `'error'` — those are only surfaced by the [`useDataDetector`](#usedatadetectoroptions) hook.) + +### `isModelReady(options?)` + +Convenience wrapper around `getModelStatus`. Resolves `true` when the model for the given language is available. + +**Parameters:** `options.language` — `ModelLanguage` (Android only, default `'en'`). + +**Returns:** `Promise` + +### `useDataDetector(options?)` + +React hook that tracks model availability and, on Android, downloads the language model automatically. On iOS the model is always available, so `status` settles on `'ready'`. + +**Parameters:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `options.language` | `ModelLanguage` | `'en'` | Which language model to use (Android only). Changing it re-checks/prepares the new model. | +| `options.autoPrepare` | `boolean` | `true` | Download the model on mount if not already available (Android). | + +**Returns:** `UseDataDetectorResult` + +| Property | Type | Description | +|----------|------|-------------| +| `detect` | `(text, options?) => Promise` | Detect entities using the configured language. `options.types` selects entity types. | +| `prepare` | `() => Promise` | Manually (re)download the configured language model. | +| `status` | `ModelStatus` | `'notDownloaded' \| 'downloading' \| 'ready' \| 'error'`. | +| `isReady` | `boolean` | `true` when `status === 'ready'`. | +| `error` | `Error \| null` | The last preparation error, or `null`. | + +### `downloadModel()` + +> **Deprecated since 0.3.0** — use [`prepareModel()`](#preparemodeloptions) instead. Kept as an alias (always targets the default `'en'` model) and will be removed in a future major version. ### `detect(text, options?)` @@ -89,6 +165,7 @@ Detects entities in the given text using native platform APIs. | Property | Type | Default | Description | |----------|------|---------|-------------| | `types` | `DetectionType[]` | All types | Which entity types to detect | +| `language` | `ModelLanguage` | `'en'` | Which language model to use (Android only). Ignored on iOS. | **DetectionType:** `'phoneNumber' | 'link' | 'email' | 'address' | 'date'` @@ -114,13 +191,22 @@ Detects entities in the given text using native platform APIs. | `address` | `{ street, city, state, zip, country }` (iOS) / `{ address }` (Android) | | `date` | `{ date }` ISO 8601 string | +## Supported Languages + +The `language` option selects which **Android** ML Kit model is used. It is a no-op on iOS, where `NSDataDetector` is language-agnostic. + +`ModelLanguage`: `'ar'` (Arabic), `'nl'` (Dutch), `'en'` (English), `'fr'` (French), `'de'` (German), `'it'` (Italian), `'ja'` (Japanese), `'ko'` (Korean), `'pl'` (Polish), `'pt'` (Portuguese), `'ru'` (Russian), `'es'` (Spanish), `'th'` (Thai), `'tr'` (Turkish), `'zh'` (Chinese). + +Each language is a separate ~5.6MB on-device model, downloaded on demand. + ## Platform Differences | Feature | iOS | Android | |---------|-----|---------| | Engine | NSDataDetector | ML Kit Entity Extraction | -| Offline | Always | After `downloadModel()` or first `detect()` call | -| Model download | Not needed | ~5.6MB, on-device at runtime | +| Offline | Always | After `prepareModel()` or first `detect()` call | +| Model download | Not needed | ~5.6MB per language, on-device at runtime | +| Language selection | Language-agnostic (option ignored) | 15 selectable language models | | Address parsing | Structured components | Raw string | | Date output | ISO 8601 | ISO 8601 | diff --git a/android/src/main/java/expo/modules/datadetector/ReactNativeDataDetectorModule.kt b/android/src/main/java/expo/modules/datadetector/ReactNativeDataDetectorModule.kt index 46cf34d..6bd2e98 100644 --- a/android/src/main/java/expo/modules/datadetector/ReactNativeDataDetectorModule.kt +++ b/android/src/main/java/expo/modules/datadetector/ReactNativeDataDetectorModule.kt @@ -1,8 +1,10 @@ package expo.modules.datadetector +import com.google.mlkit.common.model.RemoteModelManager import com.google.mlkit.nl.entityextraction.Entity import com.google.mlkit.nl.entityextraction.EntityExtraction import com.google.mlkit.nl.entityextraction.EntityExtractionParams +import com.google.mlkit.nl.entityextraction.EntityExtractionRemoteModel import com.google.mlkit.nl.entityextraction.EntityExtractorOptions import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module @@ -16,9 +18,8 @@ class ReactNativeDataDetectorModule : Module() { override fun definition() = ModuleDefinition { Name("ReactNativeDataDetector") - AsyncFunction("downloadModel") { promise: Promise -> - val options = EntityExtractorOptions.Builder(EntityExtractorOptions.ENGLISH) - .build() + AsyncFunction("prepareModel") { language: String, promise: Promise -> + val options = EntityExtractorOptions.Builder(modelIdentifierFor(language)).build() val extractor = EntityExtraction.getClient(options) extractor.downloadModelIfNeeded() @@ -32,9 +33,20 @@ class ReactNativeDataDetectorModule : Module() { } } - AsyncFunction("detect") { text: String, types: List, promise: Promise -> - val options = EntityExtractorOptions.Builder(EntityExtractorOptions.ENGLISH) - .build() + AsyncFunction("getModelStatus") { language: String, promise: Promise -> + val model = EntityExtractionRemoteModel.Builder(modelIdentifierFor(language)).build() + + RemoteModelManager.getInstance().isModelDownloaded(model) + .addOnSuccessListener { downloaded -> + promise.resolve(if (downloaded) "ready" else "notDownloaded") + } + .addOnFailureListener { e -> + promise.reject("MODEL_STATUS_ERROR", e.message ?: "Failed to read ML Kit model status", e) + } + } + + AsyncFunction("detect") { text: String, types: List, language: String, promise: Promise -> + val options = EntityExtractorOptions.Builder(modelIdentifierFor(language)).build() val extractor = EntityExtraction.getClient(options) extractor.downloadModelIfNeeded() @@ -81,6 +93,32 @@ class ReactNativeDataDetectorModule : Module() { } } + /** + * Maps an ISO 639-1 language code from JavaScript to its ML Kit + * [EntityExtractorOptions] model identifier. Falls back to English for + * unknown codes so detection still works. + */ + private fun modelIdentifierFor(language: String): String { + return when (language) { + "ar" -> EntityExtractorOptions.ARABIC + "nl" -> EntityExtractorOptions.DUTCH + "en" -> EntityExtractorOptions.ENGLISH + "fr" -> EntityExtractorOptions.FRENCH + "de" -> EntityExtractorOptions.GERMAN + "it" -> EntityExtractorOptions.ITALIAN + "ja" -> EntityExtractorOptions.JAPANESE + "ko" -> EntityExtractorOptions.KOREAN + "pl" -> EntityExtractorOptions.POLISH + "pt" -> EntityExtractorOptions.PORTUGUESE + "ru" -> EntityExtractorOptions.RUSSIAN + "es" -> EntityExtractorOptions.SPANISH + "th" -> EntityExtractorOptions.THAI + "tr" -> EntityExtractorOptions.TURKISH + "zh" -> EntityExtractorOptions.CHINESE + else -> EntityExtractorOptions.ENGLISH + } + } + private fun mapEntityType(entity: Entity): String? { return when (entity.type) { Entity.TYPE_PHONE -> "phoneNumber" diff --git a/example/App.tsx b/example/App.tsx index c79bd97..d53c981 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -12,10 +12,10 @@ import { View, } from 'react-native'; import { - detect, - downloadModel, + useDataDetector, type DetectedEntity, type DetectionType, + type ModelLanguage, } from 'react-native-data-detector'; const SAMPLE_TEXT = @@ -41,12 +41,25 @@ const TYPE_LABELS: Record = { date: 'Date', }; +const LANGUAGES: ModelLanguage[] = ['en', 'fr', 'es', 'de', 'ja', 'zh']; + +const STATUS_LABELS: Record = { + notDownloaded: 'Model not downloaded', + downloading: 'Downloading model…', + ready: 'Model ready', + error: 'Model error', +}; + export default function App() { const [text, setText] = useState(SAMPLE_TEXT); const [selectedTypes, setSelectedTypes] = useState>(new Set(ALL_TYPES)); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); - const [modelStatus, setModelStatus] = useState(null); + const [detectError, setDetectError] = useState(null); + const [language, setLanguage] = useState('en'); + + // The hook tracks model readiness and auto-downloads on Android. + const { detect, status, isReady, prepare, error } = useDataDetector({ language }); const toggleType = (type: DetectionType) => { setSelectedTypes((prev) => { @@ -60,26 +73,17 @@ export default function App() { }); }; - const handleDownloadModel = async () => { - setModelStatus('Downloading…'); - try { - const success = await downloadModel(); - setModelStatus(success ? 'Model ready' : 'No download needed'); - } catch (e: any) { - setModelStatus(`Error: ${e.message}`); - } - }; - const handleDetect = async () => { if (!text.trim()) return; setLoading(true); + setDetectError(null); try { const types = Array.from(selectedTypes); const entities = await detect(text, types.length < ALL_TYPES.length ? { types } : undefined); setResults(entities); } catch (e: any) { setResults([]); - setModelStatus(`Detection error: ${e.message}`); + setDetectError(`Detection error: ${e.message}`); } finally { setLoading(false); } @@ -96,10 +100,34 @@ export default function App() { {Platform.OS === 'android' && ( - - Download Model (Android) - - {modelStatus && {modelStatus}} + Language Model + + {LANGUAGES.map((lang) => { + const active = language === lang; + return ( + setLanguage(lang)} + > + + {lang.toUpperCase()} + + + ); + })} + + + {status === 'downloading' && } + + {error ? `Error: ${error.message}` : (STATUS_LABELS[status] ?? status)} + + + {status === 'error' && ( + prepare().catch(() => {})}> + Retry Download + + )} )} @@ -139,17 +167,21 @@ export default function App() { {loading ? ( ) : ( - Detect Entities + + {isReady ? 'Detect Entities' : 'Preparing model…'} + )} + {detectError && {detectError}} + {results.length > 0 && ( @@ -273,12 +305,31 @@ const styles = StyleSheet.create({ fontSize: 15, fontWeight: '600', }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + marginTop: 10, + }, statusText: { fontSize: 13, color: '#8E8E93', - marginTop: 6, textAlign: 'center', }, + errorText: { + fontSize: 13, + color: '#FF3B30', + textAlign: 'center', + marginBottom: 16, + }, + langChip: { + borderColor: '#C7C7CC', + }, + langChipActive: { + backgroundColor: '#007AFF', + borderColor: '#007AFF', + }, card: { backgroundColor: '#fff', borderRadius: 12, diff --git a/ios/ReactNativeDataDetectorModule.swift b/ios/ReactNativeDataDetectorModule.swift index 0ac691d..932ee13 100644 --- a/ios/ReactNativeDataDetectorModule.swift +++ b/ios/ReactNativeDataDetectorModule.swift @@ -5,12 +5,19 @@ public class ReactNativeDataDetectorModule: Module { public func definition() -> ModuleDefinition { Name("ReactNativeDataDetector") - // No-op on iOS — NSDataDetector requires no model download - AsyncFunction("downloadModel") { () -> Bool in + // No-op on iOS — NSDataDetector requires no model download. + // The `language` argument exists for API parity with Android and is ignored. + AsyncFunction("prepareModel") { (_: String) -> Bool in return true } - AsyncFunction("detect") { (text: String, types: [String]) -> [[String: Any]] in + // NSDataDetector is always available, so the model is always ready. + AsyncFunction("getModelStatus") { (_: String) -> String in + return "ready" + } + + // `language` is ignored — NSDataDetector is language-agnostic. + AsyncFunction("detect") { (text: String, types: [String], _: String) -> [[String: Any]] in var checkingTypes: NSTextCheckingResult.CheckingType = [] for type in types { diff --git a/package-lock.json b/package-lock.json index a26dfa4..dd8922b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.2.0", "license": "MIT", "devDependencies": { + "@types/react": "^19.0.0", "expo-module-scripts": "^4.0.0", "expo-modules-core": "^2.0.0", + "react": "^19.0.0", "typescript": "^5.3.0" }, "peerDependencies": { @@ -4931,6 +4933,16 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/react": { + "version": "19.2.16", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", + "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -6595,6 +6607,13 @@ "dev": true, "license": "MIT" }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -13918,7 +13937,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index 2532069..e285d62 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,10 @@ "expo-module": "expo-module" }, "devDependencies": { + "@types/react": "^19.0.0", "expo-module-scripts": "^4.0.0", "expo-modules-core": "^2.0.0", + "react": "^19.0.0", "typescript": "^5.3.0" }, "peerDependencies": { diff --git a/src/ReactNativeDataDetector.ts b/src/ReactNativeDataDetector.ts index f1ebe36..bc2eb27 100644 --- a/src/ReactNativeDataDetector.ts +++ b/src/ReactNativeDataDetector.ts @@ -1,16 +1,51 @@ import { requireNativeModule } from 'expo-modules-core'; -import type { DetectedEntity, DetectOptions } from './ReactNativeDataDetector.types'; +import type { + DetectedEntity, + DetectOptions, + ModelLanguage, + ModelOptions, + ModelStatus, +} from './ReactNativeDataDetector.types'; const NativeModule = requireNativeModule('ReactNativeDataDetector'); +const DEFAULT_LANGUAGE: ModelLanguage = 'en'; +const ALL_TYPES = ['phoneNumber', 'link', 'email', 'address', 'date'] as const; + /** - * Pre-downloads the ML Kit entity extraction model on Android. - * No-op on iOS (NSDataDetector requires no model download). - * Call this at app startup to ensure `detect()` works offline later. + * Pre-downloads the entity-detection model for the given language so that + * `detect()` can run offline afterwards. + * + * On Android this downloads the ML Kit model for `language` (~5.6MB) if it is + * not already cached. On iOS this is a no-op that resolves immediately — + * `NSDataDetector` is built into the OS and requires no model. + * + * @returns `true` once the model is ready. */ -export async function downloadModel(): Promise { - return NativeModule.downloadModel(); +export async function prepareModel(options?: ModelOptions): Promise { + return NativeModule.prepareModel(options?.language ?? DEFAULT_LANGUAGE); +} + +/** + * Returns the download status of the model for the given language. + * + * On Android this reflects whether the ML Kit model is cached on the device + * (`'ready'` or `'notDownloaded'`). On iOS this always resolves to `'ready'`. + * + * A pure status query never returns `'downloading'` or `'error'` — those states + * are only surfaced by the {@link useDataDetector} hook while it drives a download. + */ +export async function getModelStatus(options?: ModelOptions): Promise { + return NativeModule.getModelStatus(options?.language ?? DEFAULT_LANGUAGE); +} + +/** + * Convenience wrapper around {@link getModelStatus} that resolves `true` when the + * model for the given language is available and `detect()` can run offline. + */ +export async function isModelReady(options?: ModelOptions): Promise { + return (await getModelStatus(options)) === 'ready'; } /** @@ -18,6 +53,15 @@ export async function downloadModel(): Promise { * using native platform APIs (NSDataDetector on iOS, ML Kit on Android). */ export async function detect(text: string, options?: DetectOptions): Promise { - const types = options?.types ?? ['phoneNumber', 'link', 'email', 'address', 'date']; - return NativeModule.detect(text, types); + const types = options?.types ?? [...ALL_TYPES]; + return NativeModule.detect(text, types, options?.language ?? DEFAULT_LANGUAGE); +} + +/** + * @deprecated Renamed to {@link prepareModel} in 0.3.0. This alias will be removed + * in a future major version. `prepareModel` accepts a `{ language }` option; + * `downloadModel` always targets the default (`'en'`) model. + */ +export async function downloadModel(): Promise { + return prepareModel(); } diff --git a/src/ReactNativeDataDetector.types.ts b/src/ReactNativeDataDetector.types.ts index ca5d724..869aea0 100644 --- a/src/ReactNativeDataDetector.types.ts +++ b/src/ReactNativeDataDetector.types.ts @@ -3,6 +3,53 @@ */ export type DetectionType = 'phoneNumber' | 'link' | 'email' | 'address' | 'date'; +/** + * A language model supported by ML Kit Entity Extraction on Android, expressed as + * an ISO 639-1 code. Selects which on-device model is used for detection. + * + * Ignored on iOS — `NSDataDetector` is language-agnostic and needs no model. + */ +export type ModelLanguage = + | 'ar' // Arabic + | 'nl' // Dutch + | 'en' // English + | 'fr' // French + | 'de' // German + | 'it' // Italian + | 'ja' // Japanese + | 'ko' // Korean + | 'pl' // Polish + | 'pt' // Portuguese + | 'ru' // Russian + | 'es' // Spanish + | 'th' // Thai + | 'tr' // Turkish + | 'zh'; // Chinese + +/** + * The download state of a detection model. + * + * - `notDownloaded` — the model is not available on the device yet (Android only). + * - `downloading` — a download is currently in progress. Only reported by the + * {@link useDataDetector} hook while it drives a download; native status queries + * never return this. + * - `ready` — the model is available and `detect()` can run offline. iOS always + * reports `ready` since `NSDataDetector` requires no model. + * - `error` — the last preparation attempt failed. Only reported by the hook. + */ +export type ModelStatus = 'notDownloaded' | 'downloading' | 'ready' | 'error'; + +/** + * Options that select which language model a model operation targets. + */ +export interface ModelOptions { + /** + * Which language model to target (Android only, defaults to `'en'`). + * Ignored on iOS. + */ + language?: ModelLanguage; +} + /** * A single detected entity within the text. */ @@ -25,4 +72,9 @@ export interface DetectedEntity { export interface DetectOptions { /** Which entity types to detect. Defaults to all types. */ types?: DetectionType[]; + /** + * Which language model to use (Android only, defaults to `'en'`). + * Ignored on iOS. + */ + language?: ModelLanguage; } diff --git a/src/index.ts b/src/index.ts index 59ae6cb..afe8ba1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,17 @@ -export { detect, downloadModel } from './ReactNativeDataDetector'; -export type { DetectedEntity, DetectionType, DetectOptions } from './ReactNativeDataDetector.types'; +export { + detect, + downloadModel, + getModelStatus, + isModelReady, + prepareModel, +} from './ReactNativeDataDetector'; +export { useDataDetector } from './useDataDetector'; +export type { UseDataDetectorOptions, UseDataDetectorResult } from './useDataDetector'; +export type { + DetectedEntity, + DetectionType, + DetectOptions, + ModelLanguage, + ModelOptions, + ModelStatus, +} from './ReactNativeDataDetector.types'; diff --git a/src/useDataDetector.ts b/src/useDataDetector.ts new file mode 100644 index 0000000..dc73bba --- /dev/null +++ b/src/useDataDetector.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { detect as detectFn, getModelStatus, prepareModel } from './ReactNativeDataDetector'; +import type { + DetectedEntity, + DetectionType, + ModelLanguage, + ModelStatus, +} from './ReactNativeDataDetector.types'; + +const DEFAULT_LANGUAGE: ModelLanguage = 'en'; + +export interface UseDataDetectorOptions { + /** + * Which language model to use (Android only, defaults to `'en'`). Ignored on iOS. + * Changing this re-checks/prepares the model for the new language. + */ + language?: ModelLanguage; + /** + * When `true` (default), the hook downloads the model on mount if it is not + * already available (Android). Set to `false` to manage downloads yourself via + * the returned `prepare()`. No effect on iOS, which is always ready. + */ + autoPrepare?: boolean; +} + +export interface UseDataDetectorResult { + /** + * Detects entities in `text` using the hook's configured language. Safe to call + * before the model is ready — on Android the underlying call downloads the model + * on demand if needed. + */ + detect: (text: string, options?: { types?: DetectionType[] }) => Promise; + /** Manually (re)download the model for the configured language. */ + prepare: () => Promise; + /** Current model download state. */ + status: ModelStatus; + /** `true` when the model is available and `detect()` can run offline. */ + isReady: boolean; + /** The last preparation error, or `null`. */ + error: Error | null; +} + +/** + * React hook for data detection that tracks model availability and, on Android, + * downloads the language model automatically. + * + * On iOS the model is always available, so `status` settles on `'ready'` and + * `autoPrepare` has no effect. + * + * @example + * const { detect, status, isReady } = useDataDetector(); + * // later: const entities = await detect(text, { types: ['email'] }); + */ +export function useDataDetector(options?: UseDataDetectorOptions): UseDataDetectorResult { + const language = options?.language ?? DEFAULT_LANGUAGE; + const autoPrepare = options?.autoPrepare ?? true; + + const [status, setStatus] = useState('notDownloaded'); + const [error, setError] = useState(null); + + // The language the hook currently cares about. Lets late-resolving promises + // from a previous language (after a switch or unmount) be ignored. + const activeLanguage = useRef(language); + + const prepare = useCallback(async () => { + setError(null); + setStatus('downloading'); + try { + await prepareModel({ language }); + if (activeLanguage.current === language) setStatus('ready'); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + if (activeLanguage.current === language) { + setError(err); + setStatus('error'); + } + throw err; + } + }, [language]); + + useEffect(() => { + activeLanguage.current = language; + let active = true; + + (async () => { + setError(null); + try { + const current = await getModelStatus({ language }); + if (!active) return; + if (current === 'ready') { + setStatus('ready'); + return; + } + if (!autoPrepare) { + setStatus(current); + return; + } + setStatus('downloading'); + await prepareModel({ language }); + if (active) setStatus('ready'); + } catch (e) { + if (active) { + setError(e instanceof Error ? e : new Error(String(e))); + setStatus('error'); + } + } + })(); + + return () => { + active = false; + }; + }, [language, autoPrepare]); + + const detect = useCallback( + (text: string, opts?: { types?: DetectionType[] }): Promise => + detectFn(text, { types: opts?.types, language }), + [language], + ); + + return { detect, prepare, status, isReady: status === 'ready', error }; +}