Files
iNaturalistReactNative/src/realmModels/Observation.js
Ken-ichi a3a43c4e2c g11n audit (#2189)
* chore: validate and normalize translations in addition to src strings
* fix: globalize lat/lng/acc in LocationPicker
* refactor: extract language picker into component
* refactor: globalized text
* feat: added a script to i18ncli called "checkify" that generates
  localization files prepended with a checkmark to help see strings that are
  not globalized
* fix: localize date formatting (closes #1622)

Closes #2102
2024-09-26 22:33:23 -07:00

528 lines
18 KiB
JavaScript

import { Realm } from "@realm/react";
import uuid from "react-native-uuid";
import { getNowISO } from "sharedHelpers/dateAndTime.ts";
import { readExifFromMultiplePhotos } 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 Sound from "./Sound";
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";
// 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,
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,
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,
positional_accuracy: true,
preferences: {
prefers_community_taxon: true
}
};
static EXPLORE_LIST_FIELDS = {
created_at: true,
comments: {
current: true
},
geojson: true,
geoprivacy: true,
id: true,
identifications: {
current: true
},
latitude: true,
longitude: true,
observation_photos: ObservationPhoto.OBSERVATION_PHOTOS_FIELDS,
place_guess: true,
quality_grade: true,
obscured: true,
observation_sounds: {
id: true
},
taxon: {
iconic_taxon_name: true,
is_active: true,
name: true,
preferred_common_name: true,
rank: true,
rank_level: true
},
taxon_geoprivacy: true,
time_observed_at: true
};
static LIST_FIELDS = {
...Observation.EXPLORE_LIST_FIELDS,
comments: Comment.COMMENT_FIELDS,
created_at: true,
identifications: Identification.ID_FIELDS,
observation_sounds: ObservationSound.OBSERVATION_SOUNDS_FIELDS,
private_geojson: true,
private_place_guess: true,
taxon: Taxon.TAXON_FIELDS,
user: User && User.FIELDS
};
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( {
sound: await Sound.new( { file_url: 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 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,
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 mapObservationForFlashList( obs ) {
// return {
// _created_at: obs._created_at,
// _deleted_at: obs._deleted_at,
// _synced_at: obs._synced_at,
// _updated_at: obs._updated_at,
// uuid: obs.uuid,
// comments: obs.comments,
// description: obs.description,
// geoprivacy: obs.geoprivacy,
// id: obs.id,
// identifications: obs.identifications,
// latitude: obs.latitude,
// longitude: obs.longitude,
// observationPhotos: obs.observationPhotos,
// observationSounds: obs.observationSounds,
// observed_on_string: obs.observed_on_string,
// obscured: obs.obscured,
// place_guess: obs.place_guess,
// positional_accuracy: obs.positional_accuracy,
// quality_grade: obs.quality_grade,
// taxon: obs.taxon,
// time_observed_at: obs.time_observed_at,
// comments_viewed: obs.comments_viewed,
// identifications_viewed: obs.identifications_viewed,
// privateLatitude: obs.privateLatitude,
// privateLongitude: obs.privateLongitude
// };
// }
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 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 );
const newObservation = await readExifFromMultiplePhotos( photoUris );
return Observation.new( newObservation );
};
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 = async ( realm, uuidToDelete ) => {
const observation = realm?.objectForPrimaryKey( "Observation", uuidToDelete );
if ( observation ) {
await safeRealmWrite( realm, ( ) => {
realm?.delete( observation );
}, `deleting local observation ${uuidToDelete} in deleteLocalObservation` );
return true;
}
return false;
};
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?",
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?",
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 );
}
}
export default Observation;