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:
Ken-ichi
2024-09-26 22:33:23 -07:00
committed by GitHub
parent 9b53d3b951
commit a3a43c4e2c
48 changed files with 1112 additions and 416 deletions

View File

@@ -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

View File

@@ -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( );

View File

@@ -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;

View File

@@ -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( {

View File

@@ -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;

View File

@@ -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 );

View 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;

View File

@@ -1136,7 +1136,7 @@ const FilterModal = ( {
value={establishmentValues[establishmentKey]}
checked={
establishmentValues[establishmentKey].value
=== establishmentMean
=== establishmentMean
}
onPress={() => dispatch( {
type: EXPLORE_ACTION.SET_ESTABLISHMENT_MEAN,

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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 );

View File

@@ -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" );

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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 (

View 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;

View File

@@ -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

View File

@@ -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} `

View File

@@ -36,7 +36,7 @@ type Props = {
removeStyling?: boolean,
prefersCommonNames?: boolean,
scientificNameFirst?: boolean,
showOneNameOnly: boolean,
showOneNameOnly?: boolean,
selectable?: boolean,
small?: boolean,
taxon: Object,

View File

@@ -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}

View File

@@ -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 (

View File

@@ -15,7 +15,7 @@ type Props = {
confirm: Function,
headerText: string,
pickerValues: Object,
selectedValue: boolean,
selectedValue: boolean | string,
insideModal?: boolean
};

View File

@@ -28,7 +28,7 @@ type Props = {
headerText: string,
initialInput?: string,
maxLength?: number,
placeholder: string,
placeholder?: string,
textInputStyle?: Object
}

View File

@@ -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(

View File

@@ -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";

View File

@@ -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={

View File

@@ -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">

View File

@@ -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

View File

@@ -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",

View File

@@ -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 = Youve previously denied locati
Youve-previously-denied-microphone-permissions = Youve 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 }×

View File

@@ -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": "Youve previously denied location permissions, so please enable them in settings.",
"Youve-previously-denied-microphone-permissions": "Youve 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 }×"
}
}

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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>

View File

@@ -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>"
}

View File

@@ -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 = Youve previously denied locati
Youve-previously-denied-microphone-permissions = Youve 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 }×

View File

@@ -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( )

View File

@@ -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
};

View 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
};

View File

@@ -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 {}

View File

@@ -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" );
}
}

View File

@@ -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 => {

View File

@@ -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( );
} );

View File

@@ -359,7 +359,8 @@ exports[`TaxonResult should render correctly 1`] = `
]
}
>
Family
Family
<Text
maxFontSizeMultiplier={2}
style={

View File

@@ -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 );
} );
} );

View File

@@ -18,6 +18,7 @@
"stores": ["src/stores"],
"styles": ["src/styles"],
"tests": ["tests"],
}
},
"types": ["nativewind/types"]
}
}