import { Realm } from "@realm/react"; import { Alert } from "react-native"; import { getNowISO } from "sharedHelpers/dateAndTime"; import { log } from "sharedHelpers/logger"; import readExifFromMultiplePhotos from "sharedHelpers/parseExif"; import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import * as uuid from "uuid"; import Application from "./Application"; import Comment from "./Comment"; import Identification from "./Identification"; import ObservationPhoto from "./ObservationPhoto"; import ObservationSound from "./ObservationSound"; import Taxon from "./Taxon"; import User from "./User"; import Vote from "./Vote"; export const GEOPRIVACY_OPEN = "open"; export const GEOPRIVACY_OBSCURED = "obscured"; export const GEOPRIVACY_PRIVATE = "private"; const logger = log.extend( "index.js" ); // noting that methods like .toJSON( ) are only accessible when the model // class is extended with Realm.Object per this issue: // https://github.com/realm/realm-js/issues/3600#issuecomment-785828614 class Observation extends Realm.Object { static PROJECT_FIELDS = { id: true, icon: true, title: true, project_type: true, }; static FIELDS = { application: Application.APPLICATION_FIELDS, captive: true, comments: Comment.COMMENT_FIELDS, created_at: true, description: true, geojson: true, geoprivacy: true, id: true, identifications: Identification.ID_FIELDS, latitude: true, license_code: true, location: true, longitude: true, obscured: true, observation_photos: ObservationPhoto.OBSERVATION_PHOTOS_FIELDS, observed_on: true, place_guess: true, quality_grade: true, observation_sounds: ObservationSound.OBSERVATION_SOUNDS_FIELDS, observed_time_zone: true, taxon: Taxon.TAXON_FIELDS, taxon_geoprivacy: true, time_observed_at: true, user: User && { ...User.FIELDS, preferences: { prefers_community_taxa: true, }, }, updated_at: true, viewer_trusted_by_observer: true, votes: Vote.VOTE_FIELDS, private_geojson: true, private_location: true, private_place_guess: true, project_ids: true, project_observations: { project: Observation.PROJECT_FIELDS, }, non_traditional_projects: { project: Observation.PROJECT_FIELDS, }, positional_accuracy: true, preferences: { prefers_community_taxon: true, }, }; static DEFAULT_MODE_LIST_FIELDS = { created_at: true, id: true, // needed to get next page in infinite queries observation_photos: { id: true, photo: { id: true, url: true, }, uuid: true, }, observation_sounds: { uuid: true, }, quality_grade: true, taxon: { id: true, name: true, preferred_common_name: true, // rank and rank_level are needed to italicize scientific names rank: true, rank_level: true, }, time_observed_at: true, uuid: true, }; static ADVANCED_MODE_LIST_FIELDS = { ...Observation.DEFAULT_MODE_LIST_FIELDS, identifications: { uuid: true, current: true, }, comments: { uuid: true, }, geoprivacy: true, id: true, latitude: true, longitude: true, obscured: true, observed_on: true, observed_time_zone: true, place_guess: true, private_place_guess: true, taxon_geoprivacy: true, }; static async new( obs ) { return { ...obs, captive_flag: false, geoprivacy: GEOPRIVACY_OPEN, owners_identification_from_vision: false, observed_on: obs?.observed_on, observed_on_string: obs ? obs?.observed_on_string : getNowISO( ), quality_grade: "needs_id", needs_sync: true, uuid: uuid.v4( ), }; } static async createObsWithSoundPath( soundPath ) { const observation = await Observation.new( ); const sound = await ObservationSound.new( soundPath ); observation.observationSounds = [sound]; return observation; } static upsertRemoteObservations( remoteObservations, realm, options = {} ) { if ( !remoteObservations ) return; if ( remoteObservations.length === 0 ) return; const obsToUpsert = options.force ? remoteObservations : remoteObservations.filter( obs => !Observation.isUnsyncedObservation( realm, obs ) ); // const msg = obsToUpsert.map( remoteObservation => { // const obsPhotoUUIDs = remoteObservation.observation_photos?.map( op => op.uuid ); // return `obs ${remoteObservation.uuid}, ops: ${obsPhotoUUIDs}`; // } ); // Trying to debug disappearing photos safeRealmWrite( realm, ( ) => { obsToUpsert.forEach( remoteObservation => { const obsMappedForRealm = Observation.mapApiToRealm( remoteObservation, realm ); realm.create( "Observation", obsMappedForRealm, "modified", ); } ); }, "upserting remote observations in Observation" ); } static mapApiToRealm( obs, realm = null ) { if ( !obs ) return obs; const existingObs = realm?.objectForPrimaryKey( "Observation", obs.uuid ); const taxon = obs.taxon ? Taxon.mapApiToRealm( obs.taxon, realm ) : null; const observationPhotos = ( obs.observation_photos || obs.observationPhotos || [] ).map( obsPhoto => { const mappedObsPhoto = ObservationPhoto.mapApiToRealm( obsPhoto, realm ); const existingObsPhoto = existingObs?.observationPhotos?.find( op => op.uuid === obsPhoto.uuid, ); if ( !existingObsPhoto ) { mappedObsPhoto._created_at = new Date( ); mappedObsPhoto.photo._created_at = new Date( ); } return mappedObsPhoto; } ); const observationSounds = ( obs.observation_sounds || obs.observationSounds || [] ).map( obsSound => { const mappedObsSound = ObservationSound.mapApiToRealm( obsSound, realm ); const existingObsSound = existingObs?.observationSounds?.find( os => os.uuid === obsSound.uuid, ); if ( !existingObsSound ) { mappedObsSound._created_at = new Date( ); mappedObsSound.sound._created_at = new Date( ); } return mappedObsSound; } ); const localObs = { ...obs, _synced_at: new Date( ), // obs detail on web says geojson coords are preferred over lat/long // https://github.com/inaturalist/inaturalist/blob/df6572008f60845b8ef5972a92a9afbde6f67829/app/webpack/observations/show/ducks/observation.js#L145 latitude: obs.geojson && obs.geojson.coordinates && obs.geojson.coordinates[1], longitude: obs.geojson && obs.geojson.coordinates && obs.geojson.coordinates[0], privateLatitude: obs.private_geojson && obs.private_geojson.coordinates && obs.private_geojson.coordinates[1], privateLongitude: obs.private_geojson && obs.private_geojson.coordinates && obs.private_geojson.coordinates[0], observationPhotos, observationSounds, prefers_community_taxon: obs.preferences?.prefers_community_taxon, taxon, }; if ( localObs.user ) { localObs.user.prefers_community_taxa = ( localObs.user.prefers_community_taxa || localObs.user.preferences?.prefers_community_taxa ); } if ( !existingObs ) { localObs._created_at = new Date( localObs.created_at ); if ( isNaN( localObs._created_at ) ) { localObs._created_at = new Date( ); } } return localObs; } static async saveLocalObservationForUpload( obs, realm ) { // make sure local observations have user details for ObsDetail const currentUser = User.currentUser( realm ); if ( currentUser ) { obs.user = currentUser; } const timestamps = { _updated_at: new Date( ), }; const existingObservation = realm.objectForPrimaryKey( "Observation", obs.uuid ); if ( !existingObservation ) { timestamps._created_at = new Date( ); timestamps._synced_at = null; } const addTimestampsToEvidence = evidence => ( evidence ? evidence.map( record => ( { ...record, ...timestamps, } ) ) : evidence ); const taxon = obs.taxon || null; const observationPhotos = addTimestampsToEvidence( obs.observationPhotos ); const observationSounds = addTimestampsToEvidence( obs.observationSounds ); const obsToSave = { // just ...obs causes problems when obs is a realm object // ...obs.toJSON( ), ...obs, ...timestamps, needs_sync: true, taxon, observationPhotos, observationSounds, }; safeRealmWrite( realm, ( ) => { // using 'modified' here for the case where a new observation has the same Taxon // as a previous observation; otherwise, realm will error out // also using modified for updating observations which were already saved locally realm.create( "Observation", obsToSave, "modified" ); }, "saving local observation for upload in Observation" ); return realm.objectForPrimaryKey( "Observation", obs.uuid ); } static mapObservationForUpload( obs ) { return { captive_flag: obs.captive_flag, description: obs.description, geoprivacy: obs.geoprivacy, latitude: obs.latitude, longitude: obs.longitude, observed_on_string: obs.observed_on_string, owners_identification_from_vision: obs.owners_identification_from_vision, place_guess: obs.place_guess, positional_accuracy: obs.positional_accuracy, species_guess: obs.species_guess, taxon_id: obs.taxon && obs.taxon.id, uuid: obs.uuid, }; } static mapObservationForMyObsDefaultMode( obs ) { return { uuid: obs.uuid, id: obs.id, observationPhotos: obs.observationPhotos.length > 0 ? obs.observationPhotos .map( op => ObservationPhoto.mapObservationPhotoForMyObsDefaultMode( op ) ) : [], observationSounds: obs.observationSounds.length > 0 ? obs.observationSounds .map( os => ObservationSound.mapObservationSoundForMyObsDefaultMode( os ) ) : [], quality_grade: obs.quality_grade, taxon: obs.taxon ? { id: obs?.taxon?.id, name: obs?.taxon?.name, preferred_common_name: obs?.taxon?.preferred_common_name, rank: obs?.taxon?.rank, rank_level: obs?.taxon?.rank_level, iconic_taxon_name: obs?.taxon?.iconic_taxon_name, } : null, comments_viewed: obs.comments_viewed, identifications_viewed: obs.identifications_viewed, missing_basics: typeof obs.missingBasics === "function" ? obs.missingBasics() : undefined, needs_sync: typeof obs.needsSync === "function" ? obs.needsSync() : obs.needs_sync, }; } static mapObservationForMyObsAdvancedMode( obs ) { return { ...Observation.mapObservationForMyObsDefaultMode( obs ), comments: obs.comments.length > 0 ? obs.comments .map( c => Comment.mapCommentForMyObsAdvancedMode( c ) ) : [], geoprivacy: obs.geoprivacy, identifications: obs.identifications.length > 0 ? obs.identifications .map( id => Identification.mapIdentificationForMyObsAdvancedMode( id ) ) : [], latitude: obs.latitude, longitude: obs.longitude, obscured: obs.obscured, observed_on: obs.observed_on, observed_on_string: obs.observed_on_string, observed_time_zone: obs.observed_time_zone, place_guess: obs.place_guess, positional_accuracy: obs.positional_accuracy, privateLatitude: obs.privateLatitude, privateLongitude: obs.privateLongitude, taxon_geoprivacy: obs.taxon_geoprivacy, time_observed_at: obs.time_observed_at, }; } static projectUri = obs => { const photo = obs?.observation_photos?.[0]; if ( !photo ) { return null; } if ( !photo.photo ) { return null; } if ( !photo.photo.url ) { return null; } return { uri: obs.observation_photos[0].photo.url }; }; static filterUnsyncedObservations = realm => { const unsyncedFilter = "_synced_at == null || _synced_at <= _updated_at"; const photosUnsyncedFilter = "ANY observationPhotos._synced_at == null"; const soundsUnsyncedFilter = "ANY observationSounds._synced_at == null"; const obs = realm.objects( "Observation" ); // we sort unsynced observations here to make sure observations // with an older _created_at date get uploaded first const unsyncedObs = obs.filtered( `${unsyncedFilter} || ${photosUnsyncedFilter} || ${soundsUnsyncedFilter}`, ).sorted( "_created_at", true ); return unsyncedObs; }; static isUnsyncedObservation = ( realm, obs ) => { const obsList = Observation.filterUnsyncedObservations( realm ); const unsyncedObs = obsList.filtered( `uuid == "${obs.uuid}"` ); return unsyncedObs.length > 0; }; static createObservationFromGalleryPhotos = async photos => { const photoUris = photos.map( photo => photo?.image?.uri ); try { const newObservation = await readExifFromMultiplePhotos( photoUris ); return Observation.new( newObservation ); } catch ( createObservationFromGalleryError ) { logger.error( "Error reading EXIF from multiple gallery photos", createObservationFromGalleryError, ); Alert.alert( "Creating Observation from Gallery Error", createObservationFromGalleryError.message, ); return null; } }; static createObservationWithPhotos = async photos => { const newLocalObs = await Observation.createObservationFromGalleryPhotos( photos ); newLocalObs.observationPhotos = await ObservationPhoto .createObsPhotosWithPosition( photos, { position: 0 } ); return newLocalObs; }; static updateObsExifFromPhotos = async ( photoUris, currentObservation ) => { const updatedObs = currentObservation; const unifiedExif = await readExifFromMultiplePhotos( photoUris ); if ( unifiedExif.latitude && !currentObservation.latitude ) { updatedObs.latitude = unifiedExif.latitude; } if ( unifiedExif.longitude && !currentObservation.longitude ) { updatedObs.longitude = unifiedExif.longitude; } if ( unifiedExif.observed_on_string && !currentObservation.observed_on_string ) { updatedObs.observed_on_string = unifiedExif.observed_on_string; } if ( unifiedExif.positional_accuracy && !currentObservation.positional_accuracy ) { updatedObs.positional_accuracy = unifiedExif.positional_accuracy; } return updatedObs; }; static appendObsPhotos = ( obsPhotos, currentObservation ) => { const updatedObs = currentObservation; // need empty case for when a user creates an observation with no photos, // then tries to add photos to observation later const currentObservationPhotos = updatedObs?.observationPhotos || []; updatedObs.observationPhotos = [...currentObservationPhotos, ...obsPhotos]; return updatedObs; }; static appendObsSounds = ( obsSounds, currentObservation ) => { const updatedObs = currentObservation; // need empty case for when a user creates an observation with no sounds, // then tries to add sounds to observation later const currentObservationSounds = updatedObs?.observationSounds || []; updatedObs.observationSounds = [...currentObservationSounds, ...obsSounds]; return updatedObs; }; static deleteLocalObservation = ( realm, uuidToDelete ) => { const observation = realm?.objectForPrimaryKey( "Observation", uuidToDelete ); if ( observation ) { safeRealmWrite( realm, ( ) => { realm?.delete( observation ); }, `deleting local observation ${uuidToDelete} in deleteLocalObservation` ); } }; static markPendingDeletion( realm, uuidToDelete ) { const observation = realm.objectForPrimaryKey( "Observation", uuidToDelete ); if ( observation ) { safeRealmWrite( realm, ( ) => { observation._pending_deletion = true; } ); } } static clearPendingDeletion( realm, uuidToDelete ) { const observation = realm.objectForPrimaryKey( "Observation", uuidToDelete ); if ( observation ) { safeRealmWrite( realm, ( ) => { observation._pending_deletion = false; } ); } } static schema = { name: "Observation", primaryKey: "uuid", properties: { _pending_deletion: "bool?", // datetime the observation was created on the device _created_at: "date?", // datetime the observation was requested to be deleted _deleted_at: "date?", // datetime the observation was last synced with the server _synced_at: "date?", // datetime the observation was updated on the device (i.e. edited locally) _updated_at: "date?", uuid: "string", application: "Application?", captive_flag: "bool?", comments: "Comment[]", // timestamp of when observation was created on the server; not editable created_at: { type: "string", mapTo: "createdAt", optional: true }, description: "string?", geoprivacy: "string?", id: "int?", identifications: "Identification[]", latitude: "double?", license_code: { type: "string", mapTo: "licenseCode", optional: true }, longitude: "double?", observationPhotos: "ObservationPhoto[]", observationSounds: "ObservationSound[]", // date and/or time submitted to the server when a new obs is uploaded observed_on_string: "string?", observed_on: "string?", observed_time_zone: "string?", obscured: "bool?", owners_identification_from_vision: "bool?", species_guess: "string?", place_guess: { type: "string", mapTo: "placeGuess", optional: true }, positional_accuracy: "double?", prefers_community_taxon: "bool?", quality_grade: { type: "string", mapTo: "qualityGrade", optional: true }, taxon: "Taxon?", taxon_geoprivacy: "string?", // datetime when the observer observed the organism; user-editable, but // only by changing observed_on_string time_observed_at: { type: "string", mapTo: "timeObservedAt", optional: true }, user: "User?", updated_at: "date?", comments_viewed: "bool?", identifications_viewed: { type: "bool", mapTo: "identificationsViewed", optional: true }, viewer_trusted_by_observer: { type: "bool", mapTo: "viewerTrustedByObserver", optional: true, }, votes: "Vote[]", private_place_guess: { type: "string", mapTo: "privatePlaceGuess", optional: true }, private_location: { type: "string", mapTo: "privateLocation", optional: true }, privateLatitude: "double?", privateLongitude: "double?", needs_sync: { type: "bool", default: false, indexed: true }, }, }; needsSync( ) { const obsPhotosNeedSync = this.observationPhotos .filter( obsPhoto => obsPhoto.needsSync( ) ).length > 0; const obsSoundsNeedSync = this.observationSounds .filter( obsSound => obsSound.needsSync( ) ).length > 0; return !this._synced_at || this._synced_at <= this._updated_at || obsPhotosNeedSync || obsSoundsNeedSync; } updateNeedsSync() { this.needsSync = this.needsSync(); } wasSynced( ) { return this._synced_at !== null; } viewed() { return !!( this.comments_viewed && this.identifications_viewed ); } unviewed() { return !this.viewed(); } // Faves are the subset of votes for which vote_scope is null faves() { return this.votes.filter( vote => vote?.vote_scope === null ); } missingBasics() { const missingDate = !Date.parse( this.observed_on_string ) && !this.time_observed_at; const missingCoords = typeof ( this.latitude ) !== "number" && typeof ( this.privateLatitude ) !== "number"; return missingDate || missingCoords; } } export default Observation;