Files
iNaturalistReactNative/src/realmModels/Observation.js
Amanda Bullington 8e6269c8d0 Wrap realm.write in safeRealmWrite transaction (#1123)
* Add a safeRealmWrite transaction for better logging around writes; code cleanup and realm update

* Add safeRealmWrite to tests and make sure action is called synchronously

* Fix final test

* Only write to realm when useObservationsUpdates data changes; code cleanup

* Code cleanup
2024-02-09 16:16:32 -08:00

398 lines
13 KiB
JavaScript

import { Realm } from "@realm/react";
import uuid from "react-native-uuid";
import { createObservedOnStringForUpload } from "sharedHelpers/dateAndTime";
import { formatExifDateAsString, parseExif } from "sharedHelpers/parseExif";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
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";
// 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 FIELDS = {
application: Application.APPLICATION_FIELDS,
captive: true,
comments: Comment.COMMENT_FIELDS,
created_at: true,
description: true,
faves: Vote.VOTE_FIELDS,
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,
sounds: ObservationSound.OBSERVATION_SOUNDS_FIELDS,
taxon: Taxon.TAXON_FIELDS,
time_observed_at: true,
user: User && {
...User.FIELDS,
preferences: {
prefers_community_taxa: true
}
},
updated_at: true,
viewer_trusted_by_observer: true,
private_geojson: true,
private_location: true,
private_place_guess: true,
positional_accuracy: true,
preferences: {
prefers_community_taxon: true
}
};
static LIST_FIELDS = {
comments: Comment.COMMENT_FIELDS,
created_at: true,
geojson: true,
geoprivacy: true,
id: true,
identifications: Identification.ID_FIELDS,
latitude: true,
longitude: true,
observation_photos: ObservationPhoto.OBSERVATION_PHOTOS_FIELDS,
place_guess: true,
private_geojson: true,
private_place_guess: true,
quality_grade: true,
sounds: { file_url: true },
taxon: Taxon.TAXON_FIELDS,
time_observed_at: true,
user: User && User.FIELDS
};
static async new( obs ) {
return {
...obs,
captive_flag: false,
geoprivacy: "open",
owners_identification_from_vision: false,
observed_on: obs?.observed_on,
observed_on_string: obs
? obs?.observed_on_string
: createObservedOnStringForUpload( ),
quality_grade: "needs_id",
uuid: uuid.v4( )
};
}
static async createObsWithSounds( ) {
const observation = await Observation.new( );
const sound = await ObservationSound.new( );
observation.observationSounds = [sound];
return observation;
}
static upsertRemoteObservations( observations, realm ) {
if ( observations && observations.length > 0 ) {
const obsToUpsert = observations.filter(
obs => !Observation.isUnsyncedObservation( realm, obs )
);
safeRealmWrite( realm, ( ) => {
obsToUpsert.forEach( obs => {
realm.create(
"Observation",
Observation.mapApiToRealm( obs, realm ),
"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 identifications = obs.identifications
? obs.identifications.map( id => Identification.mapApiToRealm( id, realm ) )
: [];
const localObs = {
...obs,
_synced_at: new Date( ),
identifications,
// 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: obs.sounds?.map( sound => ( {
...sound,
// TODO fix this... and rework the model. We need to get the sound
// UUID from the server, but we also need the server to reply with
// observation_sounds, otherwise we don't have the ability to delete
// sounds
uuid: uuid.v4( )
} ) ),
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,
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 {
species_guess: obs.species_guess,
description: obs.description,
observed_on_string: obs.observed_on_string,
place_guess: obs.place_guess,
latitude: obs.latitude,
longitude: obs.longitude,
positional_accuracy: obs.positional_accuracy,
taxon_id: obs.taxon && obs.taxon.id,
geoprivacy: obs.geoprivacy,
uuid: obs.uuid,
captive_flag: obs.captive_flag,
owners_identification_from_vision: obs.owners_identification_from_vision
};
}
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 mediumUri = obs => {
const photo = obs.observation_photos[0];
if ( !photo ) { return null; }
if ( !photo.photo ) { return null; }
if ( !photo.photo.url ) { return null; }
const mediumUri = obs.observation_photos[0].photo.url.replace( "square", "medium" );
return { uri: mediumUri };
};
static filterUnsyncedObservations = realm => {
const unsyncedFilter = "_synced_at == null || _synced_at <= _updated_at";
const photosUnsyncedFilter = "ANY observationPhotos._synced_at == null";
const obs = realm.objects( "Observation" );
const unsyncedObs = obs.filtered( `${unsyncedFilter} || ${photosUnsyncedFilter}` );
return unsyncedObs;
};
static isUnsyncedObservation = ( realm, obs ) => {
const obsList = Observation.filterUnsyncedObservations( realm );
const unsyncedObs = obsList.filtered( `uuid == "${obs.uuid}"` );
return unsyncedObs.length > 0;
};
static createObservationFromGalleryPhoto = async photo => {
const firstPhotoExif = await parseExif( photo?.image?.uri );
const { latitude, longitude } = firstPhotoExif;
const newObservation = {
latitude,
longitude,
observed_on_string: formatExifDateAsString( firstPhotoExif.date ) || null
};
if ( firstPhotoExif.positional_accuracy ) {
// $FlowIgnore
newObservation.positional_accuracy = firstPhotoExif.positional_accuracy;
}
return Observation.new( newObservation );
};
static createObservationWithPhotos = async photos => {
const newLocalObs = await Observation.createObservationFromGalleryPhoto( photos[0] );
newLocalObs.observationPhotos = await ObservationPhoto
.createObsPhotosWithPosition( photos, { position: 0 } );
return newLocalObs;
};
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 schema = {
name: "Observation",
primaryKey: "uuid",
properties: {
// 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?",
faves: "Vote[]",
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?",
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?",
// 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
},
private_place_guess: { type: "string", mapTo: "privatePlaceGuess", optional: true },
private_location: { type: "string", mapTo: "privateLocation", optional: true },
privateLatitude: "double?",
privateLongitude: "double?"
}
};
needsSync( ) {
const obsPhotosNeedSync = this.observationPhotos
.filter( obsPhoto => obsPhoto.needsSync( ) ).length > 0;
return !this._synced_at || this._synced_at <= this._updated_at || obsPhotosNeedSync;
}
wasSynced( ) {
return this._synced_at !== null;
}
viewed() {
return this.comments_viewed && this.identifications_viewed;
}
unviewed() {
return !this.viewed();
}
}
export default Observation;