mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2025-12-23 22:18:36 -05:00
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
This commit is contained in:
@@ -129,7 +129,7 @@ const About = (): Node => {
|
||||
className="items-center justify-center"
|
||||
onPress={() => onVersionPressed()}
|
||||
>
|
||||
<Body1>{`Version ${appVersion} (${buildVersion})`}</Body1>
|
||||
<Body1>{ t( "Version-app-build", { appVersion, buildVersion } )}</Body1>
|
||||
</Pressable>
|
||||
{isDebug && (
|
||||
<Button
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
useShare
|
||||
} from "sharedHooks";
|
||||
|
||||
// import useChangeLocale from "./hooks/useChangeLocale";
|
||||
import useChangeLocale from "./hooks/useChangeLocale";
|
||||
import useFreshInstall from "./hooks/useFreshInstall";
|
||||
import useLinking from "./hooks/useLinking";
|
||||
import useLockOrientation from "./hooks/useLockOrientation";
|
||||
@@ -71,7 +71,7 @@ const App = ( { children }: Props ): Node => {
|
||||
useReactQueryRefetch( );
|
||||
useFreshInstall( currentUser );
|
||||
useLinking( currentUser );
|
||||
// useChangeLocale( currentUser );
|
||||
useChangeLocale( currentUser );
|
||||
|
||||
useLockOrientation( );
|
||||
useShare( );
|
||||
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
showZoomButton: boolean;
|
||||
}
|
||||
|
||||
const CameraZoom = ( {
|
||||
const Zoom = ( {
|
||||
rotatableAnimatedStyle,
|
||||
handleZoomButtonPress,
|
||||
zoomClassName,
|
||||
@@ -33,8 +33,6 @@ const CameraZoom = ( {
|
||||
return null;
|
||||
}
|
||||
|
||||
const zoomButtonText = `${zoomTextValue}×`;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={!isTablet && rotatableAnimatedStyle}
|
||||
@@ -48,11 +46,11 @@ const CameraZoom = ( {
|
||||
accessibilityState={{ disabled: false }}
|
||||
>
|
||||
<Body3 className="text-s text-white">
|
||||
{zoomButtonText}
|
||||
{t( "zoom-x", { zoom: Number( zoomTextValue ) } )}
|
||||
</Body3>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraZoom;
|
||||
export default Zoom;
|
||||
|
||||
@@ -243,7 +243,7 @@ const PhotoCarousel = ( {
|
||||
ref={containerRef}
|
||||
onLayout={
|
||||
// When the container gets rendered, we store its position on screen
|
||||
// in state so we can layout content inside the modal in exactly the
|
||||
// in state so we can lay out content inside the modal in exactly the
|
||||
// same position
|
||||
( ) => containerRef?.current?.measure(
|
||||
( _x, _y, w, h, pageX, pageY ) => setContainerPos( {
|
||||
|
||||
@@ -16,6 +16,8 @@ import { log } from "sharedHelpers/logger";
|
||||
import { useWatchPosition } from "sharedHooks";
|
||||
import useStore from "stores/useStore";
|
||||
|
||||
import { displayName as appName } from "../../../../app.json";
|
||||
|
||||
const logger = log.extend( "usePrepareStoreAndNavigate" );
|
||||
|
||||
type Options = {
|
||||
@@ -52,7 +54,9 @@ export async function savePhotosToCameraGallery(
|
||||
// and skipping the album if we don't
|
||||
if ( readWritePermissionResult === RESULTS.GRANTED ) {
|
||||
saveOptions.type = "photo";
|
||||
saveOptions.album = "iNaturalist Next";
|
||||
// Note: we do not translate our brand name, so this should not be
|
||||
// globalized
|
||||
saveOptions.album = appName;
|
||||
}
|
||||
if ( location ) {
|
||||
saveOptions.latitude = location.latitude;
|
||||
|
||||
@@ -83,7 +83,10 @@ const useTakePhoto = (
|
||||
// Set the camera to inactive immediately after taking the photo,
|
||||
// this does leave a short period of time where the camera preview is still active
|
||||
// after taking the photo which we might to revisit if it doesn't look good.
|
||||
const cameraPhoto = await camera.current.takePhoto( takePhotoOptions );
|
||||
const cameraPhoto = await camera?.current?.takePhoto( takePhotoOptions );
|
||||
if ( !cameraPhoto ) {
|
||||
throw new Error( "Failed to take photo: missing camera" );
|
||||
}
|
||||
if ( options.inactivateCallback ) options.inactivateCallback();
|
||||
const uri = await saveRotatedPhotoToDocumentsDirectory( cameraPhoto );
|
||||
await updateStore( uri, options );
|
||||
|
||||
16
src/components/Developer/Debug.tsx
Normal file
16
src/components/Developer/Debug.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
// Wrapper around things that should only be visible in debug mode
|
||||
import { View } from "components/styledComponents";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { useDebugMode } from "sharedHooks";
|
||||
|
||||
const Debug = ( { children }: PropsWithChildren ) => {
|
||||
const { isDebug } = useDebugMode( );
|
||||
if ( !isDebug ) return null;
|
||||
return (
|
||||
<View className="bg-deeppink">
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Debug;
|
||||
@@ -1136,7 +1136,7 @@ const FilterModal = ( {
|
||||
value={establishmentValues[establishmentKey]}
|
||||
checked={
|
||||
establishmentValues[establishmentKey].value
|
||||
=== establishmentMean
|
||||
=== establishmentMean
|
||||
}
|
||||
onPress={() => dispatch( {
|
||||
type: EXPLORE_ACTION.SET_ESTABLISHMENT_MEAN,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import classNames from "classnames";
|
||||
import { Body3 } from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
import { getShadow } from "styles/global";
|
||||
|
||||
const DROP_SHADOW = getShadow( {
|
||||
@@ -16,7 +16,8 @@ interface Props {
|
||||
light?: boolean;
|
||||
}
|
||||
|
||||
const NumberBadge = ( { number, light }: Props ): Node => {
|
||||
const NumberBadge = ( { number, light }: Props ) => {
|
||||
const { t } = useTranslation();
|
||||
const backgroundColor = light
|
||||
? "bg-white"
|
||||
: "bg-inatGreen";
|
||||
@@ -31,7 +32,7 @@ const NumberBadge = ( { number, light }: Props ): Node => {
|
||||
)}
|
||||
style={DROP_SHADOW}
|
||||
>
|
||||
<Body3 className={textColor}>{number}</Body3>
|
||||
<Body3 className={textColor}>{t( "Intl-number", { val: number } )}</Body3>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
import { getShadow } from "styles/global";
|
||||
|
||||
const DROP_SHADOW = getShadow( );
|
||||
@@ -16,21 +17,22 @@ type Props = {
|
||||
};
|
||||
|
||||
const DisplayLatLng = ( { region, accuracy }: Props ): Node => {
|
||||
const formatDecimal = coordinate => coordinate && coordinate.toFixed( 6 );
|
||||
|
||||
const displayLocation = ( ) => {
|
||||
let location = "";
|
||||
if ( region.latitude ) {
|
||||
location += `Lat: ${formatDecimal( region.latitude )}`;
|
||||
}
|
||||
if ( region.longitude ) {
|
||||
location += `, Lon: ${formatDecimal( region.longitude )}`;
|
||||
}
|
||||
const { t } = useTranslation( );
|
||||
let displayLocation = "";
|
||||
if ( region.latitude && region.longitude ) {
|
||||
if ( accuracy ) {
|
||||
location += `, Acc: ${accuracy.toFixed( 0 )}`;
|
||||
displayLocation = t( "Lat-Lon-Acc", {
|
||||
latitude: region.latitude,
|
||||
longitude: region.longitude,
|
||||
accuracy
|
||||
} );
|
||||
} else {
|
||||
displayLocation = t( "Lat-Lon", {
|
||||
latitude: region.latitude,
|
||||
longitude: region.longitude
|
||||
} );
|
||||
}
|
||||
return location;
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -38,7 +40,7 @@ const DisplayLatLng = ( { region, accuracy }: Props ): Node => {
|
||||
style={DROP_SHADOW}
|
||||
>
|
||||
<Body4 className="pt-[7px] pl-[14px]">
|
||||
{displayLocation( )}
|
||||
{displayLocation}
|
||||
</Body4>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -66,7 +66,6 @@ async function deleteSensitiveItem( key: string, options = {} ) {
|
||||
return await RNSInfo.deleteItem( key, options );
|
||||
} catch ( e ) {
|
||||
const deleteItemError = e as Error;
|
||||
console.log( "[DEBUG AuthenticationService.js] deleteItemError: ", deleteItemError );
|
||||
if ( deleteItemError.message.match( /Protected data not available yet/ ) ) {
|
||||
await sleep( 500 );
|
||||
return RNSInfo.deleteItem( key, options );
|
||||
|
||||
@@ -60,6 +60,8 @@ const MainMediaDisplay = ( {
|
||||
] ), [photos, sounds] );
|
||||
const atLastItem = selectedMediaIndex === items.length - 1;
|
||||
|
||||
// t changes a lot, but these strings don't, so using them as useCallback
|
||||
// dependencies keeps that method from getting redefined a lot
|
||||
const deletePhotoLabel = t( "Delete-photo" );
|
||||
const deleteSoundLabel = t( "Delete-sound" );
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RealmContext } from "providers/contexts.ts";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { useTheme } from "react-native-paper";
|
||||
import { formatIdDate } from "sharedHelpers/dateAndTime";
|
||||
import { formatDifferenceForHumans } from "sharedHelpers/dateAndTime.ts";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
@@ -20,7 +20,7 @@ const { useRealm } = RealmContext;
|
||||
};
|
||||
|
||||
const ObsNotification = ( { item }: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const { i18n } = useTranslation( );
|
||||
const { identification, comment } = item;
|
||||
const type = item?.notifier_type;
|
||||
const { user } = identification || comment;
|
||||
@@ -62,7 +62,7 @@ const ObsNotification = ( { item }: Props ): Node => {
|
||||
{item.created_at
|
||||
&& (
|
||||
<Body4>
|
||||
{formatIdDate( item.created_at, t )}
|
||||
{formatDifferenceForHumans( item.created_at, i18n )}
|
||||
</Body4>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import classnames from "classnames";
|
||||
import { Body4, DisplayTaxonName } from "components/SharedComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import React, { FC, useCallback } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { useCurrentUser } from "sharedHooks";
|
||||
|
||||
interface Props {
|
||||
taxon:{
|
||||
id: number;
|
||||
name: string;
|
||||
preferred_common_name?: string;
|
||||
rank: string;
|
||||
rank_level: number;
|
||||
},
|
||||
username: string,
|
||||
withdrawn?: boolean
|
||||
taxon:{
|
||||
id: number;
|
||||
name: string;
|
||||
preferred_common_name?: string;
|
||||
rank: string;
|
||||
rank_level: number;
|
||||
},
|
||||
username: string,
|
||||
withdrawn?: boolean
|
||||
}
|
||||
|
||||
const DisagreementText = ( { taxon, username, withdrawn }: Props ): Node => {
|
||||
const currentUser = useCurrentUser( );
|
||||
// TODO replace when we've properly typed Realm object
|
||||
interface User {
|
||||
prefers_common_names?: boolean;
|
||||
prefers_scientific_name_first?: boolean
|
||||
}
|
||||
|
||||
const showTaxonName = fontComponent => (
|
||||
const DisagreementText = ( { taxon, username, withdrawn }: Props ) => {
|
||||
const currentUser = useCurrentUser( ) as User;
|
||||
|
||||
const showTaxonName = useCallback( ( fontComponent: FC ) => (
|
||||
<DisplayTaxonName
|
||||
layout="horizontal"
|
||||
prefersCommonNames={currentUser?.prefers_common_names}
|
||||
removeStyling
|
||||
scientificNameFirst={currentUser?.prefers_scientific_name_first}
|
||||
@@ -30,24 +35,29 @@ const DisagreementText = ( { taxon, username, withdrawn }: Props ): Node => {
|
||||
topTextComponent={fontComponent}
|
||||
withdrawn={withdrawn}
|
||||
/>
|
||||
);
|
||||
), [
|
||||
currentUser?.prefers_common_names,
|
||||
currentUser?.prefers_scientific_name_first,
|
||||
taxon,
|
||||
withdrawn
|
||||
] );
|
||||
|
||||
return (
|
||||
<Trans
|
||||
i18nKey="Disagreement"
|
||||
values={{ username }}
|
||||
components={[
|
||||
<Body4 className={
|
||||
classnames(
|
||||
{
|
||||
"line-through": withdrawn
|
||||
}
|
||||
)
|
||||
}
|
||||
/>,
|
||||
showTaxonName( Body4 )
|
||||
]}
|
||||
/>
|
||||
<Body4
|
||||
className={
|
||||
withdrawn
|
||||
? "line-through"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="Disagreement"
|
||||
values={{ username }}
|
||||
components={[
|
||||
showTaxonName( Body4 )
|
||||
]}
|
||||
/>
|
||||
</Body4>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Pressable, View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
createObservedOnStringFromDatePicker,
|
||||
displayDateTimeObsEdit
|
||||
} from "sharedHelpers/dateAndTime";
|
||||
formatISONoSeconds,
|
||||
formatLongDatetime
|
||||
} from "sharedHelpers/dateAndTime.ts";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
|
||||
type Props = {
|
||||
@@ -16,22 +16,24 @@ type Props = {
|
||||
}
|
||||
|
||||
const DatePicker = ( { currentObservation, updateObservationKeys }: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const { t, i18n } = useTranslation( );
|
||||
const [showModal, setShowModal] = useState( false );
|
||||
|
||||
const openModal = () => setShowModal( true );
|
||||
const closeModal = () => setShowModal( false );
|
||||
|
||||
const handlePicked = value => {
|
||||
const dateString = createObservedOnStringFromDatePicker( value );
|
||||
const dateString = formatISONoSeconds( value );
|
||||
updateObservationKeys( {
|
||||
observed_on_string: dateString
|
||||
} );
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const displayDate = ( ) => displayDateTimeObsEdit(
|
||||
currentObservation?.observed_on_string || currentObservation?.time_observed_at
|
||||
const displayDate = ( ) => formatLongDatetime(
|
||||
currentObservation?.observed_on_string || currentObservation?.time_observed_at,
|
||||
i18n,
|
||||
{ missing: null }
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
79
src/components/Settings/LanguageSetting.tsx
Normal file
79
src/components/Settings/LanguageSetting.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import fetchAvailableLocales from "api/translations";
|
||||
import {
|
||||
Button,
|
||||
Heading4,
|
||||
PickerSheet
|
||||
} from "components/SharedComponents";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
import { zustandStorage } from "stores/useStore";
|
||||
|
||||
type LocalesResponse = Array<{
|
||||
locale: string;
|
||||
language_in_locale: string;
|
||||
}>;
|
||||
|
||||
type Props = {
|
||||
onChange: ( newLocale: string ) => void;
|
||||
}
|
||||
|
||||
const LanguageSetting = ( { onChange }: Props ) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [availableLocales, setAvailableLocales] = useState<LocalesResponse>( [] );
|
||||
const availableLocalesOptions = Object.fromEntries(
|
||||
availableLocales.map( locale => [locale.locale, {
|
||||
label: locale.language_in_locale,
|
||||
value: locale.locale
|
||||
}] )
|
||||
);
|
||||
const [localeSheetOpen, setLocaleSheetOpen] = useState( false );
|
||||
|
||||
useEffect( () => {
|
||||
async function fetchLocales() {
|
||||
// Whenever possible, save latest available locales from server
|
||||
const currentLocales = zustandStorage.getItem( "availableLocales" );
|
||||
|
||||
setAvailableLocales( currentLocales
|
||||
? JSON.parse( currentLocales )
|
||||
: [] );
|
||||
|
||||
const locales = await fetchAvailableLocales();
|
||||
zustandStorage.setItem( "availableLocales", JSON.stringify( locales ) );
|
||||
setAvailableLocales( locales as LocalesResponse );
|
||||
}
|
||||
fetchLocales();
|
||||
}, [] );
|
||||
|
||||
if ( availableLocales.length === 0 ) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading4 className="mt-7">{t( "APP-LANGUAGE" )}</Heading4>
|
||||
<Button
|
||||
className="mt-4"
|
||||
text={t( "CHANGE-APP-LANGUAGE" )}
|
||||
onPress={() => {
|
||||
setLocaleSheetOpen( true );
|
||||
}}
|
||||
accessibilityLabel={t( "CHANGE-APP-LANGUAGE" )}
|
||||
/>
|
||||
{localeSheetOpen && (
|
||||
<PickerSheet
|
||||
headerText={t( "APP-LANGUAGE" )}
|
||||
confirm={( newLocale: string ) => {
|
||||
setLocaleSheetOpen( false );
|
||||
// Remember the new locale locally
|
||||
zustandStorage.setItem( "currentLocale", newLocale );
|
||||
i18n.changeLanguage( newLocale );
|
||||
onChange( newLocale );
|
||||
}}
|
||||
handleClose={() => setLocaleSheetOpen( false )}
|
||||
selectedValue={i18n.language}
|
||||
pickerValues={availableLocalesOptions}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSetting;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { updateUsers } from "api/users";
|
||||
import Debug from "components/Developer/Debug.tsx";
|
||||
import {
|
||||
signOut
|
||||
} from "components/LoginSignUp/AuthenticationService.ts";
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
} from "sharedHooks";
|
||||
import useStore from "stores/useStore";
|
||||
|
||||
import LanguageSetting from "./LanguageSetting";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
const SETTINGS_URL = `${Config.OAUTH_API_URL}/users/edit?noh1=true`;
|
||||
@@ -42,26 +45,15 @@ const Settings = ( ) => {
|
||||
const realm = useRealm( );
|
||||
const { isConnected } = useNetInfo( );
|
||||
const navigation = useNavigation( );
|
||||
const { t } = useTranslation( );
|
||||
// const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser( );
|
||||
const {
|
||||
remoteUser, isLoading, refetchUserMe
|
||||
} = useUserMe();
|
||||
const isAdvancedUser = useStore( state => state.isAdvancedUser );
|
||||
const setIsAdvancedUser = useStore( state => state.setIsAdvancedUser );
|
||||
|
||||
const [settings, setSettings] = useState( {} );
|
||||
// const [currentLocale, setCurrentLocale] = useState( i18n.language );
|
||||
const [isSaving, setIsSaving] = useState( false );
|
||||
// const [availableLocales, setAvailableLocales] = useState( [] );
|
||||
// const availableLocalesOptions = Object.fromEntries(
|
||||
// availableLocales.map( locale => [locale.locale, {
|
||||
// label: locale.language_in_locale,
|
||||
// value: locale.locale
|
||||
// }] )
|
||||
// );
|
||||
// const [localeSheetOpen, setLocaleSheetOpen] = useState( false );
|
||||
|
||||
const confirmInternetConnection = useCallback( ( ) => {
|
||||
if ( !isConnected ) {
|
||||
@@ -79,7 +71,6 @@ const Settings = ( ) => {
|
||||
( params, optsWithAuth ) => updateUsers( params, optsWithAuth ),
|
||||
{
|
||||
onSuccess: () => {
|
||||
console.log( "[DEBUG Settings.js] updated user, refetching userMe" );
|
||||
queryClient.invalidateQueries( { queryKey: ["fetchUserMe"] } );
|
||||
refetchUserMe();
|
||||
},
|
||||
@@ -96,7 +87,6 @@ const Settings = ( ) => {
|
||||
realm.create( "User", remoteUser, "modified" );
|
||||
}, "modifying current user via remote fetch in Settings" );
|
||||
setSettings( remoteUser );
|
||||
// setCurrentLocale( remoteUser.locale );
|
||||
setIsSaving( false );
|
||||
}
|
||||
}, [remoteUser, realm] );
|
||||
@@ -113,27 +103,6 @@ const Settings = ( ) => {
|
||||
};
|
||||
}, [refetchUserMe] );
|
||||
|
||||
// useEffect( () => {
|
||||
// async function fetchLocales() {
|
||||
// const savedLocale = zustandStorage.getItem( "currentLocale" );
|
||||
// if ( savedLocale ) {
|
||||
// setCurrentLocale( savedLocale );
|
||||
// }
|
||||
|
||||
// // Whenever possible, save latest available locales from server
|
||||
// const currentLocales = zustandStorage.getItem( "availableLocales" );
|
||||
|
||||
// setAvailableLocales( currentLocales
|
||||
// ? JSON.parse( currentLocales )
|
||||
// : [] );
|
||||
|
||||
// const locales = await fetchAvailableLocales();
|
||||
// zustandStorage.setItem( "availableLocales", JSON.stringify( locales ) );
|
||||
// setAvailableLocales( locales );
|
||||
// }
|
||||
// fetchLocales();
|
||||
// }, [] );
|
||||
|
||||
const changeTaxonNameDisplay = v => {
|
||||
setIsSaving( true );
|
||||
|
||||
@@ -155,17 +124,6 @@ const Settings = ( ) => {
|
||||
updateUserMutation.mutate( payload );
|
||||
};
|
||||
|
||||
// const changeUserLocale = locale => {
|
||||
// setIsSaving( true );
|
||||
|
||||
// const payload = {
|
||||
// id: settings?.id
|
||||
// };
|
||||
|
||||
// payload["user[locale]"] = locale;
|
||||
// updateUserMutation.mutate( payload );
|
||||
// };
|
||||
|
||||
const renderLoggedOut = ( ) => (
|
||||
<>
|
||||
<Heading4>{t( "OBSERVATION-BUTTON" )}</Heading4>
|
||||
@@ -218,38 +176,16 @@ const Settings = ( ) => {
|
||||
label={t( "Scientific-Name" )}
|
||||
/>
|
||||
</View>
|
||||
{/* 20240730 amanda - hiding this since we're not including in soft launch */}
|
||||
{/* {availableLocales.length > 0 && (
|
||||
<>
|
||||
<Heading4 className="mt-7">{t( "APP-LANGUAGE" )}</Heading4>
|
||||
<Button
|
||||
className="mt-4"
|
||||
text={t( "CHANGE-APP-LANGUAGE" )}
|
||||
onPress={() => {
|
||||
setLocaleSheetOpen( true );
|
||||
}}
|
||||
accessibilityLabel={t( "CHANGE-APP-LANGUAGE" )}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{localeSheetOpen
|
||||
&& (
|
||||
<PickerSheet
|
||||
headerText={t( "APP-LANGUAGE" )}
|
||||
confirm={newLocale => {
|
||||
setLocaleSheetOpen( false );
|
||||
// Remember the new locale locally
|
||||
zustandStorage.setItem( "currentLocale", newLocale );
|
||||
i18n.changeLanguage( newLocale );
|
||||
|
||||
// Also try and set the locale remotely
|
||||
changeUserLocale( newLocale );
|
||||
}}
|
||||
handleClose={() => setLocaleSheetOpen( false )}
|
||||
selectedValue={currentLocale || i18n.language}
|
||||
pickerValues={availableLocalesOptions}
|
||||
/>
|
||||
)} */}
|
||||
<Debug>
|
||||
<LanguageSetting
|
||||
onChange={newLocale => {
|
||||
updateUserMutation.mutate( {
|
||||
id: settings?.id,
|
||||
"user[locale]": newLocale
|
||||
} );
|
||||
}}
|
||||
/>
|
||||
</Debug>
|
||||
<Heading4 className="mt-7">{t( "INATURALIST-ACCOUNT-SETTINGS" )}</Heading4>
|
||||
<Body2 className="mt-2">{t( "To-access-all-other-settings" )}</Body2>
|
||||
<Button
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Body4, INatIcon } from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { formatApiDatetime, formatMonthYearDate } from "sharedHelpers/dateAndTime";
|
||||
import { formatApiDatetime, formatMonthYearDate } from "sharedHelpers/dateAndTime.ts";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
|
||||
type Props = {
|
||||
@@ -17,20 +17,30 @@ type Props = {
|
||||
};
|
||||
|
||||
const DateDisplay = ( {
|
||||
geoprivacy, taxonGeoprivacy, belongsToCurrentUser, dateString, label, classNameMargin
|
||||
belongsToCurrentUser,
|
||||
classNameMargin,
|
||||
dateString,
|
||||
geoprivacy,
|
||||
label,
|
||||
taxonGeoprivacy
|
||||
}: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const { i18n } = useTranslation( );
|
||||
const obscuredDate = geoprivacy === "obscured"
|
||||
|| taxonGeoprivacy === "obscured"
|
||||
|| geoprivacy === "private"
|
||||
|| taxonGeoprivacy === "private";
|
||||
|| taxonGeoprivacy === "obscured"
|
||||
|| geoprivacy === "private"
|
||||
|| taxonGeoprivacy === "private";
|
||||
|
||||
const formatDate = useMemo( () => {
|
||||
if ( !belongsToCurrentUser && obscuredDate ) {
|
||||
return formatMonthYearDate( dateString, t );
|
||||
return formatMonthYearDate( dateString, i18n );
|
||||
}
|
||||
return formatApiDatetime( dateString, t );
|
||||
}, [belongsToCurrentUser, obscuredDate, dateString, t] );
|
||||
return formatApiDatetime( dateString, i18n );
|
||||
}, [
|
||||
belongsToCurrentUser,
|
||||
obscuredDate,
|
||||
dateString,
|
||||
i18n
|
||||
] );
|
||||
|
||||
const date = useMemo( ( ) => ( label
|
||||
? `${label} `
|
||||
|
||||
@@ -36,7 +36,7 @@ type Props = {
|
||||
removeStyling?: boolean,
|
||||
prefersCommonNames?: boolean,
|
||||
scientificNameFirst?: boolean,
|
||||
showOneNameOnly: boolean,
|
||||
showOneNameOnly?: boolean,
|
||||
selectable?: boolean,
|
||||
small?: boolean,
|
||||
taxon: Object,
|
||||
|
||||
@@ -8,6 +8,7 @@ import React from "react";
|
||||
import { I18nManager } from "react-native";
|
||||
import { useTheme } from "react-native-paper";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
import { dropShadow } from "styles/global";
|
||||
|
||||
const HEIGHT = 24;
|
||||
@@ -17,6 +18,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const PhotoCount = ( { count }: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const { isRTL } = I18nManager;
|
||||
const theme = useTheme( );
|
||||
|
||||
@@ -44,7 +46,7 @@ const PhotoCount = ( { count }: Props ): Node => {
|
||||
}
|
||||
)}
|
||||
>
|
||||
{photoCount}
|
||||
{t( "Intl-number", { val: photoCount } )}
|
||||
</Body3>
|
||||
<Svg
|
||||
height={HEIGHT}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { random } from "lodash";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import Taxon from "realmModels/Taxon";
|
||||
import { translatedRank } from "sharedHelpers/taxon";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
|
||||
type Props = {
|
||||
fontComponent: Object,
|
||||
@@ -33,6 +35,7 @@ const ScientificName = ( {
|
||||
taxonId,
|
||||
textClassName
|
||||
}: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const scientificNameArray = scientificNamePieces?.map( ( piece, index ) => {
|
||||
const isItalics = piece !== rankPiece && (
|
||||
rankLevel <= Taxon.SPECIES_LEVEL || rankLevel === Taxon.GENUS_LEVEL
|
||||
@@ -63,7 +66,8 @@ const ScientificName = ( {
|
||||
} );
|
||||
|
||||
if ( rank && rankLevel > Taxon.SPECIES_LEVEL ) {
|
||||
scientificNameArray.unshift( `${rank} ` );
|
||||
scientificNameArray.unshift( " " );
|
||||
scientificNameArray.unshift( translatedRank( rank, t ) );
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
confirm: Function,
|
||||
headerText: string,
|
||||
pickerValues: Object,
|
||||
selectedValue: boolean,
|
||||
selectedValue: boolean | string,
|
||||
insideModal?: boolean
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ type Props = {
|
||||
headerText: string,
|
||||
initialInput?: string,
|
||||
maxLength?: number,
|
||||
placeholder: string,
|
||||
placeholder?: string,
|
||||
textInputStyle?: Object
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import {
|
||||
tailwindFontRegular
|
||||
} from "appConstants/fontFamilies.ts";
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
import React, { ComponentPropsWithoutRef } from "react";
|
||||
import { Text } from "react-native";
|
||||
|
||||
const Subheading1 = ( props: Object ) => (
|
||||
const Subheading1 = ( props: ComponentPropsWithoutRef<typeof Text> ) => (
|
||||
<Text
|
||||
maxFontSizeMultiplier={2}
|
||||
className={classnames(
|
||||
|
||||
@@ -12,7 +12,7 @@ import { View } from "components/styledComponents";
|
||||
import type { Suggestions } from "components/Suggestions/SuggestionsContainer";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { formatISONoTimezone } from "sharedHelpers/dateAndTime";
|
||||
import { formatISONoTimezone } from "sharedHelpers/dateAndTime.ts";
|
||||
import { useDebugMode, useTranslation } from "sharedHooks";
|
||||
|
||||
import Attribution from "./Attribution";
|
||||
|
||||
@@ -2,6 +2,8 @@ import classnames from "classnames";
|
||||
import { Body2 } from "components/SharedComponents";
|
||||
import React from "react";
|
||||
import Taxon from "realmModels/Taxon";
|
||||
import { translatedRank } from "sharedHelpers/taxon";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
|
||||
interface Props {
|
||||
rank: string;
|
||||
@@ -22,6 +24,7 @@ const TaxonomyScientificName = ( {
|
||||
hasCommonName,
|
||||
scientificNameFirst
|
||||
}: Props ) => {
|
||||
const { t } = useTranslation( );
|
||||
const underline = ( !hasCommonName || scientificNameFirst ) && !isCurrentTaxon;
|
||||
// italics part ported over from DisplayTaxonName
|
||||
const scientificNameComponent = scientificNamePieces?.map( ( piece, index ) => {
|
||||
@@ -79,9 +82,10 @@ const TaxonomyScientificName = ( {
|
||||
} )
|
||||
}
|
||||
>
|
||||
{`${rank} `}
|
||||
{ translatedRank( rank, t ) }
|
||||
</Body2>
|
||||
)}
|
||||
{ " " }
|
||||
{scientificNameComponent}
|
||||
{hasCommonName && !scientificNameFirst && (
|
||||
<Body2 className={
|
||||
|
||||
@@ -19,7 +19,7 @@ import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import User from "realmModels/User.ts";
|
||||
import { formatUserProfileDate } from "sharedHelpers/dateAndTime";
|
||||
import { formatLongDate } from "sharedHelpers/dateAndTime.ts";
|
||||
import {
|
||||
useAuthenticatedQuery,
|
||||
useCurrentUser,
|
||||
@@ -38,7 +38,7 @@ const UserProfile = ( ): Node => {
|
||||
const { userId, login } = params;
|
||||
const [showLoginSheet, setShowLoginSheet] = useState( false );
|
||||
const [showUnfollowSheet, setShowUnfollowSheet] = useState( false );
|
||||
const { t } = useTranslation( );
|
||||
const { t, i18n } = useTranslation( );
|
||||
|
||||
const fetchId = userId || login;
|
||||
const { data: remoteUser, isError, error } = useAuthenticatedQuery(
|
||||
@@ -159,10 +159,10 @@ const UserProfile = ( ): Node => {
|
||||
</View>
|
||||
) }
|
||||
<Body2 className="mb-5">
|
||||
{t( "Joined-date", { date: formatUserProfileDate( user.created_at, t ) } )}
|
||||
{t( "Joined-date", { date: formatLongDate( user.created_at, i18n ) } )}
|
||||
</Body2>
|
||||
<Body2 className="mb-5">
|
||||
{t( "Last-Active-date", { date: formatUserProfileDate( user.updated_at, t ) } )}
|
||||
{t( "Last-Active-date", { date: formatLongDate( user.updated_at, i18n ) } )}
|
||||
</Body2>
|
||||
{user.site && (
|
||||
<Body2 className="mb-5">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import {
|
||||
useDebugMode,
|
||||
useTranslation,
|
||||
useUserMe
|
||||
} from "sharedHooks";
|
||||
@@ -12,9 +13,12 @@ const useChangeLocale = ( currentUser: ?Object ) => {
|
||||
// fetch current user from server and save to realm in useEffect
|
||||
// this is used for changing locale and also for showing UserCard
|
||||
const { remoteUser } = useUserMe( { updateRealm: true } );
|
||||
const { isDebug } = useDebugMode( );
|
||||
const changeLanguageToLocale = useCallback(
|
||||
locale => i18n.changeLanguage( locale ),
|
||||
[i18n]
|
||||
locale => {
|
||||
if ( isDebug ) i18n.changeLanguage( locale );
|
||||
},
|
||||
[i18n, isDebug]
|
||||
);
|
||||
|
||||
// When we get the updated current user, update the record in the database
|
||||
|
||||
@@ -20,6 +20,38 @@ const {
|
||||
uniq
|
||||
} = require( "lodash" );
|
||||
|
||||
function checkifyText( ftlTxt ) {
|
||||
if ( ftlTxt.indexOf( "<0>" ) >= 0 ) {
|
||||
return ftlTxt.replace( "<0>", "<0>✅" );
|
||||
}
|
||||
return `✅${ftlTxt}`;
|
||||
}
|
||||
|
||||
// This will create a version of localizations that has a ✅ in
|
||||
// front of all text, as a way to see if there are untranslated strings
|
||||
function checkifyLocalizations( localizations ) {
|
||||
// Mostly date format strings that will break with extra stuff in them
|
||||
const keysToSkip = [
|
||||
"date-month-year",
|
||||
"Date-short-format",
|
||||
"Date-this-year",
|
||||
"date-format-short",
|
||||
"datetime-format-short"
|
||||
];
|
||||
return Object.keys( localizations ).reduce( ( memo, key ) => {
|
||||
memo[key] = localizations[key];
|
||||
if ( keysToSkip.indexOf( key ) >= 0 ) {
|
||||
return memo;
|
||||
}
|
||||
if ( memo[key].val ) {
|
||||
memo[key].val = checkifyText( memo[key].val );
|
||||
} else {
|
||||
memo[key] = checkifyText( memo[key] );
|
||||
}
|
||||
return memo;
|
||||
}, {} );
|
||||
}
|
||||
|
||||
// Convert a single FTL file to JSON
|
||||
const jsonifyPath = async ( inPath, outPath, options = { } ) => {
|
||||
let ftlTxt;
|
||||
@@ -41,8 +73,11 @@ const jsonifyPath = async ( inPath, outPath, options = { } ) => {
|
||||
// comments, which are going to add a lot of bulk to these files
|
||||
const ftl2js = util.promisify( fluent.ftl2js );
|
||||
const localizations = await ftl2js( ftlTxt.toString( ) );
|
||||
const massagedLocalizations = options.checkify
|
||||
? checkifyLocalizations( localizations )
|
||||
: localizations;
|
||||
try {
|
||||
await writeFile( outPath, `${JSON.stringify( localizations, null, 2 )}\n` );
|
||||
await writeFile( outPath, `${JSON.stringify( massagedLocalizations, null, 2 )}\n` );
|
||||
} catch ( writeFileErr ) {
|
||||
console.error( `Failed to write ${outPath} with error:` );
|
||||
console.error( writeFileErr );
|
||||
@@ -104,9 +139,12 @@ const writeLoadTranslations = async ( ) => {
|
||||
out.write( "};\n" );
|
||||
};
|
||||
|
||||
async function validate( ) {
|
||||
const stringsPath = path.join( __dirname, "strings.ftl" );
|
||||
const ftlTxt = await readFile( stringsPath );
|
||||
async function l10nFtlPaths() {
|
||||
return glob( path.join( __dirname, "l10n", "*.ftl" ) );
|
||||
}
|
||||
|
||||
async function validateFtlFile( ftlPath, options = {} ) {
|
||||
const ftlTxt = await readFile( ftlPath );
|
||||
const ftl = parseFtl( ftlTxt.toString( ) );
|
||||
const errors = [];
|
||||
// Chalk does not expose a CommonJS module, so we have to do this
|
||||
@@ -134,18 +172,38 @@ async function validate( ) {
|
||||
}
|
||||
} );
|
||||
if ( errors.length > 0 ) {
|
||||
console.error( `❌ ${errors.length} errors found in ${stringsPath}:` );
|
||||
console.error( `❌ ${errors.length} errors found in ${ftlPath}:` );
|
||||
errors.forEach( error => {
|
||||
console.error( chalk.red( "[Error]" ), error );
|
||||
} );
|
||||
if ( options.noExit ) {
|
||||
return false;
|
||||
}
|
||||
process.exit( 1 );
|
||||
}
|
||||
console.log( `✅ ${stringsPath} validated` );
|
||||
if ( !options.quiet ) {
|
||||
console.log( `✅ ${ftlPath} validated` );
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function normalize( ) {
|
||||
const stringsPath = path.join( __dirname, "strings.ftl" );
|
||||
const ftlTxt = await readFile( stringsPath );
|
||||
async function validate() {
|
||||
// Validate source strings
|
||||
await validateFtlFile( path.join( __dirname, "strings.ftl" ) );
|
||||
// Validate translations
|
||||
const l10nPaths = await l10nFtlPaths( );
|
||||
const results = await Promise.allSettled( l10nPaths.map(
|
||||
ftlPath => validateFtlFile( ftlPath, { quiet: true, noExit: true } )
|
||||
) );
|
||||
if ( results.find( r => r.value === false ) ) {
|
||||
process.exit( 1 );
|
||||
} else {
|
||||
console.log( "✅ l10n FTL validated" );
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeFtlFile( ftlPath, options = {} ) {
|
||||
const ftlTxt = await readFile( ftlPath );
|
||||
const ftl = parseFtl( ftlTxt.toString( ) );
|
||||
const resourceComments = [];
|
||||
const messages = [];
|
||||
@@ -167,8 +225,17 @@ async function normalize( ) {
|
||||
...sortedMessages
|
||||
] );
|
||||
const newFtlTxt = serializeFtl( newResource );
|
||||
await writeFile( stringsPath, newFtlTxt );
|
||||
console.log( `✅ ${stringsPath} normalized` );
|
||||
await writeFile( ftlPath, newFtlTxt );
|
||||
if ( !options.quiet ) {
|
||||
console.log( `✅ ${ftlPath} normalized` );
|
||||
}
|
||||
}
|
||||
|
||||
async function normalize( ) {
|
||||
await normalizeFtlFile( path.join( __dirname, "strings.ftl" ) );
|
||||
const l10nPaths = await l10nFtlPaths( );
|
||||
await Promise.all( l10nPaths.map( ftlPath => normalizeFtlFile( ftlPath, { quiet: true } ) ) );
|
||||
console.log( "✅ l10n FTL normalized" );
|
||||
}
|
||||
|
||||
async function getKeys( ) {
|
||||
@@ -250,6 +317,16 @@ yargs
|
||||
writeLoadTranslations( );
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"checkify",
|
||||
"Prepend translations w/ ✅ to help see unglobalized text",
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
( ) => {},
|
||||
async argv => {
|
||||
jsonifyLocalizations( { ...argv, checkify: true } );
|
||||
writeLoadTranslations( );
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"validate",
|
||||
"Validate source strings",
|
||||
|
||||
@@ -239,19 +239,18 @@ Data-quality-casual-description = This observation needs more information verifi
|
||||
Data-quality-needs-id-description = This observation needs more identifications to reach research grade
|
||||
Data-quality-research-description = This observation has enough identifications to be considered research grade
|
||||
DATE = DATE
|
||||
# Used when displaying a relative time - in this case, X days ago (e.g. 3d = 3 days ago)
|
||||
Date-days = { $count }d
|
||||
# Date formatting using date-fns
|
||||
# Used for things like User Profile join date
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
date-format-long = PP
|
||||
# Date formatting using date-fns
|
||||
# Used when displaying a relative time - in this case, shows only month+year (same year) - e.g. Jul 3
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
date-format-month-day = MMM d
|
||||
# Use when only showing an observations month and year
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
date-format-month-year = MMM yyyy
|
||||
# Short date, e.g. on notifications from over a year ago
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
date-format-short = M/d/yy
|
||||
# Used when displaying a relative time - in this case, X hours ago (e.g. 3h = 3 hours ago)
|
||||
Date-hours = { $count }h
|
||||
# Used when displaying a relative time - in this case, X minutes ago (e.g. 3m = 3 minutes ago)
|
||||
Date-minutes = { $count }m
|
||||
date-month-year = MMM yyyy
|
||||
DATE-OBSERVED = DATE OBSERVED
|
||||
Date-observed = Date observed
|
||||
Date-observed-header-short = Observed
|
||||
@@ -261,18 +260,23 @@ DATE-OBSERVED-OLDEST = DATE OBSERVED - OLDEST TO NEWEST
|
||||
Date-Range = Date Range
|
||||
# Label for controls over a range of dates
|
||||
DATE-RANGE = DATE RANGE
|
||||
# Used when displaying a relative time - in this case, shows an absolute date (e.g. 12/31/22)
|
||||
Date-short-format = MM/dd/yy
|
||||
# Used when displaying a relative time - in this case, shows only month+year (same year) - e.g. Jul 3
|
||||
Date-this-year = MMM d
|
||||
DATE-UPLOADED = DATE UPLOADED
|
||||
Date-uploaded = Date uploaded
|
||||
Date-uploaded-header-short = Uploaded
|
||||
DATE-UPLOADED-NEWEST = DATE UPLOADED - NEWEST TO OLDEST
|
||||
DATE-UPLOADED-OLDEST = DATE UPLOADED - OLDEST TO NEWEST
|
||||
# Used when displaying a relative time - in this case, X days ago (e.g. 3d = 3 days ago)
|
||||
datetime-difference-days = { $count }d
|
||||
# Used when displaying a relative time - in this case, X hours ago (e.g. 3h = 3 hours ago)
|
||||
datetime-difference-hours = { $count }h
|
||||
# Used when displaying a relative time - in this case, X minutes ago (e.g. 3m = 3 minutes ago)
|
||||
datetime-difference-minutes = { $count }m
|
||||
# Used when displaying a relative time - in this case, X weeks ago (e.g. 3w = 3 weeks ago)
|
||||
Date-weeks = { $count }w
|
||||
# Date formatting using date-fns
|
||||
datetime-difference-weeks = { $count }w
|
||||
# Longer datetime, e.g. on ObsEdit
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
datetime-format-long = Pp
|
||||
# Shorter datetime, e.g. on comments and IDs
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
datetime-format-short = M/d/yy h:mm a
|
||||
# Month of December
|
||||
@@ -301,7 +305,9 @@ Deleting-x-of-y-observations =
|
||||
DETAILS = DETAILS
|
||||
# Button that disables the camera's flash
|
||||
Disable-flash = Disable flash
|
||||
Disagreement = <0>*@{ $username } disagrees this is </0><1></1>
|
||||
# Disagreement notice with an identificaiton, <0/> will get replaced by a
|
||||
# taxon name
|
||||
Disagreement = *@{ $username } disagrees this is <0/>
|
||||
# Button that discards changes or an item, e.g. a photo
|
||||
DISCARD = DISCARD
|
||||
# Button that discards all items, e.g. imported photos
|
||||
@@ -519,9 +525,9 @@ June = June
|
||||
Just-make-sure-the-organism-is-wild = Just make sure the organism is wild (not a pet, zoo animal, or garden plant)
|
||||
# Shows date user last active on iNaturalist on user profile
|
||||
Last-Active-date = Last Active: { $date }
|
||||
# Latitude, longitude on a single line on a single line
|
||||
# Latitude, longitude on a single line
|
||||
Lat-Lon = { NUMBER($latitude, maximumFractionDigits: 6) }, { NUMBER($longitude, maximumFractionDigits: 6) }
|
||||
# Latitude, longitude, and accuracy on a single line on a single line
|
||||
# Latitude, longitude, and accuracy on a single line
|
||||
Lat-Lon-Acc = Lat: { NUMBER($latitude, maximumFractionDigits: 6) }, Lon: { NUMBER($longitude, maximumFractionDigits: 6) }, Acc: { $accuracy }
|
||||
# Identification category
|
||||
leading--identification = Leading
|
||||
@@ -752,41 +758,77 @@ Quality-Grade-research = Quality Grade Research Grade
|
||||
# Quality grade options
|
||||
quality-grade-research = Research Grade
|
||||
Ranks-CLASS = CLASS
|
||||
Ranks-Class = Class
|
||||
Ranks-COMPLEX = COMPLEX
|
||||
Ranks-Complex = Complex
|
||||
Ranks-EPIFAMILY = EPIFAMILY
|
||||
Ranks-Epifamily = Epifamily
|
||||
Ranks-FAMILY = FAMILY
|
||||
Ranks-Family = Family
|
||||
Ranks-FORM = FORM
|
||||
Ranks-Form = Form
|
||||
Ranks-GENUS = GENUS
|
||||
Ranks-Genus = Genus
|
||||
Ranks-GENUSHYBRID = GENUSHYBRID
|
||||
Ranks-Genushybrid = Genushybrid
|
||||
Ranks-HYBRID = HYBRID
|
||||
Ranks-Hybrid = Hybrid
|
||||
Ranks-INFRACLASS = INFRACLASS
|
||||
Ranks-Infraclass = Infraclass
|
||||
Ranks-INFRAHYBRID = INFRAHYBRID
|
||||
Ranks-Infrahybrid = Infrahybrid
|
||||
Ranks-INFRAORDER = INFRAORDER
|
||||
Ranks-Infraorder = Infraorder
|
||||
Ranks-KINGDOM = KINGDOM
|
||||
Ranks-Kingdom = Kingdom
|
||||
Ranks-ORDER = ORDER
|
||||
Ranks-Order = Order
|
||||
Ranks-PARVORDER = PARVORDER
|
||||
Ranks-Parvorder = Parvorder
|
||||
Ranks-PHYLUM = PHYLUM
|
||||
Ranks-Phylum = Phylum
|
||||
Ranks-SECTION = SECTION
|
||||
Ranks-Section = Section
|
||||
Ranks-SPECIES = SPECIES
|
||||
Ranks-Species = Species
|
||||
Ranks-Statefmatter = State of matter
|
||||
Ranks-STATEOFMATTER = STATE OF MATTER
|
||||
Ranks-SUBCLASS = SUBCLASS
|
||||
Ranks-Subclass = Subclass
|
||||
Ranks-SUBFAMILY = SUBFAMILY
|
||||
Ranks-Subfamily = Subfamily
|
||||
Ranks-SUBGENUS = SUBGENUS
|
||||
Ranks-Subgenus = Subgenus
|
||||
Ranks-SUBKINGDOM = SUBKINGDOM
|
||||
Ranks-Subkingdom = Subkingdom
|
||||
Ranks-SUBORDER = SUBORDER
|
||||
Ranks-Suborder = Suborder
|
||||
Ranks-SUBPHYLUM = SUBPHYLUM
|
||||
Ranks-Subphylum = Subphylum
|
||||
Ranks-SUBSECTION = SUBSECTION
|
||||
Ranks-Subsection = Subsection
|
||||
Ranks-SUBSPECIES = SUBSPECIES
|
||||
Ranks-Subspecies = Subspecies
|
||||
Ranks-SUBTERCLASS = SUBTERCLASS
|
||||
Ranks-Subterclass = Subterclass
|
||||
Ranks-SUBTRIBE = SUBTRIBE
|
||||
Ranks-Subtribe = Subtribe
|
||||
Ranks-SUPERCLASS = SUPERCLASS
|
||||
Ranks-Superclass = Superclass
|
||||
Ranks-SUPERFAMILY = SUPERFAMILY
|
||||
Ranks-Superfamily = Superfamily
|
||||
Ranks-SUPERORDER = SUPERORDER
|
||||
Ranks-Superorder = Superorder
|
||||
Ranks-SUPERTRIBE = SUPERTRIBE
|
||||
Ranks-Supertribe = Supertribe
|
||||
Ranks-TRIBE = TRIBE
|
||||
Ranks-Tribe = Tribe
|
||||
Ranks-VARIETY = VARIETY
|
||||
Ranks-Variety = Variety
|
||||
Ranks-ZOOSECTION = ZOOSECTION
|
||||
Ranks-Zoosection = Zoosection
|
||||
Ranks-ZOOSUBSECTION = ZOOSUBSECTION
|
||||
Ranks-Zoosubsection = Zoosubsection
|
||||
Read-more-on-Wikipedia = Read more on Wikipedia
|
||||
# Heading for the sound recorder
|
||||
RECORD-NEW-SOUND = RECORD NEW SOUND
|
||||
@@ -997,6 +1039,8 @@ Traditional-Project = Traditional Project
|
||||
Umbrella-Project = Umbrella Project
|
||||
UNFOLLOW = UNFOLLOW
|
||||
UNFOLLOW-USER = UNFOLLOW USER?
|
||||
# Text to show when a taoxn rank is unknown or missing
|
||||
Unknown--rank = Unknown
|
||||
# Text to show when a taxon or identification is unknown or missing
|
||||
Unknown--taxon = Unknown
|
||||
# Text to show when a user (or their name) is unknown or missing
|
||||
@@ -1040,6 +1084,8 @@ USERNAME = USERNAME
|
||||
# Appears above the text fields
|
||||
USERNAME-OR-EMAIL = USERNAME OR EMAIL
|
||||
Using-iNaturalist-requires-the-storage = Using iNaturalist requires the storage of personal information like your email address, all iNaturalist data is stored in the United States, and we cannot be sure what legal jurisdiction you are in when you are using iNaturalist, so in order to comply with privacy laws like the GDPR, you must acknowledge that you understand and accept this risk and consent to transferring your personal information to iNaturalist's servers in the US.
|
||||
# Listing of app and build versions
|
||||
Version-app-build = Version { $appVersion } ({ $buildVersion })
|
||||
VIEW-CHILDREN-TAXA = VIEW CHILDREN TAXA
|
||||
VIEW-DATA-QUALITY-ASSESSMENT = VIEW DATA QUALITY ASSESSMENT
|
||||
VIEW-EDUCATORS-GUIDE = VIEW EDUCATOR'S GUIDE
|
||||
@@ -1209,3 +1255,5 @@ Youve-previously-denied-location-permissions = You’ve previously denied locati
|
||||
Youve-previously-denied-microphone-permissions = You’ve previously denied microphone permissions, so please enable them in settings.
|
||||
Zoom-in-as-much-as-possible-to-improve = Zoom in as much as possible to improve location accuracy and get better identifications.
|
||||
Zoom-to-current-location = Zoom to current location
|
||||
# Label for button that shows zoom level, e.g. on a camera
|
||||
zoom-x = { $zoom }×
|
||||
|
||||
@@ -334,27 +334,22 @@
|
||||
"Data-quality-needs-id-description": "This observation needs more identifications to reach research grade",
|
||||
"Data-quality-research-description": "This observation has enough identifications to be considered research grade",
|
||||
"DATE": "DATE",
|
||||
"Date-days": {
|
||||
"comment": "Used when displaying a relative time - in this case, X days ago (e.g. 3d = 3 days ago)",
|
||||
"val": "{ $count }d"
|
||||
},
|
||||
"date-format-long": {
|
||||
"comment": "Date formatting using date-fns\nSee complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format",
|
||||
"comment": "Used for things like User Profile join date\nSee complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format",
|
||||
"val": "PP"
|
||||
},
|
||||
"date-format-month-day": {
|
||||
"comment": "Used when displaying a relative time - in this case, shows only month+year (same year) - e.g. Jul 3\nSee complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format",
|
||||
"val": "MMM d"
|
||||
},
|
||||
"date-format-month-year": {
|
||||
"comment": "Use when only showing an observations month and year\nSee complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format",
|
||||
"val": "MMM yyyy"
|
||||
},
|
||||
"date-format-short": {
|
||||
"comment": "Date formatting using date-fns\nSee complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format",
|
||||
"comment": "Short date, e.g. on notifications from over a year ago\nSee complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format",
|
||||
"val": "M/d/yy"
|
||||
},
|
||||
"Date-hours": {
|
||||
"comment": "Used when displaying a relative time - in this case, X hours ago (e.g. 3h = 3 hours ago)",
|
||||
"val": "{ $count }h"
|
||||
},
|
||||
"Date-minutes": {
|
||||
"comment": "Used when displaying a relative time - in this case, X minutes ago (e.g. 3m = 3 minutes ago)",
|
||||
"val": "{ $count }m"
|
||||
},
|
||||
"date-month-year": "MMM yyyy",
|
||||
"DATE-OBSERVED": "DATE OBSERVED",
|
||||
"Date-observed": "Date observed",
|
||||
"Date-observed-header-short": "Observed",
|
||||
@@ -368,25 +363,33 @@
|
||||
"comment": "Label for controls over a range of dates",
|
||||
"val": "DATE RANGE"
|
||||
},
|
||||
"Date-short-format": {
|
||||
"comment": "Used when displaying a relative time - in this case, shows an absolute date (e.g. 12/31/22)",
|
||||
"val": "MM/dd/yy"
|
||||
},
|
||||
"Date-this-year": {
|
||||
"comment": "Used when displaying a relative time - in this case, shows only month+year (same year) - e.g. Jul 3",
|
||||
"val": "MMM d"
|
||||
},
|
||||
"DATE-UPLOADED": "DATE UPLOADED",
|
||||
"Date-uploaded": "Date uploaded",
|
||||
"Date-uploaded-header-short": "Uploaded",
|
||||
"DATE-UPLOADED-NEWEST": "DATE UPLOADED - NEWEST TO OLDEST",
|
||||
"DATE-UPLOADED-OLDEST": "DATE UPLOADED - OLDEST TO NEWEST",
|
||||
"Date-weeks": {
|
||||
"datetime-difference-days": {
|
||||
"comment": "Used when displaying a relative time - in this case, X days ago (e.g. 3d = 3 days ago)",
|
||||
"val": "{ $count }d"
|
||||
},
|
||||
"datetime-difference-hours": {
|
||||
"comment": "Used when displaying a relative time - in this case, X hours ago (e.g. 3h = 3 hours ago)",
|
||||
"val": "{ $count }h"
|
||||
},
|
||||
"datetime-difference-minutes": {
|
||||
"comment": "Used when displaying a relative time - in this case, X minutes ago (e.g. 3m = 3 minutes ago)",
|
||||
"val": "{ $count }m"
|
||||
},
|
||||
"datetime-difference-weeks": {
|
||||
"comment": "Used when displaying a relative time - in this case, X weeks ago (e.g. 3w = 3 weeks ago)",
|
||||
"val": "{ $count }w"
|
||||
},
|
||||
"datetime-format-long": {
|
||||
"comment": "Longer datetime, e.g. on ObsEdit\nSee complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format",
|
||||
"val": "Pp"
|
||||
},
|
||||
"datetime-format-short": {
|
||||
"comment": "Date formatting using date-fns\nSee complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format",
|
||||
"comment": "Shorter datetime, e.g. on comments and IDs\nSee complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format",
|
||||
"val": "M/d/yy h:mm a"
|
||||
},
|
||||
"December": {
|
||||
@@ -424,7 +427,10 @@
|
||||
"comment": "Button that disables the camera's flash",
|
||||
"val": "Disable flash"
|
||||
},
|
||||
"Disagreement": "<0>*@{ $username } disagrees this is </0><1></1>",
|
||||
"Disagreement": {
|
||||
"comment": "Disagreement notice with an identificaiton, <0/> will get replaced by a\ntaxon name",
|
||||
"val": "*@{ $username } disagrees this is <0/>"
|
||||
},
|
||||
"DISCARD": {
|
||||
"comment": "Button that discards changes or an item, e.g. a photo",
|
||||
"val": "DISCARD"
|
||||
@@ -701,11 +707,11 @@
|
||||
"val": "Last Active: { $date }"
|
||||
},
|
||||
"Lat-Lon": {
|
||||
"comment": "Latitude, longitude on a single line on a single line",
|
||||
"comment": "Latitude, longitude on a single line",
|
||||
"val": "{ NUMBER($latitude, maximumFractionDigits: \"6\") }, { NUMBER($longitude, maximumFractionDigits: \"6\") }"
|
||||
},
|
||||
"Lat-Lon-Acc": {
|
||||
"comment": "Latitude, longitude, and accuracy on a single line on a single line",
|
||||
"comment": "Latitude, longitude, and accuracy on a single line",
|
||||
"val": "Lat: { NUMBER($latitude, maximumFractionDigits: \"6\") }, Lon: { NUMBER($longitude, maximumFractionDigits: \"6\") }, Acc: { $accuracy }"
|
||||
},
|
||||
"leading--identification": {
|
||||
@@ -1028,41 +1034,77 @@
|
||||
"val": "Research Grade"
|
||||
},
|
||||
"Ranks-CLASS": "CLASS",
|
||||
"Ranks-Class": "Class",
|
||||
"Ranks-COMPLEX": "COMPLEX",
|
||||
"Ranks-Complex": "Complex",
|
||||
"Ranks-EPIFAMILY": "EPIFAMILY",
|
||||
"Ranks-Epifamily": "Epifamily",
|
||||
"Ranks-FAMILY": "FAMILY",
|
||||
"Ranks-Family": "Family",
|
||||
"Ranks-FORM": "FORM",
|
||||
"Ranks-Form": "Form",
|
||||
"Ranks-GENUS": "GENUS",
|
||||
"Ranks-Genus": "Genus",
|
||||
"Ranks-GENUSHYBRID": "GENUSHYBRID",
|
||||
"Ranks-Genushybrid": "Genushybrid",
|
||||
"Ranks-HYBRID": "HYBRID",
|
||||
"Ranks-Hybrid": "Hybrid",
|
||||
"Ranks-INFRACLASS": "INFRACLASS",
|
||||
"Ranks-Infraclass": "Infraclass",
|
||||
"Ranks-INFRAHYBRID": "INFRAHYBRID",
|
||||
"Ranks-Infrahybrid": "Infrahybrid",
|
||||
"Ranks-INFRAORDER": "INFRAORDER",
|
||||
"Ranks-Infraorder": "Infraorder",
|
||||
"Ranks-KINGDOM": "KINGDOM",
|
||||
"Ranks-Kingdom": "Kingdom",
|
||||
"Ranks-ORDER": "ORDER",
|
||||
"Ranks-Order": "Order",
|
||||
"Ranks-PARVORDER": "PARVORDER",
|
||||
"Ranks-Parvorder": "Parvorder",
|
||||
"Ranks-PHYLUM": "PHYLUM",
|
||||
"Ranks-Phylum": "Phylum",
|
||||
"Ranks-SECTION": "SECTION",
|
||||
"Ranks-Section": "Section",
|
||||
"Ranks-SPECIES": "SPECIES",
|
||||
"Ranks-Species": "Species",
|
||||
"Ranks-Statefmatter": "State of matter",
|
||||
"Ranks-STATEOFMATTER": "STATE OF MATTER",
|
||||
"Ranks-SUBCLASS": "SUBCLASS",
|
||||
"Ranks-Subclass": "Subclass",
|
||||
"Ranks-SUBFAMILY": "SUBFAMILY",
|
||||
"Ranks-Subfamily": "Subfamily",
|
||||
"Ranks-SUBGENUS": "SUBGENUS",
|
||||
"Ranks-Subgenus": "Subgenus",
|
||||
"Ranks-SUBKINGDOM": "SUBKINGDOM",
|
||||
"Ranks-Subkingdom": "Subkingdom",
|
||||
"Ranks-SUBORDER": "SUBORDER",
|
||||
"Ranks-Suborder": "Suborder",
|
||||
"Ranks-SUBPHYLUM": "SUBPHYLUM",
|
||||
"Ranks-Subphylum": "Subphylum",
|
||||
"Ranks-SUBSECTION": "SUBSECTION",
|
||||
"Ranks-Subsection": "Subsection",
|
||||
"Ranks-SUBSPECIES": "SUBSPECIES",
|
||||
"Ranks-Subspecies": "Subspecies",
|
||||
"Ranks-SUBTERCLASS": "SUBTERCLASS",
|
||||
"Ranks-Subterclass": "Subterclass",
|
||||
"Ranks-SUBTRIBE": "SUBTRIBE",
|
||||
"Ranks-Subtribe": "Subtribe",
|
||||
"Ranks-SUPERCLASS": "SUPERCLASS",
|
||||
"Ranks-Superclass": "Superclass",
|
||||
"Ranks-SUPERFAMILY": "SUPERFAMILY",
|
||||
"Ranks-Superfamily": "Superfamily",
|
||||
"Ranks-SUPERORDER": "SUPERORDER",
|
||||
"Ranks-Superorder": "Superorder",
|
||||
"Ranks-SUPERTRIBE": "SUPERTRIBE",
|
||||
"Ranks-Supertribe": "Supertribe",
|
||||
"Ranks-TRIBE": "TRIBE",
|
||||
"Ranks-Tribe": "Tribe",
|
||||
"Ranks-VARIETY": "VARIETY",
|
||||
"Ranks-Variety": "Variety",
|
||||
"Ranks-ZOOSECTION": "ZOOSECTION",
|
||||
"Ranks-Zoosection": "Zoosection",
|
||||
"Ranks-ZOOSUBSECTION": "ZOOSUBSECTION",
|
||||
"Ranks-Zoosubsection": "Zoosubsection",
|
||||
"Read-more-on-Wikipedia": "Read more on Wikipedia",
|
||||
"RECORD-NEW-SOUND": {
|
||||
"comment": "Heading for the sound recorder",
|
||||
@@ -1353,6 +1395,10 @@
|
||||
"Umbrella-Project": "Umbrella Project",
|
||||
"UNFOLLOW": "UNFOLLOW",
|
||||
"UNFOLLOW-USER": "UNFOLLOW USER?",
|
||||
"Unknown--rank": {
|
||||
"comment": "Text to show when a taoxn rank is unknown or missing",
|
||||
"val": "Unknown"
|
||||
},
|
||||
"Unknown--taxon": {
|
||||
"comment": "Text to show when a taxon or identification is unknown or missing",
|
||||
"val": "Unknown"
|
||||
@@ -1406,6 +1452,10 @@
|
||||
"val": "USERNAME OR EMAIL"
|
||||
},
|
||||
"Using-iNaturalist-requires-the-storage": "Using iNaturalist requires the storage of personal information like your email address, all iNaturalist data is stored in the United States, and we cannot be sure what legal jurisdiction you are in when you are using iNaturalist, so in order to comply with privacy laws like the GDPR, you must acknowledge that you understand and accept this risk and consent to transferring your personal information to iNaturalist's servers in the US.",
|
||||
"Version-app-build": {
|
||||
"comment": "Listing of app and build versions",
|
||||
"val": "Version { $appVersion } ({ $buildVersion })"
|
||||
},
|
||||
"VIEW-CHILDREN-TAXA": "VIEW CHILDREN TAXA",
|
||||
"VIEW-DATA-QUALITY-ASSESSMENT": "VIEW DATA QUALITY ASSESSMENT",
|
||||
"VIEW-EDUCATORS-GUIDE": "VIEW EDUCATOR'S GUIDE",
|
||||
@@ -1524,5 +1574,9 @@
|
||||
"Youve-previously-denied-location-permissions": "You’ve previously denied location permissions, so please enable them in settings.",
|
||||
"Youve-previously-denied-microphone-permissions": "You’ve previously denied microphone permissions, so please enable them in settings.",
|
||||
"Zoom-in-as-much-as-possible-to-improve": "Zoom in as much as possible to improve location accuracy and get better identifications.",
|
||||
"Zoom-to-current-location": "Zoom to current location"
|
||||
"Zoom-to-current-location": "Zoom to current location",
|
||||
"zoom-x": {
|
||||
"comment": "Label for button that shows zoom level, e.g. on a camera",
|
||||
"val": "{ $zoom }×"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ count-observations =
|
||||
[one] 1 observation
|
||||
*[other] { $count } observations
|
||||
}
|
||||
date-format-short = d/M/yy
|
||||
date-observed = Date observed: { $date }
|
||||
date-uploaded = Date uploaded: { $date }
|
||||
datetime-format-short = d/M/yy h:mm a
|
||||
# Label for a view that shows observations as a grid of photos
|
||||
Grid-View = Grid View
|
||||
# Label for a view that shows observations a list
|
||||
List-View = List View
|
||||
Observations = Observations
|
||||
Your-Observations = Tus observaciones
|
||||
date-format-short = d/M/yy
|
||||
datetime-format-short = d/M/yy h:mm a
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"count-observations": "{ $count ->\n [one] 1 observation\n *[other] { $count } observations\n}",
|
||||
"date-format-short": "d/M/yy",
|
||||
"date-observed": "Date observed: { $date }",
|
||||
"date-uploaded": "Date uploaded: { $date }",
|
||||
"datetime-format-short": "d/M/yy h:mm a",
|
||||
"Grid-View": {
|
||||
"comment": "Label for a view that shows observations as a grid of photos",
|
||||
"val": "Grid View"
|
||||
@@ -11,7 +13,5 @@
|
||||
"val": "List View"
|
||||
},
|
||||
"Observations": "Observations",
|
||||
"Your-Observations": "Tus observaciones",
|
||||
"date-format-short": "d/M/yy",
|
||||
"datetime-format-short": "d/M/yy h:mm a"
|
||||
"Your-Observations": "Tus observaciones"
|
||||
}
|
||||
|
||||
@@ -2,16 +2,18 @@ date-observed = Fecha de observación: { $date }
|
||||
date-uploaded = Fecha de subido: { $date }
|
||||
# Label for a view that shows observations as a grid of photos
|
||||
Grid-View = Ver rejilla
|
||||
# Latitude, longitude, and accuracy on a single line
|
||||
Lat-Lon-Acc = Lat: { NUMBER($latitude, maximumFractionDigits: 6) }, Lon: { NUMBER($longitude, maximumFractionDigits: 6) }, Pre: { $accuracy }
|
||||
# Label for a view that shows observations a list
|
||||
List-View = Ver lista
|
||||
Observation = Observación
|
||||
Observations = Observaciones
|
||||
Your-Observations = Sus observaciones
|
||||
TAXONOMY-header = TAXONOMIE es bueno
|
||||
Welcome-to-iNaturalist = ¡Bienvenido a iNaturalist!
|
||||
Welcome-user = <0>Bienvenido de nuevo,</0><1>{ $userHandle }</1>
|
||||
X-Observations =
|
||||
{ $count ->
|
||||
[one] 1 Observación
|
||||
*[other] { $count } Observaciones
|
||||
}
|
||||
Welcome-to-iNaturalist = ¡Bienvenido a iNaturalist!
|
||||
Welcome-user = <0>Bienvenido de nuevo,</0><1>{$userHandle}</1>
|
||||
Your-Observations = Sus observaciones
|
||||
|
||||
@@ -5,15 +5,19 @@
|
||||
"comment": "Label for a view that shows observations as a grid of photos",
|
||||
"val": "Ver rejilla"
|
||||
},
|
||||
"Lat-Lon-Acc": {
|
||||
"comment": "Latitude, longitude, and accuracy on a single line",
|
||||
"val": "Lat: { NUMBER($latitude, maximumFractionDigits: \"6\") }, Lon: { NUMBER($longitude, maximumFractionDigits: \"6\") }, Pre: { $accuracy }"
|
||||
},
|
||||
"List-View": {
|
||||
"comment": "Label for a view that shows observations a list",
|
||||
"val": "Ver lista"
|
||||
},
|
||||
"Observation": "Observación",
|
||||
"Observations": "Observaciones",
|
||||
"Your-Observations": "Sus observaciones",
|
||||
"TAXONOMY-header": "TAXONOMIE es bueno",
|
||||
"X-Observations": "{ $count ->\n [one] 1 Observación\n *[other] { $count } Observaciones\n}",
|
||||
"Welcome-to-iNaturalist": "¡Bienvenido a iNaturalist!",
|
||||
"Welcome-user": "<0>Bienvenido de nuevo,</0><1>{ $userHandle }</1>"
|
||||
"Welcome-user": "<0>Bienvenido de nuevo,</0><1>{ $userHandle }</1>",
|
||||
"X-Observations": "{ $count ->\n [one] 1 Observación\n *[other] { $count } Observaciones\n}",
|
||||
"Your-Observations": "Sus observaciones"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
Native-to-place = Originaire { VOWORCON($place) ->
|
||||
[vow] d'{ $place }
|
||||
*[con] de { $place }
|
||||
}
|
||||
date-format-short = d/M/yy
|
||||
Date-short-format = dd/MM/yy
|
||||
Date-this-year = d MMM
|
||||
Date-weeks = { $count }S
|
||||
datetime-format-short = d/M/yy HH:mm
|
||||
Native-to-place =
|
||||
Originaire { VOWORCON($place) ->
|
||||
[vow] d'{ $place }
|
||||
*[con] de { $place }
|
||||
}
|
||||
Welcome-user = <0>Bienvenue à toi,</0><1>{ $userHandle }</1>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
{
|
||||
"Native-to-place": "Originaire { VOWORCON($place) ->\n [vow] d'{ $place }\n *[con] de { $place }\n}"
|
||||
"date-format-short": "d/M/yy",
|
||||
"Date-short-format": "dd/MM/yy",
|
||||
"Date-this-year": "d MMM",
|
||||
"Date-weeks": "{ $count }S",
|
||||
"datetime-format-short": "d/M/yy HH:mm",
|
||||
"Native-to-place": "Originaire { VOWORCON($place) ->\n [vow] d'{ $place }\n *[con] de { $place }\n}",
|
||||
"Welcome-user": "<0>Bienvenue à toi,</0><1>{ $userHandle }</1>"
|
||||
}
|
||||
|
||||
@@ -239,19 +239,18 @@ Data-quality-casual-description = This observation needs more information verifi
|
||||
Data-quality-needs-id-description = This observation needs more identifications to reach research grade
|
||||
Data-quality-research-description = This observation has enough identifications to be considered research grade
|
||||
DATE = DATE
|
||||
# Used when displaying a relative time - in this case, X days ago (e.g. 3d = 3 days ago)
|
||||
Date-days = { $count }d
|
||||
# Date formatting using date-fns
|
||||
# Used for things like User Profile join date
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
date-format-long = PP
|
||||
# Date formatting using date-fns
|
||||
# Used when displaying a relative time - in this case, shows only month+year (same year) - e.g. Jul 3
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
date-format-month-day = MMM d
|
||||
# Use when only showing an observations month and year
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
date-format-month-year = MMM yyyy
|
||||
# Short date, e.g. on notifications from over a year ago
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
date-format-short = M/d/yy
|
||||
# Used when displaying a relative time - in this case, X hours ago (e.g. 3h = 3 hours ago)
|
||||
Date-hours = { $count }h
|
||||
# Used when displaying a relative time - in this case, X minutes ago (e.g. 3m = 3 minutes ago)
|
||||
Date-minutes = { $count }m
|
||||
date-month-year = MMM yyyy
|
||||
DATE-OBSERVED = DATE OBSERVED
|
||||
Date-observed = Date observed
|
||||
Date-observed-header-short = Observed
|
||||
@@ -261,18 +260,23 @@ DATE-OBSERVED-OLDEST = DATE OBSERVED - OLDEST TO NEWEST
|
||||
Date-Range = Date Range
|
||||
# Label for controls over a range of dates
|
||||
DATE-RANGE = DATE RANGE
|
||||
# Used when displaying a relative time - in this case, shows an absolute date (e.g. 12/31/22)
|
||||
Date-short-format = MM/dd/yy
|
||||
# Used when displaying a relative time - in this case, shows only month+year (same year) - e.g. Jul 3
|
||||
Date-this-year = MMM d
|
||||
DATE-UPLOADED = DATE UPLOADED
|
||||
Date-uploaded = Date uploaded
|
||||
Date-uploaded-header-short = Uploaded
|
||||
DATE-UPLOADED-NEWEST = DATE UPLOADED - NEWEST TO OLDEST
|
||||
DATE-UPLOADED-OLDEST = DATE UPLOADED - OLDEST TO NEWEST
|
||||
# Used when displaying a relative time - in this case, X days ago (e.g. 3d = 3 days ago)
|
||||
datetime-difference-days = { $count }d
|
||||
# Used when displaying a relative time - in this case, X hours ago (e.g. 3h = 3 hours ago)
|
||||
datetime-difference-hours = { $count }h
|
||||
# Used when displaying a relative time - in this case, X minutes ago (e.g. 3m = 3 minutes ago)
|
||||
datetime-difference-minutes = { $count }m
|
||||
# Used when displaying a relative time - in this case, X weeks ago (e.g. 3w = 3 weeks ago)
|
||||
Date-weeks = { $count }w
|
||||
# Date formatting using date-fns
|
||||
datetime-difference-weeks = { $count }w
|
||||
# Longer datetime, e.g. on ObsEdit
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
datetime-format-long = Pp
|
||||
# Shorter datetime, e.g. on comments and IDs
|
||||
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
|
||||
datetime-format-short = M/d/yy h:mm a
|
||||
# Month of December
|
||||
@@ -301,7 +305,9 @@ Deleting-x-of-y-observations =
|
||||
DETAILS = DETAILS
|
||||
# Button that disables the camera's flash
|
||||
Disable-flash = Disable flash
|
||||
Disagreement = <0>*@{ $username } disagrees this is </0><1></1>
|
||||
# Disagreement notice with an identificaiton, <0/> will get replaced by a
|
||||
# taxon name
|
||||
Disagreement = *@{ $username } disagrees this is <0/>
|
||||
# Button that discards changes or an item, e.g. a photo
|
||||
DISCARD = DISCARD
|
||||
# Button that discards all items, e.g. imported photos
|
||||
@@ -519,9 +525,9 @@ June = June
|
||||
Just-make-sure-the-organism-is-wild = Just make sure the organism is wild (not a pet, zoo animal, or garden plant)
|
||||
# Shows date user last active on iNaturalist on user profile
|
||||
Last-Active-date = Last Active: { $date }
|
||||
# Latitude, longitude on a single line on a single line
|
||||
# Latitude, longitude on a single line
|
||||
Lat-Lon = { NUMBER($latitude, maximumFractionDigits: 6) }, { NUMBER($longitude, maximumFractionDigits: 6) }
|
||||
# Latitude, longitude, and accuracy on a single line on a single line
|
||||
# Latitude, longitude, and accuracy on a single line
|
||||
Lat-Lon-Acc = Lat: { NUMBER($latitude, maximumFractionDigits: 6) }, Lon: { NUMBER($longitude, maximumFractionDigits: 6) }, Acc: { $accuracy }
|
||||
# Identification category
|
||||
leading--identification = Leading
|
||||
@@ -752,41 +758,77 @@ Quality-Grade-research = Quality Grade Research Grade
|
||||
# Quality grade options
|
||||
quality-grade-research = Research Grade
|
||||
Ranks-CLASS = CLASS
|
||||
Ranks-Class = Class
|
||||
Ranks-COMPLEX = COMPLEX
|
||||
Ranks-Complex = Complex
|
||||
Ranks-EPIFAMILY = EPIFAMILY
|
||||
Ranks-Epifamily = Epifamily
|
||||
Ranks-FAMILY = FAMILY
|
||||
Ranks-Family = Family
|
||||
Ranks-FORM = FORM
|
||||
Ranks-Form = Form
|
||||
Ranks-GENUS = GENUS
|
||||
Ranks-Genus = Genus
|
||||
Ranks-GENUSHYBRID = GENUSHYBRID
|
||||
Ranks-Genushybrid = Genushybrid
|
||||
Ranks-HYBRID = HYBRID
|
||||
Ranks-Hybrid = Hybrid
|
||||
Ranks-INFRACLASS = INFRACLASS
|
||||
Ranks-Infraclass = Infraclass
|
||||
Ranks-INFRAHYBRID = INFRAHYBRID
|
||||
Ranks-Infrahybrid = Infrahybrid
|
||||
Ranks-INFRAORDER = INFRAORDER
|
||||
Ranks-Infraorder = Infraorder
|
||||
Ranks-KINGDOM = KINGDOM
|
||||
Ranks-Kingdom = Kingdom
|
||||
Ranks-ORDER = ORDER
|
||||
Ranks-Order = Order
|
||||
Ranks-PARVORDER = PARVORDER
|
||||
Ranks-Parvorder = Parvorder
|
||||
Ranks-PHYLUM = PHYLUM
|
||||
Ranks-Phylum = Phylum
|
||||
Ranks-SECTION = SECTION
|
||||
Ranks-Section = Section
|
||||
Ranks-SPECIES = SPECIES
|
||||
Ranks-Species = Species
|
||||
Ranks-Statefmatter = State of matter
|
||||
Ranks-STATEOFMATTER = STATE OF MATTER
|
||||
Ranks-SUBCLASS = SUBCLASS
|
||||
Ranks-Subclass = Subclass
|
||||
Ranks-SUBFAMILY = SUBFAMILY
|
||||
Ranks-Subfamily = Subfamily
|
||||
Ranks-SUBGENUS = SUBGENUS
|
||||
Ranks-Subgenus = Subgenus
|
||||
Ranks-SUBKINGDOM = SUBKINGDOM
|
||||
Ranks-Subkingdom = Subkingdom
|
||||
Ranks-SUBORDER = SUBORDER
|
||||
Ranks-Suborder = Suborder
|
||||
Ranks-SUBPHYLUM = SUBPHYLUM
|
||||
Ranks-Subphylum = Subphylum
|
||||
Ranks-SUBSECTION = SUBSECTION
|
||||
Ranks-Subsection = Subsection
|
||||
Ranks-SUBSPECIES = SUBSPECIES
|
||||
Ranks-Subspecies = Subspecies
|
||||
Ranks-SUBTERCLASS = SUBTERCLASS
|
||||
Ranks-Subterclass = Subterclass
|
||||
Ranks-SUBTRIBE = SUBTRIBE
|
||||
Ranks-Subtribe = Subtribe
|
||||
Ranks-SUPERCLASS = SUPERCLASS
|
||||
Ranks-Superclass = Superclass
|
||||
Ranks-SUPERFAMILY = SUPERFAMILY
|
||||
Ranks-Superfamily = Superfamily
|
||||
Ranks-SUPERORDER = SUPERORDER
|
||||
Ranks-Superorder = Superorder
|
||||
Ranks-SUPERTRIBE = SUPERTRIBE
|
||||
Ranks-Supertribe = Supertribe
|
||||
Ranks-TRIBE = TRIBE
|
||||
Ranks-Tribe = Tribe
|
||||
Ranks-VARIETY = VARIETY
|
||||
Ranks-Variety = Variety
|
||||
Ranks-ZOOSECTION = ZOOSECTION
|
||||
Ranks-Zoosection = Zoosection
|
||||
Ranks-ZOOSUBSECTION = ZOOSUBSECTION
|
||||
Ranks-Zoosubsection = Zoosubsection
|
||||
Read-more-on-Wikipedia = Read more on Wikipedia
|
||||
# Heading for the sound recorder
|
||||
RECORD-NEW-SOUND = RECORD NEW SOUND
|
||||
@@ -997,6 +1039,8 @@ Traditional-Project = Traditional Project
|
||||
Umbrella-Project = Umbrella Project
|
||||
UNFOLLOW = UNFOLLOW
|
||||
UNFOLLOW-USER = UNFOLLOW USER?
|
||||
# Text to show when a taoxn rank is unknown or missing
|
||||
Unknown--rank = Unknown
|
||||
# Text to show when a taxon or identification is unknown or missing
|
||||
Unknown--taxon = Unknown
|
||||
# Text to show when a user (or their name) is unknown or missing
|
||||
@@ -1040,6 +1084,8 @@ USERNAME = USERNAME
|
||||
# Appears above the text fields
|
||||
USERNAME-OR-EMAIL = USERNAME OR EMAIL
|
||||
Using-iNaturalist-requires-the-storage = Using iNaturalist requires the storage of personal information like your email address, all iNaturalist data is stored in the United States, and we cannot be sure what legal jurisdiction you are in when you are using iNaturalist, so in order to comply with privacy laws like the GDPR, you must acknowledge that you understand and accept this risk and consent to transferring your personal information to iNaturalist's servers in the US.
|
||||
# Listing of app and build versions
|
||||
Version-app-build = Version { $appVersion } ({ $buildVersion })
|
||||
VIEW-CHILDREN-TAXA = VIEW CHILDREN TAXA
|
||||
VIEW-DATA-QUALITY-ASSESSMENT = VIEW DATA QUALITY ASSESSMENT
|
||||
VIEW-EDUCATORS-GUIDE = VIEW EDUCATOR'S GUIDE
|
||||
@@ -1209,3 +1255,5 @@ Youve-previously-denied-location-permissions = You’ve previously denied locati
|
||||
Youve-previously-denied-microphone-permissions = You’ve previously denied microphone permissions, so please enable them in settings.
|
||||
Zoom-in-as-much-as-possible-to-improve = Zoom in as much as possible to improve location accuracy and get better identifications.
|
||||
Zoom-to-current-location = Zoom to current location
|
||||
# Label for button that shows zoom level, e.g. on a camera
|
||||
zoom-x = { $zoom }×
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Realm } from "@realm/react";
|
||||
import uuid from "react-native-uuid";
|
||||
import { createObservedOnStringForUpload } from "sharedHelpers/dateAndTime";
|
||||
import { getNowISO } from "sharedHelpers/dateAndTime.ts";
|
||||
import { readExifFromMultiplePhotos } from "sharedHelpers/parseExif";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
|
||||
@@ -116,7 +116,7 @@ class Observation extends Realm.Object {
|
||||
observed_on: obs?.observed_on,
|
||||
observed_on_string: obs
|
||||
? obs?.observed_on_string
|
||||
: createObservedOnStringForUpload( ),
|
||||
: getNowISO( ),
|
||||
quality_grade: "needs_id",
|
||||
needs_sync: true,
|
||||
uuid: uuid.v4( )
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import {
|
||||
differenceInDays,
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
formatDistanceToNow,
|
||||
formatISO,
|
||||
fromUnixTime,
|
||||
getUnixTime,
|
||||
getYear,
|
||||
parseISO
|
||||
} from "date-fns";
|
||||
|
||||
const formatISONoTimezone = date => {
|
||||
if ( !date ) {
|
||||
return "";
|
||||
}
|
||||
const formattedISODate = formatISO( date );
|
||||
if ( !formattedISODate ) {
|
||||
return "";
|
||||
}
|
||||
// Always take the first part of the time/date string,
|
||||
// without any extra timezone, etc (just "2022-12-31T23:59:59")
|
||||
return formattedISODate.substring( 0, 19 );
|
||||
};
|
||||
|
||||
// two options for observed_on_string in uploader are:
|
||||
// 2020-03-01 00:00 or 2021-03-24T14:40:25
|
||||
// this is using the second format
|
||||
// https://github.com/inaturalist/inaturalist/blob/b12f16099fc8ad0c0961900d644507f6952bec66/spec/controllers/observation_controller_api_spec.rb#L161
|
||||
const formatDateStringFromTimestamp = timestamp => {
|
||||
if ( !timestamp ) {
|
||||
return "";
|
||||
}
|
||||
const date = fromUnixTime( timestamp );
|
||||
return formatISONoTimezone( date );
|
||||
};
|
||||
|
||||
const createObservedOnStringForUpload = date => formatDateStringFromTimestamp(
|
||||
getUnixTime( date || new Date( ) )
|
||||
);
|
||||
|
||||
const createObservedOnStringFromDatePicker = date => {
|
||||
// DatePicker does not support seconds, so we're returning a date,
|
||||
// without timezone and without seconds (just "2022-12-31T23:59")
|
||||
const isoDate = formatISO( date );
|
||||
const isoDateNoSeconds = isoDate.substring( 0, 16 );
|
||||
return isoDateNoSeconds;
|
||||
};
|
||||
|
||||
const displayDateTimeObsEdit = date => {
|
||||
if ( !date ) { return ""; }
|
||||
// this displays date times formatted like 08/09/2024, 12:14 PM
|
||||
return format( new Date( date ), "Pp" );
|
||||
};
|
||||
|
||||
const timeAgo = pastTime => formatDistanceToNow( new Date( pastTime ) );
|
||||
|
||||
const formatApiDatetime = ( dateString, t ) => {
|
||||
if ( !dateString || dateString === "" ) {
|
||||
return t( "Missing-Date" );
|
||||
}
|
||||
const hasTime = dateString.includes( "T" );
|
||||
const date = parseISO( dateString );
|
||||
|
||||
if ( hasTime ) {
|
||||
return format( date, t( "datetime-format-short" ) );
|
||||
}
|
||||
return format( date, t( "date-format-short" ) );
|
||||
};
|
||||
|
||||
const formatObsListTime = date => {
|
||||
const dateTime = "M/d/yy h:mm a";
|
||||
if ( typeof date === "string" ) {
|
||||
return format( parseISO( date ), dateTime );
|
||||
}
|
||||
return format( date, dateTime );
|
||||
};
|
||||
|
||||
const formatIdDate = ( date, t ) => {
|
||||
const d = typeof date === "string"
|
||||
? parseISO( date )
|
||||
: new Date( date );
|
||||
const now = new Date();
|
||||
|
||||
const days = differenceInDays( now, d );
|
||||
|
||||
if ( days <= 30 ) {
|
||||
// Less than 30 days ago - display as 3m (mins), 3h (hours), 3d (days) or 3w (weeks)
|
||||
if ( days < 1 ) {
|
||||
const hours = differenceInHours( now, d );
|
||||
if ( hours < 1 ) {
|
||||
const minutes = differenceInMinutes( now, d );
|
||||
return t( "Date-minutes", { count: minutes } );
|
||||
}
|
||||
return t( "Date-hours", { count: hours } );
|
||||
} if ( days < 7 ) {
|
||||
return t( "Date-days", { count: days } );
|
||||
}
|
||||
return t( "Date-weeks", { count: parseInt( days / 7, 10 ) } );
|
||||
}
|
||||
if ( getYear( now ) !== getYear( d ) ) {
|
||||
// Previous year(s)
|
||||
return format( d, t( "Date-short-format" ) );
|
||||
}
|
||||
// Current year
|
||||
return format( d, t( "Date-this-year" ) );
|
||||
};
|
||||
|
||||
const formatMonthYearDate = ( dateString, t ) => {
|
||||
if ( !dateString || dateString === "" ) {
|
||||
return t( "Missing-Date" );
|
||||
}
|
||||
|
||||
const date = parseISO( dateString );
|
||||
return format( date, t( "date-month-year" ) );
|
||||
};
|
||||
|
||||
const formatUserProfileDate = ( date, t ) => format( parseISO( date ), t( "date-format-long" ) );
|
||||
|
||||
export {
|
||||
createObservedOnStringForUpload,
|
||||
createObservedOnStringFromDatePicker,
|
||||
displayDateTimeObsEdit,
|
||||
formatApiDatetime,
|
||||
formatDateStringFromTimestamp,
|
||||
formatIdDate,
|
||||
formatISONoTimezone,
|
||||
formatMonthYearDate,
|
||||
formatObsListTime,
|
||||
formatUserProfileDate,
|
||||
timeAgo
|
||||
};
|
||||
356
src/sharedHelpers/dateAndTime.ts
Normal file
356
src/sharedHelpers/dateAndTime.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
// Helpers for working with dates
|
||||
|
||||
// Note: this is a collection of reusable helpers. Please don't give them
|
||||
// names that are specific to particular views,
|
||||
// like "myObsDateFormat"
|
||||
|
||||
import {
|
||||
differenceInDays,
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
formatISO,
|
||||
fromUnixTime,
|
||||
getUnixTime,
|
||||
getYear,
|
||||
parseISO
|
||||
} from "date-fns";
|
||||
import {
|
||||
ar,
|
||||
be,
|
||||
bg,
|
||||
// br,
|
||||
ca,
|
||||
cs,
|
||||
da,
|
||||
de,
|
||||
el,
|
||||
// en,
|
||||
enGB,
|
||||
enIN,
|
||||
enNZ,
|
||||
enUS,
|
||||
eo,
|
||||
es,
|
||||
// esAR,
|
||||
// esCO,
|
||||
// esCR,
|
||||
// esMX,
|
||||
et,
|
||||
eu,
|
||||
fi,
|
||||
fr,
|
||||
frCA,
|
||||
gl,
|
||||
he,
|
||||
hr,
|
||||
hu,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
ka,
|
||||
kk,
|
||||
kn,
|
||||
ko,
|
||||
lb,
|
||||
lt,
|
||||
lv,
|
||||
// mi,
|
||||
mk,
|
||||
// mr,
|
||||
nb,
|
||||
nl,
|
||||
oc,
|
||||
pl,
|
||||
pt,
|
||||
ptBR,
|
||||
ru,
|
||||
// sat,
|
||||
sk,
|
||||
sl,
|
||||
sq,
|
||||
sr,
|
||||
sv,
|
||||
th,
|
||||
tr,
|
||||
uk,
|
||||
zhCN,
|
||||
zhTW
|
||||
} from "date-fns/locale";
|
||||
import { i18n as i18next } from "i18next";
|
||||
|
||||
// Convert iNat locale to date-fns locale. Note that coverage is *not*
|
||||
// complete, so some locales will see dates formatted in a nearby locale,
|
||||
// e.g. Breton users will see French dates, including French month
|
||||
// abbreviations. The only solution is to contribute new locales to date-fns:
|
||||
// https://date-fns.org/v4.1.0/docs/I18n-Contribution-Guide
|
||||
function dateFnsLocale( i18nextLanguage: string ) {
|
||||
switch ( i18nextLanguage ) {
|
||||
case "ar":
|
||||
return ar;
|
||||
case "be":
|
||||
return be;
|
||||
case "bg":
|
||||
return bg;
|
||||
case "br":
|
||||
// Assuming Breton uses the same date format as France
|
||||
return fr;
|
||||
case "ca":
|
||||
return ca;
|
||||
case "cs":
|
||||
return cs;
|
||||
case "da":
|
||||
return da;
|
||||
case "de":
|
||||
return de;
|
||||
case "el":
|
||||
return el;
|
||||
case "en":
|
||||
return enUS;
|
||||
case "en-GB":
|
||||
return enGB;
|
||||
case "eo":
|
||||
return eo;
|
||||
case "es":
|
||||
return es;
|
||||
case "es-AR":
|
||||
// date-fns doesn't have New World Spanish date formats, so we'll see how this goes
|
||||
return es;
|
||||
case "es-CO":
|
||||
// date-fns doesn't have New World Spanish date formats, so we'll see how this goes
|
||||
return es;
|
||||
case "es-CR":
|
||||
// date-fns doesn't have New World Spanish date formats, so we'll see how this goes
|
||||
return es;
|
||||
case "es-MX":
|
||||
// date-fns doesn't have New World Spanish date formats, so we'll see how this goes
|
||||
return es;
|
||||
case "et":
|
||||
return et;
|
||||
case "eu":
|
||||
return eu;
|
||||
case "fi":
|
||||
return fi;
|
||||
case "fr":
|
||||
return fr;
|
||||
case "fr-CA":
|
||||
return frCA;
|
||||
case "gl":
|
||||
return gl;
|
||||
case "he":
|
||||
return he;
|
||||
case "hr":
|
||||
return hr;
|
||||
case "hu":
|
||||
return hu;
|
||||
case "id":
|
||||
return id;
|
||||
case "it":
|
||||
return it;
|
||||
case "ja":
|
||||
return ja;
|
||||
case "ka":
|
||||
return ka;
|
||||
case "kk":
|
||||
return kk;
|
||||
case "kn":
|
||||
return kn;
|
||||
case "ko":
|
||||
return ko;
|
||||
case "lb":
|
||||
return lb;
|
||||
case "lt":
|
||||
return lt;
|
||||
case "lv":
|
||||
return lv;
|
||||
case "mi":
|
||||
// No support for Maori yet
|
||||
return enNZ;
|
||||
case "mk":
|
||||
return mk;
|
||||
case "mr":
|
||||
// No support for Marathi yet
|
||||
return enIN;
|
||||
case "nb":
|
||||
return nb;
|
||||
case "nl":
|
||||
return nl;
|
||||
case "oc":
|
||||
return oc;
|
||||
case "pl":
|
||||
return pl;
|
||||
case "pt":
|
||||
return pt;
|
||||
case "pt-BR":
|
||||
return ptBR;
|
||||
case "ru":
|
||||
return ru;
|
||||
case "sat":
|
||||
// No support for Santali yet
|
||||
return enIN;
|
||||
case "sk":
|
||||
return sk;
|
||||
case "sl":
|
||||
return sl;
|
||||
case "sq":
|
||||
return sq;
|
||||
case "sr":
|
||||
return sr;
|
||||
case "sv":
|
||||
return sv;
|
||||
case "th":
|
||||
return th;
|
||||
case "tr":
|
||||
return tr;
|
||||
case "uk":
|
||||
return uk;
|
||||
case "zh-CN":
|
||||
return zhCN;
|
||||
case "zh-TW":
|
||||
return zhTW;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
}
|
||||
|
||||
function formatISONoTimezone( date: Date ) {
|
||||
if ( !date ) {
|
||||
return "";
|
||||
}
|
||||
const formattedISODate = formatISO( date );
|
||||
if ( !formattedISODate ) {
|
||||
return "";
|
||||
}
|
||||
// Always take the first part of the time/date string,
|
||||
// without any extra timezone, etc (just "2022-12-31T23:59:59")
|
||||
return formattedISODate.substring( 0, 19 );
|
||||
}
|
||||
|
||||
// two options for observed_on_string in uploader are:
|
||||
// 2020-03-01 00:00 or 2021-03-24T14:40:25
|
||||
// this is using the second format
|
||||
// https://github.com/inaturalist/inaturalist/blob/b12f16099fc8ad0c0961900d644507f6952bec66/spec/controllers/observation_controller_api_spec.rb#L161
|
||||
function formatDateStringFromTimestamp( timestamp: number ) {
|
||||
if ( !timestamp ) {
|
||||
return "";
|
||||
}
|
||||
const date = fromUnixTime( timestamp );
|
||||
return formatISONoTimezone( date );
|
||||
}
|
||||
|
||||
function getNowISO( ) {
|
||||
return formatDateStringFromTimestamp(
|
||||
getUnixTime( new Date( ) )
|
||||
);
|
||||
}
|
||||
|
||||
// Some components, like DatePicker, do not support seconds, so we're
|
||||
// returning a date, without timezone and without seconds
|
||||
// (just "2022-12-31T23:59")
|
||||
function formatISONoSeconds( date: Date ) {
|
||||
const isoDate = formatISO( date );
|
||||
const isoDateNoSeconds = isoDate.substring( 0, 16 );
|
||||
return isoDateNoSeconds;
|
||||
}
|
||||
|
||||
function formatDifferenceForHumans( date: Date, i18n: i18next ) {
|
||||
const d = typeof date === "string"
|
||||
? parseISO( date )
|
||||
: new Date( date );
|
||||
const now = new Date();
|
||||
|
||||
const days = differenceInDays( now, d );
|
||||
|
||||
if ( days <= 30 ) {
|
||||
// Less than 30 days ago - display as 3m (mins), 3h (hours), 3d (days) or 3w (weeks)
|
||||
if ( days < 1 ) {
|
||||
const hours = differenceInHours( now, d );
|
||||
if ( hours < 1 ) {
|
||||
const minutes = differenceInMinutes( now, d );
|
||||
return i18n.t( "datetime-difference-minutes", { count: minutes } );
|
||||
}
|
||||
return i18n.t( "datetime-difference-hours", { count: hours } );
|
||||
} if ( days < 7 ) {
|
||||
return i18n.t( "datetime-difference-days", { count: days } );
|
||||
}
|
||||
return i18n.t( "datetime-difference-weeks", { count: days / 7 } );
|
||||
}
|
||||
console.log( "[DEBUG dateAndTime.ts] i18n.language: ", i18n.language );
|
||||
const formatOpts = { locale: dateFnsLocale( i18n.language ) };
|
||||
if ( getYear( now ) !== getYear( d ) ) {
|
||||
// Previous year(s)
|
||||
return format( d, i18n.t( "date-format-short" ), formatOpts );
|
||||
}
|
||||
// Current year
|
||||
return format( d, i18n.t( "date-format-month-day" ), formatOpts );
|
||||
}
|
||||
|
||||
type FormatDateStringOptions = {
|
||||
missing?: string | null;
|
||||
}
|
||||
|
||||
function formatDateString(
|
||||
dateString: string,
|
||||
fmt: string,
|
||||
i18n: i18next,
|
||||
options: FormatDateStringOptions = { }
|
||||
) {
|
||||
if ( !dateString || dateString === "" ) {
|
||||
return options.missing === undefined
|
||||
? i18n.t( "Missing-Date" )
|
||||
: options.missing;
|
||||
}
|
||||
return format(
|
||||
parseISO( dateString ),
|
||||
fmt,
|
||||
{ locale: dateFnsLocale( i18n.language ) }
|
||||
);
|
||||
}
|
||||
|
||||
function formatMonthYearDate(
|
||||
dateString: string,
|
||||
i18n: i18next,
|
||||
options: FormatDateStringOptions = {}
|
||||
) {
|
||||
return formatDateString( dateString, i18n.t( "date-format-month-year" ), i18n, options );
|
||||
}
|
||||
|
||||
function formatLongDate(
|
||||
dateString: string,
|
||||
i18n: i18next,
|
||||
options: FormatDateStringOptions = {}
|
||||
) {
|
||||
return formatDateString( dateString, i18n.t( "date-format-long" ), i18n, options );
|
||||
}
|
||||
|
||||
function formatLongDatetime(
|
||||
dateString: string,
|
||||
i18n: i18next,
|
||||
options: FormatDateStringOptions = {}
|
||||
) {
|
||||
return formatDateString( dateString, i18n.t( "datetime-format-long" ), i18n, options );
|
||||
}
|
||||
|
||||
function formatApiDatetime(
|
||||
dateString: string,
|
||||
i18n: i18next,
|
||||
options: FormatDateStringOptions = {}
|
||||
) {
|
||||
const hasTime = String( dateString ).includes( "T" );
|
||||
if ( hasTime ) {
|
||||
return formatDateString( dateString, i18n.t( "datetime-format-short" ), i18n, options );
|
||||
}
|
||||
return formatDateString( dateString, i18n.t( "date-format-short" ), i18n, options );
|
||||
}
|
||||
|
||||
export {
|
||||
formatApiDatetime,
|
||||
formatDateStringFromTimestamp,
|
||||
formatDifferenceForHumans,
|
||||
formatISONoSeconds,
|
||||
formatISONoTimezone,
|
||||
formatLongDate,
|
||||
formatLongDatetime,
|
||||
formatMonthYearDate,
|
||||
getNowISO
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { utcToZonedTime } from "date-fns-tz";
|
||||
import { readExif, writeLocation } from "react-native-exif-reader";
|
||||
import { formatISONoTimezone } from "sharedHelpers/dateAndTime";
|
||||
import { formatISONoTimezone } from "sharedHelpers/dateAndTime.ts";
|
||||
|
||||
class UsePhotoExifDateFormatError extends Error {}
|
||||
|
||||
|
||||
@@ -169,3 +169,83 @@ export async function fetchTaxonAndSave( id, realm, params = {}, opts = {} ) {
|
||||
}, "saving remote taxon in ObsDetails" );
|
||||
return mappedRemoteTaxon;
|
||||
}
|
||||
|
||||
// Translates rank in a way that can be statically checked
|
||||
export function translatedRank( rank, t ) {
|
||||
switch ( rank ) {
|
||||
case "Class":
|
||||
return t( "Ranks-Class" );
|
||||
case "Complex":
|
||||
return t( "Ranks-Complex" );
|
||||
case "Epifamily":
|
||||
return t( "Ranks-Epifamily" );
|
||||
case "Family":
|
||||
return t( "Ranks-Family" );
|
||||
case "Form":
|
||||
return t( "Ranks-Form" );
|
||||
case "Genus":
|
||||
return t( "Ranks-Genus" );
|
||||
case "Genushybrid":
|
||||
return t( "Ranks-Genushybrid" );
|
||||
case "Hybrid":
|
||||
return t( "Ranks-Hybrid" );
|
||||
case "Infraclass":
|
||||
return t( "Ranks-Infraclass" );
|
||||
case "Infrahybrid":
|
||||
return t( "Ranks-Infrahybrid" );
|
||||
case "Infraorder":
|
||||
return t( "Ranks-Infraorder" );
|
||||
case "Kingdom":
|
||||
return t( "Ranks-Kingdom" );
|
||||
case "Order":
|
||||
return t( "Ranks-Order" );
|
||||
case "Parvorder":
|
||||
return t( "Ranks-Parvorder" );
|
||||
case "Phylum":
|
||||
return t( "Ranks-Phylum" );
|
||||
case "Section":
|
||||
return t( "Ranks-Section" );
|
||||
case "Species":
|
||||
return t( "Ranks-Species" );
|
||||
case "Statefmatter":
|
||||
return t( "Ranks-Statefmatter" );
|
||||
case "Subclass":
|
||||
return t( "Ranks-Subclass" );
|
||||
case "Subfamily":
|
||||
return t( "Ranks-Subfamily" );
|
||||
case "Subgenus":
|
||||
return t( "Ranks-Subgenus" );
|
||||
case "Subkingdom":
|
||||
return t( "Ranks-Subkingdom" );
|
||||
case "Suborder":
|
||||
return t( "Ranks-Suborder" );
|
||||
case "Subphylum":
|
||||
return t( "Ranks-Subphylum" );
|
||||
case "Subsection":
|
||||
return t( "Ranks-Subsection" );
|
||||
case "Subspecies":
|
||||
return t( "Ranks-Subspecies" );
|
||||
case "Subterclass":
|
||||
return t( "Ranks-Subterclass" );
|
||||
case "Subtribe":
|
||||
return t( "Ranks-Subtribe" );
|
||||
case "Superclass":
|
||||
return t( "Ranks-Superclass" );
|
||||
case "Superfamily":
|
||||
return t( "Ranks-Superfamily" );
|
||||
case "Superorder":
|
||||
return t( "Ranks-Superorder" );
|
||||
case "Supertribe":
|
||||
return t( "Ranks-Supertribe" );
|
||||
case "Tribe":
|
||||
return t( "Ranks-Tribe" );
|
||||
case "Variety":
|
||||
return t( "Ranks-Variety" );
|
||||
case "Zoosection":
|
||||
return t( "Ranks-Zoosection" );
|
||||
case "Zoosubsection":
|
||||
return t( "Ranks-Zoosubsection" );
|
||||
default:
|
||||
return t( "Unknown--rank" );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import createUploadObservationsSlice from "./createUploadObservationsSlice";
|
||||
|
||||
export const storage = new MMKV( );
|
||||
|
||||
// TODO do *not* export this. This allows any consumer to overwrite *any* part
|
||||
// of state, circumventing any getter/setter logic we have in the stores. If
|
||||
// you need to modify state, you should be doing so through a store.
|
||||
export const zustandStorage = {
|
||||
setItem: ( name, value ) => storage.set( name, value ),
|
||||
getItem: name => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import ObsDetailsContainer from "components/ObsDetails/ObsDetailsContainer";
|
||||
import i18next, { t } from "i18next";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { formatApiDatetime } from "sharedHelpers/dateAndTime";
|
||||
import { formatApiDatetime } from "sharedHelpers/dateAndTime.ts";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import * as useCurrentUser from "sharedHooks/useCurrentUser.ts";
|
||||
import * as useLocalObservation from "sharedHooks/useLocalObservation";
|
||||
@@ -197,11 +197,11 @@ describe( "ObsDetails", () => {
|
||||
it( "renders observed date of observation in header", async ( ) => {
|
||||
renderObsDetails( );
|
||||
const observedDate = await screen.findByText(
|
||||
formatApiDatetime( mockObservation.time_observed_at, i18next.t )
|
||||
formatApiDatetime( mockObservation.time_observed_at, i18next )
|
||||
);
|
||||
expect( observedDate ).toBeVisible( );
|
||||
const createdDate = screen.queryByText(
|
||||
formatApiDatetime( mockObservation.created_at, i18next.t )
|
||||
formatApiDatetime( mockObservation.created_at, i18next )
|
||||
);
|
||||
expect( createdDate ).toBeFalsy( );
|
||||
} );
|
||||
|
||||
@@ -359,7 +359,8 @@ exports[`TaxonResult should render correctly 1`] = `
|
||||
]
|
||||
}
|
||||
>
|
||||
Family
|
||||
Family
|
||||
|
||||
<Text
|
||||
maxFontSizeMultiplier={2}
|
||||
style={
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import {
|
||||
parseISO,
|
||||
subDays,
|
||||
subHours,
|
||||
subMinutes
|
||||
} from "date-fns";
|
||||
import factory from "factoria";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import i18next from "i18next";
|
||||
import { formatApiDatetime } from "sharedHelpers/dateAndTime";
|
||||
import {
|
||||
formatApiDatetime,
|
||||
formatDifferenceForHumans,
|
||||
formatISONoSeconds,
|
||||
getNowISO
|
||||
} from "sharedHelpers/dateAndTime.ts";
|
||||
|
||||
const remoteObservation = factory( "RemoteObservation", {
|
||||
created_at: "2015-02-12T20:41:10-08:00"
|
||||
@@ -16,47 +27,50 @@ const remoteComment = factory( "RemoteComment", {
|
||||
|
||||
describe( "formatApiDatetime", ( ) => {
|
||||
describe( "in default locale", ( ) => {
|
||||
beforeAll( async ( ) => {
|
||||
await initI18next( { lng: "en" } );
|
||||
} );
|
||||
it( "should return missing date string if no date is present", async ( ) => {
|
||||
expect( formatApiDatetime( null, i18next.t ) ).toEqual( "Missing Date" );
|
||||
expect( formatApiDatetime( null, i18next ) ).toEqual( "Missing Date" );
|
||||
} );
|
||||
|
||||
it( "should return missing date string if date is empty", ( ) => {
|
||||
const date = "";
|
||||
expect( formatApiDatetime( date, i18next.t ) ).toEqual( "Missing Date" );
|
||||
expect( formatApiDatetime( date, i18next ) ).toEqual( "Missing Date" );
|
||||
} );
|
||||
|
||||
it( "should return a localized date when a date string is passed in", ( ) => {
|
||||
const date = "2022-11-02";
|
||||
expect( formatApiDatetime( date, i18next.t ) ).toEqual( "11/2/22" );
|
||||
expect( formatApiDatetime( date, i18next ) ).toEqual( "11/2/22" );
|
||||
} );
|
||||
|
||||
it( "should return a localized datetime when a datetime string is passed in", ( ) => {
|
||||
const date = "2022-11-02T18:43:00+00:00";
|
||||
expect( formatApiDatetime( date, i18next.t ) ).toEqual( "11/2/22 6:43 PM" );
|
||||
expect( formatApiDatetime( date, i18next ) ).toEqual( "11/2/22 6:43 PM" );
|
||||
} );
|
||||
|
||||
it( "should return a localized datetime for a remote observation created_at date", ( ) => {
|
||||
expect(
|
||||
formatApiDatetime( remoteObservation.created_at, i18next.t )
|
||||
formatApiDatetime( remoteObservation.created_at, i18next )
|
||||
).toEqual( "2/13/15 4:41 AM" );
|
||||
} );
|
||||
|
||||
it( "should return a localized datetime for a remote identification created_at date", ( ) => {
|
||||
expect(
|
||||
formatApiDatetime( remoteIdentification.created_at, i18next.t )
|
||||
formatApiDatetime( remoteIdentification.created_at, i18next )
|
||||
).toEqual( "2/13/15 5:12 AM" );
|
||||
} );
|
||||
|
||||
it( "should return a localized datetime for a remote comment created_at date", ( ) => {
|
||||
expect(
|
||||
formatApiDatetime( remoteComment.created_at, i18next.t )
|
||||
formatApiDatetime( remoteComment.created_at, i18next )
|
||||
).toEqual( "2/13/15 5:15 AM" );
|
||||
} );
|
||||
|
||||
it( "should return the date in the local time zone by default", () => {
|
||||
expect( process.env.TZ ).toEqual( "UTC" );
|
||||
expect(
|
||||
formatApiDatetime( "2023-01-02T08:00:00+01:00", i18next.t )
|
||||
formatApiDatetime( "2023-01-02T08:00:00+01:00", i18next )
|
||||
).toEqual( "1/2/23 7:00 AM" );
|
||||
} );
|
||||
|
||||
@@ -73,7 +87,59 @@ describe( "formatApiDatetime", ( ) => {
|
||||
|
||||
it( "should return a localized date when a date string is passed in", ( ) => {
|
||||
const date = "2022-11-02";
|
||||
expect( formatApiDatetime( date, i18next.t ) ).toEqual( "2/11/22" );
|
||||
expect( formatApiDatetime( date, i18next ) ).toEqual( "2/11/22" );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( "getNowISO", ( ) => {
|
||||
it( "should return a valid ISO8601 string", ( ) => {
|
||||
const dateString = getNowISO( );
|
||||
expect( parseISO( dateString ) ).not.toBeNull( );
|
||||
} );
|
||||
it( "should not have a time zone", ( ) => {
|
||||
const dateString = getNowISO( );
|
||||
expect( parseISO( dateString ) ).not.toContain( "Z" );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( "formatISONoSeconds", ( ) => {
|
||||
it( "should not include seconds", ( ) => {
|
||||
const dateString = formatISONoSeconds( new Date( ) );
|
||||
expect( dateString.split( ":" ).length ).toEqual( 2 );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( "formatDifferenceForHumans", ( ) => {
|
||||
beforeAll( async ( ) => {
|
||||
await initI18next( { lng: "en" } );
|
||||
} );
|
||||
it( "should show difference in minutes", ( ) => {
|
||||
expect( formatDifferenceForHumans( subMinutes( new Date(), 3 ), i18next ) ).toMatch( /\d+m/ );
|
||||
} );
|
||||
it( "should show difference in hours", ( ) => {
|
||||
expect( formatDifferenceForHumans( subHours( new Date(), 3 ), i18next ) ).toMatch( /\d+h/ );
|
||||
} );
|
||||
it( "should show difference in days", ( ) => {
|
||||
expect( formatDifferenceForHumans( subDays( new Date(), 3 ), i18next ) ).toMatch( /\d+d/ );
|
||||
} );
|
||||
it( "should show difference in weeks", ( ) => {
|
||||
expect( formatDifferenceForHumans( subDays( new Date(), 14 ), i18next ) ).toMatch( /\d+w/ );
|
||||
} );
|
||||
it( "should show day and month if over 30 days ago but still this year", ( ) => {
|
||||
const date = subDays( new Date(), 40 );
|
||||
const dateString = formatDifferenceForHumans( date, i18next );
|
||||
const pattern = new RegExp( `\\w+ ${date.getDate()}` );
|
||||
expect( dateString ).toMatch( pattern );
|
||||
} );
|
||||
it( "should show full date for prior years", ( ) => {
|
||||
const date = subDays( new Date(), 400 );
|
||||
const dateString = formatDifferenceForHumans( date, i18next );
|
||||
const pattern = new RegExp( [
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
date.getFullYear() % 1000
|
||||
].join( "/" ) );
|
||||
expect( dateString ).toMatch( pattern );
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"stores": ["src/stores"],
|
||||
"styles": ["src/styles"],
|
||||
"tests": ["tests"],
|
||||
}
|
||||
},
|
||||
"types": ["nativewind/types"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user