diff --git a/android/app/build.gradle b/android/app/build.gradle index 1084da92383b60929c2d2fad7db1ef1559371998..fa56b475b947fe38815dcf124054886df40ae966 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -139,6 +139,8 @@ android { dependencies { compile project(':react-native-vector-icons') + compile project(':react-native-fs') + compile project(':react-native-document-picker') compile project(':react-native-share') compile project(':realm') compile fileTree(dir: "libs", include: ["*.jar"]) diff --git a/android/app/src/main/java/com/drip/MainApplication.java b/android/app/src/main/java/com/drip/MainApplication.java index 44756abd812f72286b8f54b1a044468f8899ef4d..4dc63a4e890ef14381f722b9a044c3114945f809 100644 --- a/android/app/src/main/java/com/drip/MainApplication.java +++ b/android/app/src/main/java/com/drip/MainApplication.java @@ -4,6 +4,8 @@ import android.app.Application; import com.facebook.react.ReactApplication; import com.oblador.vectoricons.VectorIconsPackage; +import com.rnfs.RNFSPackage; +import com.reactnativedocumentpicker.ReactNativeDocumentPicker; import cl.json.RNSharePackage; import cl.json.ShareApplication; import io.realm.react.RealmReactPackage; @@ -28,6 +30,8 @@ public class MainApplication extends Application implements ReactApplication, Sh return Arrays.<ReactPackage>asList( new MainReactPackage(), new VectorIconsPackage(), + new RNFSPackage(), + new ReactNativeDocumentPicker(), new RNSharePackage(), new RealmReactPackage() ); diff --git a/android/settings.gradle b/android/settings.gradle index 4e17216719a3e145126ce7913aba6a4b8acefe3c..6a9c8c84200f3d0b63b9697454df468f2d5e1484 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,6 +1,10 @@ rootProject.name = 'drip' include ':react-native-vector-icons' project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android') +include ':react-native-fs' +project(':react-native-fs').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fs/android') +include ':react-native-document-picker' +project(':react-native-document-picker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-document-picker/android') include ':react-native-share' project(':react-native-share').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-share/android') include ':realm' diff --git a/components/cycle-day/cycle-day-overview.js b/components/cycle-day/cycle-day-overview.js index f92934b54305277f47588f56b4087ad296573db2..b127b7471fe1742792f6f1e3bf9b8bd781799515 100644 --- a/components/cycle-day/cycle-day-overview.js +++ b/components/cycle-day/cycle-day-overview.js @@ -98,11 +98,20 @@ export default class DayView extends Component { <Text style={styles.symptomDayView}>Desire</Text> <View style={styles.symptomEditButton}> <Button - onPress={() => this.showView('desireEditView')} + onPress={() => this.showView('DesireEditView')} title={getLabel('desire', cycleDay.desire)}> </Button> </View> </View> + <View style={styles.symptomViewRowInline}> + <Text style={styles.symptomDayView}>Sex</Text> + <View style={styles.symptomEditButton}> + <Button + onPress={() => this.showView('SexEditView')} + title={getLabel('sex', cycleDay.sex)}> + </Button> + </View> + </View> </View > ) } @@ -132,15 +141,22 @@ function getLabel(symptomName, symptom) { typeof mucus.texture === 'number' && typeof mucus.value === 'number' ) { - let mucusLabel = `${feelingLabels[mucus.feeling]} + ${textureLabels[mucus.texture]} ( ${computeSensiplanMucusLabels[mucus.value]} )` + let mucusLabel = + `${feelingLabels[mucus.feeling]} + + ${textureLabels[mucus.texture]} + ( ${computeSensiplanMucusLabels[mucus.value]} )` if (mucus.exclude) mucusLabel = "( " + mucusLabel + " )" return mucusLabel } }, cervix: cervix => { if (cervix.opening > -1 && cervix.firmness > -1) { - let cervixLabel = `${openingLabels[cervix.opening]} + ${firmnessLabels[cervix.firmness]}` - if (cervix.position > -1) cervixLabel += `+ ${positionLabels[cervix.position]}` + let cervixLabel = + `${openingLabels[cervix.opening]} + + ${firmnessLabels[cervix.firmness]}` + if (cervix.position > -1) { + cervixLabel += `+ ${positionLabels[cervix.position]}` + } if (cervix.exclude) cervixLabel = "( " + cervixLabel + " )" return cervixLabel } @@ -153,6 +169,17 @@ function getLabel(symptomName, symptom) { const desireLabel = `${intensityLabels[desire.value]}` return desireLabel } + }, + sex: sex => { + let sexLabel = '' + if ( sex.solo || sex.partner ) { + sexLabel += 'Activity ' + } + if (sex.condom || sex.pill || sex.iud || + sex.patch || sex.ring || sex.implant || sex.other) { + sexLabel += 'Contraceptive' + } + return sexLabel ? sexLabel : 'edit' } } diff --git a/components/cycle-day/labels/labels.js b/components/cycle-day/labels/labels.js index 68735ee1305ea3cdfc2001311c716d67835a172f..ee88428f9eae95670ebf853af6ebafc85b4d33c7 100644 --- a/components/cycle-day/labels/labels.js +++ b/components/cycle-day/labels/labels.js @@ -6,6 +6,19 @@ export const cervixOpening = ['closed', 'medium', 'open'] export const cervixFirmness = ['hard', 'soft'] export const cervixPosition = ['low', 'medium', 'high'] export const intensity = ['low', 'medium', 'high'] +export const sexActivity = { + solo: 'Solo', + partner: 'Partner' +} +export const contraceptives = { + condom: 'Condom', + pill: 'Pill', + iud: 'IUD', + patch: 'Patch', + ring: 'Ring', + implant: 'Implant', + other: 'Other' +} export const fertilityStatus = { fertile: 'fertile', diff --git a/components/cycle-day/symptoms/index.js b/components/cycle-day/symptoms/index.js index 617dbab46d2d1d59b522ddc17712a98271c1ed25..c3583df86348327d9dd3bdaa1d83975926d3694b 100644 --- a/components/cycle-day/symptoms/index.js +++ b/components/cycle-day/symptoms/index.js @@ -4,6 +4,7 @@ import MucusEditView from './mucus' import CervixEditView from './cervix' import NoteEditView from './note' import DesireEditView from './desire' +import SexEditView from './sex' export default { BleedingEditView, @@ -11,5 +12,6 @@ export default { MucusEditView, CervixEditView, NoteEditView, - DesireEditView -} \ No newline at end of file + DesireEditView, + SexEditView +} diff --git a/components/cycle-day/symptoms/sex.js b/components/cycle-day/symptoms/sex.js new file mode 100644 index 0000000000000000000000000000000000000000..d9ec3366402edab57e054a993363cecca41d6ab0 --- /dev/null +++ b/components/cycle-day/symptoms/sex.js @@ -0,0 +1,156 @@ +import React, { Component } from 'react' +import { + CheckBox, + Text, + TextInput, + View +} from 'react-native' +import styles from '../../../styles' +import { saveSymptom } from '../../../db' +import { + sexActivity as activityLabels, + contraceptives as contraceptiveLabels +} from '../labels/labels' + +export default class Sex extends Component { + constructor(props) { + super(props) + this.cycleDay = props.cycleDay + this.state = {} + if (this.cycleDay.sex !== null ) { + Object.assign(this.state, this.cycleDay.sex) + // We make sure other is always true when there is a note, + // e.g. when import is messed up. + if (this.cycleDay.sex && this.cycleDay.sex.note) { + this.state.other = true + } + } + } + + render() { + + return ( + <View style={styles.symptomEditView}> + <Text style={styles.symptomDayView}>SEX</Text> + <View style={styles.symptomViewRowInline}> + <Text style={styles.symptomDayView}>{activityLabels.solo}</Text> + <CheckBox + value={this.state.solo} + onValueChange={(val) => { + this.setState({solo: val}) + }} + /> + <Text style={styles.symptomDayView}>{activityLabels.partner}</Text> + <CheckBox + value={this.state.partner} + onValueChange={(val) => { + this.setState({partner: val}) + }} + /> + </View> + <Text style={styles.symptomDayView}>CONTRACEPTIVES</Text> + <View style={styles.symptomViewRowInline}> + <Text style={styles.symptomDayView}> + {contraceptiveLabels.condom} + </Text> + <CheckBox + value={this.state.condom} + onValueChange={(val) => { + this.setState({condom: val}) + }} + /> + <Text style={styles.symptomDayView}> + {contraceptiveLabels.pill} + </Text> + <CheckBox + value={this.state.pill} + onValueChange={(val) => { + this.setState({pill: val}) + }} + /> + </View> + <View style={styles.symptomViewRowInline}> + <Text style={styles.symptomDayView}> + {contraceptiveLabels.iud} + </Text> + <CheckBox + value={this.state.iud} + onValueChange={(val) => { + this.setState({iud: val}) + }} + /> + <Text style={styles.symptomDayView}> + {contraceptiveLabels.patch} + </Text> + <CheckBox + value={this.state.patch} + onValueChange={(val) => { + this.setState({patch: val}) + }} + /> + </View> + <View style={styles.symptomViewRowInline}> + <Text style={styles.symptomDayView}> + {contraceptiveLabels.ring} + </Text> + <CheckBox + value={this.state.ring} + onValueChange={(val) => { + this.setState({ring: val}) + }} + /> + <Text style={styles.symptomDayView}> + {contraceptiveLabels.implant} + </Text> + <CheckBox + value={this.state.implant} + onValueChange={(val) => { + this.setState({implant: val}) + }} + /> + </View> + <View style={styles.symptomViewRowInline}> + <Text style={styles.symptomDayView}> + {contraceptiveLabels.other} + </Text> + <CheckBox + value={this.state.other} + onValueChange={(val) => { + this.setState({ + other: val, + focusTextArea: true + }) + }} + /> + </View> + { this.state.other && + <TextInput + autoFocus={this.state.focusTextArea} + multiline={true} + placeholder="Enter" + value={this.state.note} + onChangeText={(val) => { + this.setState({note: val}) + }} + /> + } + <View style={styles.actionButtonRow}> + {this.props.makeActionButtons( + { + symptom: 'sex', + cycleDay: this.cycleDay, + saveAction: () => { + const copyOfState = Object.assign({}, this.state) + if (!copyOfState.other) { + copyOfState.note = null + } + saveSymptom('sex', this.cycleDay, copyOfState) + }, + saveDisabled: Object.values(this.state).every(value => !value) + } + )} + </View> + </View> + ) + } +} diff --git a/components/labels.js b/components/labels.js index cd361c8499803cd2d6cd83d1d961a710891646c7..99400a2de63b6480c53cc5175b4fd1c24884bc59 100644 --- a/components/labels.js +++ b/components/labels.js @@ -1,10 +1,33 @@ export const settings = { - errors: { - noData: 'There is no data to export', - couldNotConvert: 'Could not convert data to CSV', - problemSharing: 'There was a problem sharing the data export file' + shared: { + cancel: 'Cancel', + errorTitle: 'Error', + successTitle: 'Success' }, - exportTitle: 'My Drip data export', - exportSubject: 'My Drip data export', - buttonLabel: 'Export data' + export: { + errors: { + noData: 'There is no data to export', + couldNotConvert: 'Could not convert data to CSV', + problemSharing: 'There was a problem sharing the data export file' + }, + title: 'My Drip data export', + subject: 'My Drip data export', + button: 'Export data', + }, + import: { + button: 'Import data', + title: 'Keep existing data?', + message: `There are two options for the import: +1. Keep existing cycle days and replace only the ones in the import file. +2. Delete all existing cycle days and import cycle days from file.`, + replaceOption: 'Import and replace', + deleteOption: 'Import and delete existing', + errors: { + couldNotOpenFile: 'Could not open file', + postFix: 'No data was imported or changed' + }, + success: { + message: 'Data successfully imported' + } + } } \ No newline at end of file diff --git a/components/settings.js b/components/settings.js index 3abc167c6701c1c7db6ce8d1a252d023f0343961..97907b101cd7e527d4f351d5025fd93fe39adb42 100644 --- a/components/settings.js +++ b/components/settings.js @@ -7,9 +7,12 @@ import { } from 'react-native' import Share from 'react-native-share' -import getDataAsCsvDataUri from '../lib/export-to-csv' +import { DocumentPicker, DocumentPickerUtil } from 'react-native-document-picker' +import rnfs from 'react-native-fs' import styles from '../styles/index' import { settings as labels } from './labels' +import getDataAsCsvDataUri from '../lib/import-export/export-to-csv' +import importCsv from '../lib/import-export/import-from-csv' export default class Settings extends Component { render() { @@ -18,36 +21,100 @@ export default class Settings extends Component { <View style={styles.homeButtons}> <View style={styles.homeButton}> <Button - onPress={async () => { - let data - try { - data = getDataAsCsvDataUri() - if (!data) { - return Alert.alert(labels.errors.noData) - } - } catch (err) { - console.error(err) - return Alert.alert(labels.errors.couldNotConvert) - } - - try { - await Share.open({ - title: labels.exportTitle, - url: data, - subject: labels.exportSubject, - type: 'text/csv', - showAppsToView: true - }) - } catch (err) { - console.error(err) - return Alert.alert(labels.errors.problemSharing) - } - }} - title={labels.buttonLabel}> + onPress={ openShareDialogAndExport } + title={labels.export.button}> + </Button> + </View> + <View style={styles.homeButton}> + <Button + title={labels.import.button} + onPress={ openImportDialogAndImport }> </Button> </View> </View> </ScrollView> ) } +} + +async function openShareDialogAndExport() { + let data + try { + data = getDataAsCsvDataUri() + if (!data) { + return alertError(labels.errors.noData) + } + } catch (err) { + console.error(err) + return alertError(labels.errors.couldNotConvert) + } + + try { + await Share.open({ + title: labels.export.title, + url: data, + subject: labels.export.subject, + type: 'text/csv', + showAppsToView: true + }) + } catch (err) { + console.error(err) + return alertError(labels.export.errors.problemSharing) + } +} + +function openImportDialogAndImport() { + Alert.alert( + labels.import.title, + labels.import.message, + [{ + text: labels.import.replaceOption, + onPress: () => getFileContentAndImport({ deleteExisting: false }) + }, { + text: labels.import.deleteOption, + onPress: () => getFileContentAndImport({ deleteExisting: true }) + }, { + text: labels.shared.cancel, style: 'cancel', onPress: () => { } + }] + ) +} + +async function getFileContentAndImport({ deleteExisting }) { + let fileInfo + try { + fileInfo = await new Promise((resolve, reject) => { + DocumentPicker.show({ + filetype: [DocumentPickerUtil.allFiles()], + }, (err, res) => { + if (err) return reject(err) + resolve(res) + }) + }) + } catch (err) { + // because cancelling also triggers an error, we do nothing here + return + } + + let fileContent + try { + fileContent = await rnfs.readFile(fileInfo.uri, 'utf8') + } catch (err) { + return importError(labels.import.errors.couldNotOpenFile) + } + + try { + await importCsv(fileContent, deleteExisting) + Alert.alert(labels.import.success.title, labels.import.success.message) + } catch(err) { + importError(err.message) + } +} + +function alertError(msg) { + Alert.alert(labels.shared.errorTitle, msg) +} + +function importError(msg) { + const postFixed = `${msg}\n\n${labels.import.errors.postFix}` + alertError(postFixed) } \ No newline at end of file diff --git a/db/index.js b/db/index.js index 55b34b4710fdc7a80cf73ef7b8d22fb9e6ee5ecf..42fd320e8e30c324b841278c7786ea88b5610572 100644 --- a/db/index.js +++ b/db/index.js @@ -60,6 +60,22 @@ const DesireSchema = { } } +const SexSchema = { + name: 'Sex', + properties: { + solo: { type: 'bool', optional: true }, + partner: { type: 'bool', optional: true }, + condom: { type: 'bool', optional: true }, + pill: { type: 'bool', optional: true }, + iud: { type: 'bool', optional: true }, + patch: { type: 'bool', optional: true }, + ring: { type: 'bool', optional: true }, + implant: { type: 'bool', optional: true }, + other: { type: 'bool', optional: true }, + note: { type: 'string', optional: true } + } +} + const CycleDaySchema = { name: 'CycleDay', primaryKey: 'date', @@ -88,6 +104,10 @@ const CycleDaySchema = { desire: { type: 'Desire', optional: true + }, + sex: { + type: 'Sex', + optional: true } } } @@ -100,7 +120,8 @@ const realmConfig = { MucusSchema, CervixSchema, NoteSchema, - DesireSchema + DesireSchema, + SexSchema ], // we only want this in dev mode deleteRealmIfMigrationNeeded: true @@ -175,21 +196,17 @@ function getPreviousTemperature(cycleDay) { return winner.temperature.value } -function getColumnNamesForCsv() { - return getPrefixedKeys('CycleDay') - - function getPrefixedKeys(schemaName, prefix) { - const schema = db.schema.find(x => x.name === schemaName).properties - return Object.keys(schema).reduce((acc, key) => { - const prefixedKey = prefix ? [prefix, key].join('.') : key - const childSchemaName = schema[key].objectType - if (!childSchemaName) { - acc.push(prefixedKey) - return acc - } - acc.push(...getPrefixedKeys(childSchemaName, prefixedKey)) - return acc - }, []) +const schema = db.schema.reduce((acc, curr) => { + acc[curr.name] = curr.properties + return acc +}, {}) + +function tryToCreateCycleDay(day, i) { + try { + db.create('CycleDay', day) + } catch (err) { + const msg = `Line ${i + 1}(${day.date}): ${err.message}` + throw new Error(msg) } } @@ -202,6 +219,23 @@ function getAmountOfCycleDays() { return earliestAsLocalDate.until(today, ChronoUnit.DAYS) } +function tryToImportWithDelete(cycleDays) { + db.write(() => { + db.delete(db.objects('CycleDay')) + cycleDays.forEach(tryToCreateCycleDay) + }) +} + +function tryToImportWithoutDelete(cycleDays) { + db.write(() => { + cycleDays.forEach((day, i) => { + const existing = getCycleDay(day.date) + if (existing) db.delete(existing) + tryToCreateCycleDay(day, i) + }) + }) +} + export { saveSymptom, getOrCreateCycleDay, @@ -212,6 +246,8 @@ export { deleteAll, getPreviousTemperature, getCycleDay, - getColumnNamesForCsv, - getAmountOfCycleDays + getAmountOfCycleDays, + schema, + tryToImportWithDelete, + tryToImportWithoutDelete } diff --git a/ios/drip.xcodeproj/project.pbxproj b/ios/drip.xcodeproj/project.pbxproj index b6ef381adb071d4eeaa197654f688d901e5f4343..25f1f7601b795048c76bb96a0d0b2d95dddf0838 100644 --- a/ios/drip.xcodeproj/project.pbxproj +++ b/ios/drip.xcodeproj/project.pbxproj @@ -56,6 +56,8 @@ DB91E6CCC3EB4A549D947797 /* Octicons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4902D5DCD46748BD8DC403FD /* Octicons.ttf */; }; 3DF2498A20844F298CD84CC3 /* SimpleLineIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E954835D62BD45F0A5FFC523 /* SimpleLineIcons.ttf */; }; A1410AC4C98A49B2820D9E45 /* Zocial.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B6F5078F7DEC470782757471 /* Zocial.ttf */; }; + 29DF0CCC1AEA4C92BCA0BCCD /* libRNDocumentPicker.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D211D71BE5A8436A978770A9 /* libRNDocumentPicker.a */; }; + 17AD822C42A44BADA96BD860 /* libRNFS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 84CCEBD3B2C44758853BC941 /* libRNFS.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -383,6 +385,10 @@ 4902D5DCD46748BD8DC403FD /* Octicons.ttf */ = {isa = PBXFileReference; name = "Octicons.ttf"; path = "../node_modules/react-native-vector-icons/Fonts/Octicons.ttf"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; }; E954835D62BD45F0A5FFC523 /* SimpleLineIcons.ttf */ = {isa = PBXFileReference; name = "SimpleLineIcons.ttf"; path = "../node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; }; B6F5078F7DEC470782757471 /* Zocial.ttf */ = {isa = PBXFileReference; name = "Zocial.ttf"; path = "../node_modules/react-native-vector-icons/Fonts/Zocial.ttf"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; }; + 1F05FE29622E4F21AF70C2B7 /* RNDocumentPicker.xcodeproj */ = {isa = PBXFileReference; name = "RNDocumentPicker.xcodeproj"; path = "../node_modules/react-native-document-picker/ios/RNDocumentPicker.xcodeproj"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = wrapper.pb-project; explicitFileType = undefined; includeInIndex = 0; }; + D211D71BE5A8436A978770A9 /* libRNDocumentPicker.a */ = {isa = PBXFileReference; name = "libRNDocumentPicker.a"; path = "libRNDocumentPicker.a"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = archive.ar; explicitFileType = undefined; includeInIndex = 0; }; + 49089E09BFCF4F3DB209B6E9 /* RNFS.xcodeproj */ = {isa = PBXFileReference; name = "RNFS.xcodeproj"; path = "../node_modules/react-native-fs/RNFS.xcodeproj"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = wrapper.pb-project; explicitFileType = undefined; includeInIndex = 0; }; + 84CCEBD3B2C44758853BC941 /* libRNFS.a */ = {isa = PBXFileReference; name = "libRNFS.a"; path = "libRNFS.a"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = archive.ar; explicitFileType = undefined; includeInIndex = 0; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -416,6 +422,8 @@ D91133DCE120440893E2FD2E /* libz.tbd in Frameworks */, 26DC04B498C64CE5AAA0C4F8 /* libRNShare.a in Frameworks */, AED64B7892744F21B3A156BB /* libRNVectorIcons.a in Frameworks */, + 29DF0CCC1AEA4C92BCA0BCCD /* libRNDocumentPicker.a in Frameworks */, + 17AD822C42A44BADA96BD860 /* libRNFS.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -608,6 +616,8 @@ 7F6C9FA9B66B453CA602B334 /* RealmReact.xcodeproj */, 4E6AB77B55F2491487B6124E /* RNShare.xcodeproj */, D1E5ACC4B66345868F556374 /* RNVectorIcons.xcodeproj */, + 1F05FE29622E4F21AF70C2B7 /* RNDocumentPicker.xcodeproj */, + 49089E09BFCF4F3DB209B6E9 /* RNFS.xcodeproj */, ); name = Libraries; sourceTree = "<group>"; @@ -1272,12 +1282,16 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", ); HEADER_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/../node_modules/realm/src/**", "$(SRCROOT)/../node_modules/react-native-share/ios", "$(SRCROOT)/../node_modules/react-native-vector-icons/RNVectorIconsManager", + "$(SRCROOT)/../node_modules/react-native-document-picker/ios/RNDocumentPicker", + "$(SRCROOT)/../node_modules/react-native-fs/**", ); }; name = Debug; @@ -1299,12 +1313,16 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", ); HEADER_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/../node_modules/realm/src/**", "$(SRCROOT)/../node_modules/react-native-share/ios", "$(SRCROOT)/../node_modules/react-native-vector-icons/RNVectorIconsManager", + "$(SRCROOT)/../node_modules/react-native-document-picker/ios/RNDocumentPicker", + "$(SRCROOT)/../node_modules/react-native-fs/**", ); }; name = Release; @@ -1329,6 +1347,8 @@ "$(SRCROOT)/../node_modules/realm/src/**", "$(SRCROOT)/../node_modules/react-native-share/ios", "$(SRCROOT)/../node_modules/react-native-vector-icons/RNVectorIconsManager", + "$(SRCROOT)/../node_modules/react-native-document-picker/ios/RNDocumentPicker", + "$(SRCROOT)/../node_modules/react-native-fs/**", ); }; name = Debug; @@ -1352,6 +1372,8 @@ "$(SRCROOT)/../node_modules/realm/src/**", "$(SRCROOT)/../node_modules/react-native-share/ios", "$(SRCROOT)/../node_modules/react-native-vector-icons/RNVectorIconsManager", + "$(SRCROOT)/../node_modules/react-native-document-picker/ios/RNDocumentPicker", + "$(SRCROOT)/../node_modules/react-native-fs/**", ); }; name = Release; @@ -1382,12 +1404,16 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", ); HEADER_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/../node_modules/realm/src/**", "$(SRCROOT)/../node_modules/react-native-share/ios", "$(SRCROOT)/../node_modules/react-native-vector-icons/RNVectorIconsManager", + "$(SRCROOT)/../node_modules/react-native-document-picker/ios/RNDocumentPicker", + "$(SRCROOT)/../node_modules/react-native-fs/**", ); }; name = Debug; @@ -1418,12 +1444,16 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", ); HEADER_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/../node_modules/realm/src/**", "$(SRCROOT)/../node_modules/react-native-share/ios", "$(SRCROOT)/../node_modules/react-native-vector-icons/RNVectorIconsManager", + "$(SRCROOT)/../node_modules/react-native-document-picker/ios/RNDocumentPicker", + "$(SRCROOT)/../node_modules/react-native-fs/**", ); }; name = Release; @@ -1453,12 +1483,16 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", ); HEADER_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/../node_modules/realm/src/**", "$(SRCROOT)/../node_modules/react-native-share/ios", "$(SRCROOT)/../node_modules/react-native-vector-icons/RNVectorIconsManager", + "$(SRCROOT)/../node_modules/react-native-document-picker/ios/RNDocumentPicker", + "$(SRCROOT)/../node_modules/react-native-fs/**", ); }; name = Debug; @@ -1488,12 +1522,16 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", + "\"$(SRCROOT)/$(TARGET_NAME)\"", ); HEADER_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/../node_modules/realm/src/**", "$(SRCROOT)/../node_modules/react-native-share/ios", "$(SRCROOT)/../node_modules/react-native-vector-icons/RNVectorIconsManager", + "$(SRCROOT)/../node_modules/react-native-document-picker/ios/RNDocumentPicker", + "$(SRCROOT)/../node_modules/react-native-fs/**", ); }; name = Release; diff --git a/lib/export-to-csv.js b/lib/import-export/export-to-csv.js similarity index 91% rename from lib/export-to-csv.js rename to lib/import-export/export-to-csv.js index d3b1321662431f90a7ead8a2f87ac6bf038b161f..c5bca2b3ffb4c4afd5669fa3d62c93e4ac1468ca 100644 --- a/lib/export-to-csv.js +++ b/lib/import-export/export-to-csv.js @@ -1,7 +1,7 @@ import objectPath from 'object-path' import { Base64 } from 'js-base64' - -import { getColumnNamesForCsv, cycleDaysSortedByDate } from '../db' +import { cycleDaysSortedByDate } from '../../db' +import getColumnNamesForCsv from './get-csv-column-names' export default function makeDataURI() { if (!cycleDaysSortedByDate.length) return null diff --git a/lib/import-export/get-csv-column-names.js b/lib/import-export/get-csv-column-names.js new file mode 100644 index 0000000000000000000000000000000000000000..1b63943ab6b5a6b05eee6460684b62b3947f3075 --- /dev/null +++ b/lib/import-export/get-csv-column-names.js @@ -0,0 +1,19 @@ +import { schema } from '../../db' + +export default function getColumnNamesForCsv() { + return getPrefixedKeys('CycleDay') + + function getPrefixedKeys(schemaName, prefix) { + const model = schema[schemaName] + return Object.keys(model).reduce((acc, key) => { + const prefixedKey = prefix ? [prefix, key].join('.') : key + const childSchemaName = model[key].objectType + if (!childSchemaName) { + acc.push(prefixedKey) + return acc + } + acc.push(...getPrefixedKeys(childSchemaName, prefixedKey)) + return acc + }, []) + } +} \ No newline at end of file diff --git a/lib/import-export/import-from-csv.js b/lib/import-export/import-from-csv.js new file mode 100644 index 0000000000000000000000000000000000000000..77c2444430072f4441340dcef4e732ebcf2fbf9a --- /dev/null +++ b/lib/import-export/import-from-csv.js @@ -0,0 +1,82 @@ +import csvParser from 'csvtojson' +import isObject from 'isobject' +import { schema, tryToImportWithDelete, tryToImportWithoutDelete } from '../../db' +import getColumnNamesForCsv from './get-csv-column-names' + +export default async function importCsv(csv, deleteFirst) { + const parseFuncs = { + bool: val => { + if (val.toLowerCase() === 'true') return true + if (val.toLowerCase() === 'false') return false + return val + }, + int: parseNumberIfPossible, + float: parseNumberIfPossible, + double: parseNumberIfPossible, + string: val => val + } + + function parseNumberIfPossible(val) { + // Number and parseFloat catch different cases of weirdness, + // so we test them both + if (isNaN(Number(val)) || isNaN(parseFloat(val))) return val + return Number(val) + } + + const config = { + ignoreEmpty: true, + colParser: getColumnNamesForCsv().reduce((acc, colName) => { + const path = colName.split('.') + const dbType = getDbType(schema.CycleDay, path) + acc[colName] = item => { + if (item === '') return null + return parseFuncs[dbType](item) + } + return acc + }, {}) + } + + const cycleDays = await csvParser(config) + .fromString(csv) + .on('header', validateHeaders) + + //remove symptoms where all fields are null + putNullForEmptySymptoms(cycleDays) + + if (deleteFirst) { + tryToImportWithDelete(cycleDays) + } else { + tryToImportWithoutDelete(cycleDays) + } +} + +function validateHeaders(headers) { + const expectedHeaders = getColumnNamesForCsv() + if (!headers.every(header => { + return expectedHeaders.indexOf(header) > -1 + })) { + const msg = `Expected CSV column titles to be ${expectedHeaders.join()}` + throw new Error(msg) + } +} + +function putNullForEmptySymptoms(data) { + data.forEach(replaceWithNullIfAllPropertiesAreNull) + + function replaceWithNullIfAllPropertiesAreNull(obj) { + Object.keys(obj).forEach((key) => { + if (!isObject(obj[key])) return + if (Object.values(obj[key]).every(val => val === null)) { + obj[key] = null + return + } + replaceWithNullIfAllPropertiesAreNull(obj[key]) + }) + } +} + +function getDbType(modelProperties, path) { + if (path.length === 1) return modelProperties[path[0]].type + const modelName = modelProperties[path[0]].objectType + return getDbType(schema[modelName], path.slice(1)) +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 86dcf0073f06b33f99e247ebd19f22b8e339005e..9823a713e6ae35c8dbfa4b2a799e41bcfce4de33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2015,6 +2015,11 @@ } } }, + "base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha1-eAqZyE59YAJgNhURxId2E78k9rs=" + }, "base64-js": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", @@ -2066,6 +2071,11 @@ "inherits": "~2.0.0" } }, + "bluebird": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", + "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" + }, "boom": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", @@ -2545,6 +2555,26 @@ "boom": "2.x.x" } }, + "csvtojson": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/csvtojson/-/csvtojson-2.0.8.tgz", + "integrity": "sha512-DC6YFtsJiA7t/Yz+KjzT6GXuKtU/5gRbbl7HJqvDVVir+dxdw2/1EgwfgJdnsvUT7lOnON5DvGftKuYWX1nMOQ==", + "requires": { + "bluebird": "^3.5.1", + "lodash": "^4.17.3", + "strip-bom": "^2.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "requires": { + "is-utf8": "^0.2.0" + } + } + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -3341,6 +3371,16 @@ "randomatic": "^3.0.0", "repeat-element": "^1.1.2", "repeat-string": "^1.5.2" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "requires": { + "isarray": "1.0.0" + } + } } }, "finalhandler": { @@ -4657,6 +4697,11 @@ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" + }, "is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -4673,12 +4718,9 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "isobject": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", - "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", - "requires": { - "isarray": "1.0.0" - } + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, "isomorphic-fetch": { "version": "2.2.1", @@ -6351,6 +6393,11 @@ "resolved": "https://registry.npmjs.org/react-native-dismiss-keyboard/-/react-native-dismiss-keyboard-1.0.0.tgz", "integrity": "sha1-MohiQrPyMX4SHzrrmwpYXiuHm0k=" }, + "react-native-document-picker": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-2.1.0.tgz", + "integrity": "sha512-BFCBXwz8xuLvHLVFVeQM+RhaY8yZ38PEWt9WSbq5VIoZ/VssP6uu51XxOfdwaMALOrAHIojK0SiYnd155upZAg==" + }, "react-native-drawer-layout": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-1.3.2.tgz", @@ -6367,6 +6414,15 @@ "react-native-drawer-layout": "1.3.2" } }, + "react-native-fs": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.10.14.tgz", + "integrity": "sha512-4bCzkg4dE/xUyXkMVz0AiyqLKAgTZPlZl/nEzRiSr2q6VnWDgO229MSgHLHhUtD2cqZkV0Z83WEbGpvXxWOAHA==", + "requires": { + "base-64": "^0.1.0", + "utf8": "^2.1.1" + } + }, "react-native-modal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-native-modal/-/react-native-modal-3.1.0.tgz", @@ -8323,6 +8379,11 @@ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" }, + "utf8": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz", + "integrity": "sha1-H6DZJw6b6FDZsFAn9jUZv0ZFfZY=" + }, "util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", diff --git a/package.json b/package.json index 58f26381f17f6a93962d6b0ef88a1ae8fd9050e0..4a07999ff691225c6f4e3a13a3519d973a56e896 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ }, "dependencies": { "assert": "^1.4.1", + "csvtojson": "^2.0.8", "date-range": "0.0.2", + "isobject": "^3.0.1", "js-base64": "^2.4.8", "js-joda": "^1.8.2", "moment": "^2.22.1", @@ -24,6 +26,8 @@ "react": "16.4.1", "react-native": "^0.56.0", "react-native-calendars": "^1.19.3", + "react-native-document-picker": "^2.1.0", + "react-native-fs": "^2.10.14", "react-native-modal-datetime-picker-nevo": "^4.11.0", "react-native-share": "^1.1.0", "react-native-simple-radio-button": "^2.7.1",